Untuk satu berkas WebM yang sama, ffprobe melaporkan dua angka yang tidak mungkin sama-sama benar. Yang pertama r_frame_rate=36/1. Yang kedua durasi 5,047 detik. Berkas itu berisi 242 frame, dan pada 36 frame per detik, 242 frame butuh 242 dibagi 36, sekitar 6,72 detik. Angka 6,72 itu saya kenal, karena itu durasi versi sebelumnya, sebelum loop ini saya percepat untuk kedua kalinya.
Kalau yang dipercaya r_frame_rate, kesimpulannya Chrome, yang mengambil WebM, memutar loop ini lebih lambat daripada Safari. Kesimpulan itu keliru, karena MP4 dan WebM-nya membawa timestamp yang sama. Nilai 36 tadi header basi, bukan tempo asli, dan yang menentukan pemutaran adalah timestamp tiap paket.
Kiriman HEVC yang cuma jalan di Safari dan judder akibat menurunkan frame rate punya ceritanya sendiri di blog ini. Yang saya bahas di sini berbeda, yaitu mempercepat loop yang sudah terpasang, dua kali dalam satu hari dengan dua cara berbeda, dan satu kolom ffprobe yang tertinggal sesudah cara yang kedua.
Tempo yang jadi patokan klien
Loop ini video produk pendek di sebuah section tema Shopify. Versi keempatnya, v4, dibuat dari berkas yang dikirim klien sendiri, dan berkas itu bukan render bersih dari animator: HEVC dengan audio AAC, 608x960, 241 frame pada 24fps, durasi 10,04 detik. Dari situ saya encode dua keluaran tanpa audio, MP4 H.264 Main level 4.0 di CRF 18 dengan +faststart seberat 2,14 MB, dan WebM VP9 di CRF 28 seberat 1,47 MB. Nama berkasnya sama dengan aset sebelumnya, jadi template section-nya tidak disentuh sama sekali. v4 masuk produksi pada 29 Agustus 2026.
Klien lalu meminta loopnya "dipercepat sedikit". Akar permintaan itu bisa diukur. Aset lama yang dilihat klien sejak Juni berdurasi 6,00 detik, sedangkan v4 berdurasi 10,04 detik. Kalau 10,04 dibagi 6,00, hasilnya sekitar 1,67, jadi satu putaran v4 makan waktu 67% lebih lama daripada tempo yang selama ini jadi patokan klien.
Ronde pertama: fps dinaikkan, frame tidak dibuang
Untuk v5 saya memilih percepatan 1,5x, dan caranya menaikkan frame rate, bukan membuang frame. Perintahnya saya tampilkan dalam versi yang disederhanakan, hanya bagian yang mengatur tempo:
# disederhanakan: flag encode lainnya tidak ditampilkan
ffmpeg -i <in> -vf "setpts=PTS/1.5" -r 36 <flag-encode> <out>Filter setpts=PTS/1.5 membagi setiap timestamp dengan 1,5, sehingga frame yang sama selesai diputar lebih cepat. Angka 36 di -r adalah 24 dikali 1,5, frame rate keluaran yang cukup rapat untuk menampung semua frame dalam waktu yang lebih pendek. Hasilnya, semua 241 frame sumber bertahan, menjadi 242 karena penyesuaian ke frame rate konstan, durasinya 6,72 detik, dan geraknya justru lebih halus, bukan tersendat.
Alternatif yang saya tolak adalah setpts yang sama tanpa menaikkan fps. Dengan keluaran tetap 24fps, cara itu mendesimasi frame. Hitungannya begini. Durasi 10,04 detik dibagi 1,5 sekitar 6,69 detik, dan 6,69 detik pada 24fps hanya muat sekitar 161 frame. Dari 241 frame sumber, kira-kira 80, atau sepertiganya, harus dibuang.
v5 di-encode ulang dari berkas HEVC arsip, bukan dari keluaran v4, supaya tidak ada generasi kompresi kedua. Commit-nya tercatat pukul 08.16 pagi, lalu di-push ke repo dan ke tema live dengan --only untuk dua berkas aset itu saja. Pemeriksaan produksi sesudahnya mendapati checksum MP4 dan WebM identik dengan salinan lokal, 36fps, 6,72 detik, tanpa stream audio.
Sumber HEVC yang hilang di antara dua ronde
Saat permintaan v5 datang, berkas kiriman klien di folder Downloads sudah hilang dan scratchpad sesi sebelumnya sudah dibersihkan. Satu-satunya salinan sumber yang tersisa ada di assets/backupassets/. Aturan yang saya tulis hari itu sederhana: setiap berkas mentah dari klien diarsipkan ke folder itu begitu diterima, dan Downloads atau scratchpad tidak boleh diandalkan.
Sebelum ronde berikutnya, salinan itu pun lenyap. Berkas HEVC 608x960 seberat 7,9 MB di folder arsip sudah tidak ada, dan pencarian di Downloads, Trash, serta seluruh home tidak menemukannya. Catatan saya hanya menduga berkasnya terhapus manual. Yang pasti, berkas itu tidak bisa dipulihkan dari git, karena folder arsipnya memang dikecualikan di .gitignore tema:
## Local scratch / backups (not part of deployed theme)
backup/
assets/backupassets/Dampaknya terbagi dua. Perubahan yang cuma retiming masih aman, karena bisa dikerjakan dari aset yang sudah terkirim tanpa kehilangan apa pun. Apa pun yang butuh encode ulang dari piksel asli, misalnya mengganti crop, skala, chroma-key, atau menaikkan resolusi, sudah tidak mungkin tanpa meminta ulang berkasnya ke klien. Tindakan yang saya catat adalah meminta klien mengirim ulang berkas itu lalu mengarsipkannya, sambil menimbang untuk menyimpan master klien di luar backupassets/ yang ter-gitignore, atau setidaknya di tempat yang tidak gampang tersapu.
Ronde kedua: timestamp digeser, frame tidak disentuh
Sesudah v5, klien meminta loopnya dipercepat lagi. Pesan commit v6 mencatatnya sebagai permintaan kecepatan kedua, karena menurut klien 6,72 detik masih terasa lambat. Saya memilih 5,05 detik, yaitu dua kali tempo v4 dan sedikit lebih gesit daripada aset lama yang 6,00 detik.
Karena sumber aslinya sudah tidak ada, v6 dibangun dari aset v5 yang sudah terkirim, dan kali ini tidak ada encode sama sekali:
ffmpeg -itsscale 0.75 -i <in> -c copy <out>-itsscale adalah opsi input yang mengalikan timestamp masukan dengan faktor yang diberikan, dan -c copy menyalin paket apa adanya tanpa decode dan encode. Faktor 0,75 memampatkan setiap timestamp jadi tiga perempat nilainya, jadi loop 36fps dari v5 sekarang berjalan di 36 dibagi 0,75, yaitu 48fps.
Karena frame yang sudah terenkode dipakai apa adanya, tidak ada generasi kompresi tambahan, dan latar yang rata di satu nilai 250 (#FAFAFA), yang sudah saya periksa sejak v5 di MP4 maupun WebM, dijamin tidak bisa bergeser. Saya membuktikannya dengan hash: frame 0 dan frame 120 identik byte dengan aset sebelumnya. Ukuran berkasnya pun nyaris diam. Menurut git show --stat, MP4 bergeser dari 1.804.768 ke 1.804.776 byte, bertambah 8 byte, dan WebM dari 1.444.065 ke 1.444.054 byte, berkurang 11 byte.
v6 di-commit pukul 17.51 di hari yang sama, lalu di-push ke repo dan ke tema live. Aturan yang saya tetapkan dari ronde ini: untuk perubahan kecepatan murni, selalu pakai cara ini, jangan encode ulang.
Header yang masih mengiklankan 36fps
Jebakannya muncul tepat sesudah retiming, dan nyaris kelewat. ffprobe melaporkan r_frame_rate=36/1 untuk WebM v6, padahal durasinya sudah 5,047 detik. Itu dua angka dari pembuka tulisan ini, dan asalnya sekarang jelas. Angka 36 adalah tempo v5. -itsscale menggeser timestamp setiap paket, tapi header WebM masih mengiklankan 36fps dari sebelum retiming.
Di halaman ini, angka basi itu menyesatkan ke arah yang spesifik. Pemeriksaan produksi waktu v4 mendapati homepage merujuk aset tema dan tidak memuat MP4 dari CDN Shopify, yang saya artikan sebagai setelan video section itu kosong, sehingga cabang template untuk aset bawaan yang berjalan. Di cabang itu, sumber WebM didaftarkan sebelum MP4 di dalam elemen video. Chrome mengambil WebM, jadi siapa pun yang hanya membaca r_frame_rate akan menyimpulkan bahwa Chrome memutar loop ini lebih lambat daripada Safari.
Tempo yang sebenarnya ada di timestamp paket. Saya membacanya dengan -show_entries packet=pts_time, dan hasilnya sama untuk kedua berkas: MP4 dan WebM sama-sama berisi 242 paket yang berakhir di 5,02 detik, efektif 48fps, cocok dengan hitungan 36 dibagi 0,75 tadi. Bentuk perintahnya kira-kira seperti ini, dalam versi ringkas dengan nama berkas netral:
# angka di header, yang di kasus ini basi
ffprobe -v error -select_streams v:0 \
-show_entries stream=r_frame_rate -of csv=p=0 loop.webm
# timestamp paket: jumlahnya, lalu yang terbesar
ffprobe -v error -select_streams v:0 \
-show_entries packet=pts_time -of csv=p=0 loop.webm | wc -l
ffprobe -v error -select_streams v:0 \
-show_entries packet=pts_time -of csv=p=0 loop.webm | sort -n | tail -n 1sort -n di baris terakhir ada alasannya. ffprobe mencetak paket menurut urutan penyimpanannya di berkas, yaitu urutan decode. Pada H.264 yang memakai B-frame, urutan decode tidak sama dengan urutan tampil, jadi baris terakhir belum tentu timestamp yang terbesar.
Pesan commit v6 merangkum semuanya lebih ringkas daripada saya:
- both containers verified to carry the same timing: 242 packets with the
last at 5.02s, so mp4 and webm play at the same speed. The webm header
still advertises 36fps but that value is stale, the packet timestamps
are what browsers followHeader basi itu sendiri tidak saya ubah. Jalan keluar dari jebakan ini ada di cara memeriksa, yaitu membaca pts_time dan bukan r_frame_rate. Pemeriksaan produksi untuk v6 juga sepenuhnya berbasis berkas: checksum MP4 dan WebM identik dengan lokal, keduanya 242 frame yang berakhir di 5,02 detik, latar frame 0 rata di 250, dan atribut poster masih terpasang. Saya tidak mencatat pengamatan pemutaran berdampingan di Chrome dan Safari, jadi pernyataan bahwa keduanya berputar sama cepat bersandar pada timestamp paket yang identik, bukan pada pengamatan langsung.
Kalau kamu mengubah tempo dengan -itsscale dan -c copy, anggap r_frame_rate di WebM hasilnya sebagai sisa dari berkas sebelumnya sampai terbukti sebaliknya. Bukti bahwa frame tidak berubah datang dari hash, dan bukti tempo datang dari pts_time kedua berkas.