Textbasierter Videoschnitt sieht aus wie eine Textverarbeitung, ist darunter aber eine Liste von Zeitbereichen und ein einziger ffmpeg-Befehl. Das Transkript gibt jedem Wort eine Start- und eine Endzeit, die gelöschten Wörter werden zu Schnitten, und alles zwischen den Schnitten bleibt erhalten. Der Videoeditor in der Desktop-App von osFoundry macht das in Rust (canvas_transcript.rs). Unten steht die Pipeline in Python, ausgeführt an einem echten Interviewclip von 37,3 Sekunden, dazu ein Vergleich mit der Abkürzung, die gleichwertig aussieht, es aber nicht ist: jedes Stück mit -c copy schneiden und zusammenfügen.

Von den Wortzeiten ausgehen

Jeder Transkribierer, der Zeitstempel pro Wort liefert, funktioniert. Whisper kann das, und die meisten gehosteten Speech-to-Text-APIs auch, wenn man sie darum bittet. Der Clip hier ist ein NASA-Interview mit der Astronautin Christina Koch, 640×360 bei 30 fps, das Transkript hat 119 Wörter:

{"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} 

Wörter werden über den Index angesprochen, nicht über die Zeit, weil der Index gleich bleibt, während der Nutzer Wörter an- und abwählt. Die folgende Bearbeitung löscht die Wörter 30 bis 81 (ein Exkurs von 16,7 Sekunden) und 111 bis 118 (die Verabschiedung).

Die App schickt nur die Tonspur an den Transkribierer, und diesen Schritt sollte man auch in einem Skript übernehmen. Anbieter rechnen nach Dauer ab, aber es gibt keinen Grund, die Videospur hochzuladen:

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

Jeden Schnitt auf ein Einzelbild legen

Ein Wort, das bei 9.420 ms beginnt, beginnt bei 30 fps mitten in einem Frame. Wird die Schnittzeit nicht auf eine Framegrenze verschoben, weichen die Zahl, die der Nutzer sieht, und das Frame, an dem ffmpeg tatsächlich schneidet, voneinander ab. Die App rundet auf das nächste Frame, statt abzuschneiden, und ihr Kommentar erklärt warum: Abschneiden zieht jeden Schnitt nach vorn, und nach einem Dutzend Bearbeitungen hört man, dass Wortanfänge abgeschnitten werden.

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

Gelöschte Wörter in zu behaltende Bereiche umwandeln

Die gelöschten Wörter werden zu Schnittbereichen. Überlappende oder aneinanderstoßende Bereiche werden zusammengeführt, und alles zwischen den Schnitten bleibt erhalten, auch die Pausen zwischen behaltenen Wörtern. Diese letzte Regel sorgt dafür, dass das Ergebnis nicht wie ein automatischer Jump-Cut klingt: Es verschwindet nur die Zeit, die ein gelöschtes Wort einnimmt, nicht jede Stille.

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 

Für diese Bearbeitung liefert die Funktion vier Bereiche, nicht die zwei, die man erwarten würde:

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

Der zweite ist 134 ms lang. Mitten im gelöschten Exkurs macht der Sprecher zwischen "road" und "but" eine Pause von 120 ms. Kein gelöschtes Wort deckt diese Pause ab, also behält die Regel "jede Lücke behalten" sie, und im Ergebnis blitzt für vier Frames die Mitte eines Satzes auf, den man gelöscht hat. Der letzte Bereich ist dasselbe am Ende: 367 ms Raumton nach der gelöschten Verabschiedung.

Die Lösung ist, festzulegen, wozu eine Pause gehört. Eine Lücke zwischen zwei gelöschten Wörtern oder zwischen einem gelöschten Wort und dem Anfang oder Ende des Clips geht mit ihnen weg. Eine Lücke neben einem behaltenen Wort bleibt:

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 

Mit cut_spans statt der Wortbereiche ergibt dieselbe Bearbeitung zwei Bereiche, (0, 9433) (26133, 34700), und ein Ergebnis von 18,0 Sekunden.

Video und Audio in einem Graphen schneiden

Jeder Bereich wird zu einem trim für das Video und einem atrim für das Audio, und ein einziges concat fügt alles zusammen, Video und Audio gemeinsam:

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) 

Dabei wird die gesamte Ausgabe neu kodiert, und das ist der Preis: 4,5 bis 7,4 Sekunden für diesen Clip mit ffmpeg 7.1 auf einem Apple M1, je nachdem, was sonst lief.

Warum nicht -c copy

Der naheliegende schnellere Weg ist, jeden Bereich ohne Neukodierung zu schneiden und die Stücke mit dem concat-Demuxer zu verbinden:

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 

Beide Varianten wurden mit denselben vier Bereichen ausgeführt und mit der Quelle verglichen. Die Videoframes wurden per Hash (die Kopie behält die Originalframes) und per nächstliegendem Vorschaubild der Quelle zugeordnet, das Audio über die Korrelation kurzer Fenster der Ausgabe mit dem Quellaudio.

Filtergraph -c copy + concat
Videolänge 18,50 s 18,87 s
dritter Abschnitt beginnt in der Quelle bei 26,133 s 26,000 s
Länge des dekodierten Audios 18,53 s 20,85 s
Audio gegenüber Bild an 7 Prüfpunkten 0 bis 1 ms etwa 1 s hinterher nach dem zweiten Zusammenfügen

Die Quelle hat nur alle 8,33 Sekunden ein Keyframe, deshalb kann ein kopierter Schnitt nicht dort beginnen, wo das Transkript es sagt. Jedes kopierte Stück kam mit unterschiedlich langem Video und Audio heraus (8,73 s gegenüber 8,58 s beim dritten Bereich), das Zusammenfügen brachte vier Frames eines gelöschten Wortes zurück, und ffmpeg warnte beim Dekodieren des zusammengefügten Audios vor rückwärts laufenden Zeitstempeln. Die Übereinstimmungen beim kopierten Audio waren außerdem schwach (Korrelation 0,2 bis 0,8, gegenüber 1,00 beim Filtergraphen), "etwa eine Sekunde" ist also eine Näherung. Klar ist, dass es nicht synchron war, und ein Zuschauer sähe Lippen, die nicht zu den Wörtern passen.

Stream Copy ist in Ordnung für einen einzelnen Schnitt auf einem Keyframe. Für Schnitte, die ein Transkript setzt, ist die Neukodierung der Preis dafür, dass die Schnitte dort liegen, wo die Wörter sind.

Alles zusammen

Die Teile oben ergeben weniger als 60 Zeilen Python, nur mit der Standardbibliothek und einer ffmpeg-Binärdatei. Der Canvas-Videoeditor in osFoundry legt eine solche Pipeline hinter ein Transkript-Panel: über Wörter ziehen, Entf drücken, Schnitte anwenden. Die App ist auf der osFoundry-Downloadseite kostenlos.