跳到正文
DEV Community· Hanako H.·· 3 小時前AI 評分63

如何用 ffmpeg 按逐字稿剪輯影片

Cara memotong video berdasarkan transkripnya dengan ffmpeg

AI 導讀

作者示範以 Python 和 ffmpeg 根據逐字稿的逐詞時間戳剪輯影片,並透過重編碼令切點對準詞語所在位置。腳本會把相鄰刪除詞之間的停頓一併剪除,並用同一個 filter graph 處理影片和音訊。作者比較同一組四個區間的輸出,發現 -c copy 受來源 keyframe 間距限制,合併後音畫可相差約 1 秒。

正文

Penyuntingan video berbasis teks tampak seperti pengolah kata, tetapi di baliknya hanyalah daftar rentang waktu dan satu perintah ffmpeg. Transkrip memberi setiap kata waktu mulai dan waktu selesai, kata yang kamu hapus menjadi potongan, dan semua yang ada di antara potongan dipertahankan. Editor video di aplikasi desktop osFoundry melakukan ini dengan Rust (canvas_transcript.rs). Di bawah ini pipeline-nya ditulis dalam Python, dijalankan pada klip wawancara asli berdurasi 37,3 detik, lalu dibandingkan dengan jalan pintas yang tampak setara tetapi sebenarnya tidak: memotong tiap bagian dengan -c copy lalu menggabungkannya.

Mulai dari waktu tiap kata

Transkriber apa pun yang mengembalikan stempel waktu per kata bisa dipakai. Whisper bisa, begitu juga sebagian besar API speech-to-text yang di-host jika diminta. Klip di sini adalah wawancara NASA dengan astronaut Christina Koch, 640×360 pada 30 fps, dan transkripnya berisi 119 kata:

{"words": [
  {"index": 0, "text": "Well",  "start_ms": 0,   "end_ms": 500},
  {"index": 1, "text": "I",     "start_ms": 740, "end_ms": 840},
  {"index": 2, "text": "think", "start_ms": 840, "end_ms": 1060},
  ...
], "duration_ms": 37300}

Kata dirujuk lewat indeks, bukan waktu, karena indeks tetap sama saat pengguna memilih dan membatalkan pilihan. Penyuntingan di bawah menghapus kata 30 sampai 81 (selingan 16,7 detik) dan 111 sampai 118 (kalimat penutup).

Aplikasi hanya mengirim audio ke transkriber, dan langkah ini layak ditiru bahkan di skrip. Penyedia menagih berdasarkan durasi, tetapi tidak ada alasan untuk mengunggah trek videonya:

ffmpeg -i clip.mp4 -vn -ac 1 -ar 16000 -f wav clip.wav

Tempatkan setiap potongan tepat pada frame

Kata yang mulai di 9.420 ms mulai di tengah sebuah frame pada 30 fps. Kalau waktu potong tidak digeser ke batas frame, angka yang dilihat pengguna dan frame tempat ffmpeg benar-benar memotong akan berbeda. Aplikasi membulatkan ke frame terdekat, bukan memotong angka di belakang koma, dan komentarnya menjelaskan alasannya: pemotongan angka membuat setiap potongan maju lebih awal, dan setelah belasan suntingan awal kata terdengar terpotong.

def snap(ms, fps):
    frame = 1000 / fps
    return round(round(ms / frame) * frame)

Ubah kata yang dihapus menjadi rentang yang dipertahankan

Kata yang dihapus menjadi rentang potong. Rentang yang tumpang tindih atau bersentuhan digabung, dan apa pun di antara potongan dipertahankan, termasuk jeda di antara kata yang dipertahankan. Aturan terakhir inilah yang membuat hasilnya tidak terdengar seperti jump cut otomatis: yang hilang hanya waktu yang ditempati kata yang dihapus, bukan setiap keheningan.

def keep_ranges(words, deleted, duration, fps):
    cuts = sorted((snap(w["start_ms"], fps), snap(w["end_ms"], fps))
                  for w in words if w["index"] in deleted)
    merged = []
    for s, e in cuts:
        if merged and s <= merged[-1][1]:
            merged[-1][1] = max(merged[-1][1], e)
        else:
            merged.append([s, e])
    keeps, cursor = [], 0
    for s, e in merged:
        if s > cursor:
            keeps.append((cursor, s))
        cursor = max(cursor, e)
    if cursor < duration:
        keeps.append((cursor, duration))
    return [k for k in keeps if k[1] - k[0] >= 1000 / fps]  # a sub-frame keep can't be encoded

Untuk suntingan ini, hasilnya empat rentang, bukan dua seperti yang mungkin kamu duga:

(0, 9433) (16833, 16967) (26133, 34700) (36933, 37300)

Rentang kedua panjangnya 134 ms. Di dalam selingan yang dihapus, pembicara berhenti 120 ms di antara "road" dan "but". Tidak ada kata terhapus yang mencakup jeda itu, jadi aturan "pertahankan setiap celah" mempertahankannya, dan keluaran memuat kilasan empat frame dari tengah kalimat yang sudah kamu hapus. Rentang terakhir adalah hal yang sama di akhir: 367 ms suasana ruangan setelah kalimat penutup yang dihapus.

Perbaikannya adalah menentukan jeda itu milik siapa. Celah di antara dua kata yang dihapus, atau di antara kata yang dihapus dan awal atau akhir klip, ikut dihapus. Celah di sebelah kata yang dipertahankan tetap ada:

def cut_spans(words, deleted, duration, fps):
    spans = []
    for i, w in enumerate(words):
        if w["index"] not in deleted:
            continue
        s, e = w["start_ms"], w["end_ms"]
        if i == 0 or words[i - 1]["index"] in deleted:
            s = 0 if i == 0 else words[i - 1]["end_ms"]
        if i == len(words) - 1 or words[i + 1]["index"] in deleted:
            e = duration if i == len(words) - 1 else words[i + 1]["start_ms"]
        spans.append((snap(s, fps), snap(e, fps)))
    return spans

Dengan cut_spans sebagai pengganti rentang kata, suntingan yang sama menghasilkan dua rentang, (0, 9433) (26133, 34700), dan hasil berdurasi 18,0 detik.

Trim video dan audio dalam satu graf

Setiap rentang menjadi trim untuk video dan atrim untuk audio, lalu satu concat menggabungkan semuanya, video dan audio sekaligus:

def graph(keeps):
    p = []
    for i, (s, e) in enumerate(keeps):
        p.append(f"[0:v]trim=start={s/1000:.3f}:end={e/1000:.3f},setpts=PTS-STARTPTS[v{i}]")
        p.append(f"[0:a]atrim=start={s/1000:.3f}:end={e/1000:.3f},asetpts=PTS-STARTPTS[a{i}]")
    p.append("".join(f"[v{i}][a{i}]" for i in range(len(keeps)))
             + f"concat=n={len(keeps)}:v=1:a=1[outv][outa]")
    return ";".join(p)

subprocess.run(["ffmpeg", "-y", "-i", "clip.mp4", "-filter_complex", graph(keeps),
                "-map", "[outv]", "-map", "[outa]", "out.mp4"], check=True)

Ini meng-encode ulang seluruh keluaran, dan itulah harganya: 4,5 sampai 7,4 detik untuk klip ini dengan ffmpeg 7.1 di Apple M1, tergantung apa lagi yang sedang berjalan.

Mengapa bukan -c copy

Jalan yang lebih cepat dan paling jelas adalah memotong tiap rentang tanpa encode ulang dan menggabungkan potongannya dengan demuxer concat:

ffmpeg -ss 26.133 -to 34.700 -i clip.mp4 -c copy seg2.mp4
ffmpeg -f concat -safe 0 -i list.txt -c copy out-copy.mp4

Kedua versi dijalankan pada empat rentang yang sama lalu dicek terhadap sumbernya. Frame video dicocokkan dengan sumber lewat hash (hasil copy mempertahankan frame aslinya) dan lewat thumbnail terdekat, sedangkan audio lewat korelasi jendela pendek dari keluaran terhadap audio sumber.

filter graph -c copy + concat
durasi video 18,50 s 18,87 s
bagian ketiga dimulai di sumber pada 26,133 s 26,000 s
durasi audio hasil decode 18,53 s 20,85 s
audio dibanding gambar di 7 titik cek 0 sampai 1 ms tertinggal sekitar 1 s setelah penggabungan kedua

Sumbernya hanya punya keyframe setiap 8,33 detik, jadi potongan hasil copy tidak bisa dimulai di tempat yang ditunjuk transkrip. Setiap potongan hasil copy keluar dengan video dan audio yang durasinya berbeda (8,73 s dibanding 8,58 s untuk rentang ketiga), penggabungan memunculkan kembali empat frame dari kata yang sudah dihapus, dan ffmpeg memberi peringatan tentang stempel waktu yang mundur saat men-decode audio gabungan. Kecocokan pada audio hasil copy juga lemah (korelasi 0,2 sampai 0,8, dibanding 1,00 untuk filter graph), jadi "sekitar satu detik" bersifat perkiraan. Yang jelas, hasilnya tidak sinkron, dan penonton akan melihat bibir yang tidak cocok dengan kata-katanya.

Stream copy tidak masalah untuk satu potongan di keyframe. Untuk potongan yang ditentukan transkrip, encode ulang adalah harga agar potongan jatuh tepat di tempat kata-kata berada.

Semuanya digabung

Potongan-potongan di atas totalnya kurang dari 60 baris Python, hanya dengan pustaka standar dan satu binary ffmpeg. Editor video canvas di osFoundry menaruh pipeline seperti ini di balik panel transkrip: seret di atas kata, tekan Delete, terapkan potongan. Aplikasinya gratis di halaman unduh osFoundry.

來源:DEV Community · dev.to