Dev.to · 8 min read

Wie man mit ffmpeg Szenenschnitte findet und ein Video genau an diesen Stellen teilt

Wie man mit ffmpeg Szenenschnitte findet und ein Video genau an diesen Stellen teilt

ffmpeg findet die Schnitte in einem geschnittenen Video mit einem einzigen Filter, und die meisten Anleitungen hören genau dort auf: select='gt(scene,0.4)',showinfo, Zeitstempel ablesen, fertig. Bei einem echten Schnitt bleiben danach aber zwei Fragen offen. Welcher Schwellenwert trennt Schnitte von Kamerawackeln, und wie macht man aus den Zeitstempeln Dateien, die tatsächlich am Schnitt beginnen? Der Videoeditor in der Desktop-App von osFoundry erledigt die erste Hälfte in Rust (canvas_scene_split in canvas_operations.rs) und umgeht die zweite komplett. Unten werden beide Hälften in Python nachgebaut und an einem Video gemessen, jeweils mit den Zahlen, die die Entscheidung begründen. Die Testdatei sind die gemeinfreien Launch-Highlights der NASA vom Testflug Artemis II: 92 Sekunden H.264 in 640×360, 30 fps, 2.760 Frames, zusammengeschnitten aus 19 Einstellungen. Alle Zeiten stammen von einem Apple M1 mit ffmpeg 7.1 und Python 3.9. Die Schnitte erkennen Der Befehl ist derselbe wie in jeder Anleitung. Was einen leicht einen Nachmittag kostet, ist, wohin die Ausgabe geht: showinfo schreibt ins Log von ffmpeg, also nach stderr. Wer stdout liest, bekommt von einem erfolgreichen Lauf eine leere Liste. 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)] Mit 0,27, dem Standardwert der App, liefert die Funktion 18 Schnittzeiten in Millisekunden, von 3.133 bis 73.033. Der Durchlauf dauerte 0,80 s (Median aus 5 Läufen). Die Erkennung ist also billig genug, um sie jederzeit mit einem anderen Schwellenwert neu laufen zu lassen. Was der Scene-Score misst Den Schwellenwert wählt man leichter, wenn man weiß, was die Zahl bedeutet. In libavfilter/f_select.c berechnet get_scene_score die mittlere absolute Differenz zwischen diesem und dem vorherigen Frame (mafd) und zieht davon die mafd des vorherigen Frames ab: score = min(mafd, |mafd - prev_mafd|) / 100 Bei YUV-Eingabe wird nur die Luma-Ebene verglichen. Dadurch lässt sich der Score in ein paar Zeilen Python nachbauen, und das ist der schnellste Weg, einen überraschenden Wert zu verstehen: 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 Es macht einen Unterschied, die Y-Ebene aus yuv420p zu nehmen statt -pix_fmt gray anzufordern: Die Gray-Konvertierung streckt den Wertebereich, und die nachgebauten Scores lagen dann etwa 16 % zu hoch. Mit der rohen Y-Ebene stimmen vier Frames aus diesem Video mit den Scores von ffmpeg selbst überein: Frame Was passiert mafd vorherige mafd Score 33,300 s harter Schnitt auf die Zuschauer 57,12 1,31 0,558 33,333 s der Frame nach diesem Schnitt 13,75 57,12 0,138 58,567 s Kamerawackeln bei der Triebwerkszündung 27,03 10,88 0,161 63,667 s Schnitt von der Startrampe auf eine weite Himmelsaufnahme 34,64 0,90 0,337 Die Subtraktion sorgt dafür, dass bewegte Einstellungen nicht auslösen. Der Zündungs-Frame unterscheidet sich vom vorherigen im Schnitt um 27 Helligkeitsstufen, doppelt so viel wie der Frame nach dem Schnitt auf die Zuschauer. Aber der Frame davor hatte sich schon um 10,9 verändert, also zählt nur der Anstieg, und der Score liegt bei 0,16. Der Schnitt von der Rampe auf den Himmel verändert weniger als viele andere Schnitte, folgt aber auf einen fast stillstehenden Frame und kommt deshalb auf 0,34. Ein Schnitt ist ein Sprung in der Veränderung, nicht einfach viel Veränderung. Den Schwellenwert aus den Daten ablesen Statt zu raten, gibt man einmal den Score jedes Frames aus und schaut, wo die Lücke liegt: ffmpeg -i launch.mp4 -an -vf "select='gte(scene,0)',metadata=print:file=-" -f null - \ | grep -o 'scene_score=[0-9.]*' Der Median-Frame dieses Videos hat einen Score von 0,0015. Siebzehn Frames liegen über 0,5, einer bei 0,337, und der nächsthöchste ist der Zündungs-Frame mit 0,161. Jeder Schwellenwert zwischen 0,162 und 0,337 liefert dieselben 18 Schnitte. Der Durchlauf über mehrere Werte, mit und ohne 500 ms Mindestabstand zwischen Schnitten: Schwellenwert Frames darüber Schnitte nach 500 ms Abstand 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 Der Bereich 0,3–0,5, den die ffmpeg-Mailingliste und viele Tutorials empfehlen, ist für dieses Video größtenteils zu hoch: Ab 0,35 fehlt der Schnitt von der Rampe auf den Himmel. Ab 0,15 abwärts erzeugt die Zündung Schnitte. Bei Material mit viel schneller Bewegung kann der sichere Bereich schmal sein. Es lohnt sich also, einmal auf die Verteilung zu schauen, bevor man einer Zahl vertraut, die jemand an einem anderen Video ermittelt hat. Der Mindestabstand Die Zeilen von 0,08 bis 0,12 zeigen, warum die App Treffer verwirft, die zu kurz auf den vorherigen folgen (parse_scene_times, standardmäßig 500 ms). Bei 0,12 löst der Schnitt bei 33,300 s im nächsten Frame bei 33,333 s noch einmal aus, weil sich dieser Frame immer noch um 13,75 von seinem Nachbarn unterscheidet, während der Sprung davor riesig war. Bei 0,08 erzeugt die Zündung acht Treffer zwischen 57,7 s und 59,0 s. Ein Abstand von einer halben Sekunde fasst jede dieser Salven zu einer Markierung zusammen: 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 Einen zu niedrigen Schwellenwert repariert das nicht, nur die Duplikate. Bei 0,08 bleiben trotz Abstand zwei falsche Schnitte in der Zündung übrig. Mit -c copy landen die Teile nicht auf den Schnitten Der naheliegende nächste Schritt ist der Segment-Muxer von ffmpeg mit Stream Copy: ffmpeg -i launch.mp4 -c copy -f segment -segment_times 3.133,6.333,... \ -reset_timestamps 1 scene%02d.mp4 Das dauerte 0,05 s und ergab 18 Dateien statt 19, alle zwischen 3,3 und 6,0 Sekunden lang. Die Szenen selbst sind zwischen 1,5 und 19,0 Sekunden lang. Ein Stream Copy kann eine Datei nur an einem Keyframe beginnen, und dieses Encoding hat 18 davon, höchstens 6 Sekunden auseinander. ffprobe -skip_frame nokey listet sie auf: ffprobe -v error -select_streams v -skip_frame nokey \ -show_entries frame=pts_time -of csv=p=0 launch.mp4 Nur 5 der 18 Schnitte fallen auf einen Keyframe, nämlich dort, wo der ursprüngliche Encoder zufällig einen gesetzt hat. Jede andere Dateigrenze kommt zu spät, im schlimmsten Fall um etwa 4 Sekunden, und wo zwei Schnitte denselben nächsten Keyframe haben, fallen sie zu einer Grenze zusammen. Neu encodieren, und die Halber-Frame-Falle Encodiert man neu und erzwingt an jedem Schnitt einen Keyframe, können die Grenzen überall landen: 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 Mit $T als den erkannten Zeiten, auf Millisekunden gerundet, ergab das 19 Dateien, und 4 der 18 Schnitte lagen trotzdem einen Frame zu spät. Ein Schnitt ging ganz verloren, sodass zwei Szenen in einer 12,3 Sekunden langen Datei landeten, und eine überzählige Datei enthielt einen einzigen Frame. Schuld ist das Runden. Ein Schnitt bei Frame 269 liegt bei 8,9666… s, und „8.967“ liegt knapp dahinter, also wählt der Vergleich den nächsten Frame. Alle vier verspäteten Schnitte liegen bei Zeiten, die auf eine periodische 6 enden, und der überzählige Frame bei 44,067 s ebenfalls. Verschiebt man jede Zeit um einen halben Frame nach vorn (1/60 s bei 30 fps), liegt sie eindeutig vor dem Schnitt-Frame: T = ",".join(f"{ms / 1000 - 0.5 / 30:.4f}" for ms in cuts) Mit dieser Änderung landeten alle 18 Grenzen auf dem Schnitt-Frame, und die Längen der 19 Dateien stimmten framegenau mit den Szenenlängen überein. Das Neu-Encodieren dauerte für den 92-Sekunden-Clip 6,9 s und schrieb 15,2 MB, gegenüber 18,2 MB für die Quelle, bei CRF 18. Oder gar keine Dateien schreiben Die App geht einen dritten Weg. Ihre Split at-Buttons rufen canvas_split_clip in canvas_layers.rs auf. Die Funktion kürzt den ausgewählten Clip und fügt auf demselben Asset einen neuen Clip mit späterem In-Point ein. Nichts wird decodiert oder geschrieben, es gibt also kein Keyframe-Problem und keinen Generationsverlust, und encodiert wird genau einmal, wenn der fertige Schnitt exportiert wird. Wenn das Ziel ist, das Video neu zu schneiden, und nicht, jemandem 19 Dateien zu geben, ist das die Variante zum Nachbauen: die Schnittliste als In- und Out-Points behalten und die framegenaue Arbeit dem finalen Rendern überlassen. Das vollständige Skript mit allen Messungen oben umfasst etwa 120 Zeilen Python mit reiner Standardbibliothek rund um ffmpeg und ffprobe. In der Desktop-App von osFoundry ist dieselbe Erkennung das Preset Find the cuts im Videoeditor, und sie läuft auf deinem eigenen Rechner, wie auf der Local-First-Seite beschrieben. Die App gibt es kostenlos auf der Download-Seite von osFoundry.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News