DEV Community

Cover image for Come tagliare un video a partire dalla sua trascrizione con ffmpeg

Come tagliare un video a partire dalla sua trascrizione con ffmpeg

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Per questa modifica restituisce quattro intervalli, non i due che ci si aspetterebbe:

(0, 9433) (16833, 16967) (26133, 34700) (36933, 37300)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)