ffmpeg bisa menemukan titik potong di video yang sudah diedit hanya dengan satu filter, dan kebanyakan panduan berhenti di situ: select='gt(scene,0.4)',showinfo, baca timestamp-nya, selesai. Di editan sungguhan, masih ada dua hal yang belum terjawab. Threshold berapa yang memisahkan cut dari guncangan kamera, dan bagaimana mengubah timestamp itu menjadi file yang benar-benar dimulai tepat di cut? Editor video di aplikasi desktop osFoundry mengerjakan bagian pertama dengan Rust (canvas_scene_split di canvas_operations.rs) dan sama sekali menghindari bagian kedua. Di bawah ini kedua bagian itu dibuat ulang dengan Python dan diukur pada satu video, lengkap dengan angka yang menentukan setiap pilihan.
File ujinya adalah cuplikan peluncuran dari uji terbang Artemis II milik NASA yang berstatus public domain: 92 detik H.264 dengan resolusi 640×360, 30 fps, 2,760 frame, disusun dari 19 shot. Semua waktu diukur di Apple M1 dengan ffmpeg 7.1 dan Python 3.9.
Mendeteksi cut
Perintahnya sama dengan yang ada di setiap panduan. Detail yang sering bikin orang buang waktu satu sore adalah ke mana output-nya pergi: showinfo menulis ke log ffmpeg, yaitu stderr. Kalau yang dibaca stdout, hasilnya list kosong padahal prosesnya berhasil.
import re, subprocess
def detect(video, threshold):
vf = f"select='gt(scene,{threshold})',showinfo"
p = subprocess.run(["ffmpeg", "-hide_banner", "-i", video, "-an",
"-filter_complex", vf, "-f", "null", "-"],
capture_output=True, text=True)
return [round(float(t) * 1000)
for t in re.findall(r"pts_time:([0-9.]+)", p.stderr)]
Dengan 0.27, nilai default di aplikasi, fungsi ini mengembalikan 18 waktu cut dalam milidetik, dari 3,133 sampai 73,033. Prosesnya makan waktu 0.80 s (median dari 5 kali jalan), jadi deteksi ini cukup murah untuk dijalankan ulang dengan threshold lain kapan saja.
Apa yang diukur oleh scene score
Threshold lebih mudah dipilih kalau kita tahu arti angkanya. Di libavfilter/f_select.c, get_scene_score menghitung rata-rata selisih absolut antara frame ini dan frame sebelumnya (mafd), lalu menguranginya dengan mafd milik frame sebelumnya:
score = min(mafd, |mafd - prev_mafd|) / 100
Untuk input YUV, yang dibandingkan hanya plane luma. Karena itu score-nya mudah dibuat ulang dengan beberapa baris Python, dan ini cara tercepat untuk memahami nilai yang terlihat aneh:
def luma(video, n):
p = subprocess.run(["ffmpeg", "-v", "error", "-i", video,
"-vf", f"select='eq(n,{n})'", "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "yuv420p", "-"],
capture_output=True)
return p.stdout[:640 * 360] # Y plane only
def mafd(a, b):
return sum(abs(x - y) for x, y in zip(a, b)) / len(a)
f0, f1, f2 = (luma(VIDEO, k) for k in (n - 2, n - 1, n))
prev, cur = mafd(f0, f1), mafd(f1, f2)
score = min(cur, abs(cur - prev)) / 100
Mengambil plane Y dari yuv420p, bukan meminta -pix_fmt gray, ternyata penting: konversi gray meregangkan rentang nilai, dan score hasil buatan ulang jadi sekitar 16 % terlalu tinggi. Dengan plane Y mentah, empat frame dari video ini cocok dengan score dari ffmpeg sendiri:
| frame | apa yang terjadi | mafd | mafd sebelumnya | score |
|---|---|---|---|---|
| 33.300 s | hard cut ke penonton | 57.12 | 1.31 | 0.558 |
| 33.333 s | frame setelah cut itu | 13.75 | 57.12 | 0.138 |
| 58.567 s | kamera berguncang saat mesin menyala | 27.03 | 10.88 | 0.161 |
| 63.667 s | cut dari landasan ke shot langit yang lebar | 34.64 | 0.90 | 0.337 |
Pengurangan itulah yang membuat shot bergerak tidak ikut terdeteksi. Frame penyalaan mesin berbeda dari frame sebelumnya rata-rata 27 tingkat kecerahan, dua kali lipat frame setelah cut ke penonton. Tapi frame sebelumnya sudah berubah 10.9, jadi yang dihitung hanya kenaikannya, dan score-nya 0.16. Cut dari landasan ke langit perubahannya lebih kecil daripada banyak cut lain, tapi datang setelah frame yang hampir diam, sehingga score-nya 0.34. Cut adalah lonjakan perubahan, bukan sekadar perubahan yang besar.
Memilih threshold dari data
Daripada menebak, keluarkan score setiap frame sekali saja lalu lihat di mana celahnya:
ffmpeg -i launch.mp4 -an -vf "select='gte(scene,0)',metadata=print:file=-" -f null - \
| grep -o 'scene_score=[0-9.]*'
Median score frame di video ini adalah 0.0015. Tujuh belas frame punya score di atas 0.5, satu di 0.337, dan yang tertinggi berikutnya adalah frame penyalaan mesin di 0.161. Threshold berapa pun antara 0.162 dan 0.337 menghasilkan 18 cut yang sama. Hasil sweep-nya, dengan dan tanpa jarak minimum 500 ms antar-cut:
| threshold | frame di atasnya | cut setelah jarak 500 ms |
|---|---|---|
| 0.08 | 27 | 20 |
| 0.10 | 24 | 20 |
| 0.12 | 20 | 19 |
| 0.15 | 19 | 19 |
| 0.20 | 18 | 18 |
| 0.27 | 18 | 18 |
| 0.35 | 17 | 17 |
| 0.40 | 17 | 17 |
| 0.50 | 17 | 17 |
Sebagian besar rentang 0.3–0.5 yang disarankan mailing list ffmpeg dan banyak tutorial terlalu tinggi untuk video ini: mulai 0.35 ke atas, cut dari landasan ke langit hilang. Di 0.15 ke bawah, penyalaan mesin mulai menghasilkan cut. Pada footage dengan banyak gerakan cepat, rentang amannya bisa sempit, jadi ada baiknya melihat distribusinya sekali sebelum memercayai angka dari video orang lain.
Jarak minimum
Baris 0.08 sampai 0.12 menunjukkan kenapa aplikasi membuang hit yang datang terlalu cepat setelah hit sebelumnya (parse_scene_times, default 500 ms). Di 0.12, cut di 33.300 s terdeteksi lagi di frame berikutnya pada 33.333 s, karena frame itu masih berbeda 13.75 dari tetangganya sementara lonjakan sebelumnya sangat besar. Di 0.08, penyalaan mesin menghasilkan delapan hit antara 57.7 s dan 59.0 s. Jarak setengah detik meringkas setiap rentetan itu menjadi satu penanda:
def min_gap(hits, gap_ms=500):
out = []
for ms in hits:
if not out or ms - out[-1] >= gap_ms:
out.append(ms)
return out
Ini tidak memperbaiki threshold yang terlalu rendah, hanya duplikatnya. Di 0.08, setelah jarak minimum pun masih tersisa dua cut palsu di penyalaan mesin.
Memotong dengan -c copy tidak jatuh tepat di cut
Langkah berikutnya yang paling jelas adalah segment muxer ffmpeg dengan stream copy:
ffmpeg -i launch.mp4 -c copy -f segment -segment_times 3.133,6.333,... \
-reset_timestamps 1 scene%02d.mp4
Prosesnya 0.05 s dan menghasilkan 18 file, bukan 19, semuanya berdurasi antara 3.3 dan 6.0 detik. Padahal scene-nya sendiri berdurasi 1.5 sampai 19.0 detik. Stream copy hanya bisa memulai file di keyframe, dan encode ini punya 18 keyframe dengan jarak paling jauh 6 detik. ffprobe -skip_frame nokey menampilkan daftarnya:
ffprobe -v error -select_streams v -skip_frame nokey \
-show_entries frame=pts_time -of csv=p=0 launch.mp4
Hanya 5 dari 18 cut yang jatuh di keyframe, yaitu di tempat yang kebetulan diberi keyframe oleh encoder aslinya. Batas file lainnya semua terlambat, yang paling parah sekitar 4 detik, dan kalau dua cut punya keyframe berikutnya yang sama, keduanya melebur jadi satu batas.
Re-encode, dan jebakan setengah frame
Re-encode dengan keyframe yang dipaksa di setiap cut membuat batasnya bisa jatuh di mana saja:
ffmpeg -i launch.mp4 -c:v libx264 -crf 18 -preset fast -c:a aac \
-force_key_frames "$T" -f segment -segment_times "$T" \
-reset_timestamps 1 scene%02d.mp4
Dengan $T berisi waktu hasil deteksi yang dibulatkan ke milidetik, perintah ini menghasilkan 19 file, tapi 4 dari 18 cut masih terlambat satu frame. Satu cut terlewat sama sekali sehingga dua scene tergabung dalam file 12.3 detik, dan ada satu file nyasar yang isinya cuma satu frame. Penyebabnya pembulatan. Cut di frame 269 ada di 8.9666… s, dan "8.967" sedikit lewat dari situ, jadi perbandingannya memilih frame berikutnya. Keempat cut yang terlambat semuanya berada di waktu yang berakhiran angka 6 berulang, begitu juga frame nyasar di 44.067 s.
Menggeser setiap waktu setengah frame lebih awal (1/60 s pada 30 fps) membuatnya pasti berada sebelum frame cut:
T = ",".join(f"{ms / 1000 - 0.5 / 30:.4f}" for ms in cuts)
Dengan perubahan itu, ke-18 batas jatuh tepat di frame cut, dan panjang ke-19 file cocok dengan panjang scene sampai ke frame-nya. Re-encode-nya makan 6.9 s untuk klip 92 detik dan menulis 15.2 MB, dibanding 18.2 MB untuk sumbernya, pada CRF 18.
Atau jangan tulis file sama sekali
Aplikasinya memakai cara ketiga. Tombol Split at memanggil canvas_split_clip di canvas_layers.rs, yang memendekkan klip yang dipilih lalu menyisipkan klip baru dari asset yang sama dengan in-point yang lebih belakang. Tidak ada yang di-decode atau ditulis, jadi tidak ada masalah keyframe dan tidak ada generation loss, dan encode hanya terjadi sekali, saat editan final diekspor. Kalau tujuannya memotong ulang video, bukan memberikan 19 file ke orang lain, versi inilah yang layak ditiru: simpan daftar cut sebagai in/out point dan biarkan render terakhir yang mengerjakan bagian yang harus akurat per frame.
Script lengkapnya, dengan semua pengukuran di atas, sekitar 120 baris Python yang hanya memakai standard library di sekitar ffmpeg dan ffprobe. Di aplikasi desktop osFoundry, deteksi yang sama tersedia sebagai preset Find the cuts di editor video, dan berjalan di komputer Anda sendiri seperti dijelaskan di halaman local-first. Aplikasinya gratis dari halaman download osFoundry.

Top comments (0)