Saya tidak membuat pipeline ini karena ingin mencoba codec baru. Masalahnya lebih praktis: Animated WebP, GIF, dan APNG sering kali sebenarnya berfungsi sebagai video pendek tanpa audio, tetapi belum tentu menjadi representation paling hemat ketika asset yang sama dikirim berulang kali lewat web.
Workload saya sebagian besar berupa animasi beberapa detik dengan puluhan displayed frame dan temporal redundancy yang tinggi. Encoding dilakukan sekali; delivery terjadi berkali-kali. Karena itu ukuran file akhir dan playback yang membosankan di browser lebih penting daripada kecepatan encode.
“Ubah animasi menjadi MP4” terdengar mudah. Versi production tidak sesederhana itu. Animated image tidak selalu berupa sequence full-size frame dengan satu FPS yang rapi. Ia bisa menyimpan partial rectangle, blend/disposal rule, alpha, delay yang tidak teratur, frame berdurasi nol, dan timing metadata yang diringkas secara menyesatkan oleh tool umum.
Pipeline saya akhirnya menjadi rangkaian invariant berikut:
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 hanya satu bagian. Pekerjaan yang sulit adalah mempertahankan arti animasi saat representation di bawahnya diganti.
Aturan pertama: extract displayed canvas, bukan rectangle yang tersimpan
Shortcut paling berbahaya adalah menganggap setiap stored frame merupakan gambar penuh yang menggantikan frame sebelumnya.
Animated WebP memiliki position, rectangle size, blend mode, dan disposal mode. APNG memiliki offset, dimensions, dispose operation, dan blend operation. GIF memiliki Graphic Control Extension yang mengatur disposal behavior.
Artinya stored frame bisa hanya berupa patch kecil yang bergantung pada canvas sebelumnya. Jika patch itu di-encode sebagai full frame, hasilnya bukan animasi yang lebih kecil, tetapi animasi yang salah.
Batas extraction saya adalah displayed canvas state: gambar composited penuh yang benar-benar akan ditampilkan decoder setelah disposal dan blending diterapkan.
Jika pixel sudah salah di sini, setting H.264 apa pun tidak bisa memperbaikinya kemudian.
Timing frame adalah source data, bukan FPS hasil tebakan
- Animated WebP menyimpan duration per frame dalam unit 1 ms.
- GIF menyimpan delay dalam seperseratus detik.
- APNG memakai delay numerator/denominator; denominator 0 diperlakukan sebagai 100 oleh spesifikasi PNG.
Delay per frame inilah timeline sebenarnya. Average FPS hanyalah summary.
Salah satu WebP nyata berukuran 1264×720 dengan 49 displayed frame. Delay-nya bergantian 62 dan 63 ms, total duration 3.063 s. Itu pada dasarnya cadence 16 fps karena satu frame pada 16 fps adalah 62.5 ms.
Sebuah probe melaporkan 25 fps untuk source yang sama. Jika saya mempercayainya lalu encode 25 atau 30 fps, timing asli bisa berubah atau duplicate frame yang tidak perlu akan muncul.
Saya normalize timing yang buruk sebelum memilih CFR
Zero delay atau delay yang sangat kecil dapat diperlakukan berbeda oleh implementation. Karena itu production pipeline perlu policy eksplisit. Saya menjaga timeline dalam millisecond precision, memakai minimum frame delay 10 ms, dan 100 ms hanya sebagai fallback ketika timing yang berguna tidak tersedia.
Angka itu bukan hukum universal. Prinsipnya: timing valid dipertahankan, normalization dilakukan sebelum frame-rate quantization.
Mengapa saya tidak memaksa semuanya menjadi 30 fps
Animation yang berubah sekitar 12 atau 16 kali per detik tidak mendapat motion baru hanya karena disimpan 30 fps. H.264 memang dapat mengompresi duplicate frame, tetapi data temporal yang tidak perlu tetaplah data yang sebaiknya tidak dibuat.
Untuk setiap source saya memilih CFR dari:
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
Selector memilih candidate terendah yang masih merepresentasikan source timeline dengan bersih. Jika candidate lebih rendah tidak cukup, fallback-nya 30 fps. Untuk delay 62/63 ms, 16 fps adalah fit alami.
Final MP4 tidak harus punya satu global CFR
Setiap animation di-encode sebagai segment independen. Satu bisa 12 fps, satu 16, yang lain 24 atau 30. Saya tidak merusak cadence source hanya agar seluruh collection memiliki angka yang sama.
Setiap segment CFR secara lokal, tetapi packet duration pada final timeline bisa berubah di boundary antar-segment. Itu disengaja.
Mengapa saya memakai time base 90,000 Hz
| FPS | ticks/frame |
|---|---|
| 10 | 9000 |
| 12 | 7500 |
| 15 | 6000 |
| 16 | 5625 |
| 18 | 5000 |
| 20 | 4500 |
| 24 | 3750 |
| 25 | 3600 |
| 30 | 3000 |
Semua FPS yang diizinkan membagi 90,000 menjadi integer ticks/frame. Ini memberi grid yang bersih untuk concat dan validation.
Geometry: downscale bila perlu, jangan pernah upscale
- landscape: maksimum 1280×720
- portrait: maksimum 720×1280
- mixed/square: sekitar 960×960
Source yang lebih kecil tetap kecil. Mengubah 900×600 menjadi 1280×720 tidak menciptakan detail; hanya menambah pixel untuk encoder.
Saya juga tidak stretch aspect ratio. Saya memakai proportional scaling dan padding. Alpha di-flatten ke background yang jelas karena output H.264/yuv420p ini tidak membawa transparency.
Mengapa H.264 MP4 sering jauh lebih kecil untuk workload ini
GIF, APNG, dan Animated WebP sendiri punya optimasi seperti partial updates dan disposal, jadi saya tidak mengklaim MP4 selalu lebih kecil.
Namun H.264 memang dirancang untuk temporal video compression. Pada illustrated scene dengan background yang banyak tetap, inter prediction, reference pictures, P-frames, dan B-frames sangat cocok memanfaatkan kemiripan antargambar.
Apple merekomendasikan H.264 MP4 untuk static web video dan menyarankan MP4 sebagai pengganti animated GIF. Dokumentasi Safari menyebut GIF dapat menggunakan hingga 12× bandwidth dan sekitar 2× energy dibanding modern video codec. Angka 12× itu contoh Apple, bukan benchmark saya.
Animated WebP perlu lebih hati-hati. Jika source WebP sudah lossy, H.264 menjadi lossy generation kedua. Ukuran bisa tetap turun besar, tetapi quality harus divalidasi.
Profile H.264 saya sengaja konservatif
| Setting | Value |
|---|---|
| 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 | sekitar 5 s |
| VBV | 4M maxrate / 8M bufsize |
| Color | BT.709, limited range |
| Track/movie timescale | 90,000 |
| Audio/subtitles/data | none |
Main@3.1 bukan batas kemampuan device modern. Itu conservative one-file baseline saya. Android mewajibkan Main Profile decoder sejak Android 6.0 dan memberi 1280×720@30 fps sebagai rekomendasi H.264 HD. Apple juga merekomendasikan H.264 MP4 untuk static web video.
CPU dibayar sekali, bandwidth dibayar terus
-preset veryslow
Encoding dilakukan offline sekali, sedangkan delivery berulang. Karena itu memberi x264 lebih banyak CPU untuk mencari representation yang efisien masuk akal. Decoder complexity dibatasi terpisah lewat profile, level, refs, dan B-frame structure.
CRF 28 adalah baseline untuk illustrated bandwidth-first workload saya, bukan universal recommendation.
Setiap animation di-encode menjadi segment independen
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
Sebelum command ini, timeline-aware frame sequence dan geometry sudah siap. GOP sekitar selectedFps * 5.
Concat aman karena segment contract ketat
FFmpeg concat demuxer mengharapkan streams yang compatible dan menggunakan duration setiap file untuk menempatkan timestamp file berikutnya. Karena itu mismatch ditolak sebelum concat, bukan diperbaiki secara spekulatif saat concat.
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy -an -sn -dn -movflags +faststart final.mp4
-c:v copy menghindari lossy H.264 encode kedua.
Stream copy tidak otomatis berarti timestamp sempurna
90000 / 24 = 3750 ticks
Pada satu output nyata, validator menemukan packet 3751 ticks padahal 24 fps seharusnya 3750. Secara visual file dapat terlihat normal, tetapi exact-grid invariant sudah rusak.
Karena itu final concat sekarang memakai setts bitstream filter untuk menormalisasi PTS, DTS, dan duration ke 90-kHz grid yang diketahui sambil mempertahankan -c:v copy. Expression tepatnya dihasilkan dari timeline segment, bukan formula universal.
Validation adalah bagian dari encoder
FFmpeg exit code 0 tidak cukup. Saya memeriksa streams, profile/level, pixel format, dimensions, time base, frame/packet counts, exact durations, PTS/DTS, dan final duration, lalu decode seluruh 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 -
Atomic publish baru dilakukan setelah semua check lolos.
Kesalahan yang dicegah pipeline ini
- Menganggap raw subframe sebagai visible canvas.
- Mempercayai guessed FPS.
- Memaksa semua source menjadi 30 fps.
- Upscale source kecil.
- Stretch aspect ratio.
- Lossy encode kedua setelah concat.
- Menganggap
-c copybukti timing benar. - Hanya memeriksa header tanpa full decode.
Yang sengaja tidak saya pertahankan
Alpha di-flatten. Arbitrary VFR didekati dengan source-specific CFR. Resolution besar diturunkan. Lossy WebP mendapat satu lossy generation tambahan. Audio di luar scope. Semuanya trade-off eksplisit.
Kapan saya tidak memakai pendekatan ini
Jika transparency wajib, exact irregular timing punya makna yang tidak boleh diaproksimasi, target adalah archival master, atau sistem sudah memiliki adaptive multi-codec video stack, saya tidak akan langsung memakai pipeline ini.
Untuk Animated WebP yang sudah sangat kecil, saya benchmark dulu; tidak menganggap MP4 pasti menang.
Pipeline praktis saya sekarang
- Baca animation metadata asli.
- Reconstruct visible canvas states dengan blend/disposal.
- Normalize delay dan buat millisecond timeline.
- Pilih CFR per source dari 10/12/15/16/18/20/24/25/30.
- No upscale; proportional resize/pad; alpha flatten.
- H.264 Main@3.1/yuv420p/avc1,
veryslow, CRF 28. - 90-kHz time base.
- Validate setiap segment.
- Concat dengan
-c:v copy. - Normalize final packet timeline ke exact grid.
- Packet inspection + full decode.
- Publish setelah semuanya lolos.
Pelajaran terbesar: asset sebenarnya adalah timeline
Animated WebP, GIF, dan APNG bukan sekadar “banyak gambar”. Mereka adalah sequence canvas state yang memiliki waktu.
Begitu timeline menjadi source of truth, desain lainnya menjadi jelas: cadence bisa diturunkan tanpa menciptakan motion, resolution bisa diturunkan tanpa menciptakan pixel, tiap source bisa punya CFR sendiri, dan concat tidak membutuhkan lossy encode kedua.
H.264 mengeksploitasi temporal redundancy dan MP4 mempermudah delivery. Tetapi jika compositing atau timing rusak sebelum encoder, codec tidak akan menyelamatkannya.