video-scrub
Rebuild a video into a clean file:Picture and sound are carried over, metadata isn't carried over a single byte.
{baseDir} = the directory this file is in. The script {baseDir}/scripts/video-scrub.mjs, zero dependencies,node just run it.
The principle: allowlist, not blocklist
There are two ways to think about clearing metadata, and the difference is life or death:
| How to do it | Problem | |
|---|---|---|
| Blocklist | List the fields to delete and delete them one by one (the exiftool way) | You can't delete what you don't know about— vendor-private udta, GoPro's telemetry track, SEI in the bitstream, miss one and you've missed it |
| Allowlist | Don't say what to delete, only saywhat to carry over: one video stream, one audio stream, nothing else | —— |
Going by allowlist, the source's metadata hasno channelto get in. It isn't "deleted", it was never carried over.
But ffmpeg by default betrays this structure twice, and it must be held down:
- By default it carries the source's container tags to the output →
-map_metadata -1 - By default it auto-selects streams, which will carry GoPro's
gpmdtelemetry track along with it →-map 0:v:0 -map 0:a:0?
Item 2 is the only line of defense for sources like GoPro / DJI:Their GPS isn't in tags, it's on a separate timed metadata track. Clearing tags can't clear it, only "pick just two streams" can block it.
The real difficulty: the layers you can't see
Hold down the two above, and the tag layer is clean,ffprobe is all quiet.But there are still three identity strings lying in the file:
| Where it's hidden | Content | Visible to ffprobe |
|---|---|---|
| H.264's SEI | x264 full parameter string cabac=1 ref=3 … crf=23.0 |
✗ |
| AAC bitstream's DSE | Lavc62.28.102 |
✗ |
| avc1's compressorname | Lavc libx264 |
✗ |
The five hiding places and how to plug each one, see {baseDir}/references/residue-map.md——Each one has a measured record,
including a few "looks like it should work but actually doesn't" pits(-flags:v +bitexact and -x264-params info=0 can't block SEI).
Two modes
| Mode | Video | Audio | Speed | What extra it kills |
|---|---|---|---|---|
copy(default) |
Byte-for-byte copy | Re-encode | A 53-second clip in 1.3 seconds | —— |
encode |
Re-encode | Re-encode | Much slower | Bitstream watermarks, fragile steganography |
AudioBoth modes re-encode— AAC's version number is written in the DSE, which is inside the bitstream,-c:a copy can't erase it.
Re-encoding once is cheap, and buys you a clean audio track. Video, in copy mode, istruly not touched by a single bit(cmp verified,
except for the SEI that was stripped).
About watermarks:encode than copy kills additionally are code-domain watermarks and fragile steganography, but these two types are rare in real-world material.
The truly fatal one isrobust pixel watermarks(SynthID, forensic video watermarks), whose design goal is to survive re-encoding;neither mode can kill them.
So choose the mode based on quality and speed, and don't expect re-encoding to wash away watermarks.
Four profiles
The layer written back after wiping:
| profile | What to write |
|---|---|
obs(default) |
The look of OBS Studio. OBS itself is a wrapper around ffmpeg, so this profile isn't forgery, it just maintains the normal appearance of a similar tool |
quicktime |
macOS QuickTime screen recording:Core Media Video / Core Media Audio |
screencapture |
ScreenCaptureKit screen recording |
bare |
Write nothing.Highest privacy strength—leaves no claim that can be falsified |
There's one thing that can't be done, written here so you don't try again: the container's encoder field is occupied by the muxer itself,
-metadata encoder=Lavf60.16.100 can't be overwritten, and setting it to empty doesn't clear it either (tested). Only
-fflags +bitexact can make it disappear entirely. So the obs profile doesn't fake a version number, it just lets ffmpeg write its own real version number.
Step 0 — First look at what the source has
node {baseDir}/scripts/video-scrub.mjs inspect <video>
List everything on one screen: container tags, each stream's tags and side data, chapters,as well as those ffprobe invisible identity strings in the bitstream.
At the end it will tell you how many needles to use for scanning during acceptance, and whether the SEI knife cuts.
Look at this screen before acting. Pay special attention to two things:
- Is there a
data/timecodestream—if so, the source may carry a telemetry track (GoPro, DJI) - Is it HDR—if so, never delete SEI (explained below why)
Step 1 — Clean
node {baseDir}/scripts/video-scrub.mjs scrub <video> -o out.mp4
Default --mode copy --profile obs. Common parameters:
--mode encode # 完整重编码
--profile bare # 什么都不写,隐私强度最高
--crf 18 # 只在 encode 模式有意义
--date 2026-01-01T00:00:00Z # 指定创建时间,不给就用当前时间
Step 2 — Acceptance ⛔ Cannot skip
node {baseDir}/scripts/video-scrub.mjs verify <源> <输出>
Or all in one (use this for daily work):
node {baseDir}/scripts/video-scrub.mjs run <video> -o out.mp4
The core of acceptance is one sentence:Everything before is "I set the correct parameters", which is a claim; this step is "I scanned, it's really gone", which is proof.
The method is to extract all metadata strings from the source as "needles"—tag keys and values, handler names, chapter titles— and do abyte-level scanin the output file; if one needle hits, it's red.
Two implementation details:
- Needles must be ≥5 bytes. Shorter strings will randomly hit false positives in compressed data; skipped needles will be listed in the prompt.
- Generic strings aren't used as needles。
VideoHandlerare everywhere and carry zero information;Lavf58.45.100pins down a version, and is a fingerprint. The dividing line is "can it narrow down to a specific person, device, or export".
12 gates
| Gate | What it blocks |
|---|---|
| Byte-level residue | Source metadata strings hit the output |
| Tag-level residue | Output tag values come from the source |
| Location information | Any GPS / location field |
| Device and software identification | Things like make / model / software / artist / comment |
| Source creation_time | Output creation_time equals source's |
| Rotation matrix | In encode mode rotation isn't baked into the picture (copy mode skips it—delete it and the picture is crooked) |
| Stream composition | data / timecode / attachment streams snuck in |
| Chapters | Chapters came along |
| Bitstream identity string | Encoder name still in SEI / DSE / compressorname |
| Profile matches | Remaining tags don't match the declared profile |
| Duration | Output was secretly truncated |
| Decodable | Produced a broken file that won't open |
HDR and embedded subtitles: when the SEI knife must not be touched
ffmpeg only offers NAL levelgranularity—filter_units delete by NAL type,
h264_metadata and there's no option to "delete only a certain SEI payload". So remove_types=6 is
delete all SEI together. And besides x264's garbage string, SEI also houses:
- HDR static metadata(mastering display / content light level)—delete it and colors shift, tone mapping breaks
- CEA-608/708 embedded subtitles—delete it and subtitles are gone
So the script's strategy is:Only cut when an identity string is actually found; HDR source files are never touched(it will state plainly that nothing was touched,
and why). This judgment is made by seiPlan() , you don't need to worry about it, but you should know it's there.
Boundaries (things it doesn't do)
Doesn't remove watermarks (robust pixel watermarks can't be killed by re-encoding either, don't count on it), doesn't alter picture content (no cropping, no scaling, no rotation), doesn't fake device models and GPS (the profile only writes tool-type information, doesn't fabricate shooting device and location), doesn't do batch directory scanning (one file at a time), doesn't handle containers other than mp4/mov.
Self-test
node {baseDir}/scripts/selftest.mjs
117 assertions, doesn't touch ffmpeg, doesn't touch real files.All 12 gates have a break-through test case—proving it really does block. Run this first after modifying the script.