Quay lại blog
13 tháng 8, 2026Sergei Solod10 phút đọc

Cách tôi biến Animated WebP, GIF và APNG thành H.264 MP4 nhỏ, an toàn cho browser

Production pipeline của tôi reconstruct displayed canvas state và source timing thật, chọn CFR theo từng source, không upscale, encode các H.264 segment độc lập, concat mà không có lossy encode lần hai và validate exact packet timeline trước khi publish.

H.264FFmpegMP4Animated WebPGIFAPNGNén videoMedia pipeline

Tôi không xây pipeline này vì muốn thử một codec mới. Vấn đề thực tế hơn nhiều: Animated WebP, GIF và APNG thường đang làm công việc của một video ngắn không âm thanh, nhưng không phải lúc nào cũng là representation tiết kiệm nhất khi cùng một asset được tải đi tải lại trên web.

Workload của tôi chủ yếu là animation dài vài giây, có vài chục displayed frame và rất nhiều temporal redundancy. Encoding chỉ diễn ra một lần, còn delivery diễn ra nhiều lần. Vì vậy kích thước file cuối và playback ổn định trên browser quan trọng hơn tốc độ encode.

“Chuyển animation sang MP4” nghe đơn giản. Phiên bản production thì không. Animated image không nhất thiết là một sequence full-size frame có FPS cố định đẹp đẽ. Nó có thể chứa partial rectangle, blend/disposal rule, alpha, delay không đều, zero-duration frame và timing metadata bị tool phổ thông tóm tắt thành một con số FPS gây hiểu nhầm.

Pipeline cuối cùng của tôi là chuỗi invariant sau:

animated WebP / GIF / APNG
        ↓
decode + reconstruct displayed canvas states
        ↓
recover source timeline
        ↓
select per-source CFR
        ↓
resize / pad without upscaling
        ↓
encode one H.264 segment
        ↓
repeat independently for every source
        ↓
concat with stream copy
        ↓
normalize final packet timeline
        ↓
ffprobe + packet checks + full decode
        ↓
atomic publish

Codec chỉ là một phần. Phần khó là thay representation mà vẫn giữ nguyên ý nghĩa của animation.

Quy tắc đầu tiên: extract canvas thực sự được hiển thị, không phải rectangle được lưu

Shortcut nguy hiểm nhất là giả định mỗi stored frame là một ảnh đầy đủ thay thế frame trước.

Animated WebP có position, rectangle size, blend mode và disposal mode. APNG có offset, dimensions, dispose operation và blend operation. GIF dùng Graphic Control Extension để mô tả disposal behavior.

Vì vậy stored frame có thể chỉ là một patch nhỏ phụ thuộc vào canvas state trước đó. Encode patch như full frame sẽ không cho animation nhỏ hơn; nó cho animation sai.

Extraction boundary của tôi là displayed canvas state: ảnh composited đầy đủ mà decoder đúng thực sự hiển thị sau khi áp dụng disposal và blending.

Nếu pixel sai từ đây, CRF hay preset phía sau không thể sửa lại.

Frame timing là source data, không phải FPS đoán ra

  • Animated WebP lưu duration từng frame theo đơn vị 1 ms.
  • GIF lưu delay theo phần trăm giây.
  • APNG lưu delay numerator/denominator; nếu denominator bằng 0, PNG specification coi nó là 100.

Những per-frame delay này mới là timeline. Average FPS chỉ là summary.

Một WebP thực tế có kích thước 1264×720, 49 displayed frames, delay xen kẽ 62 và 63 ms, total duration 3.063 s. Đây gần như chính xác là cadence 16 fps vì một frame 16 fps dài 62.5 ms.

Một probe lại báo source đó là 25 fps.

Nếu tin con số này rồi encode thành 25 hoặc 30 fps, tôi có thể làm thay đổi timing hoặc tạo duplicate frame không cần thiết. Source frame durations mới là dữ liệu authoritative.

Tôi normalize timing xấu trước khi chọn CFR

Zero delay và delay cực nhỏ có thể được implementation xử lý khác nhau. Production pipeline vì thế cần policy rõ ràng. Tôi giữ timeline ở millisecond precision, dùng minimum frame delay 10 ms và chỉ dùng 100 ms làm fallback khi không có timing hữu ích.

Hai con số này không phải quy luật universal. Nguyên tắc quan trọng là giữ timing hợp lệ và normalize trước frame-rate quantization.

Vì sao tôi không ép mọi animation thành 30 fps

Animation chỉ thay đổi state khoảng 12 hoặc 16 lần mỗi giây không có thêm motion chỉ vì được lưu ở 30 fps. H.264 có thể nén duplicate frame tốt, nhưng tốt hơn là không tạo temporal sample không cần thiết.

Tôi chọn CFR riêng cho từng source từ:

10, 12, 15, 16, 18, 20, 24, 25, 30 fps

Selector chọn candidate thấp nhất vẫn biểu diễn source timeline sạch. Nếu không candidate thấp nào phù hợp, fallback là 30 fps. Với ví dụ 62/63 ms, 16 fps là fit tự nhiên.

Final MP4 không cần một global CFR duy nhất

Mỗi animation được encode thành segment độc lập. Một source có thể 12 fps, source khác 16, source khác 24 hoặc 30. Tôi không phá cadence tự nhiên chỉ để toàn collection có một con số.

Mỗi segment là CFR cục bộ, nhưng packet duration trong final concatenated timeline có thể thay đổi ở segment boundary. Đây là chủ ý thiết kế.

Vì sao dùng time base 90,000 Hz

FPSticks/frame
109000
127500
156000
165625
185000
204500
243750
253600
303000

Tất cả FPS cho phép đều chia 90,000 thành integer ticks/frame chính xác. Điều này tạo grid sạch cho concat và validation.

Geometry: downscale khi cần, không bao giờ upscale

  • landscape: tối đa 1280×720
  • portrait: tối đa 720×1280
  • mixed/square: khoảng 960×960

Source nhỏ thì giữ nhỏ. Biến 900×600 thành 1280×720 không tạo thêm detail, chỉ tăng pixel encoder phải mô tả.

Tôi cũng không stretch aspect ratio. Khi cần sẽ proportional scale rồi pad. Alpha được flatten lên background xác định vì H.264/yuv420p output này không giữ transparency.

Vì sao H.264 MP4 thường nhỏ hơn nhiều với workload này

GIF, APNG và Animated WebP bản thân cũng có partial update và disposal, vì vậy nói “MP4 luôn nhỏ hơn” là sai.

Nhưng H.264 được thiết kế cho temporal video compression. Với illustrated scene có background phần lớn đứng yên, inter prediction, reference pictures, P-frames và B-frames tận dụng sự giống nhau giữa các frame rất hiệu quả.

Apple hiện khuyến nghị H.264 MP4 cho static web video và khuyên dùng MP4 thay animated GIF. Tài liệu Safari nêu GIF có thể dùng tới 12× bandwidth và khoảng 2× energy so với modern video codec. 12× là ví dụ của Apple, không phải benchmark của tôi.

Animated WebP cần nuance hơn. Nếu source WebP đã lossy, H.264 là lossy generation thứ hai. File vẫn có thể nhỏ hơn đáng kể, nhưng quality phải được validate.

H.264 profile được cố ý giữ conservative

SettingValue
ContainerMP4
CodecH.264 / libx264
Sample entryavc1
Profile / levelMain @ 3.1
Pixel format8-bit yuv420p
Rate controlCRF 28
Presetveryslow
Tuneanimation
Reference frames4
Maximum B-frames5
Open GOPoff
B-pyramidnormal
Maximum GOPkhoảng 5 s
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/datanone

Main@3.1 không phải giới hạn của thiết bị hiện đại; đây là conservative one-file baseline của tôi. Android yêu cầu Main Profile decoder từ Android 6.0 và đưa 1280×720@30 fps làm cấu hình H.264 HD khuyến nghị. Apple cũng khuyến nghị H.264 MP4 cho static web video.

CPU trả một lần, bandwidth trả nhiều lần

-preset veryslow

Encoding là offline one-time cost, delivery là recurring cost. Vì vậy cho x264 dùng nhiều CPU để tìm representation hiệu quả hơn là hợp lý. Decoder complexity được giới hạn riêng bằng profile, level, refs và B-frame structure.

CRF 28 là baseline cho illustrated bandwidth-first workload của tôi, không phải universal setting.

Mỗi animation được encode thành segment H.264 độc lập

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

Trước command này, timeline-aware frame sequence và geometry cuối đã sẵn sàng. GOP khoảng selectedFps * 5.

Concat chỉ an toàn khi segment contract nghiêm ngặt

FFmpeg concat demuxer yêu cầu streams compatible và dùng duration của mỗi file để đặt timestamp file tiếp theo. Vì vậy mismatch phải bị reject trước concat, không vá ngẫu nhiên ở bước cuối.

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy -an -sn -dn -movflags +faststart final.mp4

-c:v copy tránh một lần H.264 lossy encode thứ hai.

Stream copy không đồng nghĩa timestamp hoàn hảo

90000 / 24 = 3750 ticks

Trong một output thật, validator phát hiện packet 3751 ticks thay vì 3750 ở 24 fps. Video có thể nhìn hoàn toàn bình thường, nhưng exact-grid invariant đã bị phá.

Copy compressed H.264 payload không chứng minh timing correctness. Final concat hiện dùng setts bitstream filter để normalize PTS, DTS và duration lên 90-kHz grid đã biết, trong khi vẫn giữ -c:v copy. Exact expression được tạo từ segment timeline, không phải công thức chung.

Validation là một phần của encoder

FFmpeg exit code 0 chưa đủ. Tôi kiểm tra streams, profile/level, pixel format, dimensions, time base, frame/packet counts, exact durations, PTS/DTS và final duration, rồi decode toàn bộ file.

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 -

Chỉ khi mọi check pass mới atomic publish.

Những lỗi pipeline này ngăn được

  • Encode raw subframe thay vì visible canvas.
  • Tin guessed FPS.
  • Ép mọi source thành 30 fps.
  • Upscale source nhỏ.
  • Stretch aspect ratio.
  • Lossy encode lần hai sau concat.
  • Cho rằng -c copy chứng minh timing đúng.
  • Chỉ check header mà không full decode.

Những thứ tôi chủ động không giữ

Alpha bị flatten. Arbitrary VFR được approximate bằng source-specific CFR. Resolution quá lớn được downscale. Lossy WebP trải qua thêm một lossy generation. Audio nằm ngoài scope. Đây là explicit trade-off.

Khi nào tôi không dùng cách này

Nếu transparency bắt buộc, exact irregular timing có ý nghĩa không được phép approximate, mục tiêu là archival master, hoặc hệ thống đã có adaptive multi-codec video stack, tôi sẽ không dùng trực tiếp single-file pipeline này.

Với Animated WebP đã được optimize rất nhỏ, tôi benchmark trước thay vì giả định MP4 chắc chắn nhỏ hơn.

Pipeline thực tế hiện tại

  1. Đọc animation metadata thật.
  2. Reconstruct visible canvas states bằng blend/disposal.
  3. Normalize delay và xây millisecond timeline.
  4. Chọn CFR per source từ 10/12/15/16/18/20/24/25/30.
  5. No upscale; proportional resize/pad; alpha flatten.
  6. H.264 Main@3.1/yuv420p/avc1, veryslow, CRF 28.
  7. 90-kHz time base.
  8. Validate từng segment.
  9. Concat với -c:v copy.
  10. Normalize final packet timeline về exact grid.
  11. Packet inspection + full decode.
  12. Pass tất cả rồi publish.

Bài học lớn nhất: asset thật sự là timeline

Animated WebP, GIF và APNG không chỉ là “nhiều hình ảnh”. Chúng là sequence canvas state gắn với thời gian.

Khi timeline trở thành source of truth, các quyết định còn lại rõ hơn: giảm cadence mà không bịa motion, giảm resolution mà không bịa pixel, mỗi source có CFR riêng, và concat không cần lossy encode lần hai.

H.264 tận dụng temporal redundancy, MP4 làm delivery đơn giản. Nhưng nếu compositing hoặc timing đã hỏng ở extraction, không codec nào cứu được.

Tài liệu primary chính