video-scrub
Rebuild a video into a clean file: video and audio carried over, not a single byte of metadata.
{baseDir} = the directory containing this file. Script at {baseDir}/scripts/video-scrub.mjs, zero dependencies, run directly with node.
Principle: whitelist, not blacklist
There are two approaches to scrubbing metadata—live or die:
| Approach | Problem | |
|---|---|---|
| Blacklist | List fields to delete and remove them one by one (like exiftool) | You can't delete what you don't know about—vendor-private udta, GoPro telemetry tracks, SEI in the bitstream; miss one and you've leaked it |
| Whitelist | Don't say what to remove, say what to carry over: one video stream, one audio stream, nothing else | — |
With the whitelist approach, the source's metadata has no channel to enter. It's not "deleted"—it was never carried over.
But ffmpeg will betray this structure twice by default, and you must stop it:
- It copies the source container tags to the output by default →
-map_metadata -1 - It auto-selects all streams by default, which will carry GoPro's
gpmdtelemetry track along →-map 0:v:0 -map 0:a:0?
Item 2 is the only defense for GoPro / DJI sources: their GPS isn't in a tag, it's in a separate timed metadata track. Clearing tags won't remove it; only "select exactly two streams" stops it.
The real challenge: invisible layers
With those two stopped, the tag layer is clean and ffprobe is silent. But three identity strings still live inside the file:
| Hidden in | Content | Visible to ffprobe |
|---|---|---|
| H.264 SEI | x264 full parameter string cabac=1 ref=3 … crf=23.0 | ✗ |
| AAC bitstream DSE | Lavc62.28.102 | ✗ |
| avc1 compressorname | Lavc libx264 | ✗ |
Five hiding places and their countermeasures are detailed at {baseDir}/references/residue-map.md—each one has empirical test records, including several traps that look like they should work but don't (-flags:v +bitexact and -x264-params info=0 both fail to block SEI).
Two modes
| Mode | Video | Audio | Speed | Extra removals |
|---|---|---|---|---|
copy (default) | Byte-for-byte copy | Re-encode | 53-second clip in 1.3s | — |
encode | Full re-encode | Re-encode | Much slower | Bitstream watermarks, fragile steganography |
Audio is re-encoded in both modes—AAC version number is written in DSE inside the bitstream, -c:a copy can't remove it. Re-encoding once is cheap and buys a clean audio track. Video in copy mode truly doesn't move a single bit (verified with cmp, except for the stripped SEI).
About watermarks: encode removes more than copy—bitstream watermarks and fragile steganography—but these are rare in real footage. The truly dangerous robust pixel watermarks (SynthID, forensic watermarks) are designed to survive re-encoding, so neither mode can remove them. Choose mode by quality and speed, don't rely on re-encoding to wash out watermarks.
Four profiles
The layer written back after scrubbing:
| Profile | Writes |
|---|---|
obs (default) | Looks like OBS Studio's output. OBS wraps with ffmpeg anyway, so this isn't forgery—it keeps the normal appearance of similar tools |
quicktime | macOS QuickTime screen recording: Core Media Video / Core Media Audio |
screencapture | ScreenCaptureKit screen recording |
bare | Writes nothing. Highest privacy strength—leaves no claims that could be disproven |
One thing that can't be done, mentioned here so you don't try: the container encoder field is owned by the muxer, -metadata encoder=Lavf60.16.100 cannot override it, and setting it to empty doesn't clear it (tested). Only -fflags +bitexact can make it disappear entirely. So the obs profile doesn't fake a version number—it lets ffmpeg write its real version.
Step 0 — Inspect the source first
node {baseDir}/scripts/video-scrub.mjs inspect <video>
Shows everything in one screen: container tags, per-stream tags and side data, chapters, and the identity strings inside the bitstream that ffprobe can't see.
Tells you how many needles to scan for during verification, and whether SEI surgery will happen.
Always run inspect before scrubbing. Pay special attention to:
- Whether there are
data/timecodestreams—if so, the source may carry telemetry (GoPro, DJI) - Whether it's HDR—if so, SEI is never removed (explained below)
Step 1 — Scrub
node {baseDir}/scripts/video-scrub.mjs scrub <video> -o out.mp4
Defaults to --mode copy --profile obs. Common options:
--mode encode # Full re-encode
--profile bare # Writes nothing, highest privacy
--crf 18 # Only meaningful in encode mode
--date 2026-01-01T00:00:00Z # Specify creation time; defaults to now if omitted
Step 2 — Verify ⛔ do not skip
node {baseDir}/scripts/video-scrub.mjs verify <source> <output>
Or all-in-one (use this daily):
node {baseDir}/scripts/video-scrub.mjs run <video> -o out.mp4
The core of verification is this: all previous steps are claims—"I set the right parameters"; this step is proof—"I scanned, it's gone."
The approach: extract every metadata string from the source as a "needle"—tag keys and values, handler names, chapter titles—then perform a byte-level scan of the output file. If any needle hits, it's flagged red.
Two implementation details:
- Needles must be ≥5 bytes. Shorter strings will randomly hit false positives inside compressed data; skipped needles are listed in the output.
- Generic strings are not needles.
VideoHandleris everywhere—zero information;Lavf58.45.100pins down a specific version—it's a fingerprint. The dividing line: "Can this string narrow down to a specific person, device, or export session?"
The 12 gates
| Gate | Blocks |
|---|---|
| Byte-level residue | Source metadata string hits the output |
| Tag layer residue | Output tag value came from source |
| Location info | Any GPS / location field |
| Device & software identifiers | make / model / software / artist / comment, etc. |
| Source timestamp | Output creation_time equals source's |
| Rotation matrix | Encode mode: rotation not baked into video (copy mode skips—removing it would distort the video) |
| Stream composition | data / timecode / attachment streams mixed in |
| Chapters | Chapters carried over |
| Bitstream identity strings | SEI / DSE / compressorname still contain encoder names |
| Profile conformance | Remaining tags don't match declared profile |
| Duration | Output secretly truncated |
| Decodability | Produced a broken file that can't be opened |
HDR and embedded subtitles: when SEI surgery is forbidden
ffmpeg only offers NAL-level granularity—filter_units removes by NAL type, and h264_metadata has no "remove only this SEI payload" option. So remove_types=6 means all SEI removed at once. But SEI contains more than x264's junk:
- HDR static metadata (mastering display / content light level)—removing it breaks color, tone mapping collapses
- CEA-608/708 embedded subtitles—removing it deletes subtitles
So the script's policy: only performs surgery when identity strings are actually detected; never touches HDR sources (it clearly states why it didn't). This decision is made by seiPlan(), you don't need to worry about it, but you should know it's there.
Boundaries (what we don't do)
No watermark removal (robust pixel watermarks survive re-encoding, don't count on it), no video content manipulation (no crop, scale, or rotation), no fake device models or GPS (profiles only write tool-class info, not fabricated capture devices or locations), no batch directory scanning (one video at a time), no containers other than mp4/mov.
Self-test
node {baseDir}/scripts/selftest.mjs
117 assertions, no ffmpeg required, no real files required. Each of the 12 gates has a break-case test—proof that the gate actually blocks. Run this after modifying any script.