Kembali ke blog
13 Agustus 2026Sergei Solod12 mnt baca

Satu tik yang merusak CFR saya: mengapa 5580 bukan 5625 dan 3751 bukan 3750

Validator saya berulang kali menolak MP4 16 fps karena satu paket berdurasi 5580 tik, bukan 5625. Belakangan ia menemukan 3751 tik ketika 24 fps seharusnya tepat 3750. Dua kegagalan ini memaksa saya memisahkan penentuan waktu sumber, kuantisasi CFR, basis waktu MP4, PTS/DTS, multiplexing, dan validasi pada tingkat paket.

FFmpegH.264CFRStempel waktu videoPTS dan DTSValidasi MP4

Suatu hari alur pemrosesan media saya berhenti menerbitkan sebagian keluaran karena kesalahan yang sekilas terlihat hampir konyol:

Invalid CFR packet duration: 5580 ticks, expected 5625

Dekoder tidak gagal. FFmpeg berhasil membuat berkas H.264. Validator saya yang menolaknya setelah pengodean, karena berkas tersebut seharusnya memiliki laju bingkai konstan, tetapi satu paket tidak berada pada kisi waktu yang sudah saya tetapkan.

Saya menjalankan ulang proses. Hasilnya tetap 5580. Saya ulang sekali lagi, tetap 5580. Kemudian sumber lain gagal dengan angka yang sama. Itu bukti penting: ini bukan gangguan jaringan sementara atau kondisi balapan yang langka. Pelanggarannya deterministik.

Belakangan saya menemukan kegagalan kedua yang tampak mirip, tetapi secara teknis berbeda. Pada 24 fps dengan trek video 90.000 tick per detik, setiap sampel normal seharusnya berdurasi tepat 3750 tick. Validator menemukan 3751.

Kedua angka itu tidak layak dijelaskan dengan kalimat kabur seperti “FFmpeg melakukan pembulatan”. Selisih 5580 dan 5625 adalah 45 tick, tepat 0,5 ms. Selisih 3751 dan 3750 hanya satu tick, sekitar 11,1 mikrodetik.

Dua bug ini memaksa saya memisahkan empat hal yang sebelumnya terlalu mudah saya campur di bawah istilah “FPS”: laju bingkai, basis waktu, PTS/DTS, dan durasi paket.

CFR bukan sekadar label “16 fps”

Sekarang ketika saya menyebut berkas yang dihasilkan sebagai CFR, maksudnya bukan hanya ffprobe menampilkan 16/1 atau 24/1.

Dalam alur pemrosesan ini CFR adalah aturan yang lebih ketat: waktu presentasi berada di kisi reguler, dan durasi setiap sampel video normal adalah satu langkah pada kisi tersebut.

Pada 16 fps, satu bingkai berlangsung:

1 / 16 = 0.0625 s = 62.5 ms

Dengan trek 90.000 tick per detik, interval yang sama menjadi bilangan bulat yang tepat:

90000 / 16 = 5625 ticks

Jadi 5625 bukan konstanta sembarang pada validator. Nilai itu langsung berasal dari dua pilihan dalam kontrak keluaran: 16 fps dan 90,000 ticks/s.

Syarat tepat seperti ini hanya masuk akal karena saya sengaja memilih laju yang membagi skala waktu secara tepat. Jika irama target tidak bisa direpresentasikan oleh satu bilangan bulat durasi, validator yang benar harus memodelkan pola bilangan bulat yang diizinkan, bukan menuntut satu nilai yang mustahil.

Laju bingkai, basis waktu, dan skala waktu MP4 adalah hal berbeda

  • Laju bingkai menjelaskan irama presentasi. CFR 16 berarti satu bingkai tampil setiap 62,5 ms.
  • Basis waktu FFmpeg adalah durasi satu unit stempel waktu bilangan bulat, misalnya 1/90000 detik.
  • Skala waktu trek MP4 menyatakan jumlah unit waktu per detik. Dengan skala waktu 90.000, satu unit berdurasi 1/90000 detik.
  • PTS menyatakan kapan gambar dipresentasikan.
  • DTS menyatakan kapan paket terkompresi harus didekode.
  • Durasi paket menyatakan durasi sampel dalam basis waktu aliran.

Dengan B-bingkai, PTS dan DTS boleh berbeda secara sah. Karena itu “memperbaiki stempel waktu” dengan sekadar PTS = DTS berbahaya; dokumentasi resmi setts juga menyebut cara itu tidak direkomendasikan ketika B-bingkai terlibat.

Validator saya memeriksa hubungan yang sah antara urutan dekode dan presentasi, bukan memaksa kedua stempel waktu sama.

Mengapa saya memilih trek 90.000 tick

90.000 bukan angka ajaib universal. Angka ini cocok untuk kebutuhan saya karena semua laju bingkai yang diizinkan alur pemrosesan menghasilkan durasi bilangan bulat yang tepat:

LajuDurasi bingkai pada 90.000 tick/s
10 fps9000 ticks
12 fps7500 ticks
15 fps6000 ticks
16 fps5625 ticks
18 fps5000 ticks
20 fps4500 ticks
24 fps3750 ticks
25 fps3600 ticks
30 fps3000 ticks

Hubungannya dengan waktu sumber dalam milidetik juga praktis:

1 ms = 90 ticks

FFmpeg menyediakan video_track_timescale pada muxer MP4, sehingga saya bisa meminta kisi waktu trek tersebut secara eksplisit. Kisi itu tidak membuat waktu menjadi benar dengan sendirinya; ia hanya membuat kontrak dapat diukur.

5580 memberi petunjuk tempat mencari masalah

Kegagalan pertama menjadi lebih menarik ketika dikonversi kembali ke waktu:

5580 / 90000 = 0.062 s = 62 ms

Nilainya tidak acak. Saya mempunyai sumber animasi nyata dengan 49 bingkai yang ditampilkan selama 3,063 detik, dan jeda antar-bingkai bergantian 62 dan 63 ms:

49 / 3.063 ≈ 15.997 frames/s

62 ms + 63 ms = 125 ms
2 frames at 16 fps = 2 × 62.5 ms = 125 ms

Di tingkat sumber, pola 62/63 ms adalah pendekatan yang sangat masuk akal untuk 16 fps pada kisi milidetik.

Namun setelah CFR 16 dipilih, keluaran harus hidup di kisi target: 62,5 ms atau 5625 tick untuk setiap bingkai normal.

Karena itu 5580 menjadi petunjuk kuat bahwa durasi sumber 62 ms masih lolos ke tahap yang seharusnya sudah berisi waktu CFR yang terkuantisasi.

Saya tetap menjaga batas bukti. Log membuktikan 5580 pada 90 kHz adalah tepat 62 ms, dan sumber nyata memang memiliki jeda 62/63 ms. Log itu sendiri tidak membuktikan fungsi mana yang pertama membiarkan durasi tersebut lolos. Ini inferensi mekanisme yang kuat, bukan alasan untuk mengarang baris kode penyebabnya.

Jam masukan 1000 Hz bukan kesalahannya

Bagian arsitektur ini saya reproduksi terpisah menggunakan FFmpeg 7.1.5 dan ffconcat. Dengan:

duration 0.010
option framerate 1000

stempel waktu paket mempertahankan posisi milidetik yang dimaksud:

0 ms
10 ms
20 ms
30 ms

Memakai masukan laju 30 pada tahap awal yang sama justru mengkuantisasi waktu kira-kira menjadi 0 dan 33,3 ms.

Jadi jam masukan 1000 Hz menjalankan tugas yang benar: mempertahankan jeda sumber sebagai acuan dengan presisi 1 ms. Itu tidak berarti video keluaran akhirnya 1000 fps.

millisecond-accurate source delays
        ↓
authoritative source timeline
        ↓
choose target CFR
        ↓
explicitly quantize onto CFR grid
        ↓
preserve that grid through encoding and muxing

Presisi tinggi pada masukan bukan bug. Masalahnya adalah transisi dari waktu sumber ke CFR yang tidak dibuat eksplisit.

CFR adalah kuantisasi waktu yang disengaja

Sumber 62/63 ms secara alami cocok dengan 16 fps jika dilihat per pasangan bingkai: keduanya mencakup 125 ms. Tetapi berkas akhir tidak boleh mempertahankan durasi 62,63,62,63 ms lalu hanya mengiklankan “16 fps”. Itu tetap durasi bervariasi.

62 ms, 63 ms, 62 ms, 63 ms
              ↓
62.5 ms, 62.5 ms, 62.5 ms, 62.5 ms

Filter fps FFmpeg adalah tempat yang wajar untuk melakukan konversi. Ia membangun laju target dengan menjatuhkan atau mengulang bingkai sesuai stempel waktu masukan dan kebijakan pembulatan yang dipilih.

Setelah kisi yang diinginkan terbentuk, saya tidak ingin tahap berikutnya melakukan konversi laju bingkai lain secara independen. Alur pemrosesan sebaiknya memiliki satu batas kuantisasi yang disengaja, bukan beberapa lapisan yang masing-masing mengambil keputusan pembulatan sendiri.

Mengapa tiga percobaan ulang tidak mengubah hasil

FAIL: duration 5580, expected 5625
retry 1/3
FAIL: duration 5580, expected 5625
retry 2/3
FAIL: duration 5580, expected 5625

Ini pelajaran tentang keandalan yang berlaku di luar video. Percobaan ulang cocok untuk kegagalan yang mungkin hilang pada percobaan berikutnya: kesalahan jaringan, penyimpanan yang sementara tidak tersedia, tekanan sumber daya, atau dependensi yang sedang tidak tersedia.

Percobaan ulang tidak memperbaiki pelanggaran kontrak deterministik yang dihasilkan oleh masukan dan algoritma yang sama.

  • Kegagalan sementara — percobaan ulang mungkin membantu;
  • Masukan rusak — arahkan atau tolak;
  • Kegagalan invarian deterministik — hentikan percobaan ulang dan diagnosis alur pemrosesan.

5580 yang berulang masuk kategori ketiga.

Lalu saya menemukan 3751, bukan 3750

Pada 24 fps nilai yang diharapkan tepat:

90000 / 24 = 3750 ticks

Tetapi satu keluaran hasil concat nyata memiliki paket berdurasi:

3751 ticks

Perbedaannya hanya:

1 / 90000 s ≈ 11.111 µs

Tidak ada penonton yang akan menyadari satu tick. Karena itu sangat menggoda untuk melonggarkan validator menjadi ±1.

Saya tidak melakukannya. Pada 24 fps di kisi ini, 3750 dapat direpresentasikan dengan tepat. Nilai 3751 bukan kompromi representasi yang tak terhindarkan; itu adalah bukti bahwa invarian kisi eksak hilang di suatu tahap.

Stream copy tidak berarti stempel waktu tetap tak berubah

Alur pemrosesan menggabungkan segmen H.264 yang kompatibel tanpa pengodean ulang lossy kedua:

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy \
  final.mp4

-c:v copy berarti muatan H.264 terkompresi tidak didekode lalu dienkode kembali. Itu tidak berarti lapisan muxing berhenti mengelola waktu.

Dokumentasi concat menyatakan bahwa durasi setiap berkas digunakan untuk menyesuaikan stempel waktu berkas berikutnya. FFmpeg juga menskalakan ulang stempel waktu bilangan bulat di antara basis waktu rasional, dan libavutil menyediakan fungsi penskalaan dengan mode pembulatan eksplisit.

Ini tidak membuktikan concat adalah satu-satunya penyebab kasus 3751 saya. Namun model “aliran bit disalin, jadi stempel waktu paket mustahil berubah” jelas salah.

Identitas gambar terkompresi dan identitas kisi stempel waktu adalah dua sifat yang berbeda.

PTS dan DTS tidak bisa diperbaiki dengan satu persamaan cantik

PTS = N * frame_duration
DTS = N * frame_duration

Pada H.264 dengan B-bingkai, ini bisa salah karena urutan dekode dapat berbeda dari urutan presentasi.

Sasaran yang benar lebih sempit: gunakan linimasa segmen yang menjadi acuan untuk memulihkan kisi presentasi dan durasi paket yang diketahui, sambil mempertahankan hubungan urutan dekode yang sah.

Itulah sebabnya saya tidak menerbitkan ekspresi setts “ajaib” tanpa konteks. Ekspresi yang benar bergantung pada batas segmen, laju yang dipilih, dan metadata waktu yang membentuk linimasa akhir.

Mengapa saya memakai setts

Filter bitstream setts milik FFmpeg dapat mengubah PTS, DTS, durasi, dan basis waktu keluaran paket tanpa mendekode dan mengenkode ulang video.

Ini adalah lapisan yang tepat untuk normalisasi setelah concat: biarkan muatan H.264 terkompresi tetap sama, lalu tegakkan kembali linimasa paket yang diturunkan dari metadata segmen yang diketahui.

known segment timeline
+ known CFR
+ 90000-tick grid
        ↓
known valid packet positions and durations
        ↓
normalize packet timing
        ↓
validate again

Ini sangat berbeda dari “kalau durasi 3751, kurangi satu”. Perbaikan harus berasal dari model waktu, bukan dari pesan kesalahan hari ini.

Mengapa saya menolak toleransi ±1 tick

Toleransi kecil benar di banyak sistem. Jika irama tidak bisa direpresentasikan persis pada basis waktu bilangan bulat tertentu, validator harus memodelkan pola pembulatan yang memang diperlukan.

Kontrak saya berbeda. Saya sengaja membatasi laju yang diizinkan sehingga 90000 / fps selalu menghasilkan bilangan bulat:

16 fps → 5625
24 fps → 3750
30 fps → 3000

Ketika durasi yang diharapkan dapat direpresentasikan dengan tepat, toleransi umum ±1 mengubah pelanggaran invarian yang belum dijelaskan menjadi keadaan yang diterima.

Satu tick tidak relevan secara visual. Namun hilangnya kontrak tanpa penjelasan tetap penting.

  • Jika kisi memang perlu pola durasi bilangan bulat bergantian, validasi pola itu;
  • jika durasi harus satu bilangan bulat yang tepat, wajibkan bilangan bulat tersebut;
  • jangan gunakan ±1 sebagai cara universal membuat validator hijau.

Bagaimana saya memvalidasi CFR di tingkat paket

avg_frame_rate dan metadata aliran serupa adalah ringkasan berguna, tetapi tidak cukup untuk aturan ini.

ffprobe -v error \
  -select_streams v:0 \
  -show_streams \
  -show_packets \
  -of json \
  final.mp4

Saya memeriksa basis waktu aliran yang sebenarnya serta pts, dts, dan duration untuk setiap paket.

expected = 90000 / 16   // 5625

for each normal video packet:
    assert packet.duration == 5625

assert decode ordering is legal
assert PTS/DTS relationship is legal for the codec
assert presentation timeline matches planned frame count and duration

Kata “normal” penting. Edit list, pemangkasan, sampel terakhir yang sengaja dibuat khusus, atau perilaku kontainer lain mungkin perlu dimodelkan secara eksplisit. Ini bukan hukum universal untuk MP4.

Namun untuk generator yang strukturnya saya kendalikan, validasi ketat jauh lebih berguna daripada sekadar “kurang lebih 16 fps”.

Validasi metadata dan dekode penuh menjawab pertanyaan berbeda

Stempel waktu yang sempurna tidak membuktikan seluruh aliran H.264 dapat didekode. Sebaliknya, sebuah berkas dapat didekode dengan bersih tetapi tetap melanggar kontrak waktunya.

ffprobe / packet validation
→ structure, timestamps, durations, stream parameters

full decode
→ whether the complete compressed stream can actually be decoded

Setelah pemeriksaan paket, saya tetap menjalankan:

ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

Berkas baru layak terbit setelah dua tahap lolos. Kode keluar nol dari encoder bukan lagi definisi saya untuk “selesai”.

Apa yang benar-benar dibuktikan dua kegagalan itu

Untuk 5580, saya bisa memastikan nilai deterministik yang sama muncul pada percobaan ulang; 5580 pada 90 kHz tepat 62 ms; waktu sumber nyata memiliki jeda 62/63 ms; CFR 16 memerlukan 62,5 ms atau 5625 ticks; dan eksperimen masukan 1000 Hz mempertahankan jeda milidetik dengan benar.

Itu membuat kebocoran durasi sumber ke tahap yang seharusnya sudah CFR menjadi penjelasan yang kuat. Baris kesalahan yang diarsipkan tidak membuktikan fungsi persis yang menyebabkannya.

Untuk 3751, saya dapat memastikan bahwa keluaran nyata memiliki paket 3751 tick ketika 24 fps memerlukan 3750; alur pemrosesan menggunakan stream-copy concat; normalisasi paket setelah concat dengan setts menjadi bagian dari solusi; dan FFmpeg mendokumentasikan baik penyesuaian stempel waktu oleh concat maupun penulisan ulang pada tingkat paket.

Fakta tersebut konsisten dengan penskalaan ulang bilangan bulat atau pembulatan pada batas mux/concat, tetapi tidak membenarkan klaim universal “concat selalu menambahkan satu tick”.

Alur kerja yang saya gunakan sekarang

  1. Pulihkan linimasa sumber yang menjadi acuan, jangan percaya satu kolom FPS hasil tebakan.
  2. Pertahankan jeda sumber dalam milidetik pada jam masukan yang cukup presisi.
  3. Pilih CFR target secara terpisah.
  4. Kuantisasi linimasa sumber secara eksplisit ke kisi CFR tersebut.
  5. Jika memungkinkan, gunakan skala waktu trek yang merepresentasikan laju yang diizinkan secara tepat.
  6. Enkode tanpa meminta tahap berikutnya melakukan konversi laju bingkai lagi.
  7. Validasi basis waktu, jumlah bingkai, PTS/DTS, dan durasi paket sebelum concat.
  8. Validasi konfigurasi codec dan kompatibilitas waktu sebelum concat dengan stream copy.
  9. Periksa kembali kisi paket setelah concat; jangan anggap -c:v copy sebagai bukti bahwa stempel waktu tidak berubah.
  10. Jika normalisasi diperlukan, turunkan dari linimasa yang diketahui dan terapkan pada tingkat paket.
  11. Validasi kembali seluruh paket.
  12. Dekode seluruh berkas akhir.
  13. Publikasikan secara atomik hanya setelah kontrak lolos.

Aturan yang saya pegang: CFR adalah kontrak waktu bilangan bulat

Dulu 16 fps hampir terasa cukup jelas dengan sendirinya. Sekarang tidak.

time base = 1/90000
normal frame duration = 5625 ticks
presentation cadence = 62.5 ms
packet timeline follows that grid
PTS/DTS remain legal for H.264 reordering

5580 penting karena membocorkan kisi milidetik lama. 3751 penting justru karena hampir tak terlihat: satu tick cukup untuk membuktikan sistem berhenti mempertahankan invarian yang sengaja dirancang.

Aturan saya sekarang sederhana: jangan validasi label “CFR”. Validasi waktu yang seharusnya membuat label itu benar.

Dokumentasi utama