video-scrub
把一條影片重建成乾淨檔案:畫面與聲音帶過去,元數據一個位元組都不帶。
{baseDir} = 本檔案所在目錄。腳本 {baseDir}/scripts/video-scrub.mjs,零依賴,node 直接執行。
原理:白名單,不是黑名單
清除元數據有兩種思路,差別是死活:
| 怎麼做 | 問題 | |
|---|---|---|
| 黑名單 | 列出要刪的欄位逐個刪(exiftool 那種) | 刪不完你不知道的東西——廠商私有的 udta、GoPro 的遙測軌、碼流裡的 SEI,漏一個就漏了 |
| 白名單 | 不說刪什麼,只說帶什麼過去:畫面一條流、聲音一條流,別的一律不要 | —— |
走白名單,原始元數據沒有任何通道能進來。它不是「被刪掉了」,是從來沒被搬運。
但 ffmpeg 預設會背叛這個結構兩次,必須按住:
- 它預設把原始容器的 tag 搬到輸出 →
-map_metadata -1 - 它預設自動挑流,會把 GoPro 的
gpmd遙測軌一起帶走 →-map 0:v:0 -map 0:a:0?
第 2 條是 GoPro / DJI 這類原始影片的唯一防線:它們的 GPS 不在 tag 裡,在一條獨立的定時元數據軌上。 清 tag 清不掉,只有「只挑兩條流」擋得住。
真正的難點:看不見的那幾層
按住上面兩條,tag 層就乾淨了,ffprobe 一片安靜。但檔案裡還躺著三處身份串:
| 藏在哪 | 內容 | ffprobe 看得見 |
|---|---|---|
| H.264 的 SEI | x264 完整參數串 cabac=1 ref=3 … crf=23.0 | ✗ |
| AAC 碼流的 DSE | Lavc62.28.102 | ✗ |
| avc1 的 compressorname | Lavc libx264 | ✗ |
五個藏身處和各自的堵法見 {baseDir}/references/residue-map.md——每一條都有實測記錄,
包括幾個「看著該管用其實沒用」的坑(-flags:v +bitexact 和 -x264-params info=0 都攔不住 SEI)。
兩種模式
| 模式 | 畫面 | 音訊 | 速度 | 多殺掉什麼 |
|---|---|---|---|---|
copy(預設) | 逐位元組照搬 | 重編 | 53 秒的片子 1.3 秒 | —— |
encode | 重編碼 | 重編 | 慢得多 | 碼流層浮水印、脆弱隱寫 |
音訊兩種模式都重編——AAC 的版本號寫在 DSE 裡,那是碼流內部,-c:a copy 抹不掉它。
重編一遍很划算,換來音軌乾淨。畫面則在 copy 模式下真的一個位元都沒動(cmp 驗證過,
除了被摘掉的 SEI)。
關於浮水印:encode 比 copy 多殺的是碼流層浮水印和脆弱隱寫,這兩類在實際素材裡少見。
真正要命的穩健像素浮水印(SynthID、影視取證浮水印)設計目標就是扛住重編碼,兩種模式都殺不掉。
所以選模式按畫質和速度選,別指望重編碼能洗掉浮水印。
四個 profile
擦白之後往回寫的那一層:
| profile | 寫什麼 |
|---|---|
obs(預設) | OBS Studio 的長相。OBS 本來就是用 ffmpeg 封裝的,所以這檔不是偽造,是保持同類工具的正常外觀 |
quicktime | macOS QuickTime 錄屏:Core Media Video / Core Media Audio |
screencapture | ScreenCaptureKit 錄屏 |
bare | 什麼都不寫。隱私強度最高——不留任何可被證偽的聲明 |
有一件事做不到,寫在這裡省得再試:容器的 encoder 欄位由 muxer 自己佔著,
-metadata encoder=Lavf60.16.100 蓋不過去,置空也清不掉(實測)。只有
-fflags +bitexact 能讓它整個消失。所以 obs 檔不偽造版本號,它就讓 ffmpeg 寫自己的真版本號。
Step 0 — 先看原始影片帶了什麼
node {baseDir}/scripts/video-scrub.mjs inspect <video>
一屏列全:容器 tag、每條流的 tag 與 side data、章節,以及碼流裡那幾處 ffprobe 看不見的身份串。
末尾會告訴你驗收時要拿幾根針去掃,以及 SEI 這刀動不動。
先看這一屏再動手。 尤其注意兩件事:
- 有沒有
data/timecode流——有就說明原始影片可能帶遙測軌(GoPro、DJI) - 是不是 HDR——是的話 SEI 一律不刪(下面說為什麼)
Step 1 — 清
node {baseDir}/scripts/video-scrub.mjs scrub <video> -o out.mp4
預設 --mode copy --profile obs。常用參數:
--mode encode # 完整重編碼
--profile bare # 什麼都不寫,隱私強度最高
--crf 18 # 只在 encode 模式有意義
--date 2026-01-01T00:00:00Z # 指定建立時間,不給就用當前時間
Step 2 — 驗收 ⛔ 不能跳
node {baseDir}/scripts/video-scrub.mjs verify <原始> <輸出>
或者一條龍(日常就用這個):
node {baseDir}/scripts/video-scrub.mjs run <video> -o out.mp4
驗收的核心是一句話:前面全是「我調了正確的參數」,屬於聲稱;這一步是「我掃了,真沒了」,屬於證明。
做法是把原始影片的所有元數據字串抓出來當「針」——tag 的鍵和值、handler 名、章節標題—— 在輸出檔案裡做位元組級掃描,扎到一根就紅。
兩條實現上的講究:
- 針要 ≥5 位元組。更短的串會在壓縮資料裡隨機撞出假陽性,跳過的針會在提示裡列出來。
- 通用串不當針。
VideoHandler滿世界都是,資訊量為零;Lavf58.45.100釘死了一個版本, 是指紋。分界線是「能不能把範圍縮小到某個人、某台裝置、某次匯出」。
12 道門
| 門 | 攔什麼 |
|---|---|
| 位元組級殘留 | 原始影片的元資料串扎中了輸出 |
| tag 層殘留 | 輸出的 tag 值來自原始影片 |
| 位置資訊 | 任何 GPS / location 欄位 |
| 裝置與軟體標識 | make / model / software / artist / comment 之類 |
| 原始影片時間戳 | 輸出的 creation_time 等於原始影片的 |
| 旋轉矩陣 | encode 模式下旋轉沒烘進畫面(copy 模式跳過——刪了畫面就是歪的) |
| 流構成 | 混進了 data / timecode / 附件流 |
| 章節 | 章節跟過來了 |
| 碼流身份串 | SEI / DSE / compressorname 裡還有編碼器名字 |
| profile 相符 | 剩下的 tag 和聲明的 profile 對不上 |
| 時長 | 輸出被偷偷截斷 |
| 可解碼 | 產出了打不開的壞檔案 |
HDR 與內嵌字幕:SEI 這刀什麼時候不許動
ffmpeg 只給得起 NAL 級的粒度——filter_units 按 NAL 類型刪,
h264_metadata 也沒有「只刪某個 SEI payload」的選項。所以 remove_types=6 是
全部 SEI 一起刪。而 SEI 裡除了 x264 那串垃圾,還住著:
- HDR 靜態元資料(mastering display / content light level)——刪了會掉色,tone mapping 崩
- CEA-608/708 內嵌字幕——刪了字幕沒了
所以腳本的策略是:只在真查到身份串時才動刀,HDR 原始影片一律不動(會明說沒動,
以及為什麼)。這個判斷由 seiPlan() 做,不需要你操心,但你該知道它在那兒。
邊界(不做的事)
不去除浮水印(穩健像素浮水印重編碼也殺不掉,別指望)、不改畫面內容(不裁切不縮放不旋轉)、 不偽造裝置型號和 GPS(profile 只寫工具類資訊,不編造拍攝裝置和位置)、 不做批次目錄掃描(一次一條片子)、不處理非 mp4/mov 之外的容器。
自測
node {baseDir}/scripts/selftest.mjs
117 項斷言,不碰 ffmpeg、不碰真檔案。12 道門每道都有擊穿用例——證明它真的會攔。 改完腳本先跑這個。