Perbaikan yang justru memperburuk angkanya jarang datang sebersih ini. Sumber loop yang saya pegang punya selisih rata-rata 1,79 RGB antara frame pertama dan frame terakhirnya. Setelah saya encode ulang dengan flag yang tujuannya persis membereskan loop, selisih itu naik jadi 3,01.
Asetnya datang dari animator sebagai WebM VP9 persegi 1080x1080, 24fps, masih membawa trek audio Opus, ukurannya sekitar 1,26 MB, dan permintaan kliennya waktu itu adalah menaikkan kualitas. Di ronde berikutnya klien melaporkan dua hal, dan yang saya kerjakan di tulisan ini adalah yang pertama: loopnya tidak halus.
Mengukur sambungannya sebelum menyentuh apa pun
Sebelum menebak, saya dekode frame pertama dan frame terakhir dari berkas sumber, lalu hitung selisih rata-rata RGB-nya dengan Python PIL. Stack yang saya pakai sepanjang urusan ini memang cuma ffmpeg, H.264, dan PIL.
from PIL import Image, ImageChops, ImageStat
first = Image.open("first.png").convert("RGB")
last = Image.open("last.png").convert("RGB")
diff = ImageChops.difference(first, last)
print(sum(ImageStat.Stat(diff).mean) / 3) # 1.79Angkanya 1,79. Untuk dua frame yang mestinya bersambung, itu praktis identik. Artinya berkas mentah dari animator bukan sumber masalahnya, sumbernya sudah seamless, dan yang patah adalah sesuatu yang terjadi setelah frame itu keluar dari sumber.
Dugaan saya waktu itu, dan ini tetap dugaan sampai akhir, hiccupnya berasal dari metadata codec dan tempat batas GOP jatuh, bukan dari isi framenya. Kalau frame batas loop kebetulan bukan keyframe, dekoder harus merangkai frame itu dari referensi sebelumnya tepat pada saat pemutaran melompat balik ke frame nol.
Ronde pertama: mengunci GOP ke panjang klip
Kalau tebakannya batas GOP, perbaikannya adalah memaksa batas loop selalu jatuh di keyframe. Klipnya 145 frame, jadi GOP-nya saya kunci di 145 juga, ditambah CFR yang dipaksa dan metadata yang dibuang.
-g 145 -keyint_min 145 -r 24 -map_metadata -1-g 145 dan -keyint_min 145 menyamakan panjang GOP dengan panjang klip dalam satuan frame, jadi batas loop mendarat di keyframe. -r 24 memaksa constant frame rate. -map_metadata -1 membuang metadata yang bisa membuat dekoder ragu. Angka 145 frame di 24fps itu setara 145 dibagi 24, sekitar enam detik.
Di ronde yang sama saya juga mengembalikan rasio tampilannya ke 3:4 dengan memotong sumber persegi:
crop=810:1080:135:0810 berbanding 1080 itu tepat 3:4, dan offset 135 datang dari 1080 dikurangi 810 lalu dibagi dua, jadi potongannya benar-benar di tengah.
Supaya kualitasnya tidak ikut turun setelah dipotong, hasil crop saya perbesar dulu sebelum encode. Rencana awalnya 1,5 kali, tapi 1,5 dikali 810 keluar 1215, dan angka ganjil itu ditolak karena x264 dengan yuv420p menuntut dimensi genap. Jadi saya pakai 2 kali, ke 1620x2160 lewat lanczos, dua-duanya genap. Pelajaran kecil yang sejak itu selalu saya cek duluan: pilih rasio skala yang keluarannya genap, jangan rasio yang kelihatan wajar tapi menghasilkan sisi ganjil.
Parameter encodenya sendiri tidak berubah dari ronde sebelumnya. H.264 Main di CRF 18 untuk MP4, VP9 di CRF 28 untuk WebM, -an untuk membuang audio, dan +faststart. Hasilnya MP4 2,9 MB dan WebM 1,8 MB.
Klien balik lagi, dan angkanya membenarkan dia
Setelah semua itu klien melapor lagi: loopnya masih tidak seamless, walaupun frame awal dan akhirnya identik, dan latarnya masih terlihat sebagai kotak.
Kali ini saya ukur berkas hasil encodenya, bukan sumbernya. Selisih rata-rata frame pertama lawan frame terakhir di berkas jadi 3,01, sementara baseline sumbernya 1,79. Observasi klien terbukti benar setelah diukur. Sambungan yang tadinya rapi memang jadi lebih renggang, dan yang membuatnya renggang adalah pekerjaan saya sendiri.
Selisih di atas baseline itu 3,01 dikurangi 1,79, jadi 1,22 RGB yang murni datang dari proses encode. Dugaan yang paling masuk akal buat saya: re-encode itu sendiri yang memasukkan variansi kuantisasi codec. Mengunci GOP memang menjawab pertanyaan soal keyframe, tapi tidak menjawab pertanyaan soal seberapa setia dua frame identik direproduksi di dua posisi berbeda dalam satu GOP.
Menambal sambungan sebelum encode, bukan sesudah
Kalau encoder yang menggeser nilainya, maka mustahil memperbaiki sambungan setelah encode. Yang bisa saya lakukan adalah menyerahkan bahan yang sambungannya sudah dijamin identik sejak sebelum encoder menyentuhnya.
Caranya dengan memecah stream di filter_complex, membuang frame terakhir sumber, lalu menempelkan frame pertama sumber sebagai frame terakhir yang baru.
[0:v]crop=...,split[m][f];
[m]trim=end_frame=144[main];
[f]trim=end_frame=1[first];
[main][first]concat=n=2:v=1[seam]Konsepnya lurus. Sumber punya 145 frame, trim=end_frame=144 menyisakan 144, lalu satu frame pertama disambungkan di ujung sehingga jumlahnya kembali 145. Bedanya, frame terakhir sekarang benar-benar salinan byte per byte dari frame pertama, bukan frame lain yang kebetulan mirip.
Setelah tambalan itu, selisih rata-rata pasca encode turun ke 2,01. Dihitung dari baseline sumber yang 1,79, kelebihannya tinggal 2,01 dikurangi 1,79, yaitu 0,22 RGB. Dari 1,22 ke 0,22, dan sisanya memang tidak hilang.
Kenapa angkanya tidak pernah nol
Dugaan saya soal sisa 0,22 itu begini: H.264 tetap kompresi lossy, jadi dia masih memasukkan variansi kecil walaupun frame masukannya identik, karena posisi GOP yang berbeda menghasilkan motion vector yang berbeda. Seam tingkat piksel masih ada, tapi di kebanyakan area dia duduk di bawah ambang yang terlihat mata.
Untuk benar-benar nol, setahu saya cuma ada dua jalan, dan dua-duanya saya tolak. Yang pertama lossless dengan -crf 0, dengan konsekuensi berkas sekitar sepuluh kali lebih besar, dan itu tidak masuk akal untuk aset yang dimuat di halaman produk. Yang kedua loop ping-pong, animasinya dibalik di paruh kedua sehingga sambungannya otomatis mulus, tapi hasil visualnya jadi gerakan yang berbeda dari yang dirancang animator.
Jadi kompromi yang saya ambil eksplisit: terima selisih rata-rata sekitar 2 RGB, dengan latar yang sudah rata. Bagian latar itu urusan terpisah yang jalan di ronde yang sama. Keluhan kotak ternyata bukan soal ukuran elemen, tapi gradasi halus di video hasil encode, sudut kiri bawah dan tengah bawah terukur 2 sampai 6 RGB lebih terang dari sisanya. Latarnya saya ratakan lewat chroma-key dengan similarity 0,02 lalu ditumpuk ke sumber warna solid, dan hasilnya saya verifikasi persis di beberapa titik setelah encode.
Berkas akhir ronde itu jadi MP4 2,08 MB, WebM 1,4 MB, dan poster 66 KB. MP4-nya mengecil dari 2,9 MB, selisih 0,82 MB, karena latar yang benar-benar rata lebih gampang dikompresi daripada latar bergradasi. Tambalan sambungan lewat split dan concat itu tetap saya pertahankan di ronde berikutnya.
Angka yang saya bandingkan ke hal yang salah
Ada satu ekor cerita yang menurut saya lebih berguna daripada seluruh urusan GOP di atas. Beberapa bulan kemudian, di penggantian aset berikutnya, saya mengukur sambungan loop dengan PSNR, bukan dengan diff PIL. Hasilnya 28,94 dB di sambungan frame terakhir ke frame pertama, sementara antar frame biasa 52,3 dB. Jurangnya besar, dan saya sempat menandainya sebagai blocker.
Itu salah banding. Aset lama yang sudah berjalan di situs dan sudah diterima klien, kalau diukur dengan cara yang sama, sambungannya 27,53 dB. Artinya aset baru itu 28,94 dikurangi 27,53, yaitu 1,41 dB lebih baik dari yang selama ini dipakai tanpa keluhan. Saya nyaris memblokir rilis karena membandingkan dua hal yang memang tidak sebanding. Angka 52,3 dB itu diukur antar frame yang gerakannya berkelanjutan, sementara 28,94 dB diukur tepat di titik potong. Jurang di antara keduanya artefak dari pembandingnya, bukan cacat asetnya.
Yang saya bawa pulang
- Ukur sumbernya dulu sebelum menyalahkan animator. Selisih 1,79 RGB antara frame pertama dan terakhir sudah cukup untuk menyimpulkan sumbernya seamless.
- Mengunci GOP ke panjang klip lewat
-gdan-keyint_minmenaruh batas loop di keyframe, tapi itu tidak menjaga nilai piksel tetap sama. - Encode ulang bisa memperburuk sambungan yang tadinya rapi. Ukur berkas hasilnya, bukan cuma sumbernya.
- Kalau butuh frame pertama dan terakhir yang identik, buat identik sebelum encode: buang frame terakhir, tempel frame pertama di ujung.
- x264 dengan
yuv420pmenuntut dimensi genap, jadi pilih rasio skala yang keluarannya genap sejak awal. - Angka sambungan cuma berarti kalau pembandingnya mengukur jenis pasangan frame yang sama. Titik potong dibanding bagian yang gerakannya berkelanjutan akan selalu memberi jurang yang tidak berarti apa-apa.