이 처리 체계를 만든 이유는 코덱을 이것저것 시험해 보고 싶어서가 아니었습니다. 실제로는 몇 초짜리 무음 영상처럼 동작하는 콘텐츠를 애니메이션 이미지로 전달하는 비용이 지나치게 커졌기 때문입니다.
제가 주로 다루는 자료는 짧은 애니메이션 WebP, GIF, APNG입니다. 대개 몇 초 길이이고, 실제로 표시되는 프레임은 수십 장 정도이며, 연속된 장면 사이의 시간적 중복이 매우 큽니다. 시청의 대부분은 모바일에서 이루어지고 같은 파일이 반복해서 요청될 수 있으므로, 한 번만 지불하는 인코딩 시간보다 이후 매 요청마다 전송되는 바이트 수가 훨씬 중요합니다.
어려운 부분은 FFmpeg를 호출하는 것이 아닙니다. 애니메이션 이미지는 일정한 프레임레이트로 배열된 완전한 크기의 이미지 묶음이라고 보장할 수 없습니다. 부분 사각형, 합성 및 폐기 규칙, 알파, 불규칙한 지연, 지속 시간이 0인 프레임, 서로 다른 방향, 일반적인 분석 도구가 잘못 요약할 수 있는 타이밍 메타데이터가 들어 있을 수 있습니다.
그래서 변환을 하나의 명령이 아니라 반드시 유지해야 하는 불변 조건의 연속으로 다룹니다.
애니메이션 WebP / GIF / APNG
↓
실제로 표시되는 완전한 캔버스 상태 재구성
↓
원본 타이밍 복구 및 정규화
↓
최종 시퀀스의 모든 원본 분석
↓
최종 MP4에 사용할 CFR 하나 선택
↓
확대 없이 가장 작은 공통 캔버스 계산
↓
호환되는 H.264 세그먼트 인코딩
↓
스트림 계약 검증
↓
스트림 복사로 연결
↓
패킷 타임라인 정규화 및 검증
↓
HTTP 전달 검증
↓
검증된 파일로 원자적으로 교체
코덱도 중요하지만, 원래 애니메이션이 실제로 무엇을 보여 주었는지를 보존하는 일이 더 중요합니다.
실측한 운영 결과: 애니메이션 WebP 217개가 78.49 MB MP4 하나가 됐다
입력은 1.49 GB짜리 영상 하나가 아니었습니다. 서로 다른 애니메이션 WebP 217개였고, 합쳐서 10,633개의 표시 프레임을 담고 있었습니다. 원본 애니메이션 전체 크기가 약 1.49 GB였습니다.
입력
애니메이션 WebP 217개
총 1.49 GB
표시 프레임 10,633개
출력
H.264 MP4 1개
78.49 MB
0.98 Mbps
1264×720
30 fps CFR
Main@3.1 / CRF 28 / veryslow
결과 H.264 파일은 78.49 MB, 약 0.98 Mbps였습니다. 원본 애니메이션 전체 바이트와 비교하면 약 19분의 1, 즉 약 94.7% 적은 데이터입니다.
이 수치는 전체 처리 과정의 종단 간 결과이지, “기존 H.264 대 새로운 H.264”를 비교한 깨끗한 A/B 실험이 아닙니다. 표현 방식 자체가 수백 개의 애니메이션 이미지에서 시간 압축을 사용하는 영상 하나로 바뀌었기 때문에, 19배 차이를 CRF 28이나 veryslow, 또는 단일 인코더 옵션 하나의 효과라고 해석하지 않습니다.
먼저 시청자가 실제로 보는 완전한 그림을 재구성한다
가장 위험한 지름길은 저장된 각 애니메이션 프레임이 이전 화면을 통째로 대체하는 완전한 이미지라고 가정하는 것입니다.
애니메이션 WebP 프레임은 특정 위치의 사각형과 합성 및 폐기 동작을 지정할 수 있습니다. APNG 프레임에는 오프셋, 크기, 지연, 폐기 및 합성 연산이 있습니다. GIF 역시 이전 캔버스를 유지하거나 특정 영역을 지우거나 이전 상태를 복원할 수 있습니다.
따라서 저장된 프레임은 앞선 프레임들이 만든 캔버스에 의존하는 작은 패치일 수 있습니다. 이런 패치를 완전한 그림처럼 인코딩하면 올바른 애니메이션의 작은 버전이 아니라 잘못된 애니메이션이 됩니다.
제가 추출하는 단위는 완전한 표시 캔버스 상태입니다. 이전 프레임의 폐기 규칙과 현재 프레임의 합성 규칙을 적용한 뒤 올바른 재생기가 보여 줄 완전히 합성된 픽셀을 가져옵니다.
이것이 전체 처리의 첫 번째 정확성 보장입니다. 잘못된 부분 프레임이 H.264에 한 번 평탄화되어 들어가면, 이후의 CRF나 프리셋, 멀티플렉싱 옵션으로는 고칠 수 없습니다.
프레임 타이밍은 원본 데이터이지 추측할 FPS가 아니다
애니메이션 형식마다 시간을 저장하는 방법이 다릅니다. 애니메이션 WebP는 프레임별 지속 시간을 1 ms 단위로 저장합니다. GIF는 100분의 1초 단위 지연을 저장합니다. APNG는 프레임별 지연에 분자와 분모를 사용하며, 분모가 0이면 PNG 규격은 100으로 취급합니다.
이 지연값들이 실제 타임라인입니다. 일반 분석 도구가 보여 주는 대표 FPS는 단지 요약값일 뿐이며 오해를 만들 수 있습니다.
실제로 처리한 WebP 하나는 1264×720, 표시 프레임 49개였습니다. 지연은 62 ms와 63 ms가 번갈아 나왔고 총 길이는 3.063초였습니다. 16 fps에서 프레임 하나는 62.5 ms이므로 사실상 16 fps 리듬입니다.
그런데 일반 분석 도구는 같은 원본을 25 fps라고 보고했습니다. 그 수치를 믿었다면 원래 타이밍을 바꾸거나 불필요한 반복 프레임을 만들었을 것입니다.
깨졌거나 모호한 지연값에 대한 정책도 필요합니다. WebP는 0인 프레임 지속 시간과 흔히 매우 짧은 지속 시간의 해석을 구현에 맡깁니다. GIF에는 0 지연이 존재할 수 있습니다. APNG도 분자가 0일 수 있으며 이는 다음 프레임을 가능한 한 빨리 렌더링하라는 뜻이지만, 뷰어가 현실적인 하한을 둘 수도 있습니다.
제 정규화 정책은 시간을 밀리초 정밀도로 유지하고, 0이거나 비현실적으로 짧은 프레임 지속 시간에는 10 ms의 작은 하한을 적용하며, 유용한 타이밍 정보가 정말 없을 때만 100 ms를 대체값으로 사용합니다. 이 값들은 제 운영 정책이지 보편 규격이 아닙니다.
기본 30 fps 대신 최종 MP4 전체에 CFR 하나를 선택한다
표시 상태와 지속 시간을 재구성한 뒤 원본 타임라인을 영상 타임라인에 대응시킵니다. 모든 것을 무조건 30 fps로 인코딩하지 않습니다.
같은 최종 MP4에 들어갈 모든 원본에 대해 다음의 작은 후보 집합을 평가합니다.
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
선택기는 최종 시퀀스 전체를 받아들일 만하게 표현할 수 있는 가장 낮은 CFR을 고릅니다. 서로 다른 최종 MP4는 다른 속도를 선택할 수 있지만, 하나의 최종 MP4 안에 독립적으로 인코딩된 모든 세그먼트는 동일한 CFR을 사용합니다.
3.063초 예시에서는 절감 효과가 명확합니다. 16 fps에서는 약 49개의 출력 프레임이 필요하지만 30 fps에서는 약 92개가 필요합니다. 같은 최종 MP4의 다른 원본이 실제로 30 fps를 요구한다면 전체 컬렉션을 30 fps로 사용합니다. 하나의 최종 스트림 안에서 세그먼트별 프레임레이트를 섞지 않습니다.
영상 트랙 타임스케일로 90,000 Hz를 사용합니다. 허용한 모든 CFR이 정확한 정수 프레임 지속 시간으로 떨어지기 때문입니다.
10 fps → 9000 ticks
12 fps → 7500 ticks
15 fps → 6000 ticks
16 fps → 5625 ticks
18 fps → 5000 ticks
20 fps → 4500 ticks
24 fps → 3750 ticks
25 fps → 3600 ticks
30 fps → 3000 ticks
정확한 프레임 격자를 만드는 것은 영상 트랙 타임스케일입니다. 일관성을 위해 MP4의 무비 타임스케일도 90,000으로 두지만, 이는 컨테이너 수준의 별도 시계입니다. 검증기는 반올림된 소수 시간 대신 영상 패킷이 이 정수 격자에 맞는지 검사합니다.
해상도 수치는 상한이지 반드시 써야 하는 캔버스가 아니다
배포 상한은 가로형 약 1280×720, 세로형 720×1280이며, 방향이 섞인 자료는 가로와 세로가 각각 960을 넘지 않게 합니다.
절대 규칙은 확대하지 않는다입니다. 900×600 원본을 1280×720으로 만든다고 세부 정보가 늘지는 않고, 인코더가 설명해야 할 보간 픽셀만 늘어납니다.
두 번째 규칙은 조금 덜 직관적입니다. 960×960은 최대 외곽 범위일 뿐, 반드시 정사각형 캔버스를 써야 한다는 뜻이 아닙니다.
먼저 각 원본에 대해 축소만 적용한 실제 영역의 크기를 계산합니다. 그 다음 이미 축소된 모든 실제 영역을 담을 수 있는 가장 작은 공통 짝수 크기 캔버스를 최종 시퀀스에 사용합니다.
예를 들어 최종 시퀀스에 960×540 가로 이미지와 500×900 세로 이미지가 필요하다면 공통 캔버스는 960×960이 아니라 960×900이면 됩니다. 모든 세그먼트의 인코딩 치수는 여전히 같으므로 스트림 복사 연결은 가능하고, 아무 역할도 하지 않는 검은 영역을 쓸데없이 인코딩하지 않습니다.
화면 비율을 유지하고 이미지를 늘이는 대신 남는 공간을 채웁니다. 이 처리에서는 검은색으로 채웁니다. 일반 H.264/yuv420p는 원본 알파 채널을 보존하지 않으므로, 투명도를 우연히 잃는 대신 의도적으로 해당 배경 위에 합성합니다.
이 배포 문제에 H.264 MP4가 잘 맞는 이유
GIF, APNG, 애니메이션 WebP는 단순한 형식이 아닙니다. 변하지 않은 영역을 매번 다시 그리지 않는 기능이 있으므로 “영상은 언제나 더 작다”는 주장은 틀립니다.
하지만 H.264는 그림 사이의 시간 예측을 위해 설계되었습니다. 정적인 배경과 작은 움직임 영역을 가진 짧은 일러스트 루프는 참조 그림, 프레임 간 예측, P 프레임과 B 프레임에 유리한 자료입니다.
Apple의 현재 Safari 안내는 정적 영상에 H.264로 인코딩한 MP4를 권장하며, 애니메이션 GIF가 현대적인 영상 코덱보다 대역폭을 최대 12배, 에너지를 약 2배 더 사용할 수 있다고 설명합니다. 이 12배 수치는 Apple의 예시이지 제 측정치가 아닙니다.
따라서 제가 측정한 19배 결과는 실제 운영 관측으로 의미가 있지만, 이미 아주 작고 잘 최적화된 모든 애니메이션 WebP가 MP4보다 크다고 가정하지 않고 직접 측정합니다.
각 애니메이션을 하나의 스트림 계약 아래 세그먼트로 인코딩한다
인코더가 실행될 때는 표시 프레임, 컬렉션 전체 CFR, 최종 캔버스, 예상 길이가 이미 정해져 있습니다.
세그먼트 명령의 중심 부분은 대략 다음과 같습니다.
ffmpeg -framerate "$COLLECTION_FPS" -i frame-%06d.png \
-c:v libx264 \
-preset veryslow \
-tune animation \
-crf 28 \
-profile:v main \
-level:v 3.1 \
-pix_fmt yuv420p \
-tag:v avc1 \
-refs 4 \
-bf 5 \
-g "$GOP_FRAMES" \
-maxrate:v 4M \
-bufsize:v 8M \
-x264-params "stitchable=1:open-gop=0:b-pyramid=normal:nal-hrd=none" \
-color_range tv \
-color_primaries bt709 \
-color_trc bt709 \
-colorspace bt709 \
-fps_mode passthrough \
-map_metadata -1 \
-map_chapters -1 \
-an -sn -dn \
-video_track_timescale 90000 \
-movie_timescale 90000 \
-t "$EXPECTED_DURATION" \
segment.mp4
GOP 상한은 선택한 CFR에서 약 5초가 되도록 계산합니다. 16 fps에서는 80프레임, 24 fps에서는 120, 30 fps에서는 150입니다.
-t "$EXPECTED_DURATION"는 장식용 안전장치가 아닙니다. 제 프레임 목록 방식에서는 이전 실제 프레임의 지속 시간이 적용되도록 마지막 이미지를 끝 표시로 한 번 더 둡니다. 명시적인 시간 제한이 없으면 이 표시가 끝부분의 추가 샘플이 될 수 있습니다.
49프레임, 3.063초인 실제 사례로 재현했습니다. -t가 없으면 16 fps에서 50프레임, 30 fps에서 94프레임이 나왔습니다. -t 3.063을 적용하면 의도한 16 fps 49프레임, 30 fps 92프레임이 나왔습니다.
스트림 복사 연결은 엄격한 호환성 검증 뒤에만 안전하다
FFmpeg의 이어붙이기 디먹서는 코덱과 시간 기준을 포함해 같은 스트림을 가진 파일을 기대하고, 각 파일의 지속 시간을 사용해 다음 파일의 위치를 정합니다. 따라서 잘못된 지속 시간 메타데이터는 시간축 왜곡을 만들 수 있습니다.
연결 단계에서 서로 맞지 않는 파일을 억지로 맞추지 않습니다. 세그먼트는 받아들이기 전에 계약을 만족해야 합니다.
컬렉션 전체 CFR = 동일
스트림 시간 기준 = 동일
MP4 비디오 트랙 타임스케일 = 동일
캔버스 치수 / SAR = 동일
프로파일 / 레벨 / 픽셀 형식 = 동일
색상 신호 = 동일
avcC / AVC 추가 데이터 = 바이트 단위로 동일
세그먼트를 독립적으로 인코딩하므로 x264의 stitchable=1을 사용하지만, 이 설정만으로 AVC 설정이 일치한다고 보지 않습니다. 연결 전에 실제 설정 바이트를 비교합니다.
계약을 만족하면 최종 연결은 영상 비트스트림 수준에서 추가 손실 없이 진행할 수 있습니다.
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
-an -sn -dn \
-movflags +faststart \
final.mp4
-c:v copy 덕분에 이미 인코딩된 H.264 세그먼트를 디코딩한 뒤 두 번째로 압축하는 일을 피합니다.
검증은 MP4 파일과 실제 전달 방식 둘 다 포함한다
FFmpeg가 종료 코드 0으로 끝났다는 이유만으로 게시하지 않습니다.
검증기는 실제 결정적 타이밍 오류를 잡았습니다. 16 fps에서 계약상 5625틱이어야 하는 곳에 5580이 있었고, 나중에는 24 fps 출력에서 정확히 3750이어야 하는 곳에 3751이 있었습니다. 한 틱 차이를 깊이 분석한 내용은 별도 주제이고, 여기서 중요한 것은 결정적 타임라인 오류는 같은 작업을 다시 시도해도 고쳐지지 않는다는 점입니다.
미디어 객체 자체는 예상 스트림 수, H.264 프로파일/레벨, 픽셀 형식, 계획한 정확한 치수, SAR, 색상 신호, 90 kHz 비디오 트랙 타임스케일, 패킷 지속 시간, 프레임/패킷 수, 전체 지속 시간, PTS/DTS 관계, 세그먼트 사이의 동일한 AVC 구성, moov가 mdat 앞에 있는지, 그리고 엄격한 오류 처리로 전체 디코딩이 되는지를 검사합니다.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
하지만 로컬에서 올바른 MP4도 웹에서 잘못 전달될 수 있습니다. 그래서 예상 Content-Type, 올바른 Content-Length, 바이트 범위 지원, 올바른 206 Partial Content 응답, 올바른 Content-Range도 확인합니다.
인코더 계약을 바꿀 때는 ffprobe만으로 하드웨어 호환성이 증명됐다고 생각하지 않고, 소규모 실기기 검증도 합니다. 최신 iPhone/Safari 경로, 보급형 Android 기기, 주요 PC 브라우저에서 재생 시작, 탐색, 반복, 백그라운드 전환과 복귀, 범위 재생을 확인합니다.
파일과 전달 경로가 모두 계약을 통과한 뒤에만 운영 파일을 원자적으로 교체합니다.
이 처리에서 의도적으로 잃는 정보
이것은 배포용 변환이지 보관용 마스터가 아닙니다. 알파는 배경에 합성됩니다. 불규칙한 원본 타이밍은 최종 MP4의 하나의 CFR로 양자화됩니다. 큰 원본은 축소될 수 있습니다. 이미 손실 압축된 애니메이션 WebP에는 한 세대의 손실 압축가 더 생깁니다. 오디오는 의도적으로 없습니다.
임의 배경 위에 합성 가능한 투명도를 남겨야 할 때, 정확한 불규칙 프레임 타이밍 자체가 콘텐츠 의미일 때, 보관용 원본을 만들 때, 또는 애플리케이션에 이미 적응형 다중 코덱 영상 배포 체계가 있을 때는 이 방식을 그대로 쓰지 않습니다.
이미 매우 작고 잘 최적화된 애니메이션 WebP 역시 MP4가 반드시 더 작다고 가정하지 않고 직접 비교합니다.
현재 사용하는 운영 처리 순서
- 애니메이션 형식을 감지하고 실제 프레임 제어 메타데이터를 읽는다.
- 형식의 합성·폐기 규칙에 따라 완전한 표시 캔버스 상태를 재구성한다.
- 각 프레임의 지속 시간을 복구하고 잘못된 값을 정규화한다.
- 밀리초 정밀도로 신뢰할 수 있는 원본 타임라인을 만든다.
- 같은 최종 MP4에 들어갈 모든 원본을 분석한다.
- 10/12/15/16/18/20/24/25/30 중 컬렉션 전체 CFR 하나를 선택한다.
- 표시 상태를 선택한 CFR 타임라인에 대응시킨다.
- 축소만 적용한 실제 크기를 계산하고 절대 확대하지 않는다.
- 최종 시퀀스에 필요한 가장 작은 공통 짝수 크기 캔버스를 만든다.
- 늘이지 않고 여백을 채우며 알파를 의도적으로 배경에 합성한다.
- 모든 원본을 동일한 스트림 계약의 H.264 Main@3.1 / yuv420p / avc1로 인코딩한다.
- 각 세그먼트를 예상 길이로 제한한다.
- 실제 AVC 설정이나 타이밍이 계약을 위반하는 세그먼트를 거부한다.
- 통과한 세그먼트를
-c:v copy로 연결한다. - 최종 패킷 타임라인을 정규화하고 검증한다.
- 결과를 끝까지 디코딩한다.
- HTTP 헤더, 바이트 범위, 부분 콘텐츠 동작을 검증한다.
- 인코더 프로필 변경 뒤 기기·브라우저 카나리 테스트를 실행한다.
- 모든 검사가 성공한 뒤에만 원자적으로 게시한다.
진실의 기준은 파일 확장자가 아니라 타임라인이다
애니메이션 WebP, GIF, APNG는 이미지 확장자를 가진 파일 묶음이 아니라 시간에 따라 변하는 완전한 표시 캔버스 상태의 연속입니다.
H.264는 시간적 중복을 매우 효율적으로 활용할 수 있지만, 잘못된 합성이나 만들어 낸 타이밍, 서로 맞지 않는 세그먼트 메타데이터를 고칠 수는 없습니다. 이 처리 체계를 신뢰할 수 있게 만든 작업의 대부분은 x264 앞뒤에서 이루어집니다.
제가 남긴 원칙은 이것입니다. 무엇이 반드시 변하지 않아야 하는지 정확히 설명할 수 있게 된 뒤에 표현 방식을 바꿉니다.
주요 문서
- Google WebP 컨테이너 규격 — 프레임 사각형, 지속 시간, 합성, 폐기.
- W3C PNG 규격 제3판 — APNG 프레임 타이밍, 오프셋, 합성 및 폐기 연산.
- GIF89a 규격 — GIF 지연과 폐기 동작.
- FFmpeg 형식 문서 — 이어붙이기 디먹서 요구 사항과 MP4 멀티플렉싱 동작.
- FFmpeg 비트스트림 필터 문서 —
setts. - ffprobe 문서 — 스트림과 패킷 검사.
- Apple: Safari용 영상 콘텐츠 전달 — 정적 영상용 H.264 MP4와 애니메이션 GIF 대체 지침.
- Android 지원 미디어 형식 — H.264 지원과 HTTP 스트리밍 요구 사항.