Il montaggio video basato sul testo sembra un word processor, ma sotto è un elenco di intervalli di tempo e un solo comando ffmpeg. La trascrizione assegna a ogni parola un tempo di inizio e uno di fine, le parole che cancelli diventano tagli e tutto ciò che sta tra i tagli viene mantenuto. L'editor video dell'app desktop di osFoundry lo fa in Rust (canvas_transcript.rs). Qui sotto trovi la pipeline scritta in Python, eseguita su una vera clip di intervista di 37,3 secondi, e un confronto con la scorciatoia che sembra equivalente ma non lo è: tagliare ogni pezzo con -c copy e unirli.
Partire dai tempi delle parole
Va bene qualsiasi trascrittore che restituisca i timestamp per parola. Whisper lo fa, e così la maggior parte delle API di speech-to-text ospitate, se richiesto. La clip è un'intervista della NASA all'astronauta Christina Koch, 640×360 a 30 fps, e la sua trascrizione ha 119 parole:
{"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}
Le parole sono indirizzate per indice e non per tempo, perché l'indice resta lo stesso mentre l'utente seleziona e deseleziona. La modifica qui sotto cancella le parole da 30 a 81 (una digressione di 16,7 secondi) e da 111 a 118 (il saluto finale).
L'app invia al trascrittore solo l'audio, e questo passaggio vale la pena copiarlo anche in uno script. I fornitori fatturano in base alla durata, ma non c'è motivo di caricare la traccia video:
ffmpeg -i clip.mp4 -vn -ac 1 -ar 16000 -f wav clip.wav
Agganciare ogni taglio a un fotogramma
Una parola che inizia a 9.420 ms inizia a metà di un fotogramma a 30 fps. Se il tempo del taglio non viene spostato su un confine di fotogramma, il numero che vede l'utente e il fotogramma su cui ffmpeg taglia davvero non coincidono. L'app arrotonda al fotogramma più vicino invece di troncare, e il suo commento spiega perché: troncare anticipa ogni taglio, e dopo una dozzina di modifiche si sente che l'inizio delle parole viene mangiato.
def snap(ms, fps):
frame = 1000 / fps
return round(round(ms / frame) * frame)
Trasformare le parole cancellate in intervalli da mantenere
Le parole cancellate diventano intervalli di taglio. Gli intervalli che si sovrappongono o si toccano vengono uniti, e quello che sta tra i tagli viene mantenuto, comprese le pause tra le parole mantenute. Quest'ultima regola evita che il risultato suoni come un montaggio automatico a jump cut: sparisce solo il tempo occupato da una parola cancellata, non ogni silenzio.
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
Per questa modifica restituisce quattro intervalli, non i due che ci si aspetterebbe:
(0, 9433) (16833, 16967) (26133, 34700) (36933, 37300)
Il secondo dura 134 ms. All'interno della digressione cancellata, chi parla fa una pausa di 120 ms tra "road" e "but". Nessuna parola cancellata copre quella pausa, quindi la regola "mantieni ogni intervallo" la mantiene, e nell'output compare un lampo di quattro fotogrammi del mezzo di una frase che avevi cancellato. L'ultimo intervallo è la stessa cosa alla fine: 367 ms di sottofondo dopo il saluto cancellato.
La soluzione è stabilire a chi appartiene una pausa. Uno spazio tra due parole cancellate, o tra una parola cancellata e l'inizio o la fine della clip, se ne va con loro. Uno spazio accanto a una parola mantenuta resta:
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
Con cut_spans al posto degli intervalli delle parole, la stessa modifica dà due intervalli, (0, 9433) (26133, 34700), e un risultato di 18,0 secondi.
Tagliare video e audio in un unico grafo
Ogni intervallo diventa un trim per il video e un atrim per l'audio, e un solo concat li unisce tutti, video e audio insieme:
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)
Questo ricodifica tutto l'output, ed è il costo da pagare: da 4,5 a 7,4 secondi per questa clip con ffmpeg 7.1 su un Apple M1, a seconda di cos'altro era in esecuzione.
Perché non -c copy
La via più veloce e più ovvia è tagliare ogni intervallo senza ricodificare e unire i pezzi con il 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
Le due versioni sono state eseguite sugli stessi quattro intervalli e confrontate con la sorgente. I fotogrammi video sono stati abbinati alla sorgente tramite hash (la copia mantiene i fotogrammi originali) e tramite la miniatura più vicina, l'audio correlando brevi finestre dell'output con l'audio della sorgente.
| grafo di filtri |
-c copy + concat |
|
|---|---|---|
| durata del video | 18,50 s | 18,87 s |
| la terza sezione inizia nella sorgente a | 26,133 s | 26,000 s |
| durata dell'audio decodificato | 18,53 s | 20,85 s |
| audio rispetto all'immagine in 7 punti di controllo | da 0 a 1 ms | circa 1 s di ritardo dopo la seconda unione |
La sorgente ha un keyframe solo ogni 8,33 secondi, quindi un taglio copiato non può iniziare dove dice la trascrizione. Ogni pezzo copiato è uscito con video e audio di durata diversa (8,73 s contro 8,58 s per il terzo intervallo), l'unione ha riportato quattro fotogrammi di una parola cancellata, e ffmpeg ha segnalato timestamp che tornano indietro durante la decodifica dell'audio unito. Anche le corrispondenze dell'audio copiato erano deboli (correlazione da 0,2 a 0,8, contro 1,00 del grafo di filtri), quindi "circa un secondo" è approssimativo. Quello che è chiaro è che non era sincronizzato, e uno spettatore vedrebbe labbra che non corrispondono alle parole.
La copia dello stream va bene per un solo taglio su un keyframe. Per tagli posizionati da una trascrizione, la ricodifica è il prezzo perché i tagli cadano dove sono le parole.
Tutto insieme
I pezzi qui sopra fanno meno di 60 righe di Python, con la sola libreria standard e un binario ffmpeg. L'editor video canvas di osFoundry mette una pipeline di questo tipo dietro un pannello di trascrizione: trascini sulle parole, premi Canc, applichi i tagli. L'app è gratuita dalla pagina di download di osFoundry.
Top comments (0)