이 파이프라인을 만든 이유는 코덱을 실험하고 싶어서가 아니었습니다. 실제로는 짧고 무음인 영상과 다르지 않은 콘텐츠를 animated image 형태로 계속 전달하는 비용이 커졌기 때문입니다.
제가 처리하는 것은 주로 몇 초 길이의 animated WebP, GIF, APNG입니다. 보이는 frame은 수십 장 정도이고 temporal redundancy가 높습니다. 트래픽은 모바일 비중이 크고 같은 asset이 반복해서 전달되기 때문에, 한 번의 encoding 시간보다 이후에 계속 전송될 byte 수가 훨씬 중요합니다.
“애니메이션을 MP4로 바꾼다”라고만 하면 간단합니다. 하지만 production에서는 animated image가 항상 같은 크기의 완성된 frame들이 일정 FPS로 이어진 형태가 아닙니다. partial rectangle, disposal rule, alpha blending, irregular delay, zero duration, 서로 다른 orientation, 그리고 일반 probe가 제대로 요약하지 못하는 timing metadata가 있을 수 있습니다.
그래서 제 pipeline은 단순한 FFmpeg 한 줄이 아니라 다음 invariant의 연속입니다.
animated WebP / GIF / APNG
↓
실제로 표시되는 canvas state를 decode하고 재구성
↓
source timeline 복원
↓
source별 CFR 선택
↓
upscale 없이 geometry 정규화
↓
보수적인 H.264 segment encode
↓
각 source를 독립적으로 반복 처리
↓
호환 segment를 stream copy로 concat
↓
final packet timeline 정규화
↓
probe + packet 검사 + 전체 decode
↓
atomic publish
코덱보다 더 어려운 부분은 표현 방식을 완전히 바꾸면서 원래 애니메이션이 무엇을 언제 보여 줬는지 보존하는 일이었습니다.
저장된 rectangle이 아니라 실제로 보이는 frame을 추출한다
가장 위험한 단순화는 파일 안의 각 frame이 이전 이미지를 완전히 대체하는 full image라고 생각하는 것입니다.
Animated WebP는 frame position, rectangle size, blend method, disposal method를 가질 수 있습니다. APNG에는 offset, dimensions, dispose_op, blend_op가 있습니다. GIF도 이전 화면을 유지하거나 background로 복원하거나 이전 canvas state를 되돌리는 disposal 동작을 가질 수 있습니다.
따라서 저장된 frame은 기존 canvas 위에 올라가는 작은 patch일 수 있습니다. 이 patch를 독립된 완성 이미지처럼 H.264에 넣으면 더 작은 같은 애니메이션이 아니라 잘못된 영상이 됩니다.
제가 extraction 기준으로 삼는 것은 정상적인 viewer가 그 순간 실제로 보여 줄 완전히 composited된 canvas state입니다. 이전 frame의 disposal과 현재 frame의 blending을 모두 적용한 결과를 사용합니다.
이 단계가 틀리면 이후의 encoder setting으로는 복구할 수 없습니다.
FPS를 추측하지 않고 source의 frame timing을 읽는다
- Animated WebP는 frame duration을 1 ms 단위로 저장합니다.
- GIF는 1/100초 단위 delay를 저장합니다.
- APNG는 delay_num/delay_den을 사용하며 denominator가 0이면 PNG specification에 따라 100으로 처리합니다.
이 per-frame delay가 실제 timeline입니다. 평균 FPS 필드는 요약일 뿐입니다.
실제로 처리한 WebP 하나는 1264×720, 보이는 frame 49개, delay가 62와 63 ms로 번갈아 가며 전체 duration이 3.063초였습니다. 사실상 약 16 fps, 즉 frame당 62.5 ms입니다.
그런데 probe는 25 fps라고 표시했습니다.
그 값을 그대로 믿었다면 timing을 바꾸거나 불필요한 duplicate frame을 만들었을 것입니다. source of truth는 frame별 duration이었습니다.
CFR을 고르기 전에 이상한 timing을 정규화한다
WebP specification은 duration 0과 매우 작은 값, 흔히 10 ms 이하가 implementation-dependent하게 해석될 수 있다고 설명합니다. GIF도 zero delay를 허용하고 APNG도 numerator 0을 허용합니다.
제 정책은 ms 정밀도를 유지하고 최소 frame delay를 10 ms로 두며, 유효한 timing을 얻지 못할 때만 100 ms를 fallback으로 사용합니다.
이 숫자가 universal rule이라는 뜻은 아닙니다. 핵심은 CFR로 양자화하기 전에 비정상 timing만 명시적으로 정리하고 정상 source timing은 임의의 default로 덮어쓰지 않는 것입니다.
모든 것을 30 fps로 만들지 않는다
애니메이션의 실제 표시 상태가 초당 약 16번만 변한다면 30 fps로 저장한다고 새로운 motion 정보가 생기지는 않습니다. H.264가 duplicate를 잘 압축하더라도 불필요한 temporal sample은 애초에 만들지 않는 편이 낫습니다.
source별로 다음 후보 중 CFR을 고릅니다.
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
실제 timeline을 기준으로 보이는 변화 시점을 충분히 잘 표현하는 가장 낮은 candidate를 선택하고, 없으면 30 fps를 fallback으로 사용합니다.
62/63 ms 예시는 16 fps가 자연스럽습니다. frame당 정확히 62.5 ms이기 때문입니다.
source마다 다른 CFR을 사용해도 하나의 MP4로 합칠 수 있다
source A → 12 fps CFR H.264 segment
source B → 16 fps CFR H.264 segment
source C → 24 fps CFR H.264 segment
source D → 30 fps CFR H.264 segment
segments → stream-copy concat → one MP4
각 segment는 CFR이지만 전체 파일은 구간별 CFR이고 전체적으로는 variable-rate에 가깝습니다. concat을 쉽게 하려고 모든 source를 하나의 global FPS로 다시 encode하지 않습니다.
90 kHz time base를 쓰는 이유
| FPS | 90 kHz에서 정확한 frame duration |
|---|---|
| 10 | 9000 ticks |
| 12 | 7500 ticks |
| 15 | 6000 ticks |
| 16 | 5625 ticks |
| 18 | 5000 ticks |
| 20 | 4500 ticks |
| 24 | 3750 ticks |
| 25 | 3600 ticks |
| 30 | 3000 ticks |
모든 후보 FPS가 90,000을 정확히 나눕니다. 24 fps frame은 3750 ticks, 16 fps는 5625 ticks입니다. video track과 movie timescale을 90,000으로 맞추고 validator도 이 exact integer grid를 검사합니다.
geometry는 encode 전에 정규화하고 upscale하지 않는다
| 형태 | 최대 범위 |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×1280 |
| orientation이 섞인 final sequence | 약 960×960 canvas |
작은 source를 키우지 않습니다. 다른 aspect ratio를 하나의 final stream에 넣어야 하면 stretch 대신 공통 canvas에 fit하고 padding합니다. 제 pipeline의 background는 black입니다.
alpha도 명시적인 선택입니다. 이 H.264/yuv420p pipeline에서는 투명도를 유지하지 않으므로 선택한 background에 flatten합니다.
이 workload에서 H.264 MP4가 더 작아지기 쉬운 이유
GIF, APNG, animated WebP도 partial update와 disposal 같은 최적화가 있으므로 모든 complete frame을 단순히 따로 저장한다고 말하면 틀립니다.
하지만 H.264는 reference picture, inter prediction, P-frame, B-frame을 이용해 시간 방향 redundancy를 압축하도록 설계된 video codec입니다. 정적인 background가 많고 일부 영역만 움직이는 illustrated clip과 잘 맞습니다.
Apple은 static web video에 H.264 MP4를 권장하고 animated GIF 대신 MP4를 사용하도록 안내합니다. Safari documentation은 GIF가 modern video codec보다 bandwidth를 최대 12배, energy를 약 2배 더 사용할 수 있다고 설명합니다. 이 12×는 제 benchmark가 아니라 Apple의 예시입니다.
Lossy animated WebP는 이미 압축되어 있기 때문에 H.264 변환은 두 번째 lossy generation입니다. 더 작아질 수 있지만 quality validation이 필요합니다.
H.264 profile은 의도적으로 보수적이다
| 설정 | 값 |
|---|---|
| Container | MP4 |
| Codec | H.264 / libx264 |
| Sample entry | avc1 |
| Profile / level | Main @ 3.1 |
| Pixel format | 8-bit yuv420p |
| Rate control | CRF 28 |
| Preset | veryslow |
| Tune | animation |
| Reference frames | 4 |
| Maximum B-frames | 5 |
| Open GOP | off |
| B-pyramid | normal |
| Maximum GOP | 약 5초 |
| VBV | 4M maxrate / 8M bufsize |
| Color | BT.709, limited range |
| Track/movie timescale | 90,000 |
| Audio/subtitles/data | 없음 |
Main@3.1은 modern device의 최대 능력이 아니라 one-file delivery를 위한 보수적인 envelope입니다. Android는 Android 6.0부터 Main Profile decoder를 요구하고 1280×720@30 fps를 H.264 HD 권장 예로 제시합니다. Apple도 static web file에 H.264 MP4를 권장합니다.
CPU는 한 번 쓰고 bandwidth는 계속 아낀다
-preset veryslow
encoding은 한 번이고 delivery는 반복됩니다. 그래서 x264가 많은 CPU를 사용해 더 효율적인 representation을 찾도록 합니다. decoder complexity는 profile, level, refs, B-frame 구조로 따로 제한합니다.
CRF 28 역시 illustrated, bandwidth-first workload에 맞춘 값이지 universal recommendation은 아닙니다.
각 애니메이션을 독립된 segment로 encode한다
ffmpeg -framerate "$SELECTED_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
이 전에 이미 timeline-aware frame sequence와 resize/pad geometry가 만들어져 있습니다. GOP는 대략 selectedFps * 5입니다.
concat은 엄격한 segment contract 덕분에 가능하다
FFmpeg concat demuxer는 compatible stream과 time base를 요구하고 각 file duration을 다음 file의 timestamp 계산에 사용합니다. 따라서 호환성은 concat 단계가 아니라 segment acceptance 단계에서 확정합니다.
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy -an -sn -dn -movflags +faststart final.mp4
-c:v copy로 두 번째 lossy encode를 피합니다.
stream copy만으로 exact timestamp가 보장되지는 않았다
90000 / 24 = 3750 ticks
실제 concatenated output에서 validator가 3751-tick packet을 발견했습니다. 화면으로는 거의 느끼기 어렵지만 설계한 invariant가 깨진 것입니다.
결론은 H.264 payload를 copy하는 것과 muxed timestamp가 정확한 grid를 지키는 것은 별개의 문제라는 점입니다.
현재 final concat은 setts bitstream filter로 PTS, DTS, duration을 알려진 90-kHz grid에 맞추면서 -c:v copy를 유지합니다. 구체적인 expression은 segment timeline에서 생성합니다.
validation은 encoder의 일부다
FFmpeg가 0으로 끝났다고 publish하지 않습니다. streams, profile/level, pixel format, dimensions, time base, frame/packet counts, exact duration, PTS/DTS, final duration을 검사하고 전체 file을 decode합니다.
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 -
모든 검사를 통과한 file만 atomic publish합니다.
이 pipeline이 막으려는 오류
- visible canvas 대신 raw subframe을 encode.
- 추정 FPS를 신뢰.
- 모두 30 fps로 강제.
- upscale.
- aspect ratio stretch.
- concat 후 re-encode.
-c copy를 timing correctness로 착각.- full decode 생략.
의도적으로 보존하지 않는 것
alpha는 flatten합니다. arbitrary VFR은 source별 CFR로 근사합니다. 지나치게 큰 resolution은 줄입니다. lossy WebP는 추가 lossy generation을 거칩니다. audio는 대상이 아닙니다. 모두 명시적인 trade-off입니다.
이 접근을 사용하지 않을 경우
transparency를 반드시 유지해야 하거나, exact irregular timing이 콘텐츠의 핵심이거나, archival master를 만들거나, 이미 adaptive multi-codec delivery stack이 있다면 이 exact pipeline을 쓰지 않습니다. 이미 아주 작은 animated WebP라면 먼저 benchmark합니다.
현재 사용하는 실전 pipeline
- 실제 animation metadata 읽기.
- blend/disposal을 적용해 full visible canvas state 재구성.
- delay sanitize 후 ms timeline 생성.
- 10/12/15/16/18/20/24/25/30에서 source별 CFR 선택.
- upscale 없는 geometry, padding, alpha flatten.
- H.264 Main@3.1/yuv420p/avc1,
veryslow, CRF 28 encode. - 90-kHz time base.
- 각 segment validation.
-c:v copyconcat.- final timeline exact grid 정규화.
- packet inspection과 full decode.
- 통과 후 publish.
가장 중요한 결론: 진짜 asset은 timeline이다
Animated WebP, GIF, APNG는 단순한 “여러 장의 이미지”가 아니라 시간 정보가 붙은 canvas state의 시퀀스입니다.
이 시퀀스를 source of truth로 취급한 뒤부터는 존재하지 않는 motion을 만들지 않고 cadence를 줄이고, 존재하지 않는 pixel을 만들지 않고 resolution을 줄이며, source별 CFR을 쓰고, 추가 lossy encode 없이 concat할 수 있었습니다.
H.264는 file을 작게 만들고 MP4는 delivery를 단순하게 만들었습니다. 하지만 frame extraction이나 timing에서 이미 animation을 망쳤다면 아무 의미가 없습니다.
codec은 쉬운 부분이었습니다. 실제 engineering은 표현 방식을 바꾸면서 animation의 의미를 보존하는 일이었습니다.