feat(wasm): FfmpegFallbackStrategy — MP4/MOV/MKV/WebM via ffmpeg-wasm, on by default (#182) #183
No reviewers
Labels
No labels
bug
documentation
duplicate
e-copy
e-features
e-mobile
enhancement
f-coverage
f-forensic
f-perf
f-privacy
forensic
good first issue
help wanted
infra
invalid
phase-a
phase-b
phase-c
phase-d
phase-e
phase-f
phase-g
phase-h
priority-1
priority-2
priority-3
privacy
question
v5
v6
video-hardening
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: forgejo_admin/exifcleaner-web#183
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/ffmpeg-poc"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #182. Closes #43.
Adds a third strategy (peer to
ExifToolFallbackStrategy) using ffmpeg-wasm. On by default for all three distributions (standalone HTML, Capacitor APK, PWA self-host);VITE_ENABLE_FFMPEG_FALLBACK=falseopts out.When enabled,
FfmpegFallbackStrategytakes priority overVideoStrategyfor.mp4/.mov/.m4v(Phase 1) and adds new coverage for.mkv/.webm(Phase 2).VideoStrategystays in the registry as the opt-out fallback; deletion is a subsequent PR after a validation window per #182's sequencing.The strategy loads
@ffmpeg/coredirectly on the main thread rather than going through the@ffmpeg/ffmpegWeb Worker wrapper. The wrapper spawnstype: "module"Workers from Blob URLs, which fail silently underfile://(null-origin) in Chromium — the standalone HTML build was hanging forever on every video strip until we cut the wrapper out.@ffmpeg/ffmpegwas dropped frompackage.jsonin9f87ff6; only@ffmpeg/coreships now.A post-strip pass runs after the ffmpeg invocation to remove muxer-added fields ffmpeg leaks even with
-map_metadata -1— specifically themoov/udta/meta/hdlrappl/mdirvendor block, theapplmovie-levelmetaatom, and per-trackbtrt(buffer/bitrate) boxes. These are written unconditionally bymov_write_meta_tagregardless of the metadata flags, so they have to be scrubbed structurally after the remux.Why
VideoStrategyhas documented coverage gaps (#38, #39, #111, #42) — each tractable but slow to close. ffmpeg closes them in one shot via its remux pipeline. More importantly, no browser-based privacy tool covers MKV/WebM stripping today — adding ffmpeg-wasm gives MetaScrub a real competitive position on long-tail container formats. WebM is particularly valuable: it's the default for browser-recorded video via MediaRecorder.The
-map 0:v? -map 0:a?invocation choice is the key departure from mat2's default-map 0. Dropping all data streams (timecode, description, gpmd, subtitle) makes GoPro Fusion / DJI files succeed where mat2 exits with code 234. It also removes the GPMF GPS, device fingerprints, and handler/compressor name leaks the walker left in.What's in the bundle
Nine commits on this branch, in chronological order:
b4fc959— docs(poc):@ffmpeg/ffmpegevaluation forFfmpegFallbackStrategy(#182). Package evaluation: ~10 MB gzipped WASM, no-network verified (static + dynamic), performance benchmarks, license analysis.d2d2edd— feat(wasm):FfmpegFallbackStrategyfor MP4/MOV/M4V (Phase 1). Strategy +docs/gap-analysis/mp4-ffmpeg.md+ tests + registry wiring. Routes MP4-family containers ahead ofVideoStrategy.5ce4200— feat(wasm):FfmpegFallbackStrategyPhase 2 — MKV/WebM + forensic runner. Addsdocs/gap-analysis/{mkv,webm}.md,docs/forensic/ffmpeg-fallback.md, andtools/forensic/ffmpeg-fallback.ts.9d60f37— ci(ffmpeg): smoke build withVITE_ENABLE_FFMPEG_FALLBACK=false. Newbuild-no-ffmpegjob exercises the opt-out build path. Adds README "Third-party engines and license notices" section.2e4b8dd— perf(standalone): inline ffmpeg trio in<script type=text/plain>. Closes the standalone-single-file gap —viteSingleFileonly inlines JS/CSS chunks, soffmpeg-core.wasm(30.7 MB),ffmpeg-core.js, and the@ffmpeg/ffmpegworker were shipping as sibling files and 404ing underfile://. Two new Vite plugins (standaloneFfmpegStubPlugin,standaloneFfmpegInlinePlugin) mirror the existing zeroperl base64-inline pattern.eae2dbe— test(forensic): add DJI Phantom 4 (236 MB) to ffmpeg-fallback runner. New real-world fixture in the forensic battery.9f87ff6— fix(standalone): main-thread@ffmpeg/core, drop@ffmpeg/ffmpegwrapper. The wrapper hardcodestype: "module"Workers from Blob URLs, which fail silently underfile://(null origin) in Chromium — the standalone build hung forever on every video strip. Switches to loading@ffmpeg/coredirectly on the main thread and removes@ffmpeg/ffmpegfrompackage.json.eb65f13— test(e2e): MP4 strip through standalone + web — would have caught the Worker bug. Two new Playwright tests:tests/e2e/standalone/standalone.spec.tsexercises the strategy through the realdist/web-standalone/index.htmloverfile://;tests/e2e/web/file-processing.spec.tsexercises the dev-server-served PWA (different asset-resolution branch — Vite?urlemission vs inline-base64). With the old wrapper, the standalone test would have hung; the new tests cap at 90s and assert sentinel-clean output.5269e7c— fix(ffmpeg): strip muxer-added fields ffmpeg leaks after-map_metadata -1. Post-strip pass scrubs the movie-levelmoov/udta/meta/hdlrmdir/applvendor block, theapplmetaatom, and per-trackbtrtboxes that ffmpeg'smov_write_meta_tagemits unconditionally regardless of metadata flags.Decisions captured in the bundle
VITE_ENABLE_FFMPEG_FALLBACK=falseopts out@ffmpeg/core0.12.10 (no SAB / COOP-COEP requirement)@ffmpeg/coreis loaded directly without the@ffmpeg/ffmpegWorker wrapper, which fails underfile://(see commit 7)@ffmpeg/coreis GPL-2.0-or-later — combined distributable inherits, MIT codebase unchanged, source pointer added to README per GPL compliance (#182: "use the better one, don't overthink about licensing")-fflags +bitexact(zeroes timestamps per privacy-invariants §6) +-metadata encoder=(suppresses theLavf<version>strip-tool fingerprint) + post-strip pass forappl/btrt. Full policy indocs/gap-analysis/mp4-ffmpeg.md.#34is the proper homePrivacy / sandbox audit
Documented in
docs/poc/ffmpeg-wasm.md. Honest framing: defense-in-depth, not "structural by construction" like the WebPerl-ExifTool case.fetchis only used to load the WASM itself (we control the URL via Vite's?urlasset emission)socket()forrtsp:///tcp:///udp://protocols) but dormant — requiresModule["websocket"]to be functional, which we don't provideconnect-src 'self'is the deploy-layer backstopnode --permissionwithout--allow-netproduces byte-identical outputThe WASM imports are minified by Emscripten (single-letter symbols), so the import-section name audit that worked for
zeroperl.wasmdoesn't apply here. The JS-side audit + dynamic test substitute.Forensic verification
npx tsx tools/forensic/ffmpeg-fallback.ts— all fixtures clean (synthetic + real-world, including the 236 MB DJI Phantom 4 added in commit 6),KNOWN_GAPSempty. After commit 9 the post-strip pass also clearsHandlerVendorID: Apple,BufferSize, andMaxBitrate/AverageBitrateleaks that exiftool was reporting from the muxer-addedmoov/udta/meta/hdlrblock.GoPro Fusion remains the most demanding fixture —
mat2fails it outright (ffmpeg -codec copyexit 234 on thetmcd/fdscdata streams). With-map 0:v? -map 0:a?those streams are dropped and all 7 device fingerprints (GoPro AVC,gpmd,GoPro AAC,GoPro TCD,GoPro MET,GoPro SOS,Fusion) are gone from the output. Better than mat2 and better than the current walker on the same file.Quality gates
yarn typecheckyarn lintyarn test— covers strategy + registry gating + post-strip passyarn check:deps— no circular depsyarn build:web— succeeds with and withoutVITE_ENABLE_FFMPEG_FALLBACK=falseyarn build:web:standalone— succeeds; ffmpeg trio inline-base64'd, single-file output verifiedyarn test:e2e:standalone+yarn test:e2e:web— MP4 strip through real built apps, 90s cap, sentinel-clean assertionsbuild-no-ffmpegjob exercises the opt-out build pathKnown limitations (follow-ups, not blockers)
VITE_ENABLE_FFMPEG_FALLBACK=false: the strategy is statically imported instrategy_registry.ts(matches the existingExifToolFallbackStrategypattern), so the 30 MBffmpeg-core.wasmships indist/web/assets/even when registration is skipped. Real tree-shaking would need dynamic imports — same limitation as the ExifTool fallback today. Worth a separate PR that improves both fallbacks.tests/fixtures/wasm/video/real-world/is a worthwhile next step.VideoStrategyafter a validation window.aviclaim (closes #44).wmv,.3gpafter per-format forensic verification#34)Phase 2 extends the strategy's claim set to .mkv + .webm. Container detection is polymorphic — ISOBMFF MP4-family ftyp box OR EBML magic (0x1A 0x45 0xDF 0xA3). detectContainer() distinguishes WebM from MKV via the EBML DocType element (looks for the ASCII "webm" in the first 64 bytes; falls back to MKV which is the superset). The strip invocation drops -movflags +faststart for matroska containers (MP4-only flag; matroska muxer ignores it with a noisy warning). Forensic runner (tools/forensic/ffmpeg-fallback.ts) now covers: - synthetic-mp4 (5647 → 2238, 5 sentinels → 0) - synthetic-mkv (1991 → 1761, 4 sentinels → 0) - synthetic-webm (1190 → 991, 4 sentinels → 0) - phone-baseline (2.7 MB samplelib Android) - gopro-fusion (5.1 MB, 7 device-fingerprints → 0) All five pass. KNOWN_GAPS empty — strategy is now forensically clean across every fixture we test. MKV seeding uses ffmpeg's -metadata directly because exiftool refuses to write to MKV/WebM ("file format not supported for writing"). docs/gap-analysis/{mkv,webm}.md document the per-source policy. docs/forensic/ffmpeg-fallback.md captures empirical results + recovery battery methodology. Quality gates: yarn typecheck + yarn lint + yarn test (505 passed) + yarn check:deps all clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Empirically closes the "drone coverage is predicted, not measured" gap I flagged earlier. The 236 MB DJI Phantom 4 fixture (Zenodo record 3604005, fetched via --include-large) now runs through the strategy and verifies: - FC6310 (drone model in UserData) — removed - AVC encoder (compressorname in sample entry) — removed - DJI.AVC (video handler description) — removed - DJI.Meta (metadata-track handler description) — removed - Full GPS flight log under [UserData] GPSCoordinates (55°N 10°E 10.8m, ~Denmark) — completely gone Performance: 236 MB strip completes in ~1.0 s wall time, peak RSS ~1.2 GB (~5× input). Full battery (6 fixtures including DJI) runs in 4.2 s. Sets a real ceiling estimate for mobile WebView: ~250 MB on tighter-memory devices (same constraint as the walker today; #34 is the proper home for streaming I/O). Gap-analysis prose updated to match what's actually measured: phone, action cam, and drone are now empirically verified; dashcam coverage remains predicted by analogy (no fixture yet — follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The user reported the standalone HTML build hanging indefinitely when stripping an 18 MB MP4. Reproduced in headless Chromium with a 2.7 MB fixture: the @ffmpeg/ffmpeg wrapper spawns `type: "module"` Workers from Blob URLs, which fail silently when the page origin is `null` (file://). The error event fires with all fields cross-origin-censored (message/filename/lineno all empty), and the wrapper's load() promise never resolves — UI hangs forever. Minimal repro confirms it's a Chromium null-origin restriction, not specific to ffmpeg. Classic Workers from Blob URLs work fine; module Workers from Blob URLs uniformly fail under null origin. The wrapper hardcodes `type: "module"` so there's no workaround inside the wrapper itself. Switching FfmpegFallbackStrategy to use @ffmpeg/core directly in the main thread: - No Worker, no Blob URL, no null-origin restriction. - Same code path everywhere (standalone / PWA / Capacitor APK) — no env-conditional behavior. - Matches ExifToolFallbackStrategy + JpegStrategy / PdfStrategy architecture: all run in the main thread. - Drops @ffmpeg/ffmpeg + @ffmpeg/util from package.json (10 KB JS each, no longer used). Trade-off: blocks the main thread during exec(). Empirically: - Phone-baseline MP4 (2.7 MB) via standalone HTML end-to-end: 5.8 s (includes cold WASM init). - Synthetic + gopro-fusion + dji-phantom4: per-file numbers unchanged from the prior Node-side POC. - DJI Phantom 4 (236 MB) strip time in Node: ~1.0 s (CPU-bound, main-thread blocking would be the same in browser). Acceptable: the strategy class itself ran in the main thread before (only the engine ran in the Worker). And the strip is short enough that a brief unresponsive UI matches users' expectation of "metadata stripper, processing now". Other changes: - Removed standaloneFfmpegStubPlugin's worker-URL stub (no longer a `?url` import to intercept). - Removed worker.js inlining from standaloneFfmpegInlinePlugin. The Vite-emitted sibling worker file is dead; we no longer delete it because we no longer trigger Vite's `new Worker(new URL("./worker.js", import.meta.url))` static discovery (no longer import @ffmpeg/ffmpeg). - Added an ambient declaration for @ffmpeg/core (no @types package). Verification: - yarn typecheck, yarn lint, yarn test (505 passed), yarn check:deps - yarn build:web + yarn build:web:standalone both succeed - Standalone HTML: end-to-end MP4 strip via DataTransfer drop works in 5.8 s on phone-baseline, produces a 2.8 MB stripped output with Lavf fingerprint removed and timestamps zeroed - Forensic battery: all 6 fixtures (3 synthetic + 3 real-world, including DJI Phantom 4 236 MB) clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two new Playwright tests exercise the FfmpegFallbackStrategy through the real built apps: - tests/e2e/standalone/standalone.spec.ts — drops sample-real.mp4 into the dist/web-standalone/index.html via file:// and asserts the download completes + is sentinel-clean. This is the regression test for the Blob-URL/type:module Worker hang fixed in9f87ff6. With the old implementation it would have hung forever; the test now caps at 90s and waits for completion. - tests/e2e/web/file-processing.spec.ts — drops sample-real.mp4 through the dev-server-served PWA. Different asset-resolution branch (Vite ?url emission vs inline-base64), so this complements the standalone test rather than duplicating it. New fixture tests/e2e/fixtures/sample-real.mp4 (5.3 KB) is a real ffmpeg-encoded 1s blue frame seeded with Title="Test Video" and Author="Test Author" sentinels. The existing sample.mp4 was adversarial (exiftool-synthesized with non-aligned sample tables; ffmpeg's -codec copy refuses it — that's the same failure mode documented for the synthetic fixture in docs/forensic/video.md). New assertVideoStripped() helper in tests/e2e/web/helpers/ metadata_assertions.ts checks for: - No Lavf<version> encoder fingerprint - No "Test Author" / "Test Video" sentinels from the fixture - No Adobe XMP namespace marker from the seeded uuid box - No Image::ExifTool authorship string Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>User reported that the stripped output gained fields that weren't in the input: - HandlerVendorID: Apple - BufferSize: 0 - MaxBitrate / AverageBitrate (per-track values) A reviewer suggested `-metadata:s:v vendor_id= -metadata:s:v handler_name=` etc. Empirically verified this doesn't address it: those flags target the per-stream trak/mdia/hdlr fields (which ffmpeg writes as zeros anyway in our config). The `HandlerVendorID: Apple` exiftool reports actually comes from a different location — the movie-level moov/udta/meta/hdlr block that ffmpeg's mov_write_meta_tag emits unconditionally with handler_type "mdir" + vendor "appl", regardless of -map_metadata -1. Two-layer fix: ffmpeg muxer flags handle what flags can: -write_btrt 0 — suppresses btrt boxes (kills BufferSize/ MaxBitrate/AverageBitrate) -write_tmcd 0 — suppresses timecode atom (defensive; we drop tmcd streams via -map anyway) -empty_hdlr_name true — zeros per-track hdlr.name (kills VideoHandler/ SoundHandler) -metadata:s:{v,a} vendor_id= handler_name= — defense-in-depth for per-stream tags (adopting the reviewer's suggestion alongside the above) Post-strip pass handles what flags can't: ffmpeg_post_strip.ts walks moov/udta/meta/hdlr and zeros the 4-byte vendor field at payload offset +12 (after FullBox header + pre_defined + handler_type). Length-preserving — all byte offsets elsewhere stay valid. No-op for matroska containers. End-to-end verification on phone-baseline.mp4 via standalone HTML: - Before: HandlerVendorID=Apple, BufferSize=0, MaxBitrate=4486713, AverageBitrate=4486713 (×2 for audio track) - After: zero hits across raw `strings | grep -ciE 'appl|btrt|Lavf|VideoHandler|SoundHandler'` - exiftool view shows nothing under HandlerVendorID, HandlerDescription, BufferSize, MaxBitrate assertVideoStripped() in tests/e2e/web/helpers/metadata_assertions.ts strengthened with checks for `mdirappl`, `btrt`, `VideoHandler`, `SoundHandler` so regressions on this class of leak fail the e2e suite instead of slipping through CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>0f719b8d0ato5269e7cf67- README.md: drop @ffmpeg/ffmpeg from the "Third-party engines" row; only @ffmpeg/core ships now (wrapper was removed in9f87ff6). - tests/infrastructure/wasm/ffmpeg_fallback_strategy.test.ts: fix stale header comment that still claimed the strategy uses the @ffmpeg/ffmpeg Web-Worker wrapper. - src/.../ffmpeg_post_strip.ts: fix file-header offset claim (was "+8", actually +12 — the off-by-4 the code already had right but the comment didn't). Refactor cleanFfmpegMp4Output to mutate in place instead of allocating a defensive copy; the only caller already owns the buffer (fresh allocation from MEMFS). Saves ~58 ms + 236 MB transient RSS on the DJI Phantom 4 strip (measured). - tests/infrastructure/wasm/ffmpeg_post_strip.test.ts: new file — 5 unit tests covering happy-path zeroing, per-track udta nesting, no-op when structure absent, malformed-meta graceful handling, and multiple-hdlr defensive coverage. Off-by-N regressions in the box walker will now fail at the Vitest level rather than only at the forensic-runner level. - docs/gap-analysis/mkv.md: document the matroska muxer fingerprint audit gap. cleanFfmpegMp4Output is MP4-only; ffmpeg's matroska muxer writes MuxingApp/WritingApp/SegmentUID that aren't audited or stripped by this PR. Suggested follow-up: extend assertVideoStripped's matroska branch + add cleanFfmpegMatroskaOutput if leaks are found. Verified: yarn typecheck, yarn lint, yarn test (510 passed — +5 from new ffmpeg_post_strip tests), yarn check:deps, forensic battery 6/6 clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>User reported cold-start time regressed when ffmpeg landed (same shape as the exiftool inline-payload regression that was previously fixed by moving to <script type="text/plain">). The remaining cost was the HTML parser allocating ~150 MB of UTF-16 text nodes for the inlined base64 blobs (zeroperl 33 MB base64 + ffmpeg core+wasm 42 MB base64). That's unavoidable in proportion to text-node size, so we shrink the text-node. Vite plugins (vite.config.web.standalone.ts): - standaloneWasmInlinePlugin: gzipSync(wasmBytes, {level:9}) before base64. zeroperl.wasm 25.3 MB → 7.6 MB gz → 10.1 MB base64. - standaloneFfmpegInlinePlugin: same — ffmpeg-core.{js,wasm} 32.3 MB → 10.3 MB gz combined. Runtime decoders: - exiftool_wasm_fetch.ts redirectWasmFetch: pipe the bytes through DecompressionStream("gzip") before handing to instantiateStreaming. - ffmpeg_wasm_fetch.ts readInlinedCore: same pattern, both core JS and wasm decoded in parallel via Promise.all. New helper base64GunzipToBytes uses fetch(data:URL) for the base64 decode (native, faster than atob+charCodeAt loop on multi-MB inputs) followed by DecompressionStream for the gunzip (~30 ms one-shot at first strip, not at page load — pure win). DecompressionStream is supported in Chrome 80+ / Firefox 113+ / Safari 16.4+ / Capacitor Chromium WebView. All standalone + APK targets are covered. Measured impact (`dist/web-standalone/index.html`): before: 116 MB raw / 40 MB gz on the wire (servable wire-gzip) after: 65 MB raw / 21 MB gz on the wire E2E impact (`yarn test:e2e:standalone` running the MP4 strip): before: 10.4 s wall (cold load + cold WASM init + strip + download) after: 7.8 s wall (-2.6 s; HTML parse is the dominant savings) Verified: yarn typecheck, yarn lint, yarn test (510 passed), yarn check:deps. Full standalone e2e suite (8 tests, both JPEG + PDF + MP4 paths) 8/8 green. Forensic runner 6/6 clean (unchanged — the strategy bytes are identical; only the transport changed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Code review
Found 1 issue:
standaloneWasmInlinePluginandstandaloneFfmpegInlinePluginboth havecloseBundlehooks that read + writedist/web-standalone/index.html. Rollup runscloseBundleviahookParallel, so the two plugins are scheduled concurrently — today the build happens to produce correct output only because both hook bodies are fully synchronous (all fs operations are*Sync, so JavaScript run-to-completion serialises them in practice). Plugin-array order doesn't enforce sequencing on its own, so the ordering is implicit. If anyone later adds anawaitinside eithercloseBundle(e.g. async hashing, a network call, or async fs APIs), the second plugin can read stale HTML mid-mutation and clobber the first plugin's injection. Worth adding an explicitenforce: "post"/order: "post"to the ffmpeg plugin so the contract is documented in code, or merging the two into a single plugin with a deterministic sequence.vite.config.web.standalone.ts L243-L300 —
standaloneWasmInlinePlugin.closeBundlevite.config.web.standalone.ts L370-L420 —
standaloneFfmpegInlinePlugin.closeBundle🤖 Generated with Claude Code
- If this code review was useful, please react with 👍. Otherwise, react with 👎.
User reported standalone cold-start still 12-16 s after the gzip win. Profiling the standalone HTML cold-load + 2.7 MB MP4 strip: step before prewarm after prewarm ------------------------ --------------- --------------- HTML loaded (parse + JS) ~1.4 s ~1.4 s UI ready (drop zone) ~1.5 s ~1.5 s strip phase (drop→done) ~5.5 s ~0.2 s total ~7.0 s ~1.7 s The cold-init (gunzip 30 MB wasm + WebAssembly.instantiate) was on the critical "drop→done" path — every first-drop paid ~3-5 s of WASM init before ffmpeg could run. With the prewarm, the same work happens in the background between page-load and first-drop; by the time the user drops a file the engine is already loaded. Mechanism: the FfmpegFallbackStrategy constructor schedules `getInstance()` (the existing cached-load entry point) via requestIdleCallback. requestIdleCallback runs the callback when the browser is idle (after first paint + initial React mount), so this doesn't compete with critical-path startup work. setTimeout fallback covers Safari (no requestIdleCallback) — irrelevant for the standalone target since we're Chromium-only, but cheap to include. Errors from the prewarm load are swallowed: strip() retries on demand and surfaces the error through Result<_, ExifError>. The prewarm exists only to overlap latency, not to gate functionality. Verified: - typecheck, lint, test (510 passing), check:deps - yarn test:e2e:standalone --grep "strips an MP4" → 7.8 s wall (matches pre-prewarm — the e2e doesn't simulate user-think time, so it can't observe the prewarm win directly. Real-user impact is the ~5 s shaved off cold "drop→done") - yarn test:e2e:web:desktop → 6.9 s (unchanged) - forensic battery 6/6 clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Six review findings scored >35 confidence in the second-round review. Bundled into one logical commit since they all touch closely-related code (the FfmpegFallbackStrategy + its asset-loading + the standalone Vite plugins). Each fix is independently sound; together they also unblock a major start-time win. 1. CLAUDE.md: production dep count is now 6, not 4. Documents @ffmpeg/core (#182) and @uswriting/exiftool (#174) alongside the existing four, with one-line justification per dep. The "Dependencies" code-conventions bullet is rephrased from "Four production deps is the current ceiling" to "Current count is 6 production deps; new deps need explicit justification". 2. ffmpeg_fallback_strategy.ts: replaces the legacy `exiftool-error` ExifError variant with the correct codes per typescript-conventions.md ("new strategies typically only return invalid-file-format, file-io-error, or parse-failed"). Non-zero ffmpeg exit → `parse-failed` (with `raw` per the variant shape). Catch-all exception → `file-io-error` (with `detail`). User- facing messages no longer say "ExifTool error:" for ffmpeg failures. 3. ffmpeg_fallback_strategy.ts: `getInstance()` now resets `this.loadPromise = null` on rejection. Without this, a transient load failure (network blip, gunzip error, dynamic import failure) would permanently brick the strategy for the page session — every subsequent strip() call returned the same cached rejection. With the reset, the next strip() triggers a fresh retry. The constructor's requestIdleCallback prewarm interacts cleanly: prewarm failure still gets swallowed, but the next user strip now retries instead of re-surfacing the rejection. 4. ffmpeg_fallback_strategy.ts: strip() wraps its body in try/finally so MEMFS input + output get unlinked on every exit path, including the previously-leaky case where `core.FS.readFile` throws (zero-byte output, MEMFS corruption, OOM). Without the finally, the user's video bytes would persist in WASM linear memory until the next strip's pre-call cleanup overwrote them — a real privacy concern on a strategy where input bytes are the sensitive payload. 5. vite.config.web.standalone.ts: merges standaloneWasmInlinePlugin + standaloneFfmpegInlinePlugin into a single standaloneInlineWasmsPlugin. The two were race-prone — both had closeBundle hooks that read+wrote the same `index.html`, and Rollup runs closeBundle via hookParallel. Today they happened to serialise because both hook bodies were fully synchronous, but any future `await` inside either would let the second plugin read stale HTML and clobber the first plugin's injection. The merge does one read + iterates an INLINE_ASSETS array (zeroperl.wasm, ffmpeg-core.js, ffmpeg-core.wasm) + one write, eliminating the implicit dependency. Closes review comment #1347. 6. ffmpeg_wasm_fetch.ts + vite.config.web.{ts,standalone.ts} + ffmpeg_core.d.ts: tree-shake the bare `import("@ffmpeg/core")` out of the standalone bundle. The PWA-path branch of resolveCore() was unreachable at runtime in the standalone build (readInlinedCore() returns first), but Vite was statically bundling the entire factory + its data:URL wasm fallback — ~43 MB of dead code. A new build-time flag `__WITH_STANDALONE_INLINE__` (declared in ffmpeg_core.d.ts, set to "true" via Vite's `define` in the standalone config, "false" in the PWA config) gates the bare import behind a throw, letting Rollup eliminate the unreachable branch. Measured impact of (6): standalone HTML size: 65.3 MB → 24.3 MB (-63%) headless drop-zone visible: 1.5 s → 0.55 s (-65%) The user reported "10 s to open the HTML and show the buttons to choose files and directories". The 43 MB of dead code in the HTML was forcing the browser to parse and allocate text nodes for it before any UI could appear. With the dead code tree-shaken, real-browser cold-open should drop proportionally — roughly 2-3 s on the same hardware. Verification (worktree state): yarn typecheck, yarn lint, yarn test (510 passing), yarn check:deps yarn build:web (PWA bundle still contains ffmpeg-core factory — confirmed the bare import IS reached in PWA at build) yarn build:web:standalone (24.3 MB index.html, one file) yarn test:e2e:standalone --grep "strips an MP4" → 6.8 s yarn test:e2e:web:desktop --grep "strips metadata from an MP4" → 6.2 s npx tsx tools/forensic/ffmpeg-fallback.ts → 6/6 fixtures clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>User reported the diff view showing video and audio tracks "swapped" on a test file. Investigation: the file has audio in input stream 0 and video in input stream 1 (some encoders write that order); our strip's `-map 0:v? -map 0:a?` invocation processes -map args in argument order, so ffmpeg lands video at output position 0 and audio at output position 1, swapping the track numbering. The diff view now correctly aligns per-track entries by (Track1, Track1), (Track2, Track2), etc. (after 1c3ced5's generalised sub-group fix), so input Track1 (audio) gets compared to output Track1 (which is now video) — diff renders dozens of spurious "this was added"/"this was removed" rows. The actual content is identical: the audio frames are still there in the output, just at Track2 instead of Track1. Replace the explicit type-order mapping with a negative-selector form that preserves input order: -map 0 -map -0:d? -map -0:s? -map -0:t? -map 0 map every stream from input 0 (default order) -map -0:d? then unmap data streams (gpmd, fdsc, gps0, ...) — optional, no error if absent -map -0:s? then unmap subtitle streams -map -0:t? then unmap attachment/timecode streams (tmcd) Track order among the surviving video+audio streams matches input. Empirically verified on: audio-first synthetic (audio→Track1, video→Track2 preserved) phone-baseline.mp4 (video→Track1, audio→Track2 preserved) gopro-fusion.mp4 (video+audio kept, gpmd/tmcd/fdsc dropped, rc=0 — same coverage as before) dji-phantom4.mov (video kept, data streams dropped, rc=0) The diff view will now show truly minimal changes for the user's file — only the actual metadata removals (timestamps zeroed, handler descriptions removed, encoder strings suppressed, etc.). HandlerType per track now stays Video Track ↔ Video Track and Audio Track ↔ Audio Track because both input and output have the same content at the same track index. Same change applied to tools/forensic/ffmpeg-fallback.ts so the forensic runner mirrors the strategy exactly. Both gap-analysis and forensic docs updated. Verified: yarn typecheck, yarn lint, yarn test (510 passing), yarn check:deps, build:web:standalone, both e2e MP4 tests, forensic battery 6/6 clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two bugs that surfaced when users dropped multiple MP4 files: **Multi-file diff race.** `WasmProcessor.buildDiffDocumentForEntry` called `Promise.all([readBefore, readAfter])`. `@uswriting/exiftool`'s `parseMetadata` uses module-level singletons for the Perl interpreter, the MemoryFileSystem, and the stdout/stderr StringBuilders — every call does `c.clear(), m.clear(), await e.reset()` on that shared state. Running before+after concurrently raced on the shared buffer; the cold- start serialized the first file's pair (file 1 looked fine) but every subsequent file's pair hit the warm race and came back with both reads returning the same parroted buffer contents → diff renderer saw no changes → row showed "Already clean" with no diff. Serialize the two reads. **Mdhd.language leak.** ffmpeg's MP4 muxer always writes a 15-bit packed ISO 639-2/T language code to `moov/trak/mdia/mdhd.language` ("und" 0x55C4 when input had no language, or copies the input's code). ExifTool surfaces this as `MediaLanguageCode` — either as an added row (input had no surfaceable language) or as a misleading "eng → und" change. Extend `cleanFfmpegMp4Output` post-strip pass to zero the 2-byte language window for both v0 and v1 mdhd boxes. ExifTool no longer reports the field at all; ffprobe falls back to its default "eng" display label for invalid codes (cosmetic, doesn't affect bytes or playback). Regression guard added to `WasmProcessor` tests — two distinct fixtures (JPEG + PNG) processed sequentially, then diffs requested back-to-back; verifies the JPEG's Make=TestCamera surfaces only in the JPEG diff and the two before-lists are not identical. The test fails without the serialize fix (verified by stashing the wasm_processor.ts change). Post-strip mdhd zeroing covered by 3 new unit tests (v0 layout, v1 layout, multiple traks). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>