Was macht video-scrub?
Baut ein Video in eine saubere Datei um: Bild und Ton wandern mit, kein einziges Byte Metadaten.
{baseDir} = Verzeichnis dieser Datei. Skript {baseDir}/scripts/video-scrub.mjs, null Abhängigkeiten, direkt mit node ausführbar.
Prinzip: Whitelist, nicht Blacklist
Es gibt zwei Ansätze, Metadaten zu entfernen – der Unterschied ist überlebenswichtig:
| Wie | Problem | |
|---|---|---|
| Blacklist | Felder einzeln auflisten und löschen (z. B. mit exiftool) | Du kannst nicht löschen, was du nicht kennst – herstellerspezifische udta-Boxen, GoPro-Telemetriespuren, SEI im Bitstrom – eines übersehen und schon ist es weg |
| Whitelist | Nicht definieren, was gelöscht wird, sondern was mitgenommen wird: eine Videospur, eine Audiospur – alles andere ignorieren | – |
Mit der Whitelist hat die Metadaten der Quelle keinen Kanal, um in die Ausgabe zu gelangen. Sie werden nicht „gelöscht“, sondern nie transportiert.
Aber ffmpeg verrät dieses Prinzip zweimal standardmäßig – das muss unterbunden werden:
- Es überträgt standardmäßig die Container-Tags der Quelle auf die Ausgabe →
-map_metadata -1 - Es wählt standardmäßig automatisch Spuren aus – dabei wandert z. B. die GoPro-
gpmd-Telemetriespur mit →-map 0:v:0 -map 0:a:0?
Punkt 2 ist die einzige Verteidigung gegen GoPro-/DJI-Quellen: Deren GPS steckt nicht in Tags, sondern in einer eigenen zeitbasierten Metadatenspur. Tag-Löschung allein hilft nicht – nur die Beschränkung auf zwei Spuren schützt.
Die eigentliche Herausforderung: Die unsichtbaren Ebenen
Wenn die beiden obigen Punkte unterbunden sind, ist die Tag-Ebene sauber – ffprobe zeigt nichts mehr. Aber in der Datei lauern noch drei Identitäts-Quellen:
| Versteckt in | Inhalt | ffprobe sichtbar? |
|---|---|---|
| SEI von H.264 | x264-Parameterkette cabac=1 ref=3 … crf=23.0 | ✗ |
| DSE im AAC-Bitstrom | Lavc62.28.102 | ✗ |
| compressorname von avc1 | Lavc libx264 | ✗ |
Die fünf Verstecke und ihre Abdichtung stehen in {baseDir}/references/residue-map.md – jedes mit Messprotokoll, inklusive mehrerer Fallstricke (-flags:v +bitexact und -x264-params info=0 stoppen SEI nicht).
Zwei Modi
| Modus | Video | Audio | Geschwindigkeit | Was zusätzlich entfernt wird |
|---|---|---|---|---|
copy (Standard) | Byte-genau kopiert | neu codiert | 53‑Sekunden-Clip in 1,3 s | – |
encode | neu codiert | neu codiert | viel langsamer | Bitstrom-Wasserzeichen, fragile Steganografie |
Audio wird in beiden Modi neu codiert – AAC-Versionsnummern stecken im DSE, der innerhalb des Bitstroms liegt. -c:a copy kann sie nicht entfernen. Eine Neucodierung ist günstig und bringt eine saubere Tonspur. Video dagegen wird im copy-Modus kein einziges Bit verändert (bestätigt mit cmp, abgesehen von den entfernten SEI).
Zu Wasserzeichen: encode entfernt zusätzlich Bitstrom-Wasserzeichen und fragile Steganografie – diese sind in echtem Material selten. Die wirklich gefährlichen robusten Pixel-Wasserzeichen (SynthID, forensische Film-Wasserzeichen) sind dafür gebaut, selbst eine Neucodierung zu überstehen – beide Modi entfernen sie nicht. Wähle den Modus also nach Qualität und Geschwindigkeit, nicht in der Hoffnung, Wasserzeichen wegzucodieren.
Vier Profile
Nach der Reinigung wird eine Schicht zurückgeschrieben:
| Profil | Schreibt |
|---|---|
obs (Standard) | Wie OBS Studio. OBS verwendet selbst ffmpeg, daher ist dies keine Fälschung, sondern die normale Erscheinung eines ähnlichen Tools |
quicktime | macOS QuickTime-Bildschirmaufnahme: Core Media Video / Core Media Audio |
screencapture | ScreenCaptureKit-Bildschirmaufnahme |
bare | Nichts. Höchster Datenschutz – keine nachweisbare Behauptung |
Eine Sache ist nicht möglich, um weitere Versuche zu ersparen: Das encoder-Attribut des Containers wird vom Muxer selbst gesetzt. -metadata encoder=Lavf60.16.100 überschreibt es nicht (bestätigt). Leeren geht auch nicht. Nur -fflags +bitexact lässt es ganz verschwinden. Deshalb fälscht das obs-Profil keine Versionen – es lässt ffmpeg seine eigene echte Version schreiben.
Schritt 0 – Erst prüfen, was die Quelle mitbringt
node {baseDir}/scripts/video-scrub.mjs inspect <video>
Ein Bildschirm voll: Container-Tags, Tags und Side Data jeder Spur, Kapitel – sowie die Identitätsstrings im Bitstrom, die ffprobe nicht sieht.
Am Ende erfährst du, wie viele Nadeln zum Scannen verwendet werden und ob der SEI-Eingriff erfolgt.
Sieh dir diesen Bildschirm an, bevor du handelst. Achte besonders auf:
data/timecode-Spuren – deutet auf Telemetriespuren (GoPro, DJI) hin- HDR – dann werden SEI niemals entfernt (Begründung weiter unten)
Schritt 1 – Bereinigen
node {baseDir}/scripts/video-scrub.mjs scrub <video> -o out.mp4
Standard: --mode copy --profile obs. Häufige Parameter:
--mode encode # vollständige Neucodierung
--profile bare # nichts schreiben, höchster Datenschutz
--crf 18 # nur im encode-Modus sinnvoll
--date 2026-01-01T00:00:00Z # Erstellungszeit setzen, sonst aktuelle Zeit
Schritt 2 – Verifikation ⛔ nicht überspringen
node {baseDir}/scripts/video-scrub.mjs verify <quelle> <ausgabe>
Oder alles in einem Befehl (täglicher Gebrauch):
node {baseDir}/scripts/video-scrub.mjs run <video> -o out.mp4
Das Kernprinzip der Verifikation: Bis hierhin war alles „ich habe die richtigen Parameter gesetzt“ – eine Behauptung. Dieser Schritt ist „ich habe gescannt, es ist weg“ – ein Beweis.
Dafür werden alle Metadaten-Strings der Quelle als „Nadeln“ extrahiert – Schlüssel und Werte von Tags, Handler-Namen, Kapiteltitel – und in der Ausgabedatei byte-genau gescannt. Wird eine gefunden, wird sie rot markiert.
Zwei Implementierungsdetails:
- Nadeln müssen ≥5 Byte sein. Kürzere Strings würden im komprimierten Datenstrom zu falschen Positiven führen. Übersprungene Nadeln werden in der Ausgabe aufgelistet.
- Generische Strings sind keine Nadeln.
VideoHandlerist überall zu finden – Informationswert null.Lavf58.45.100dagegen identifiziert eine bestimmte Version – das ist ein Fingerabdruck. Die Trennlinie ist: „Kann der String den Kreis auf eine Person, ein Gerät oder einen Export eingrenzen?“
12 Prüfungen
| Prüfung | Blockiert |
|---|---|
| Byte-Ebene-Rückstände | Metadaten-Strings der Quelle tauchen in der Ausgabe auf |
| Tag-Ebene-Rückstände | Ausgabe-Tags stammen von der Quelle |
| Ortsinformationen | Jedes GPS / location-Feld |
| Geräte- und Software-Identifikation | make / model / software / artist / comment etc. |
| Quell-Zeitstempel | creation_time der Ausgabe entspricht dem der Quelle |
| Rotationsmatrix | bei encode-Modus wurde Rotation nicht ins Bild eingebrannt (copy-Modus überspringt – Löschen würde Bild verdrehen) |
| Spurenzusammensetzung | data / timecode / Anhang-Spuren wurden übernommen |
| Kapitel | Kapitel wurden übernommen |
| Bitstrom-Identitätsstrings | SEI / DSE / compressorname enthalten noch Encoder-Namen |
| Profil-Konformität | verbleibende Tags stimmen nicht mit dem deklarierten Profil überein |
| Dauer | Ausgabe wurde unbemerkt abgeschnitten |
| Dekodierbarkeit | Ausgabe ist eine unspielbare Datei |
HDR und eingebettete Untertitel: Wann SEI nicht angerührt werden dürfen
ffmpeg bietet nur NAL-Ebene-Granularität – filter_units löscht nach NAL-Typ, h264_metadata hat keine Option „nur bestimmte SEI-Payloads löschen“. Daher löscht remove_types=6 alle SEI auf einmal. Doch in SEI stecken neben dem x264-Müll auch:
- HDR-Statische-Metadaten (mastering display / content light level) – deren Entfernung würde Farben zerstören, Tone Mapping bricht
- CEA-608/708-Eingebettete Untertitel – deren Entfernung würde Untertitel löschen
Daher lautet die Strategie des Skripts: Nur dann eingreifen, wenn tatsächlich Identitätsstrings gefunden wurden; HDR-Quellen werden nie angetastet (es wird explizit angezeigt, dass und warum kein Eingriff erfolgte). Diese Entscheidung trifft seiPlan(), ohne dass du dich darum kümmern musst – aber du solltest wissen, dass es existiert.
Grenzen (was nicht gemacht wird)
Kein Entfernen von Wasserzeichen (robuste Pixel-Wasserzeichen überleben selbst Neucodierung – keine Hoffnung machen), keine Bildinhaltsänderung (kein Zuschneiden, Skalieren, Drehen), keine Fälschung von Gerätemodell oder GPS (Profile schreiben nur Tool-Informationen, keine erfundenen Aufnahmegeräte oder -orte), kein Batch-Modus (eine Datei pro Aufruf), keine Container außer mp4/mov.
Selbsttest
node {baseDir}/scripts/selftest.mjs
117 Assertions, ohne ffmpeg, ohne echte Dateien. Jede der 12 Prüfungen hat einen Durchbruchs-Testfall – beweist, dass sie wirklich blockt. Führe diesen Test nach jeder Änderung am Skript aus.