diff --git a/.gitignore b/.gitignore index 26a47bf..054c2c4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,12 @@ node_modules dist dist-ssr release +.core-build test-results playwright-report coverage public/vendor/ffmpeg +vendor/*.tmp *.local # Editor directories and files diff --git a/CHANGELOG.md b/CHANGELOG.md index aa905af..6e50844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to av-tools are documented here. The project follows semantic versioning once a first release is legally publishable. +## 0.2.0 — 2026-07-27 + +- Allow recognized local audio/video to begin native browser playback while + FFmpeg inspection continues in the background. +- Replace the fixed 30-second media-inspection watchdog with a bounded, + size-aware budget and report timeout expiry separately from user + cancellation. +- Add an isolated, reproducible Chromium ST/MT Opus regression harness using + the reviewed cores, checked tone fixture and real built-in command plan. +- Rebuild ST/MT core 0.12.10 with the upstream 5 MiB stack fix, lock and + deterministically package the exact assets, pass non-empty Opus encode and + re-probe in both modes, and enable Opus presets behind runtime capabilities. +- Add bounded 0.04–10 second audio/video crossfades to normalized sequential + joins, including runtime `xfade`/`acrossfade` gates and clip-material checks. +- Add bounded scene-change analysis and exact-PTS thumbnail/contact-sheet + extraction, with an honest no-scenes result instead of silent substitution. +- Bundle unmodified DejaVu Sans 2.37 with byte identity, upstream archive + provenance and full terms; verify it in-browser before labelled contact + sheets and fail closed when `drawtext` is unavailable. +- Add native execution coverage, pinned-core browser matrix steps and an opt-in + large-file browser playback-during-inspection evidence run. +- Make removal of queued or active source inspections generation-safe, cancel + owned active probes and prevent stale results from updating deleted or + same-ID replacement assets. + ## 0.1.0 — 2026-07-24 - Scaffold a local-first React/Vite audio and video toolbox application. diff --git a/LICENSES/DEJAVU_FONTS.txt b/LICENSES/DEJAVU_FONTS.txt new file mode 100644 index 0000000..43c1828 --- /dev/null +++ b/LICENSES/DEJAVU_FONTS.txt @@ -0,0 +1,92 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below). + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute +the modifications to the Bitstream Vera Font Software, including without +limitation the rights to use, copy, merge, publish, distribute, and/or sell +copies of the Font Software, and to permit persons to whom the Font Software +is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Tavmjong Bah" or the +word "Arev". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Tavmjong Bah +Arev" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL TAVMJONG BAH BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, +INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT +OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT +SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Font Software without prior written authorization from Tavmjong Bah. +For further information, contact: tavmjong @ free . fr. diff --git a/LICENSES/README.md b/LICENSES/README.md index 5b1de5a..7f8aedd 100644 --- a/LICENSES/README.md +++ b/LICENSES/README.md @@ -13,9 +13,8 @@ or patent considerations of bundled third-party components. `v12.15` source at revision `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util source. -- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2. The published - `@ffmpeg/core` and `@ffmpeg/core-mt` 0.12.10 package manifests declare - `GPL-2.0-or-later`; “or later” comes from those manifests. The legal code +- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2 for GPL-enabled FFmpeg + and linked GPL components in the reviewed WebAssembly cores. The legal code was checked against the standard GPL-2.0 text in the installed Freedesktop SDK licence corpus and the SPDX licence-list text. - `CC0-1.0.txt` applies only to generated bytes under diff --git a/README.md b/README.md index d366fe7..a262ca6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ telemetry, runtime CDN, remote-media fetch or third-party script. It is also a Toolbox SDK application. The same static build runs directly or under Toolbox Portal with shared shell, theme, help and app switching. -> **Release status:** 0.1.0 is licensed GPL-3.0-or-later. Its canonical -> release consists of the exact reviewed `v0.1.0` source, production +> **Release status:** 0.2.0 is licensed GPL-3.0-or-later. Its canonical +> release consists of the exact reviewed `v0.2.0` source, production > ZIP/checksum and Corresponding Source bundle published together. See > [Licensing](#licensing). @@ -23,7 +23,7 @@ Two workflows keep the product intentionally smaller than a nonlinear editor: - **Edit:** a media bin and sequential non-overlapping clip timeline with non-destructive settings, one export at a time. -The planned 0.1 line covers typed/capability-gated conversion and remux, +The 0.2 release covers typed/capability-gated conversion and remux, fast/accurate trim, split, compatible/normalized concatenate, reorder, crop, resize, frame-rate change, audio extraction/stream removal, waveform, peak/two-pass loudness normalization, gain, audio/video fades, metadata, @@ -34,10 +34,18 @@ diagnostics and explicit browser export. The Edit workspace now exposes structural exports directly: fast stream-copy or preset-backed accurate trim uses the selected clip's timeline range, while fast concatenate requires explicit compatibility diagnostics and normalized -concatenate requires a preset plus an explicit missing-audio policy. +concatenate requires a preset plus an explicit missing-audio policy. Normalized +joins can apply a uniform, capability-gated 0.04–10 second audio/video +crossfade at every edit, with clip-material checks before queueing. Operations enter a bounded, in-memory application queue with at most eight scheduled operations and one FFmpeg execution at a time. +Thumbnail series and contact sheets support even, interval, chapter, marker or +bounded scene-change selection. Scene analysis records exact source +presentation timestamps before extraction. Contact-sheet labels use only the +bundled, SHA-256-verified DejaVu Sans 2.37 file and remain disabled unless the +active core exposes `drawtext`; there is no remote/system-font fallback. + When a source cannot be played directly, the preview panel can create a disposable H.264/AAC MP4 proxy. The user can bound it to 1–300 seconds and choose a maximum width from 480–1920 pixels. A proxy is reused from the local @@ -53,14 +61,15 @@ Exact runtime packages: - `@ffmpeg/ffmpeg` 0.12.15; - `@ffmpeg/util` 0.12.2; -- `@ffmpeg/core` 0.12.10 (single-thread); -- `@ffmpeg/core-mt` 0.12.10 (multithread). +- reviewed FFmpeg core build `0.12.10-reviewed-stack5m.1` in single-thread and + multithread profiles. -The published cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built +The reviewed cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp, FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their -JS/worker files are copied from exact npm packages and served from the app's -own origin. `npm run build` does not download them. +JS/worker files are verified against a tracked build lock, unpacked from a +deterministic repository artifact and served from the app's own origin. +`npm run build` does not download them. Every engine load queries actual demuxers, muxers, decoders, encoders and filters. Presets remain disabled when requirements are absent; an extension or @@ -92,10 +101,12 @@ WebM/VP8/Vorbis, MP3 and WAV are common preview candidates, but actual codec, browser and platform support is checked at runtime. Matroska and many other valid outputs may be download-only. A preview proxy may require re-encoding. -The pinned core has an unresolved Opus encoding regression risk (zero-byte or -out-of-bounds behavior reported around upstream stack-size fixes). Opus -presets must stay unavailable unless a real non-empty, re-probeable ST/MT smoke -passes. Details are in [FFmpeg build](docs/FFMPEG_BUILD.md). +The 2026-07-26 Chromium regression harness ran the real built-in `audio-opus` +plan against both reviewed cores. Each selected the requested ST/MT mode, +produced non-empty Opus and passed codec/duration re-probing. Opus presets are +therefore enabled when runtime capability discovery also succeeds. Exact +commands and evidence boundaries are in +[FFmpeg build](docs/FFMPEG_BUILD.md). ## Execution modes @@ -169,9 +180,14 @@ Core assets are generated local build inputs: ```sh npm run vendor:ffmpeg -npm run vendor:verify +npm run vendor:verify-runtime ``` +The runtime verifier checks the tracked deterministic core artifact, source +lock, build driver and generated same-origin files. The stricter +`npm run vendor:verify` publication gate additionally requires and hashes the +ignored Corresponding Source archive generated by `npm run source:package`. + Regenerate or verify the tiny synthetic fixtures: ```sh @@ -183,7 +199,7 @@ npm run fixtures:verify ```sh npm run manifest:check -npm run vendor:verify +npm run vendor:verify-runtime npm run typecheck npm run lint npm run format:check @@ -191,6 +207,8 @@ npm test npm run build npm run toolbox:check npm run test:browser +npm run evidence:large-preview # set AV_TOOLS_LARGE_MEDIA first +npm run evidence:opus ``` `npm run check` combines the non-browser production checks. Browser tests cover @@ -198,13 +216,27 @@ the real pinned cores, MT-to-ST fallback, cancellation/recovery, generated fixtures, nested deployment, Toolbox context and the absence of runtime CDN traffic. -The latest recorded local Vitest run on 2026-07-24 completed **303 passing -tests and one conditional skip**. It includes broad structured-command -execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in case -requires a `subtitles` filter unavailable in that host build. +`npm run evidence:opus` independently contains each ST/MT browser process, +prints one JSON report and exits normally even when the result is unsafe. +`npm run test:opus-regression` runs the same evidence as the positive Opus +enablement gate and exits nonzero unless both modes produce non-empty, +re-probeable Opus. It is a mandatory release gate for the reviewed core +profile. + +`AV_TOOLS_LARGE_MEDIA=/absolute/video.mp4 npm run evidence:large-preview` +performs the opt-in large-file regression: the native video element must mount +and advance before bounded inspection finishes, remain the same element after +the authoritative probe completes and produce no browser error. + +The latest recorded local Vitest run on 2026-07-26 completed **313 passing +tests and two conditional skips**. It includes broad structured-command +execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in and +labeled contact-sheet cases require `subtitles` and `drawtext` filters that are +unavailable in that host build. The Chromium suite exercises the pinned ST core across conversion, -trim/concat/split, analysis, waveform, image, metadata, chapter, subtitle, +trim/concat/split, bounded crossfades, analysis, waveform, scene-selected image +generation, bundled-font contact-sheet labels, metadata, chapter, subtitle, queue and recovery workflows. The MT core is directly initialized and queried, and completes a real non-empty, re-probeable H.264/AAC conversion; the same path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested @@ -243,12 +275,12 @@ repository and the `GPL-3.0-or-later` SPDX licence. Portal must consume an independently built ZIP; it must not build or import av-tools source. Immutable Portal revision -`bda9da044d61c109afd9969d357cc27f76648938` has isolation and WASM CSP -permissions but lacks `media-src blob:`, which blocks Blob previews. The -developer's active Toolbox Portal worktree currently contains the narrow local -`media-src 'self' blob:` patch; that uncommitted worktree state does not change -the immutable reference or release evidence. The exact distinction, smallest -change and fallback are documented in +`5c052bc6d1073a66600cfddc44c0e16da34bede8` (`v0.6.0`) provides the required +isolation, narrow WebAssembly CSP, Blob media policy and explicit WASM MIME +mapping. The active Portal worktree additionally extends immutable caching to +the exact reviewed FFmpeg build profile without accepting mutable-looking +suffixes. Its exact reviewed and staged +boundaries are documented in [Portal requirements](docs/PORTAL_REQUIREMENTS.md). ## Release @@ -272,11 +304,11 @@ metadata: ```sh npm run package:release npm run portal:smoke -- \ - --allow-known-header-gap + --portal-root ../toolbox-portal ``` -The header-gap override remains development-only until the Portal CSP change is -committed. Full commands and safety behavior are in +The smoke harness clones the selected Portal `HEAD`; it never consumes +uncommitted Portal files silently. Full commands and safety behavior are in [release documentation](docs/RELEASE.md). ## Licensing @@ -286,11 +318,11 @@ and licensed **GPL-3.0-or-later**. See [LICENSE](LICENSE). The application provides a visible licence, source and no-warranty notice in its Help dialog. Third-party components retain their copyright and licence terms. -The wrapper/util are MIT. Core/core-mt 0.12.10 declare -GPL-2.0-or-later and statically include FFmpeg/external codec code with -additional terms. The branch-based x264/LAME inputs have been resolved to -immutable commits in the source record. A binary publication must include -equivalent access to the reviewed Corresponding Source and notices. See: +The wrapper/util are MIT. The reviewed 0.12.10 cores statically include +GPL-enabled FFmpeg/external codec code with additional terms. The branch-based +x264/LAME inputs have been resolved to immutable commits in the source record. +A binary publication must include equivalent access to the reviewed +Corresponding Source and notices. See: - [third-party notices](THIRD_PARTY_NOTICES.md) - [source record](SOURCE.md) @@ -317,19 +349,18 @@ The auditable list is in - Metadata removal is best effort, not an anonymity guarantee. - Browser preview supports fewer combinations than FFmpeg. - MT depends on isolation; ST can still hit memory/time limits. -- Opus output is release-gated for the pinned core. +- Opus enablement is supported by Chromium ST/MT fixture evidence, not a + cross-browser or arbitrary-input guarantee. - Projects do not contain source bytes by default and require reattachment. - Cross-browser/mobile and exhaustive MT performance matrices remain unrecorded; published support claims must stay within the tested evidence. ## Roadmap -1. Complete Quick Convert and structural-export browser verification with the - real pinned ST/MT cores, including fallback and queued execution. -2. Verify waveform, loudness normalization and combined filters in browsers. -3. Verify metadata/chapter/subtitle round trips in browsers. -4. Verify bounded thumbnails/contact sheets, projects, derivative-cache reuse - and export in browsers. -5. Complete accessibility, mobile, performance and nested Portal matrices. -6. Commit/tag exact source, publish its reviewed Corresponding Source, assemble - the exact production ZIP through Portal and verify deployed headers. +1. Extend the evidence matrix beyond Chromium to Firefox, Safari and mobile; + broaden MT operation parity and replace the one-run performance observation + with repeated cold/warm measurements. +2. Evaluate optional editing breadth without weakening local-first guarantees: + additional reviewed transition shapes, richer scene-ranking controls and + explicit transition audio-curve choices beyond the current bounded uniform + crossfade. diff --git a/SOURCE.md b/SOURCE.md index 843b38b..a8c70e3 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,6 +1,6 @@ # Source and reproducibility record -This file identifies the source corresponding to av-tools 0.1.0. It is not a +This file identifies the source corresponding to av-tools 0.2.0. It is not a substitute for satisfying a component's licence or for delivering complete Corresponding Source with an object-code release. @@ -9,9 +9,9 @@ Corresponding Source with an object-code release. - Canonical repository: `git@git.add-ideas.de:zemion/av-tools.git` - Browser source: `https://git.add-ideas.de/zemion/av-tools` -- Version: `0.1.0` -- Release source identity: immutable annotated tag `v0.1.0` - (`refs/tags/v0.1.0`) in the canonical repository. +- Version: `0.2.0` +- Release source identity: immutable annotated tag `v0.2.0` + (`refs/tags/v0.2.0`) in the canonical repository. - Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in `LICENSE`. - Copyright: Copyright © 2026 Albrecht Degering. @@ -22,10 +22,10 @@ the tag exists, is annotated, points to the reviewed release commit and is available without authentication: ```sh -test "$(git cat-file -t v0.1.0)" = tag -git rev-parse v0.1.0^{commit} +test "$(git cat-file -t v0.2.0)" = tag +git rev-parse v0.2.0^{commit} git ls-remote --tags \ - https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.1.0 + https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.2.0 ``` The Toolbox manifest names the canonical repository and SPDX licence. Publish @@ -40,34 +40,66 @@ the tag/source archive beside the binary release and checksum. | `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` | | `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` | | `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` | -| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` | -| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` | -Install exact packages and recreate the copied same-origin assets without a +Install exact packages and recreate the verified same-origin assets without a network fetch during the production build: ```sh npm ci npm run vendor:ffmpeg -npm run vendor:verify +npm run vendor:verify-runtime npm run build ``` -`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the -installed core packages; it does not download them. The copied bytes are: +`scripts/copy-ffmpeg-assets.mjs` fails closed against +`scripts/reviewed-ffmpeg-core-lock.json`, verifies the deterministic tracked +ZIP and unpacks only its five declared entries. It does not download them or +fall back to published core packages. The reviewed bytes are: -| Release path | Bytes | SHA-256 | -| ------------------------------------------------ | -------: | ------------------------------------------------------------------ | -| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` | -| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` | -| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` | -| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` | -| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` | +For a publication check, generate the build-qualified Corresponding Source +archive and run `npm run vendor:verify`; that stricter gate hashes the archive +as well as every runtime input. + +| Release path | Bytes | SHA-256 | +| ---------------------------------------------------------------------------- | -------: | ------------------------------------------------------------------ | +| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` | +| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` | +| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` | +| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` | +| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` | +| `vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip` (deterministic artifact) | 21338974 | `ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f` | + +## Bundled contact-sheet font + +Contact-sheet labels use one unmodified DejaVu Sans 2.37 TrueType file. It is +served same-origin and mounted read-only for the duration of a labelled job; +the browser verifies its size and SHA-256 before use. + +| Release path | Bytes | SHA-256 | +| --------------------------- | ------: | ------------------------------------------------------------------ | +| `fonts/DejaVuSans.ttf` | 757,076 | `7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954` | +| `LICENSES/DEJAVU_FONTS.txt` | n/a | licence text embedded in the reviewed font and official archive | + +The byte-for-byte source is `ttf/DejaVuSans.ttf` in the official +`dejavu-fonts-ttf-2.37.tar.bz2` release: + +- project download record: `https://dejavu-fonts.github.io/Download.html`; +- archive URL: + `https://downloads.sourceforge.net/project/dejavu/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2`; +- archive SHA-256: + `fa9ca4d13871dd122f61258a80d01751d603b4d3ee14095d65453b4e846e17d7`; +- corresponding upstream tag `version_2_37`, commit + `0eda8a319c08835009849583cd090bb5b141ce25`. + +The release archive and the bundled file were independently hashed and the +font's internal version string is `Version 2.37`. The file is not modified or +subsetted. Its Bitstream Vera/Arev permission terms are reproduced verbatim in +`LICENSES/DEJAVU_FONTS.txt`. ## Corresponding ffmpeg.wasm and FFmpeg source -- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15, - `@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10: +- Base ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15 and the + source configuration for core 0.12.10: `v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. - FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`, peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`. @@ -88,6 +120,14 @@ The upstream build control files are `Dockerfile`, `Makefile`, `build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread; `make prd-mt` builds multithread. +The shipped profile is rebuilt by +`scripts/build-reviewed-ffmpeg-core.sh`. It applies upstream ffmpeg.wasm commit +`b409e36475bc21f0451b5b1e1d126fa82871439a` (`-sSTACK_SIZE=5MB`), uses exact +Emscripten 3.1.40 and CMake 3.27.9 identities, makes upstream container-stage +isolation explicit and refuses maintainer-local paths in output. The exact +source archive, toolchain, patch, asset and Opus-gate identities are locked in +`scripts/reviewed-ffmpeg-core-lock.json`. + The Dockerfile uses branch names for two inputs. Repository history resolves those refs to tips that predate the ffmpeg.wasm v12.15 release and have not changed since: @@ -121,10 +161,13 @@ npm run source:package ``` The command writes -`release/ffmpeg-core-0.12.10-corresponding-source.tar.xz` and its +`release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz` +and its `.sha256` sidecar. Pass `-- --force` only to replace those exact outputs. -The npm packages contain the resulting binaries but not every linked -component's source and legal file, so publish this reviewed bundle and the +The reviewed archive for this build profile is 43,050,052 bytes with SHA-256 +`2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a`. +The reviewed object-code archive does not contain every linked component's +source and legal file, so publish this Corresponding Source bundle and the notices beside the object code. A homepage link alone is not asserted to satisfy GPL source obligations. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 26144cf..9dd5a26 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -av-tools 0.1.0 is Copyright © 2026 Albrecht Degering and licensed +av-tools 0.2.0 is Copyright © 2026 Albrecht Degering and licensed GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party inputs. It is informational, not legal advice, and does not replace their terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also @@ -9,30 +9,33 @@ receives licence files shipped by installed runtime npm packages under ## Browser runtime -| Package | Version | Licence declared upstream | Project source | Role | Distribution | -| -------------------------------- | ------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -------------------------------------- | -| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled | -| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled | -| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled | -| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled | -| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references | -| `@ffmpeg/core` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | single-thread FFmpeg JavaScript and WebAssembly | shipped verbatim as self-hosted assets | -| `@ffmpeg/core-mt` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | multithread FFmpeg JavaScript, WebAssembly and pthread worker | shipped verbatim as self-hosted assets | -| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled | -| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled | -| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled | -| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled | +| Package | Version | Licence declared upstream | Project source | Role | Distribution | +| -------------------------------- | -------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- | +| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled | +| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled | +| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled | +| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled | +| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references | +| reviewed FFmpeg WASM cores | 0.12.10-reviewed-stack5m.1 | GPL-enabled combined work | `https://github.com/ffmpegwasm/ffmpeg.wasm` | ST/MT FFmpeg JavaScript, WebAssembly and pthread worker | rebuilt from locked source and shipped as verified assets | +| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled | +| DejaVu Sans | 2.37 | Bitstream Vera/Arev terms | `https://dejavu-fonts.github.io/` | deterministic local contact-sheet labels | shipped unmodified as reviewed TTF | +| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled | +| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled | +| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled | The MIT text inspected in the ffmpeg.wasm source is -`LICENSES/ffmpeg.wasm-MIT.txt`. The core npm tarballs do not contain a licence -file, but their package manifests declare `GPL-2.0-or-later`; -`LICENSES/GPL-2.0-or-later.txt` reproduces GPL version 2. These notices do not -make the complete application “MIT-only.” +`LICENSES/ffmpeg.wasm-MIT.txt`. The reviewed cores enable GPL FFmpeg features +and statically link GPL components; `LICENSES/GPL-2.0-or-later.txt` reproduces +GPL version 2. These notices do not make the complete application “MIT-only.” + +The reviewed DejaVu file is identified byte-for-byte in `SOURCE.md`; its full +embedded terms are in `LICENSES/DEJAVU_FONTS.txt`. No remote or system font is +used as a runtime fallback. ## Native components linked into the WebAssembly cores The following list is derived from the `v12.15` ffmpeg.wasm Dockerfile, the -configuration embedded in the copied 0.12.10 WASM and the resolved histories +configuration embedded in the reviewed 0.12.10 WASM and the resolved histories of its source repositories. Branch-based inputs are recorded below as exact commits. The combined core is GPL-enabled. Individual projects retain their own terms, notices, patent considerations and corresponding-source diff --git a/deployment/caddy.example b/deployment/caddy.example index 0d68d24..f5d9a3f 100644 --- a/deployment/caddy.example +++ b/deployment/caddy.example @@ -14,7 +14,7 @@ tools.example.test { @wasm path_regexp wasm \.wasm$ header @wasm Content-Type "application/wasm" - @immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$ + @immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$ header @immutable Cache-Control "public, max-age=31536000, immutable" redir /av /av/ 308 diff --git a/deployment/headers.md b/deployment/headers.md index 338cb53..8851a05 100644 --- a/deployment/headers.md +++ b/deployment/headers.md @@ -32,7 +32,7 @@ cross-origin resource policy and is outside the supported deployment profile. Use `no-cache` for `index.html`, `toolbox-app.json`, notices and other stable names. The files under the exact version directory -`vendor/ffmpeg/0.12.10/{st,mt}/` are content-verified and may use: +`vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` are content-verified and may use: ```text Cache-Control: public, max-age=31536000, immutable @@ -55,7 +55,7 @@ For a host with a `_headers`-style configuration, adapt: Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none' Cache-Control: no-cache -/av/vendor/ffmpeg/0.12.10/* +/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/* Cache-Control: public, max-age=31536000, immutable /av/assets/* @@ -68,7 +68,7 @@ real response rather than relying only on configuration: ```sh curl -sSI https://tools.example.test/av/ | sed -n '1,30p' -curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm | sed -n '1,30p' +curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm | sed -n '1,30p' ``` In browser developer tools, also verify `window.crossOriginIsolated === true`, diff --git a/deployment/nginx.example.conf b/deployment/nginx.example.conf index 2986e15..b432784 100644 --- a/deployment/nginx.example.conf +++ b/deployment/nginx.example.conf @@ -3,7 +3,7 @@ map $uri $av_tools_cache_control { default "no-cache"; - "~^/(?:.*/)?vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable"; + "~^/(?:.*/)?vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable"; "~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable"; } diff --git a/docs/FFMPEG_BUILD.md b/docs/FFMPEG_BUILD.md index 72ec04f..589be1c 100644 --- a/docs/FFMPEG_BUILD.md +++ b/docs/FFMPEG_BUILD.md @@ -1,13 +1,13 @@ # FFmpeg WebAssembly build record -av-tools ships the published ESM assets from `@ffmpeg/core` and -`@ffmpeg/core-mt` **0.12.10**. The browser wrapper is `@ffmpeg/ffmpeg` -**0.12.15** and helpers are `@ffmpeg/util` **0.12.2**. All versions are exact, -not ranges. +av-tools ships the locally rebuilt, reviewed core profile +`0.12.10-reviewed-stack5m.1` in single-thread and multithread forms. The +browser wrapper is `@ffmpeg/ffmpeg` **0.12.15** and helpers are +`@ffmpeg/util` **0.12.2**. All versions and build inputs are exact, not ranges. ## Underlying build -The matching ffmpeg.wasm upstream release is `v12.15` at +The base ffmpeg.wasm upstream release is `v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. Its Dockerfile selects: - FFmpeg **n5.1.4** (libavcodec 59.37.100); @@ -15,6 +15,14 @@ The matching ffmpeg.wasm upstream release is `v12.15` at - production `-O3 -msimd128`; - pthread flags for the MT build and explicit thread disabling for ST. +The reviewed profile then applies upstream ffmpeg.wasm commit +`b409e36475bc21f0451b5b1e1d126fa82871439a` to select a 5 MiB Emscripten stack. +That upstream fix addresses the libopus zero-byte/out-of-bounds failures +reported in issues `#591` and `#775`. The local driver additionally makes the +upstream container-stage dependency isolation explicit, disables native x265 +assembly for the wasm32 build, bounds parallel jobs and refuses output that +contains the maintainer's repository or SDK paths. + The build configuration embedded in both copied WASM files is: ```text @@ -35,8 +43,8 @@ The build configuration embedded in both copied WASM files is: --cxx=em++ --objcc=emcc --dep-cc=emcc ---extra-cflags='-I/opt/include -O3 -msimd128' ---extra-cxxflags='-I/opt/include -O3 -msimd128' +--extra-cflags='-I../../../../install/include -O3 -msimd128' +--extra-cxxflags='-I../../../../install/include -O3 -msimd128' --enable-gpl --enable-libx264 --enable-libx265 @@ -76,16 +84,26 @@ respective branch tips. `SOURCE.md` records retrieval commands. | Mode | File | Bytes | SHA-256 | | ---- | -------------------------- | -------: | ------------------------------------------------------------------ | -| ST | `st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` | -| ST | `st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` | -| MT | `mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` | -| MT | `mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` | +| ST | `st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` | +| ST | `st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` | +| MT | `mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` | +| MT | `mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` | | MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` | -`npm run vendor:ffmpeg` copies these files from `node_modules` to -`public/vendor/ffmpeg/0.12.10/`; `npm run vendor:verify` checks versions, -sizes, paths and digests. Vite then copies them unchanged into `dist`. A normal -production build performs no network download. +The deterministic five-file repository artifact is 21,338,974 bytes with +SHA-256 +`ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f`. + +`npm run vendor:ffmpeg` verifies and unpacks the deterministic tracked core +artifact to +`public/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/`; +`npm run vendor:verify-runtime` checks the build profile, +source/toolchain/patch identity, tracked artifact, generated paths, sizes and +digests without requiring a release-directory download. The stricter +`npm run vendor:verify` release gate also hashes the build-qualified +Corresponding Source archive. Vite then copies the verified bytes into `dist`. +A normal production build performs no network download and cannot fall back +to the older published npm core bytes. ## Multithread Chromium guardrail @@ -118,26 +136,42 @@ This is a conservative browser-memory guardrail, not a guarantee that sixteen large jobs or three large contact sheets fit in memory. Cancellation and failed filesystem-cleanup recovery continue to recreate the core immediately. -## Opus regression risk +## Opus regression evidence -Pinned core 0.12.10 predates upstream ffmpeg.wasm fixes associated with stack -size and Opus zero-byte/out-of-bounds failures (including upstream issues -`#591` and `#775`). Therefore: +Published core 0.12.10 predates the upstream stack-size fix and stalled in the +real Opus preset path even though it advertised the `opus` muxer and +`libopus` encoder. Capability discovery alone was therefore not accepted as +enablement evidence. -- decoder/encoder listing is not enough to certify Opus; -- real ST and MT fixture encodes must produce a non-empty, re-probeable output; -- the Opus audio and VP9/Opus presets must remain disabled with a diagnostic - if that smoke fails; -- a core upgrade requires new checksums, capability snapshots, legal review - and regression tests. +On 2026-07-26 the Chromium harness used the checked +`tests/fixtures/generated/tone.wav` fixture (32,102 bytes, SHA-256 +`0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c`) +and the real built-in `audio-opus` preset/command planner. After rebuilding +with the reviewed stack profile, the isolated ST and MT runs each: -This is a known pinned-core risk, not a claim that every Opus job fails. +- loaded the requested mode rather than a fallback; +- mapped PCM audio to `opus (libopus)`; +- opened the declared Opus output and reported its libopus encoder; +- returned non-empty bytes within its independent containment limit; +- re-probed the result as positive-duration Opus audio. + +```sh +# Record JSON evidence. +npm run evidence:opus + +# Positive Opus enablement/release gate. +npm run test:opus-regression +``` + +The Opus audio and VP9/Opus presets are enabled only when the reviewed-core +lock records both passing modes and runtime discovery finds every preset +requirement. This is exact Chromium fixture evidence, not a claim about every +browser, input or possible Opus build. ## Licence consequence -The core packages declare GPL-2.0-or-later and the actual build enables GPL and -links external codecs. The wrapper/util are MIT. av-tools is -GPL-3.0-or-later, and the distributed application must not be described as -MIT-only. Publish the reviewed Corresponding Source and external notices with -the binary release. See `LICENSE`, `LICENSES/`, `THIRD_PARTY_NOTICES.md` and -`SOURCE.md`. +The actual core build enables GPL and links external codecs. The wrapper/util +are MIT. av-tools is GPL-3.0-or-later, and the distributed application must +not be described as MIT-only. Publish the reviewed Corresponding Source and +external notices with the binary release. See `LICENSE`, `LICENSES/`, +`THIRD_PARTY_NOTICES.md` and `SOURCE.md`. diff --git a/docs/FORMAT_SUPPORT.md b/docs/FORMAT_SUPPORT.md index 56b240a..8da105a 100644 --- a/docs/FORMAT_SUPPORT.md +++ b/docs/FORMAT_SUPPORT.md @@ -5,7 +5,7 @@ extensions or this document. The app queries muxers, demuxers, decoders, encoders and filters; a preset is disabled with missing components listed. Browser playback is a separate capability checked with MIME/media APIs. -## 0.1.0 candidate fixture matrix +## 0.2.0 release fixture matrix All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test preparation and pass the checksum/probe verifier. That proves fixture @@ -41,20 +41,20 @@ browser may still be unable to preview the result. Preset availability always depends on detected runtime requirements: -| Preset family | Required examples | Browser result preview | -| ------------------ | ---------------------------------------- | ------------------------------------------------------ | -| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output | -| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | browser-dependent; Opus core regression gate applies | -| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox | -| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers | -| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview | -| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable | -| MP3 | `mp3`, `libmp3lame` | commonly playable | -| M4A/AAC | `ipod`, `aac` | commonly playable | -| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent | -| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies | -| WAV/PCM | `wav`, `pcm_s16le` | commonly playable | -| FLAC | `flac`, `flac` | browser-dependent | +| Preset family | Required examples | Browser result preview | +| ------------------ | ---------------------------------------- | -------------------------------------------------------- | +| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output | +| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | enabled when the loaded core advertises all requirements | +| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox | +| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers | +| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview | +| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable | +| MP3 | `mp3`, `libmp3lame` | commonly playable | +| M4A/AAC | `ipod`, `aac` | commonly playable | +| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent | +| Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded | +| WAV/PCM | `wav`, `pcm_s16le` | commonly playable | +| FLAC | `flac`, `flac` | browser-dependent | “Lossless” applies only to a lossless audio codec such as PCM/FLAC relative to its decoded samples. It does not make a lossy source lossless. Stream-copy @@ -77,11 +77,30 @@ remux avoids re-encoding compatible streams but may change container metadata. - Normalized concat uses an explicit export preset and requires an explicit insert-silence, drop-all-audio or reject policy when clips have mismatched audio presence. +- Normalized concat can overlap every edit with one uniform 0.04–10 second + `xfade` and, when audio is retained, `acrossfade`. End clips must contain at + least one transition duration and internal clips two; the active core must + expose each required filter. - Crop, resize, frame-rate conversion, fades, normalization, burn-in and contact sheets require specific filters discovered at runtime. - Even-dimension correction is applied when the selected pixel format/encoder requires it and is reported in the plan/result. +## Images and scene selection + +Thumbnail/contact-sheet scene mode first performs a bounded full-source +analysis with `select`/`showinfo`, stopping after at most 100 qualifying +frames. Extraction then selects those exact source PTS values; no guessed time +rounding or remote analysis is used. If no frame meets the 0.01–0.95 threshold, +the operation reports that result and asks for a lower threshold or a +deterministic selection mode. + +Contact-sheet labels require runtime `drawtext`. The app mounts only its +same-origin DejaVu Sans 2.37 asset after verifying 757,076 bytes and SHA-256 +`7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954`. +No remote or host-system font fallback is used. `SOURCE.md` and +`LICENSES/DEJAVU_FONTS.txt` provide provenance and terms. + ## Subtitles The text fixtures cover SRT, WebVTT and ASS import with explicit time shift. @@ -115,8 +134,9 @@ elements do not reliably expose them, so ffprobe round-trip is authoritative. - frame-accurate stream-copy trimming; - complete metadata erasure/anonymity; - browser preview of every FFmpeg-readable result; -- Opus presets until real non-empty ST/MT encoding smoke passes for core - 0.12.10; +- Opus on browsers, inputs or deployments outside the exact reviewed + Chromium regression: both ST and MT pass the non-empty encode/re-probe gate, + but runtime capability discovery and normal per-job limits still apply; - any capability omitted by the active core's runtime query. Update this file only from re-probed generated/public-domain fixtures and diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 0cc7e68..07d40c6 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -1,6 +1,6 @@ # Known limitations -This is the conservative 0.1.0 development baseline. A capability appearing in +This is the conservative 0.2.0 release baseline. A capability appearing in FFmpeg output does not remove these product/browser/resource limits. ## Functional @@ -13,6 +13,9 @@ FFmpeg output does not remove these product/browser/resource limits. frame-accurate. Accurate mode requires decoding/re-encoding. - Stream-copy concatenate works only when selected streams and codec/container parameters are compatible. Other input needs normalized re-encoding. +- Crossfades are uniform fade/triangular-curve transitions on normalized + sequential joins, not arbitrary transition effects or overlapping timeline + tracks. They require enough material on both sides of every internal edit. - Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but cannot restore information already lost in a source. - Metadata removal is best effort. Muxers may add structural/encoder fields, @@ -26,6 +29,12 @@ FFmpeg output does not remove these product/browser/resource limits. page discards queued work. - A file-generating batch and its managed result collection are bounded to 255 outputs, leaving the 256th ZIP entry available for an optional export report. +- Scene analysis scans the source until it finds the requested changes or + reaches the end, retains at most 100 exact PTS results and can therefore take + about as long as a full decode. It does not begin an export implicitly when + no frame meets the chosen threshold. +- Labelled contact sheets require a core with `drawtext`; the reviewed local + font does not make that filter available on a core that omits it. - Preview proxies are bounded, lossy H.264/AAC derivatives for local playback; they do not replace the source or guarantee that every browser can play the result. @@ -38,8 +47,9 @@ FFmpeg output does not remove these product/browser/resource limits. support fewer container/codec combinations. - Browser support, codec policy and memory differ across Chromium, Firefox and Safari and across desktop/mobile versions. -- Core 0.12.10 has an Opus regression risk. Opus remains release-gated until - non-empty, re-probeable ST and MT fixture outputs pass. +- The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe + gate in isolated Chromium ST and MT runs. That evidence is not a + cross-browser or every-input Opus guarantee. - MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works without isolation but is not a guarantee that a large job fits. - Top-level navigation is supported; iframe embedding is not. @@ -69,7 +79,7 @@ FFmpeg output does not remove these product/browser/resource limits. verification against the live container. - The application is GPL-3.0-or-later and its native legal files and pinned source identities are included in the release record. Publish the production - ZIP/checksum, exact `v0.1.0` source and FFmpeg Corresponding Source bundle + ZIP/checksum, exact `v0.2.0` source and FFmpeg Corresponding Source bundle together; development-only smoke artifacts must not be distributed. - Chromium covers a broad real pinned-core ST operation matrix, a real MT H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 41a0a41..b2bf61b 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -6,12 +6,13 @@ output at once. No hardware encoder is used. ## Fixed download footprint -Uncompressed self-hosted core assets for 0.12.10: +Uncompressed self-hosted core assets for +`0.12.10-reviewed-stack5m.1`: | Mode | JavaScript/worker | WASM | Total | | ------------- | ------------------: | -----------: | -----------: | -| single-thread | 111,804 B | 32,232,419 B | 32,344,223 B | -| multithread | 128,947 B + 2,115 B | 32,718,323 B | 32,849,385 B | +| single-thread | 111,804 B | 32,232,580 B | 32,344,384 B | +| multithread | 128,953 B + 2,115 B | 32,718,455 B | 32,849,523 B | Only the selected mode is loaded. HTTP compression may reduce JavaScript transfer but not necessarily WASM to the same degree; report transfer and @@ -72,9 +73,18 @@ Estimated output warns at 512 MiB and is blocked at 1 GiB. FFmpeg jobs are serial; the application retains at most eight scheduled operations in its in-memory queue. Timeline clips are capped at 100, streams per input at 64, images at 8192×8192/67,108,864 pixels, generated output files at 255, -contact-sheet cells at 500 and runtime at four hours. The documented -ffmpeg.wasm input ceiling is 2 GiB, but real limits can be substantially lower -on mobile or memory-constrained browsers. +contact-sheet cells at 500, scene-change results at 100, crossfades at 10 +seconds and runtime at four hours. Scene selection is a bounded two-step +workflow but its analysis step can still decode the complete source when few +frames meet the threshold. The documented ffmpeg.wasm input ceiling is 2 GiB, +but real limits can be substantially lower on mobile or memory-constrained +browsers. + +Source inspection has a bounded, size-aware watchdog: 30 seconds for small +files, a two-MiB-per-second scan budget for larger files, and a five-minute +ceiling. Recognized local audio/video can begin browser playback from its +temporary object URL while that worker inspection continues; completed probe +metadata remains authoritative for editing and export. Lowering resolution, duration, frame rate, passes and output count reduces work. MT can be faster but consumes more memory/CPU and requires isolation; ST diff --git a/docs/PORTAL_REQUIREMENTS.md b/docs/PORTAL_REQUIREMENTS.md index 129dfb8..07169b3 100644 --- a/docs/PORTAL_REQUIREMENTS.md +++ b/docs/PORTAL_REQUIREMENTS.md @@ -18,7 +18,7 @@ X-Content-Type-Options: nosniff Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME type and JSON as `application/json`. Test real responses under -`/apps/av/vendor/ffmpeg/0.12.10/...`; a file-extension allowlist alone does not +`/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/...`; a file-extension allowlist alone does not prove the MIME type. Suggested CSP: @@ -43,45 +43,36 @@ form-action 'none'; `'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created worker path. `media-src blob:` permits local source/result previews. -## Immutable reference and active-worktree state +## Reviewed Portal state -Portal revision `bda9da044d61c109afd9969d357cc27f76648938` already sends COOP -`same-origin`, COEP `require-corp`, CORP `same-origin`, `script-src 'self' -'wasm-unsafe-eval'`, and `worker-src 'self' blob:`. Its packaged nginx CSP does -**not** declare `media-src`; `default-src 'self'` therefore blocks local -`blob:` audio/video URLs. The expected browser diagnostic is equivalent to: +Portal revision `5c052bc6d1073a66600cfddc44c0e16da34bede8` (tag `v0.6.0`) +already sends COOP `same-origin`, COEP `require-corp`, CORP `same-origin`, +`script-src 'self' 'wasm-unsafe-eval'`, `worker-src 'self' blob:` and +`media-src 'self' blob:`. Its nginx profile also maps `.wasm` explicitly to +`application/wasm`. Local source and result Blob previews therefore require no +CSP exception beyond that reviewed narrow media directive. -```text -Refused to load media from 'blob:…' because it violates -"default-src 'self'". -``` +As inspected on 2026-07-26, the active +`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` has an additional worktree +patch that extends the immutable FFmpeg cache rule from plain semantic versions +to the one exact reviewed build profile +`0.12.10-reviewed-stack5m.1`. The corresponding deployment tests accept the +five reviewed core files and `version.json` while rejecting mutable-looking +suffixes, malformed versions and unexpected paths. A clean temporary clone +containing that exact patch passed the AV release assembly smoke without a +known-header-gap override. -Smallest required Portal change: append exactly -`media-src 'self' blob:;` to that CSP. This permits only same-origin and -in-memory Blob media; it does not permit remote media hosts, inline script or -eval. If the Portal is not changed, conversion/download can remain available -but Blob source/result preview must show a clear disabled reason. - -As inspected on 2026-07-24, the active -`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` contains that exact directive -as a local worktree patch. The worktree still resolves to the immutable -revision above, so the patch is useful for local deployment testing but is not -a committed Portal revision and must not be cited as published release -evidence. The clean-reference assembly smoke therefore continues to require -`--allow-known-header-gap` until a reviewed Portal commit contains the fix. - -The nginx profile includes `.wasm` in its static allowlist/gzip types but -relies on the base image `mime.types`. Verify `Content-Type: -application/wasm` against the pinned image; add an explicit mapping if the -response differs. +Until the cache extension is committed, cite it only as staged integration +evidence, not as a published Portal revision. The clean `v0.6.0` rule leaves +the reviewed profile at `no-cache`; that affects performance, not loading, +MIME type, isolation or preview correctness. ## Caching -The immutable Portal reference's cache map makes Vite content-hashed assets -immutable but leaves versioned FFmpeg cores at `no-cache`. Correctness is -unaffected. -For performance, the exact directory -`apps/av/vendor/ffmpeg/0.12.10/{st,mt}/` may receive: +The staged Portal cache map makes Vite content-hashed assets, plain semantic +versions and the explicitly reviewed profile immutable. For performance, the +exact directory +`apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` may receive: ```text Cache-Control: public, max-age=31536000, immutable diff --git a/docs/REFERENCE_IMPLEMENTATIONS.md b/docs/REFERENCE_IMPLEMENTATIONS.md index c82b75a..c07522e 100644 --- a/docs/REFERENCE_IMPLEMENTATIONS.md +++ b/docs/REFERENCE_IMPLEMENTATIONS.md @@ -1,28 +1,31 @@ # Reference implementations -Inspection date: **2026-07-24**. +Inspection dates: **2026-07-24** (initial implementation) and +**2026-07-26** (remaining-slice integration). The references were cloned beside av-tools under `/mnt/DATA/git/references/`; no reference repository was nested in this working tree. Revisions are full immutable commit ids. -| Repository | Revision inspected | Used for | Copied or adapted | -| -------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| av-tools, `git@git.add-ideas.de:zemion/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed | -| Toolbox SDK, `https://git.add-ideas.de/zemion/toolbox-sdk.git` | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` (SDK 0.2.2) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.2 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied | -| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the documented artifact contract informed an independent release packager and smoke harness | -| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree code was copied; exact published npm packages are dependencies and their five core assets are copied verbatim by the vendor script | +| Repository | Revision inspected | Used for | Copied or adapted | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| av-tools, `git@git.add-ideas.de:zemion/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed | +| Toolbox SDK, `https://git.add-ideas.de/zemion/toolbox-sdk.git` | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` (SDK 0.2.2) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.2 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied | +| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` initially; `5c052bc6d1073a66600cfddc44c0e16da34bede8` for final integration | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the AV smoke harness consumes only an exact cloned Portal revision; the separate Portal worktree contains its own reviewed integration patch | +| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc`; pinned release baseline `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree source was copied; wrapper/util remain exact npm dependencies, while the five core assets are rebuilt from the recorded source/toolchain/patch profile and verified against the build lock | For the **pinned** package set, the more specific upstream release tag `v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It -contains `@ffmpeg/ffmpeg` 0.12.15 and core/core-mt 0.12.10. That revision's -build selects FFmpeg n5.1.4. +contains `@ffmpeg/ffmpeg` 0.12.15 and the source configuration for core +0.12.10. That revision's build selects FFmpeg n5.1.4. The shipped +`0.12.10-reviewed-stack5m.1` assets additionally apply upstream ffmpeg.wasm +commit `b409e36475bc21f0451b5b1e1d126fa82871439a`; exact build and source +identities are in `SOURCE.md` and `docs/FFMPEG_BUILD.md`. -The user's active `/mnt/DATA/git/toolbox-portal` worktree still has Portal -revision `bda9da044d61c109afd9969d357cc27f76648938` as `HEAD`, but currently -contains a local `media-src 'self' blob:` nginx CSP patch. That mutable -worktree state is documented for deployment testing only; it does not replace -the immutable reference in the table. +The active `/mnt/DATA/git/toolbox-portal` worktree has immutable revision +`5c052bc6d1073a66600cfddc44c0e16da34bede8` as `HEAD`. Its staged cache-rule +extension is documented separately and is not represented as a published +revision. Official behavior references: diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 6d970e1..cf3095f 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -11,10 +11,10 @@ The repository already declares the application licence and immutable third-party source identities. Before any distribution: 1. commit the reviewed GPL-3.0-or-later source; -2. create and verify annotated tag `v0.1.0` at that exact commit; +2. create and verify annotated tag `v0.2.0` at that exact commit; 3. push the commit and tag to the canonical public repository; 4. assemble, review and publish corresponding source and exact external - notices for both core packages and linked libraries; + notices for both reviewed core profiles and linked libraries; 5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned. `SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not @@ -44,6 +44,7 @@ Normal packaging validates the root GPL version 3 text and matching ```sh npm run package:release npm run source:package +npm run vendor:verify ``` The legacy development-only escape hatch is retained only for checking an @@ -56,19 +57,19 @@ npm run package:release -- --allow-unlicensed-development-smoke Pass `--force` only to replace the exact same-version ZIP and sidecar. Output: ```text -release/av-tools-0.1.0.zip -release/av-tools-0.1.0.zip.sha256 -release/ffmpeg-core-0.12.10-corresponding-source.tar.xz -release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256 +release/av-tools-0.2.0.zip +release/av-tools-0.2.0.zip.sha256 +release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz +release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256 ``` Verify independently: ```sh node scripts/checksum-release.mjs -(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256) -unzip -l release/av-tools-0.1.0.zip -tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz +(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256) +unzip -l release/av-tools-0.2.0.zip +tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz ``` The packager: @@ -90,13 +91,13 @@ When used against a fixture without an application licence, the escape hatch add ## 4. Portal assembly smoke -Use the clean reference clone beside av-tools, never the user's active Portal -worktree: +Use a reviewed Portal Git repository beside av-tools. The harness reads its +exact `HEAD` and clones that revision into a temporary directory: ```sh npm run portal:smoke -- \ - --artifact release/av-tools-0.1.0.zip \ - --allow-known-header-gap + --artifact release/av-tools-0.2.0.zip \ + --portal-root ../toolbox-portal ``` The smoke harness clones the exact Portal HEAD to a temporary directory, @@ -106,19 +107,23 @@ then verifies the generated catalogue/release records and copied FFmpeg assets. The temporary directory is removed unless `--keep-temp` is explicitly passed. No machine-specific path is committed and no Portal worktree is changed. -Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP -omission of `media-src blob:` is a failing deployment check. The override still -reports the gap and is only for testing archive assembly. See -`docs/PORTAL_REQUIREMENTS.md`. +Portal `v0.6.0` +(`5c052bc6d1073a66600cfddc44c0e16da34bede8`) already passes the isolation, +Blob-media CSP and explicit WASM MIME checks. Its plain-semver cache rule does +not match the reviewed core's build-profile suffix, so the harness reports a +cache warning. The staged Portal extension accepts only the exact reviewed +profile in addition to plain semantic versions and rejects malformed or +mutable-looking suffixes; a temporary clean commit containing that patch passed +this smoke without warnings. -The user's active `/mnt/DATA/git/toolbox-portal` worktree contains the narrow -directive as a local patch, but the smoke intentionally does not consume that -mutable worktree. Until the patch exists in a reviewed immutable Portal -revision, retain the clean-reference failure/override behavior above. +`--allow-known-header-gap` remains available only for deliberately testing an +older Portal revision with a known required-header error. It is unnecessary +for `v0.6.0` and must not suppress review of new gaps. See +`docs/PORTAL_REQUIREMENTS.md`. ## 5. Publish only reviewed bytes -From a clean `v0.1.0` checkout, rerun all checks, create the ZIP once, verify +From a clean `v0.2.0` checkout, rerun all checks, create the ZIP once, verify the sidecar, assemble that exact ZIP through Portal, and publish the immutable ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put its exact lowercase digest/version/id/target in a reviewed Portal lock. Never diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c3b2b08..800875c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -39,9 +39,10 @@ exportable diagnostics. Metadata is rendered as React text, never `dangerouslySetInnerHTML`. Subtitle text is parsed and rendered or sent through a generated local file; it is not inserted as HTML. Extracted URLs are inert text. The application does not open -links from media automatically. No remote font is fetched; ASS burn-in uses -packaged/system-generic font configuration only when the required local -capability is available. +links from media automatically. No remote font is fetched. Contact-sheet labels +mount only the same-origin, size/SHA-256-verified DejaVu asset and fail closed +without `drawtext`; ASS burn-in uses packaged/system-generic font configuration +only when the required local capability is available. Preview/download URLs are `blob:` values made from local bytes. They are revoked on replacement, project close and unmount. Browser support for a diff --git a/eslint.config.mjs b/eslint.config.mjs index 22dd3b3..40aa805 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default tseslint.config( 'test-results', 'playwright-report', 'public/vendor/ffmpeg', + '.core-build', ], }, { diff --git a/package-lock.json b/package-lock.json index a098896..4bf73a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,16 @@ { "name": "av-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "av-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { "@add-ideas/toolbox-contract": "0.2.2", "@add-ideas/toolbox-shell-react": "0.2.2", - "@ffmpeg/core": "0.12.10", - "@ffmpeg/core-mt": "0.12.10", "@ffmpeg/ffmpeg": "0.12.15", "@ffmpeg/util": "0.12.2", "fflate": "0.8.3", @@ -718,24 +716,6 @@ } } }, - "node_modules/@ffmpeg/core": { - "version": "0.12.10", - "resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz", - "integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==", - "license": "GPL-2.0-or-later", - "engines": { - "node": ">=16.x" - } - }, - "node_modules/@ffmpeg/core-mt": { - "version": "0.12.10", - "resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz", - "integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==", - "license": "GPL-2.0-or-later", - "engines": { - "node": ">=16.x" - } - }, "node_modules/@ffmpeg/ffmpeg": { "version": "0.12.15", "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", diff --git a/package.json b/package.json index a518a2d..c586e6a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "av-tools", "private": true, - "version": "0.1.0", + "version": "0.2.0", "description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -22,6 +22,8 @@ "dev": "vite", "vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs", "vendor:verify": "node scripts/verify-ffmpeg-assets.mjs", + "vendor:verify-runtime": "node scripts/verify-ffmpeg-assets.mjs --runtime-only", + "vendor:package": "node scripts/package-reviewed-ffmpeg-core.mjs", "manifest:generate": "node scripts/generate-toolbox-manifest.mjs", "manifest:check": "node scripts/generate-toolbox-manifest.mjs --check", "prebuild": "npm run vendor:ffmpeg && npm run manifest:generate", @@ -34,6 +36,9 @@ "test": "vitest run", "test:watch": "vitest", "test:browser": "playwright test", + "evidence:large-preview": "playwright test tests/browser/large-media-preview.spec.ts --project=chromium --workers=1", + "evidence:opus": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs", + "test:opus-regression": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs --require-pass", "benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1", "fixtures:generate": "sh scripts/generate-fixtures.sh", "fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS", @@ -42,14 +47,12 @@ "source:package": "node scripts/package-ffmpeg-source.mjs", "checksum:release": "node scripts/checksum-release.mjs", "portal:smoke": "node scripts/portal-assembly-smoke.mjs", - "check": "npm run manifest:check && npm run vendor:verify && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check", - "release:artifact": "npm run check && npm run test:browser && npm run package:release" + "check": "npm run manifest:check && npm run vendor:verify-runtime && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check", + "release:artifact": "npm run check && npm run vendor:verify && npm run test:opus-regression && npm run test:browser && npm run package:release" }, "dependencies": { "@add-ideas/toolbox-contract": "0.2.2", "@add-ideas/toolbox-shell-react": "0.2.2", - "@ffmpeg/core": "0.12.10", - "@ffmpeg/core-mt": "0.12.10", "@ffmpeg/ffmpeg": "0.12.15", "@ffmpeg/util": "0.12.2", "fflate": "0.8.3", diff --git a/public/fonts/DejaVuSans.ttf b/public/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..e5f7eec Binary files /dev/null and b/public/fonts/DejaVuSans.ttf differ diff --git a/public/toolbox-app.json b/public/toolbox-app.json index b5a3861..f0c83c2 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.av-tools", "name": "Audio & Video Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Convert and lightly edit audio and video locally in the browser.", "entry": "./", "icon": "./favicon.svg", @@ -55,10 +55,10 @@ } ], "assets": [ - "./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js", - "./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm", - "./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js", - "./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm", - "./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js" + "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js", + "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm", + "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js", + "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm", + "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js" ] } diff --git a/scripts/build-reviewed-ffmpeg-core.sh b/scripts/build-reviewed-ffmpeg-core.sh new file mode 100644 index 0000000..ddf1ceb --- /dev/null +++ b/scripts/build-reviewed-ffmpeg-core.sh @@ -0,0 +1,420 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/build-reviewed-ffmpeg-core.sh st|mt SOURCE_ARCHIVE EMSDK_ROOT [JOBS] + +Builds one private, staged FFmpeg WebAssembly core from the exact +0.12.10 Corresponding Source archive. The only upstream source change is +ffmpeg.wasm commit b409e36475bc21f0451b5b1e1d126fa82871439a: +`-sSTACK_SIZE=5MB`, which fixes upstream issues #591 and #775. + +Output remains below .core-build/reviewed-stack5m and is never copied into the +application automatically. Re-running resumes at verified component stamps. +EOF +} + +if [[ $# -lt 3 || $# -gt 4 ]]; then + usage >&2 + exit 2 +fi + +MODE=$1 +SOURCE_ARCHIVE=$2 +EMSDK_ROOT=$3 +JOBS=${4:-2} + +if [[ "$MODE" != "st" && "$MODE" != "mt" ]]; then + echo "Mode must be st or mt." >&2 + exit 2 +fi +if [[ ! "$JOBS" =~ ^[1-8]$ ]]; then + echo "JOBS must be an integer from 1 to 8." >&2 + exit 2 +fi + +SCRIPT_DIRECTORY=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +REPOSITORY_ROOT=$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P) +SOURCE_ARCHIVE=$(realpath -- "$SOURCE_ARCHIVE") +EMSDK_ROOT=$(realpath -- "$EMSDK_ROOT") + +EXPECTED_ARCHIVE_SHA256=2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a +EXPECTED_EMCC_SHA256=b0c9551f9155fa9c028efa24591acdfd30f02f32fb247cf9266f5f0f2ede7589 +EXPECTED_CMAKE_SHA256=ade9326f83f4996b17e2cb1bbfe4848df30a237ef5769cd3c00239b9f0d3ec58 +EXPECTED_SOURCE_ROOT=ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source +SOURCE_DATE_EPOCH=1736208000 + +if [[ ! -f "$SOURCE_ARCHIVE" || -L "$SOURCE_ARCHIVE" ]]; then + echo "SOURCE_ARCHIVE must be a regular, non-symlink file." >&2 + exit 2 +fi +if [[ ! -d "$EMSDK_ROOT" || -L "$EMSDK_ROOT" ]]; then + echo "EMSDK_ROOT must be a real directory." >&2 + exit 2 +fi + +actual_archive_sha256=$(sha256sum -- "$SOURCE_ARCHIVE" | cut -d' ' -f1) +if [[ "$actual_archive_sha256" != "$EXPECTED_ARCHIVE_SHA256" ]]; then + echo "Corresponding Source archive SHA-256 mismatch." >&2 + exit 1 +fi + +# shellcheck disable=SC1091 +source "$EMSDK_ROOT/emsdk_env.sh" >/dev/null +export PATH="$REPOSITORY_ROOT/scripts/build-tools:$PATH" +if [[ -z "${AV_REVIEWED_CMAKE_BIN:-}" ]]; then + echo "AV_REVIEWED_CMAKE_BIN must identify the exact CMake 3.27.9 binary directory." >&2 + exit 2 +fi +AV_REVIEWED_CMAKE_BIN=$(realpath -- "$AV_REVIEWED_CMAKE_BIN") +if [[ ! -d "$AV_REVIEWED_CMAKE_BIN" || -L "$AV_REVIEWED_CMAKE_BIN" ]]; then + echo "AV_REVIEWED_CMAKE_BIN must be a real directory." >&2 + exit 2 +fi +if [[ $(sha256sum -- "$AV_REVIEWED_CMAKE_BIN/cmake" | cut -d' ' -f1) != "$EXPECTED_CMAKE_SHA256" ]]; then + echo "CMake binary SHA-256 mismatch; exact PyPI CMake 3.27.9 is required." >&2 + exit 1 +fi +export PATH="$AV_REVIEWED_CMAKE_BIN:$PATH" +EMCC=$(realpath -- "$EMSDK_ROOT/upstream/emscripten/emcc") +actual_emcc_sha256=$(sha256sum -- "$EMCC" | cut -d' ' -f1) +if [[ "$actual_emcc_sha256" != "$EXPECTED_EMCC_SHA256" ]]; then + echo "Emscripten compiler SHA-256 mismatch; activate exact version 3.1.40." >&2 + exit 1 +fi +if ! "$EMCC" --version | head -1 | grep -Fq \ + "3.1.40 (5c27e79dd0a9c4e27ef2326841698cdd4f6b5784)"; then + echo "Emscripten identity mismatch; exact version 3.1.40 is required." >&2 + exit 1 +fi +if [[ $(cmake --version | head -1) != "cmake version 3.27.9" ]]; then + echo "CMake identity mismatch; exact version 3.27.9 is required." >&2 + exit 1 +fi + +BUILD_PARENT="$REPOSITORY_ROOT/.core-build/reviewed-stack5m" +MODE_ROOT="$BUILD_PARENT/$MODE" +EXTRACT_ROOT="$MODE_ROOT/source" +SOURCE_ROOT="$EXTRACT_ROOT/$EXPECTED_SOURCE_ROOT/sources" +INSTALL_DIR="$MODE_ROOT/install" +OUTPUT_DIR="$MODE_ROOT/output" +STAMP_DIR="$MODE_ROOT/stamps" + +case "$MODE_ROOT" in + "$REPOSITORY_ROOT"/.core-build/reviewed-stack5m/st | \ + "$REPOSITORY_ROOT"/.core-build/reviewed-stack5m/mt) ;; + *) + echo "Refusing an unexpected build directory: $MODE_ROOT" >&2 + exit 1 + ;; +esac + +mkdir -p -- "$MODE_ROOT" "$STAMP_DIR" +if [[ ! -f "$STAMP_DIR/source-prepared" ]]; then + if [[ -e "$EXTRACT_ROOT" ]]; then + echo "Incomplete source staging already exists at $EXTRACT_ROOT." >&2 + echo "Remove only that private mode directory to restart it." >&2 + exit 1 + fi + mkdir -p -- "$EXTRACT_ROOT" + tar -xf "$SOURCE_ARCHIVE" -C "$EXTRACT_ROOT" + + lock="$EXTRACT_ROOT/$EXPECTED_SOURCE_ROOT/SOURCE_LOCK.json" + node -e ' + const fs = require("node:fs"); + const lock = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const expected = new Map([ + ["ffmpeg.wasm", "71aa99d37c02a7b4c435275ca9ef50e612f6efa1"], + ["FFmpeg", "4729204c17f756e186d622060088371d10b34f7e"], + ["Opus", "e85ed7726db5d677c9c0677298ea0cb9c65bdd23"], + ]); + for (const [name, revision] of expected) { + const entry = lock.sources.find((candidate) => candidate.name === name); + if (!entry || entry.revision !== revision) { + throw new Error(`Unexpected ${name} source identity.`); + } + } + ' "$lock" + + wasm_build_script="$SOURCE_ROOT/ffmpeg.wasm/build/ffmpeg-wasm.sh" + if grep -Fq -- "-sSTACK_SIZE=" "$wasm_build_script"; then + echo "Base archive unexpectedly already selects a stack size." >&2 + exit 1 + fi + sed -i \ + '/-sUSE_SDL=2/a\ -sSTACK_SIZE=5MB # upstream b409e364: support libopus' \ + "$wasm_build_script" + if [[ $(grep -Fc -- "-sSTACK_SIZE=5MB" "$wasm_build_script") -ne 1 ]]; then + echo "Could not apply the reviewed one-line stack patch exactly once." >&2 + exit 1 + fi + + touch "$STAMP_DIR/source-prepared" +fi + +# The upstream container uses unbounded `make -j`. Keep local builds bounded +# without changing compiled source, and allow a resumed build to select a new +# bounded job count. +while IFS= read -r -d '' build_script; do + sed -E -i \ + -e "s/ -j([1-8])?$/ -j$JOBS/" \ + -e "s/ -j([1-8])? / -j$JOBS /g" \ + "$build_script" +done < <(find "$SOURCE_ROOT/ffmpeg.wasm/build" -type f -name '*.sh' -print0) + +# The upstream container does not provide native NASM. Some development hosts +# do; allowing CMake to discover it would produce native x86 objects that +# cannot be linked into WebAssembly. Make the container's effective choice +# explicit and deterministic. +X265_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/x265.sh" +if ! grep -Fq -- "-DENABLE_ASSEMBLY=OFF" "$X265_BUILD_SCRIPT"; then + sed -i \ + '/-DENABLE_LIBNUMA=OFF/a\ -DENABLE_ASSEMBLY=OFF' \ + "$X265_BUILD_SCRIPT" +fi +if [[ $(grep -Fc -- "-DENABLE_ASSEMBLY=OFF" "$X265_BUILD_SCRIPT") -ne 1 ]]; then + echo "Could not disable native x265 assembly exactly once." >&2 + exit 1 +fi + +# Upstream builds each dependency in an isolated container stage. A cumulative +# local prefix would otherwise let FreeType discover the already-built zlib and +# let HarfBuzz discover FreeType. Their resulting private dependencies are not +# present in FFmpeg's pkg-config probes and, on a developer workstation, optional +# libraries such as libpng may even resolve to host-native /usr objects. Make the +# isolated-stage dependency choices explicit. +FREETYPE_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/freetype2.sh" +if ! grep -Fq -- "--with-zlib=no" "$FREETYPE_BUILD_SCRIPT"; then + sed -i \ + '/--without-harfbuzz/a\ --with-zlib=no\n --with-bzip2=no\n --with-png=no\n --with-brotli=no' \ + "$FREETYPE_BUILD_SCRIPT" +fi +for freetype_flag in \ + "--without-harfbuzz" \ + "--with-zlib=no" \ + "--with-bzip2=no" \ + "--with-png=no" \ + "--with-brotli=no"; do + if [[ $(grep -Fc -- "$freetype_flag" "$FREETYPE_BUILD_SCRIPT") -ne 1 ]]; then + echo "Could not select isolated FreeType dependency flags exactly once." >&2 + exit 1 + fi +done + +HARFBUZZ_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/harfbuzz.sh" +for harfbuzz_flag in \ + "--with-freetype=no" \ + "--with-glib=no" \ + "--with-gobject=no" \ + "--with-cairo=no" \ + "--with-icu=no" \ + "--with-graphite2=no" \ + "--with-uniscribe=no" \ + "--with-directwrite=no" \ + "--with-coretext=no"; do + if ! grep -Fq -- "$harfbuzz_flag" "$HARFBUZZ_BUILD_SCRIPT"; then + sed -i \ + "/--enable-static/a\\ $harfbuzz_flag" \ + "$HARFBUZZ_BUILD_SCRIPT" + fi + if [[ $(grep -Fc -- "$harfbuzz_flag" "$HARFBUZZ_BUILD_SCRIPT") -ne 1 ]]; then + echo "Could not select isolated HarfBuzz dependency flags exactly once." >&2 + exit 1 + fi +done + +mkdir -p -- "$INSTALL_DIR" "$OUTPUT_DIR" + +export INSTALL_DIR +export EM_PKG_CONFIG_PATH="$EMSDK/upstream/emscripten/system/lib/pkgconfig:$INSTALL_DIR/lib/pkgconfig" +export EM_TOOLCHAIN_FILE="$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake" +export PKG_CONFIG_PATH="$INSTALL_DIR/lib/pkgconfig:$EM_PKG_CONFIG_PATH" +export LANG=C +export LC_ALL=C +export TZ=UTC +export SOURCE_DATE_EPOCH +export ZERO_AR_DATE=1 + +MODE_FLAGS="-O3 -msimd128" +if [[ "$MODE" == "st" ]]; then + export FFMPEG_ST=yes + unset FFMPEG_MT || true +else + MODE_FLAGS="$MODE_FLAGS -sUSE_PTHREADS -pthread" + # HarfBuzz's pinned upstream build script reads this value directly under + # `set -u`, while the other scripts use its presence to disable threading. + # An exported empty value keeps both behaviours correct for the MT build. + export FFMPEG_ST= + export FFMPEG_MT=yes +fi +export CFLAGS="-I$INSTALL_DIR/include $MODE_FLAGS" +export CXXFLAGS="$CFLAGS" +export LDFLAGS="-L$INSTALL_DIR/lib $CFLAGS" + +FFMPEG_WASM="$SOURCE_ROOT/ffmpeg.wasm" + +build_component() { + local stamp=$1 + local directory=$2 + local script=$3 + if [[ -f "$STAMP_DIR/$stamp" ]]; then + echo "[reviewed-core:$MODE] reuse $stamp" + return + fi + echo "[reviewed-core:$MODE] build $stamp" + ( + cd -- "$SOURCE_ROOT/$directory" + if [[ "$stamp" == "libvpx" ]]; then + # libvpx embeds its configure command in the linked library. A relative + # prefix keeps that shipped identity independent of the checkout path + # while still installing into this mode's private prefix. + INSTALL_DIR=../../../../install \ + CFLAGS="-I../../../../install/include $MODE_FLAGS" \ + CXXFLAGS="-I../../../../install/include $MODE_FLAGS" \ + LDFLAGS="-L../../../../install/lib -I../../../../install/include $MODE_FLAGS" \ + bash "$FFMPEG_WASM/build/$script" + else + bash "$FFMPEG_WASM/build/$script" + fi + ) + touch "$STAMP_DIR/$stamp" +} + +if [[ ! -f "$STAMP_DIR/sdl2" ]]; then + if [[ "$MODE" == "st" ]]; then + embuilder build sdl2 + else + embuilder build sdl2-mt + fi + touch "$STAMP_DIR/sdl2" +fi + +build_component x264 x264 x264.sh +build_component x265 x265 x265.sh +build_component libvpx libvpx libvpx.sh +build_component lame lame lame.sh +build_component ogg ogg ogg.sh +build_component theora theora theora.sh +build_component opus opus opus.sh +build_component vorbis vorbis vorbis.sh +build_component zlib zlib zlib.sh +build_component libwebp libwebp libwebp.sh +build_component freetype2 freetype2 freetype2.sh +build_component fribidi fribidi fribidi.sh +build_component harfbuzz harfbuzz harfbuzz.sh +build_component libass libass libass.sh +build_component zimg zimg zimg.sh + +# Configuration probes from Autotools projects must never smuggle host-native +# include/library paths into the reviewed WebAssembly link. +if grep -R -E -n -- \ + '/usr/(lib|include)|/lib/(x86_64|i[3-6]86)-linux-gnu' \ + "$INSTALL_DIR/lib/pkgconfig" "$INSTALL_DIR/lib"/*.la; then + echo "A staged dependency recorded a host-native build path." >&2 + exit 1 +fi +if emnm "$INSTALL_DIR/lib/libfreetype.a" | + grep -F -- " U png_" >/dev/null; then + echo "FreeType unexpectedly retained an unbundled libpng dependency." >&2 + exit 1 +fi +if emnm "$INSTALL_DIR/lib/libfreetype.a" | + grep -E -- ' U (inflate|BZ2_|Brotli)' >/dev/null; then + echo "FreeType unexpectedly retained a cross-stage compression dependency." >&2 + exit 1 +fi + +FFMPEG_SOURCE="$SOURCE_ROOT/FFmpeg" +# FFmpeg records --extra-cflags in `ffmpeg -buildconf`. Use a path relative to +# its source root so the shipped runtime identity is independent of the +# maintainer's checkout location. Dependency builds above retain their +# absolute private staging prefix; only the final FFmpeg configuration is +# embedded in the distributed core. +FFMPEG_INSTALL_RELATIVE=../../../../install +export CFLAGS="-I$FFMPEG_INSTALL_RELATIVE/include $MODE_FLAGS" +export CXXFLAGS="$CFLAGS" +export LDFLAGS="-L$INSTALL_DIR/lib $CFLAGS" +if [[ ! -f "$STAMP_DIR/ffmpeg" ]]; then + echo "[reviewed-core:$MODE] build FFmpeg" + ( + cd -- "$FFMPEG_SOURCE" + bash "$FFMPEG_WASM/build/ffmpeg.sh" \ + --enable-gpl \ + --enable-libx264 \ + --enable-libx265 \ + --enable-libvpx \ + --enable-libmp3lame \ + --enable-libtheora \ + --enable-libvorbis \ + --enable-libopus \ + --enable-zlib \ + --enable-libwebp \ + --enable-libfreetype \ + --enable-libfribidi \ + --enable-libass \ + --enable-libzimg + ) + touch "$STAMP_DIR/ffmpeg" +fi + +if [[ ! -f "$STAMP_DIR/core" ]]; then + echo "[reviewed-core:$MODE] link ffmpeg-core with 5 MiB stack" + mkdir -p -- "$FFMPEG_SOURCE/src" + cp -a -- "$FFMPEG_WASM/src/bind" "$FFMPEG_SOURCE/src/" + cp -a -- "$FFMPEG_WASM/src/fftools" "$FFMPEG_SOURCE/src/" + cp -- "$FFMPEG_WASM/build/ffmpeg-wasm.sh" "$FFMPEG_SOURCE/build-reviewed-core.sh" + ( + cd -- "$FFMPEG_SOURCE" + bash ./build-reviewed-core.sh \ + -lx264 \ + -lx265 \ + -lvpx \ + -lmp3lame \ + -logg \ + -ltheora \ + -lvorbis \ + -lvorbisenc \ + -lvorbisfile \ + -lopus \ + -lz \ + -lwebpmux \ + -lwebp \ + -lsharpyuv \ + -lfreetype \ + -lfribidi \ + -lharfbuzz \ + -lass \ + -lzimg \ + -sEXPORT_ES6 \ + -o "$OUTPUT_DIR/ffmpeg-core.js" + ) + touch "$STAMP_DIR/core" +fi + +expected_files=(ffmpeg-core.js ffmpeg-core.wasm) +if [[ "$MODE" == "mt" ]]; then + expected_files+=(ffmpeg-core.worker.js) +fi +for expected_file in "${expected_files[@]}"; do + if [[ ! -s "$OUTPUT_DIR/$expected_file" || -L "$OUTPUT_DIR/$expected_file" ]]; then + echo "Missing staged output: $OUTPUT_DIR/$expected_file" >&2 + exit 1 + fi + if grep -aFq -- "$REPOSITORY_ROOT" "$OUTPUT_DIR/$expected_file" || + grep -aFq -- "$EMSDK_ROOT" "$OUTPUT_DIR/$expected_file"; then + echo "Staged output leaks a maintainer-local build path: $expected_file" >&2 + exit 1 + fi +done + +( + cd -- "$OUTPUT_DIR" + sha256sum -- "${expected_files[@]}" >SHA256SUMS +) + +echo "[reviewed-core:$MODE] staged outputs" +cat "$OUTPUT_DIR/SHA256SUMS" diff --git a/scripts/build-tools/install b/scripts/build-tools/install new file mode 100644 index 0000000..b79fa32 --- /dev/null +++ b/scripts/build-tools/install @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +# A deliberately small `install(1)` compatibility wrapper for build trees on +# filesystems that map every created file to a shared owner and reject chmod. +# It is used only inside the private reviewed-core build. The final asset pack +# is hash-verified, and executable permission bits are irrelevant to browser +# JavaScript/WebAssembly files. + +set -euo pipefail + +directory_mode=false +create_leading=false +target_directory= +operands=() + +while [[ $# -gt 0 ]]; do + case "$1" in + -d) + directory_mode=true + shift + ;; + -D) + create_leading=true + shift + ;; + -c | -p | -s | -v) + shift + ;; + -m | -o | -g) + if [[ $# -lt 2 ]]; then + echo "install wrapper: $1 requires a value" >&2 + exit 2 + fi + shift 2 + ;; + -m* | -o* | -g*) + shift + ;; + -t) + if [[ $# -lt 2 ]]; then + echo "install wrapper: -t requires a directory" >&2 + exit 2 + fi + target_directory=$2 + shift 2 + ;; + --) + shift + operands+=("$@") + break + ;; + -*) + echo "install wrapper: unsupported option $1" >&2 + exit 2 + ;; + *) + operands+=("$1") + shift + ;; + esac +done + +if $directory_mode; then + if [[ ${#operands[@]} -eq 0 ]]; then + echo "install wrapper: -d requires at least one directory" >&2 + exit 2 + fi + mkdir -p -- "${operands[@]}" + exit 0 +fi + +if [[ -n "$target_directory" ]]; then + if [[ ${#operands[@]} -eq 0 ]]; then + echo "install wrapper: -t requires at least one source" >&2 + exit 2 + fi + mkdir -p -- "$target_directory" + cp -f -- "${operands[@]}" "$target_directory/" + exit 0 +fi + +if [[ ${#operands[@]} -lt 2 ]]; then + echo "install wrapper: expected source and destination" >&2 + exit 2 +fi + +destination=${operands[-1]} +unset 'operands[-1]' +if $create_leading; then + mkdir -p -- "$(dirname -- "$destination")" +fi + +if [[ ${#operands[@]} -gt 1 || -d "$destination" ]]; then + mkdir -p -- "$destination" + cp -f -- "${operands[@]}" "$destination/" +else + cp -f -- "${operands[0]}" "$destination" +fi diff --git a/scripts/copy-ffmpeg-assets.mjs b/scripts/copy-ffmpeg-assets.mjs index 363513b..39f78dc 100644 --- a/scripts/copy-ffmpeg-assets.mjs +++ b/scripts/copy-ffmpeg-assets.mjs @@ -1,138 +1,203 @@ import { createHash } from 'node:crypto'; -import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; +import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { unzipSync } from 'fflate'; -const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const expectedVersion = '0.12.10'; -const outputRoot = join( - repositoryRoot, - 'public', - 'vendor', - 'ffmpeg', - expectedVersion +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' ); - -const packages = [ - { - packageName: '@ffmpeg/core', - mode: 'st', - files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'], - }, - { - packageName: '@ffmpeg/core-mt', - mode: 'mt', - files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'], - }, -]; - -const expectedHashes = new Map([ - [ - '@ffmpeg/core:ffmpeg-core.js', - '67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3', - ], - [ - '@ffmpeg/core:ffmpeg-core.wasm', - '9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7', - ], - [ - '@ffmpeg/core-mt:ffmpeg-core.js', - '270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de', - ], - [ - '@ffmpeg/core-mt:ffmpeg-core.wasm', - 'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a', - ], - [ - '@ffmpeg/core-mt:ffmpeg-core.worker.js', - 'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3', - ], +const lockPath = path.join( + repositoryRoot, + 'scripts', + 'reviewed-ffmpeg-core-lock.json' +); +const maximumArchiveBytes = 64 * 1024 * 1024; +const maximumAssetBytes = 64 * 1024 * 1024; +const requiredAssetPaths = new Set([ + 'st/ffmpeg-core.js', + 'st/ffmpeg-core.wasm', + 'mt/ffmpeg-core.js', + 'mt/ffmpeg-core.wasm', + 'mt/ffmpeg-core.worker.js', ]); -async function loadPackageVersion(packageName) { - const packagePath = join( - repositoryRoot, - 'node_modules', - ...packageName.split('/'), - 'package.json' - ); - const parsed = JSON.parse(await readFile(packagePath, 'utf8')); - if (parsed.version !== expectedVersion) { - throw new Error( - `${packageName} ${parsed.version ?? ''} is installed; expected exactly ${expectedVersion}` - ); - } +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); } -async function copyAsset(packageName, mode, fileName) { - const source = join( - repositoryRoot, - 'node_modules', - ...packageName.split('/'), - 'dist', - 'esm', - fileName - ); - const destination = join(outputRoot, mode, fileName); - const sourceBytes = await readFile(source).catch((error) => { - throw new Error(`Required FFmpeg asset is missing: ${source}`, { - cause: error, - }); +function assertSafeRelativePath(value) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + path.posix.normalize(value) !== value || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw new Error(`Unsafe reviewed-core path: ${String(value)}`); + } + return value; +} + +async function requireRealDirectory(directory, create = false) { + let details = await lstat(directory).catch((error) => { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return undefined; + } + throw error; }); - const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex'); - const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`); - if (!expectedDigest || sourceDigest !== expectedDigest) { - throw new Error( - `Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}` - ); + if (!details && create) { + await mkdir(directory); + details = await lstat(directory); } - - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { - dereference: true, - errorOnExist: false, - force: true, - preserveTimestamps: false, - }); - - const destinationBytes = await readFile(destination); - if (!sourceBytes.equals(destinationBytes)) { - throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`); - } - - return { - path: relative(outputRoot, destination).replaceAll('\\', '/'), - bytes: destinationBytes.byteLength, - sha256: sourceDigest, - }; -} - -await rm(outputRoot, { force: true, recursive: true }); - -const assets = []; -for (const entry of packages) { - await loadPackageVersion(entry.packageName); - for (const fileName of entry.files) { - assets.push(await copyAsset(entry.packageName, entry.mode, fileName)); + if (!details || details.isSymbolicLink() || !details.isDirectory()) { + throw new Error(`Required directory is unsafe: ${directory}`); } } -assets.sort((left, right) => left.path.localeCompare(right.path, 'en')); -const versionManifest = { - schemaVersion: 1, - coreVersion: expectedVersion, - packages: { - '@ffmpeg/core': expectedVersion, - '@ffmpeg/core-mt': expectedVersion, +const lock = JSON.parse(await readFile(lockPath, 'utf8')); +const lockedAssetPaths = new Set( + Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : [] +); +if ( + lock.schemaVersion !== 1 || + typeof lock.buildId !== 'string' || + !/^[a-zA-Z0-9._-]+$/.test(lock.buildId) || + lock.coreVersion !== '0.12.10' || + lock.profile !== 'reviewed-stack5m' || + lock.sourceArchive?.path !== + 'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' || + !/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') || + lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' || + !/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') || + lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' || + !/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') || + lock.patch?.upstreamCommit !== 'b409e36475bc21f0451b5b1e1d126fa82871439a' || + lock.patch?.stackSizeBytes !== 5 * 1024 * 1024 || + typeof lock.opusRegression?.singleThread !== 'boolean' || + typeof lock.opusRegression?.multiThread !== 'boolean' || + !Array.isArray(lock.assets) || + lock.assets.length !== 5 || + lock.assets.some( + (asset) => + !requiredAssetPaths.has(asset.path) || + !Number.isSafeInteger(asset.bytes) || + asset.bytes < 1 || + asset.bytes > maximumAssetBytes || + !/^[a-f0-9]{64}$/.test(asset.sha256 ?? '') + ) || + lockedAssetPaths.size !== requiredAssetPaths.size || + [...requiredAssetPaths].some((assetPath) => !lockedAssetPaths.has(assetPath)) +) { + throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`); +} + +const artifactRelativePath = assertSafeRelativePath(lock.artifact?.path); +if (artifactRelativePath !== `vendor/ffmpeg-core-${lock.buildId}.zip`) { + throw new Error('Reviewed-core artifact path and build identity disagree.'); +} +const artifact = await readFile( + path.join(repositoryRoot, ...artifactRelativePath.split('/')) +); +if ( + artifact.byteLength < 1 || + artifact.byteLength > maximumArchiveBytes || + artifact.byteLength !== lock.artifact.bytes || + sha256(artifact) !== lock.artifact.sha256 +) { + throw new Error('Reviewed FFmpeg core archive failed locked verification.'); +} + +const lockedArchiveEntries = new Map( + lock.assets.map((asset) => [asset.path, asset]) +); +const archiveEntries = new Set(); +const uncompressed = unzipSync(artifact, { + filter: (entry) => { + const relative = assertSafeRelativePath(entry.name); + const locked = lockedArchiveEntries.get(relative); + if ( + !locked || + archiveEntries.has(relative) || + entry.originalSize !== locked.bytes || + entry.originalSize < 1 || + entry.originalSize > maximumAssetBytes || + entry.size < 1 || + entry.size > maximumArchiveBytes + ) { + throw new Error( + `Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}` + ); + } + archiveEntries.add(relative); + return true; }, - assets, -}; +}); +if (archiveEntries.size !== lockedArchiveEntries.size) { + throw new Error('Reviewed FFmpeg core archive is missing an expected entry.'); +} +const vendorRoot = path.join(repositoryRoot, 'public', 'vendor', 'ffmpeg'); +const outputRoot = path.join(vendorRoot, lock.buildId); +await requireRealDirectory(repositoryRoot); +await requireRealDirectory(path.join(repositoryRoot, 'public')); +await requireRealDirectory(path.join(repositoryRoot, 'public', 'vendor'), true); +const existingVendorRoot = await lstat(vendorRoot).catch((error) => { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return undefined; + } + throw error; +}); +if ( + existingVendorRoot && + (existingVendorRoot.isSymbolicLink() || !existingVendorRoot.isDirectory()) +) { + throw new Error(`Generated FFmpeg vendor root is unsafe: ${vendorRoot}`); +} +await rm(vendorRoot, { force: true, recursive: true }); + +for (const asset of lock.assets) { + const relativeAssetPath = assertSafeRelativePath(asset.path); + const bytes = uncompressed[relativeAssetPath]; + if ( + !bytes || + bytes.byteLength < 1 || + bytes.byteLength > maximumAssetBytes || + bytes.byteLength !== asset.bytes || + sha256(bytes) !== asset.sha256 + ) { + throw new Error( + `Reviewed FFmpeg core entry failed locked verification: ${asset.path}` + ); + } + const destination = path.join(outputRoot, ...relativeAssetPath.split('/')); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, bytes, { flag: 'wx', mode: 0o644 }); +} + +const versionManifest = { + schemaVersion: 2, + buildId: lock.buildId, + profile: lock.profile, + coreVersion: lock.coreVersion, + ffmpegVersion: lock.ffmpegVersion, + sourceArchive: lock.sourceArchive, + sourceLock: lock.sourceLock, + buildDriver: lock.buildDriver, + toolchain: lock.toolchain, + patch: lock.patch, + opusRegression: lock.opusRegression, + assets: lock.assets, +}; await writeFile( - join(outputRoot, 'version.json'), - `${JSON.stringify(versionManifest, null, 2)}\n` + path.join(outputRoot, 'version.json'), + `${JSON.stringify(versionManifest, null, 2)}\n`, + { flag: 'wx', mode: 0o644 } ); console.log( - `Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}` + `Verified and unpacked ${lock.assets.length} reviewed FFmpeg assets to ${path.relative(repositoryRoot, outputRoot)}` ); diff --git a/scripts/ffmpeg-source-README.md b/scripts/ffmpeg-source-README.md index 221d364..6fd6a06 100644 --- a/scripts/ffmpeg-source-README.md +++ b/scripts/ffmpeg-source-README.md @@ -1,7 +1,9 @@ # FFmpeg WebAssembly Corresponding Source -This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt` -0.12.10 object code distributed by av-tools 0.1.0. +This archive accompanies the reviewed single-thread and multithread FFmpeg +WebAssembly core profile distributed by av-tools. The profile retains the +upstream core version 0.12.10 and FFmpeg n5.1.4 identities, but it is rebuilt +from source rather than copied from the published `@ffmpeg/core` packages. `SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and every external library selected by the upstream v12.15 build. The source trees @@ -18,10 +20,15 @@ The original build-control files are in `sources/ffmpeg.wasm/`, including: - `build/ffmpeg-wasm.sh`; - the other dependency scripts under `build/`. -The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools -does not modify the five published JavaScript/WebAssembly core assets. Their -exact sizes, hashes and embedded configure arguments are recorded in the -application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`. +The build uses Emscripten SDK 3.1.40 and FFmpeg n5.1.4. Its one source patch +applies upstream ffmpeg.wasm commit +`b409e36475bc21f0451b5b1e1d126fa82871439a`, which raises the Emscripten stack +to 5 MiB and fixes the libopus encode failure reported upstream. The tracked +`scripts/build-reviewed-ffmpeg-core.sh` driver in the matching av-tools source +release also isolates optional host dependencies and records the exact +toolchain identities. The generated assets' exact sizes, hashes and embedded +configure arguments are recorded in the application's `SOURCE.md`, +`scripts/reviewed-ffmpeg-core-lock.json` and `docs/FFMPEG_BUILD.md`. The source archive preserves each component's own licence and copyright notices. av-tools itself is distributed separately under @@ -37,14 +44,11 @@ test "$(git -C source-check rev-parse FETCH_HEAD)" = "" ``` The included ffmpeg.wasm Dockerfile documents the historical upstream build, -but still names remote tags and branches. It is not a turnkey offline or -byte-for-byte reproducible build recipe. To reconstruct a pinned build, -adapt each remote `ADD` to use the corresponding bundled source tree. Replace -the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/` -tree, which already contains its pinned GoogleTest submodule at -`test/extra/googletest/`. Keep the included build scripts and Emscripten -version unchanged. The upstream targets are `make prd` for single-thread and -`make prd-mt` for multithread. +but still names remote tags and branches. For the distributed reviewed +profile, use this archive together with the matching av-tools source release +and invoke its `scripts/build-reviewed-ffmpeg-core.sh` driver. The bundled +`sources/zimg/` tree already contains its pinned GoogleTest submodule at +`test/extra/googletest/`. General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten 3.1.40 container image is not copied into this archive. diff --git a/scripts/opus-regression.mjs b/scripts/opus-regression.mjs new file mode 100644 index 0000000..51c62d3 --- /dev/null +++ b/scripts/opus-regression.mjs @@ -0,0 +1,522 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_PATH), '..'); +const FIXTURE_RELATIVE_PATH = 'tests/fixtures/generated/tone.wav'; +const FIXTURE_PATH = path.join(REPOSITORY_ROOT, FIXTURE_RELATIVE_PATH); +const EXPECTED_FIXTURE_BYTES = 32_102; +const EXPECTED_FIXTURE_SHA256 = + '0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c'; +const DEFAULT_MODE_TIMEOUT_MILLISECONDS = 60_000; +const RESULT_PREFIX = 'OPUS_RESULT '; +const EVENT_PREFIX = 'OPUS_EVENT '; +const MODES = Object.freeze(['single-thread', 'multithread']); + +const argumentsSet = new Set(process.argv.slice(2)); +const option = (name) => + process.argv + .slice(2) + .find((argument) => argument.startsWith(`--${name}=`)) + ?.slice(name.length + 3); + +function boundedInteger(value, fallback, minimum, maximum) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum + ? parsed + : fallback; +} + +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + ...(error.stack ? { stack: error.stack.split('\n').slice(0, 8) } : {}), + }; + } + return { name: 'Error', message: String(error) }; +} + +function emit(prefix, value) { + process.stdout.write(`${prefix}${JSON.stringify(value)}\n`); +} + +function parsePrefixedJsonLines(text, prefix) { + return text + .split(/\r?\n/u) + .filter((line) => line.startsWith(prefix)) + .flatMap((line) => { + try { + return [JSON.parse(line.slice(prefix.length))]; + } catch { + return []; + } + }); +} + +function appendBounded(current, chunk, maximumLength = 32_768) { + return `${current}${chunk}`.slice(-maximumLength); +} + +function terminateProcessGroup(child) { + if (!child.pid) return; + try { + if (process.platform === 'win32') { + child.kill('SIGKILL'); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + child.kill('SIGKILL'); + } +} + +async function runIsolatedMode(mode, origin, timeoutMilliseconds) { + const startedAt = performance.now(); + const child = spawn( + process.execPath, + [SCRIPT_PATH, '--child', `--mode=${mode}`, `--origin=${origin}`], + { + cwd: REPOSITORY_ROOT, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + let stdout = ''; + let stderr = ''; + let timedOut = false; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout = appendBounded(stdout, chunk); + for (const event of parsePrefixedJsonLines(chunk, EVENT_PREFIX)) { + if (event.stage && event.stage !== 'core-log') { + process.stdout.write(`[opus:${mode}] ${event.stage}\n`); + } + } + }); + child.stderr.on('data', (chunk) => { + stderr = appendBounded(stderr, chunk); + }); + + const timeout = setTimeout(() => { + timedOut = true; + terminateProcessGroup(child); + }, timeoutMilliseconds); + + const { code, signal } = await new Promise((resolve) => { + child.once('close', (exitCode, exitSignal) => { + resolve({ code: exitCode, signal: exitSignal }); + }); + }); + clearTimeout(timeout); + + const elapsedMilliseconds = Math.round(performance.now() - startedAt); + const events = parsePrefixedJsonLines(stdout, EVENT_PREFIX); + const reported = parsePrefixedJsonLines(stdout, RESULT_PREFIX).at(-1); + if (reported) { + return { + ...reported, + elapsedMilliseconds, + process: { exitCode: code, signal }, + }; + } + + const coreReady = events.find((event) => event.stage === 'core-ready'); + const lastStage = [...events] + .reverse() + .find((event) => event.stage && event.stage !== 'core-log'); + const relevantLogs = events + .filter((event) => event.stage === 'core-log') + .slice(-16) + .map((event) => event.message); + return { + requestedMode: mode, + status: 'fail', + failure: timedOut ? 'timeout' : 'child-exited-without-result', + message: timedOut + ? `The isolated browser did not finish within ${String(timeoutMilliseconds)} ms.` + : 'The isolated browser exited without a structured result.', + ...(coreReady?.actualMode ? { actualMode: coreReady.actualMode } : {}), + ...(coreReady?.ffmpegVersion + ? { ffmpegVersion: coreReady.ffmpegVersion } + : {}), + ...(coreReady?.capabilities + ? { capabilities: coreReady.capabilities } + : {}), + ...(lastStage?.stage ? { lastStage: lastStage.stage } : {}), + ...(relevantLogs.length > 0 ? { relevantLogs } : {}), + ...(stderr.trim() ? { stderr: stderr.trim().split('\n').slice(-12) } : {}), + elapsedMilliseconds, + process: { exitCode: code, signal }, + }; +} + +async function fixtureIdentity() { + const [bytes, fixtureStat] = await Promise.all([ + readFile(FIXTURE_PATH), + stat(FIXTURE_PATH), + ]); + const sha256 = createHash('sha256').update(bytes).digest('hex'); + if ( + fixtureStat.size !== EXPECTED_FIXTURE_BYTES || + sha256 !== EXPECTED_FIXTURE_SHA256 + ) { + throw new Error( + `Opus fixture identity mismatch: expected ${String(EXPECTED_FIXTURE_BYTES)} bytes/${EXPECTED_FIXTURE_SHA256}, received ${String(fixtureStat.size)} bytes/${sha256}.` + ); + } + return { + path: FIXTURE_RELATIVE_PATH, + bytes: fixtureStat.size, + sha256, + }; +} + +async function runMain() { + const timeoutMilliseconds = boundedInteger( + option('timeout-ms') ?? process.env.AV_OPUS_REGRESSION_TIMEOUT_MS, + DEFAULT_MODE_TIMEOUT_MILLISECONDS, + 10_000, + 10 * 60_000 + ); + const [fixture, packageText, coreLockText] = await Promise.all([ + fixtureIdentity(), + readFile(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'), + readFile( + path.join(REPOSITORY_ROOT, 'scripts', 'reviewed-ffmpeg-core-lock.json'), + 'utf8' + ), + ]); + const packageManifest = JSON.parse(packageText); + const coreLock = JSON.parse(coreLockText); + const { createServer } = await import('vite'); + const server = await createServer({ + configFile: path.join(REPOSITORY_ROOT, 'vite.config.ts'), + logLevel: 'warn', + server: { + host: '127.0.0.1', + port: 0, + strictPort: false, + }, + }); + + try { + await server.listen(); + const address = server.httpServer?.address(); + if (!address || typeof address === 'string') { + throw new Error('Vite did not expose a local TCP address.'); + } + const origin = `http://127.0.0.1:${String(address.port)}`; + const modes = []; + for (const mode of MODES) { + modes.push(await runIsolatedMode(mode, origin, timeoutMilliseconds)); + } + const report = { + schemaVersion: 2, + recordedAt: new Date().toISOString(), + browser: 'chromium', + wrapperVersion: packageManifest.dependencies['@ffmpeg/ffmpeg'], + coreVersion: coreLock.coreVersion, + coreBuildId: coreLock.buildId, + coreProfile: coreLock.profile, + fixture, + perModeTimeoutMilliseconds: timeoutMilliseconds, + modes, + safe: modes.every((result) => result.status === 'pass'), + }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + if (argumentsSet.has('--require-pass') && !report.safe) { + process.exitCode = 1; + } + } finally { + await server.close(); + } +} + +async function runBrowserMode(mode, origin) { + const { chromium } = await import('@playwright/test'); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.exposeFunction('__reportOpusEvent', (event) => { + emit(EVENT_PREFIX, event); + }); + await page.goto(origin, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + return await page.evaluate( + async ({ fixtureUrl, requestedMode }) => { + const report = async (event) => { + await globalThis.__reportOpusEvent(event); + }; + const replacePaths = (plan, context) => { + const replacements = new Map(); + plan.inputs.forEach((input, index) => { + const actual = context.inputPaths[index]; + if (actual) replacements.set(input.path, actual); + }); + plan.outputs.forEach((output, index) => { + const actual = context.outputPaths[index]; + if (actual) replacements.set(output.path, actual); + }); + plan.temporaryFiles.forEach((temporary, index) => { + const actual = context.temporaryPaths[index]; + if (actual) replacements.set(temporary.path, actual); + }); + return plan.args.map((argument) => + [...replacements].reduce( + (resolved, [virtualPath, actualPath]) => + resolved.replaceAll(virtualPath, actualPath), + argument + ) + ); + }; + const errorShape = (error) => + error instanceof Error + ? { + name: error.name, + message: error.message, + ...(error.stack + ? { stack: error.stack.split('\n').slice(0, 8) } + : {}), + } + : { name: 'Error', message: String(error) }; + let manager; + const relevantLogs = []; + try { + await report({ stage: 'module-import' }); + const [{ EngineManager }, { buildConvertPlan }, { AUDIO_PRESETS }] = + await Promise.all([ + import('/src/ffmpeg/EngineManager.ts'), + import('/src/commands/convert.ts'), + import('/src/presets/audio-presets.ts'), + ]); + manager = new EngineManager(); + manager.setPreference( + requestedMode === 'single-thread' + ? 'force-single-thread' + : 'prefer-multithread' + ); + manager.onLog((event) => { + for (const line of event.message.split(/\r?\n/u)) { + if ( + relevantLogs.length < 32 && + /libopus|Output #|Stream mapping|Aborted\(\)|(?:^|\s)(?:size|progress)=|error|failed/iu.test( + line + ) + ) { + const message = line.slice(0, 600); + relevantLogs.push(message); + void report({ stage: 'core-log', message }); + } + } + }); + + await report({ stage: 'fixture-fetch' }); + const fixtureResponse = await fetch(fixtureUrl); + if (!fixtureResponse.ok) { + throw new Error( + `Fixture request failed with HTTP ${String(fixtureResponse.status)}.` + ); + } + const fixtureBytes = new Uint8Array( + await fixtureResponse.arrayBuffer() + ); + const sourceFile = new File([fixtureBytes], 'tone.wav', { + type: 'audio/wav', + }); + + await report({ stage: 'core-initializing' }); + await manager.initialize(); + const ready = manager.state; + if (ready.status !== 'ready') { + throw new Error('The FFmpeg engine did not reach ready state.'); + } + const capabilities = { + opusMuxer: ready.capabilities.muxers.has('opus'), + libopusEncoder: ready.capabilities.encoders.has('libopus'), + }; + await report({ + stage: 'core-ready', + requestedMode, + actualMode: ready.mode, + ffmpegVersion: ready.capabilities.versionText, + capabilities, + }); + if (ready.mode !== requestedMode) { + throw new Error( + `Requested ${requestedMode}, but the engine selected ${ready.mode}.` + ); + } + if (!capabilities.opusMuxer || !capabilities.libopusEncoder) { + throw new Error( + 'The pinned core does not advertise the Opus muxer and libopus encoder.' + ); + } + + const preset = AUDIO_PRESETS.find( + (candidate) => candidate.id === 'audio-opus' + ); + if (!preset) { + throw new Error('The built-in audio-opus preset is missing.'); + } + const plan = buildConvertPlan({ + jobId: `opus-regression-${requestedMode}`, + source: { + id: 'tone', + sourceIndex: 0, + fileName: 'tone.wav', + extension: 'wav', + }, + preset, + streamSelection: { audio: [0] }, + expectedDurationSeconds: 1, + timeoutMs: 30_000, + }); + await report({ + stage: 'encode-started', + presetId: preset.id, + requirements: plan.requiredCapabilities, + }); + let commandArguments = []; + const completed = await manager.runJob({ + id: `opus-regression-${requestedMode}`, + operation: 'Opus regression', + coreWorkload: plan.operation, + inputs: [sourceFile], + outputs: plan.outputs.map((output) => ({ + name: output.fileName, + mimeType: output.mediaType, + })), + expectedDurationSeconds: plan.expectedDurationSeconds, + timeoutMilliseconds: plan.timeoutMs, + buildArguments: (context) => { + commandArguments = replacePaths(plan, context); + void report({ + stage: 'encode-command', + arguments: commandArguments, + }); + return commandArguments; + }, + }); + const output = completed.outputs[0]; + if (!output || output.bytes.byteLength === 0) { + throw new Error( + 'The Opus encode did not produce non-empty output.' + ); + } + await report({ + stage: 'encode-finished', + outputBytes: output.bytes.byteLength, + }); + + const outputFile = new File([output.bytes], output.name, { + type: output.mimeType, + }); + await report({ stage: 'reprobe-started' }); + const probe = await manager.probe(outputFile, 15_000); + const probeJson = probe.json; + const streams = Array.isArray(probeJson?.streams) + ? probeJson.streams + : []; + const opusStream = streams.find( + (stream) => + stream?.codec_type === 'audio' && stream?.codec_name === 'opus' + ); + const durationSeconds = Number( + probeJson?.format?.duration ?? opusStream?.duration + ); + if (!opusStream || !(durationSeconds > 0)) { + throw new Error( + 'The generated file did not re-probe as positive-duration Opus audio.' + ); + } + const digest = new Uint8Array( + await crypto.subtle.digest('SHA-256', output.bytes) + ); + const outputSha256 = [...digest] + .map((value) => value.toString(16).padStart(2, '0')) + .join(''); + await report({ stage: 'reprobe-finished', durationSeconds }); + return { + requestedMode, + actualMode: ready.mode, + status: 'pass', + ffmpegVersion: ready.capabilities.versionText, + capabilities, + presetId: preset.id, + commandArguments, + output: { + name: output.name, + mimeType: output.mimeType, + bytes: output.bytes.byteLength, + sha256: outputSha256, + codecName: opusStream.codec_name, + codecType: opusStream.codec_type, + durationSeconds, + }, + relevantLogs, + }; + } catch (error) { + const state = manager?.state; + return { + requestedMode, + ...(state?.mode ? { actualMode: state.mode } : {}), + status: 'fail', + failure: 'browser-operation-failed', + error: errorShape(error), + relevantLogs, + }; + } finally { + manager?.dispose(); + } + }, + { + fixtureUrl: `${origin}/${FIXTURE_RELATIVE_PATH}`, + requestedMode: mode, + } + ); + } finally { + await browser.close(); + } +} + +async function runChild() { + const mode = option('mode'); + const origin = option('origin'); + if (!MODES.includes(mode) || !origin) { + throw new Error('Child mode requires valid --mode and --origin options.'); + } + emit(EVENT_PREFIX, { stage: 'browser-launching', requestedMode: mode }); + try { + const result = await runBrowserMode(mode, origin); + emit(RESULT_PREFIX, result); + } catch (error) { + emit(RESULT_PREFIX, { + requestedMode: mode, + status: 'fail', + failure: 'browser-process-failed', + error: serializeError(error), + }); + } +} + +try { + if (argumentsSet.has('--child')) { + await runChild(); + } else { + await runMain(); + } +} catch (error) { + process.stderr.write( + `Opus regression harness failed: ${JSON.stringify(serializeError(error))}\n` + ); + process.exitCode = 2; +} diff --git a/scripts/package-ffmpeg-source.mjs b/scripts/package-ffmpeg-source.mjs index 90d3d98..8c9dd71 100644 --- a/scripts/package-ffmpeg-source.mjs +++ b/scripts/package-ffmpeg-source.mjs @@ -18,6 +18,11 @@ import { createReadStream } from 'node:fs'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json'); +const reviewedLockPath = join( + repositoryRoot, + 'scripts', + 'reviewed-ffmpeg-core-lock.json' +); const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md'); const releaseDirectory = join(repositoryRoot, 'release'); const force = process.argv.includes('--force'); @@ -204,7 +209,19 @@ async function publishSourcePair({ const lock = JSON.parse(await readFile(lockPath, 'utf8')); assertLock(lock); -const archiveName = `ffmpeg-core-${lock.coreVersion}-corresponding-source.tar.xz`; +const reviewedLock = JSON.parse(await readFile(reviewedLockPath, 'utf8')); +if ( + reviewedLock?.schemaVersion !== 1 || + reviewedLock.coreVersion !== lock.coreVersion || + typeof reviewedLock.buildId !== 'string' || + !/^[a-zA-Z0-9._-]+$/u.test(reviewedLock.buildId) || + !reviewedLock.buildId.startsWith(`${lock.coreVersion}-`) +) { + throw new Error( + 'The reviewed-core lock does not identify a build-qualified source bundle.' + ); +} +const archiveName = `ffmpeg-core-${reviewedLock.buildId}-corresponding-source.tar.xz`; const archivePath = join(releaseDirectory, archiveName); const checksumPath = `${archivePath}.sha256`; await ensureReleaseDirectory(); diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs index be0a51b..890c0be 100644 --- a/scripts/package-release.mjs +++ b/scripts/package-release.mjs @@ -52,8 +52,6 @@ const ffmpegLicenseCoverage = new Map([ ['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'], ['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'], ['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'], - ['@ffmpeg/core', 'LICENSES/GPL-2.0-or-later.txt'], - ['@ffmpeg/core-mt', 'LICENSES/GPL-2.0-or-later.txt'], ]); function compareText(left, right) { @@ -479,7 +477,8 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) { } } - const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json'; + const vendorBuildId = '0.12.10-reviewed-stack5m.1'; + const vendorManifestPath = `vendor/ffmpeg/${vendorBuildId}/version.json`; const vendorManifestEntry = entriesByName.get(vendorManifestPath); if (!vendorManifestEntry) { throw new Error( @@ -488,10 +487,12 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) { } const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8')); if ( - vendorManifest.schemaVersion !== 1 || + vendorManifest.schemaVersion !== 2 || + vendorManifest.buildId !== vendorBuildId || + vendorManifest.profile !== 'reviewed-stack5m' || vendorManifest.coreVersion !== '0.12.10' || - vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' || - vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' || + vendorManifest.opusRegression?.singleThread !== true || + vendorManifest.opusRegression?.multiThread !== true || !Array.isArray(vendorManifest.assets) || vendorManifest.assets.length !== 5 ) { @@ -511,7 +512,7 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) { ); } const assetPath = safeArchivePath( - path.posix.join('vendor/ffmpeg/0.12.10', asset.path) + path.posix.join(`vendor/ffmpeg/${vendorBuildId}`, asset.path) ); if (verifiedVendorPaths.has(assetPath)) { throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`); diff --git a/scripts/package-reviewed-ffmpeg-core.mjs b/scripts/package-reviewed-ffmpeg-core.mjs new file mode 100644 index 0000000..4d1defa --- /dev/null +++ b/scripts/package-reviewed-ffmpeg-core.mjs @@ -0,0 +1,279 @@ +import { createHash } from 'node:crypto'; +import { + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { unzipSync, zipSync } from 'fflate'; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +const lockPath = path.join( + repositoryRoot, + 'scripts', + 'reviewed-ffmpeg-core-lock.json' +); +const maximumAssetBytes = 64 * 1024 * 1024; +const fixedTimestamp = new Date(1980, 0, 1, 0, 0, 0, 0); +const requiredAssetPaths = new Set([ + 'st/ffmpeg-core.js', + 'st/ffmpeg-core.wasm', + 'mt/ffmpeg-core.js', + 'mt/ffmpeg-core.wasm', + 'mt/ffmpeg-core.worker.js', +]); + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function assertSafeRelativePath(value) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + path.posix.normalize(value) !== value || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw new Error(`Unsafe reviewed-core path: ${String(value)}`); + } + return value; +} + +async function requireRealDirectory(directory, create = false) { + let details = await lstat(directory).catch((error) => { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return undefined; + } + throw error; + }); + if (!details && create) { + await mkdir(directory); + details = await lstat(directory); + } + if (!details || details.isSymbolicLink() || !details.isDirectory()) { + throw new Error(`Required directory is unsafe: ${directory}`); + } +} + +async function readLock() { + const lock = JSON.parse(await readFile(lockPath, 'utf8')); + const lockedAssetPaths = new Set( + Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : [] + ); + if ( + lock.schemaVersion !== 1 || + typeof lock.buildId !== 'string' || + !/^[a-zA-Z0-9._-]+$/.test(lock.buildId) || + lock.coreVersion !== '0.12.10' || + lock.profile !== 'reviewed-stack5m' || + lock.sourceArchive?.path !== + 'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' || + !Number.isSafeInteger(lock.sourceArchive?.bytes) || + !/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') || + lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' || + !/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') || + lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' || + !/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') || + !Array.isArray(lock.assets) || + lock.assets.length !== 5 || + lock.assets.some( + (asset) => + !requiredAssetPaths.has(asset.path) || + !Number.isSafeInteger(asset.bytes) || + asset.bytes < 1 || + asset.bytes > maximumAssetBytes || + !/^[a-f0-9]{64}$/.test(asset.sha256 ?? '') + ) || + lockedAssetPaths.size !== requiredAssetPaths.size || + [...requiredAssetPaths].some( + (assetPath) => !lockedAssetPaths.has(assetPath) + ) || + typeof lock.artifact?.path !== 'string' || + !Number.isSafeInteger(lock.artifact?.bytes) || + lock.artifact.bytes < 1 || + lock.artifact.bytes > 64 * 1024 * 1024 || + !/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '') + ) { + throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`); + } + return lock; +} + +async function readBuildAsset(asset) { + const relativeAssetPath = assertSafeRelativePath(asset.path); + const [mode, fileName, ...extra] = relativeAssetPath.split('/'); + if ( + extra.length > 0 || + !['st', 'mt'].includes(mode) || + !['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'].includes( + fileName + ) + ) { + throw new Error(`Unexpected reviewed-core asset: ${relativeAssetPath}`); + } + const outputDirectory = path.join( + repositoryRoot, + '.core-build', + 'reviewed-stack5m', + mode, + 'output' + ); + const source = path.join(outputDirectory, fileName); + const details = await lstat(source); + if (details.isSymbolicLink() || !details.isFile()) { + throw new Error(`Reviewed-core input must be a regular file: ${source}`); + } + const canonicalOutput = await realpath(outputDirectory); + const canonicalSource = await realpath(source); + if (!canonicalSource.startsWith(`${canonicalOutput}${path.sep}`)) { + throw new Error(`Reviewed-core input resolves outside output: ${source}`); + } + const bytes = await readFile(canonicalSource); + if ( + bytes.byteLength < 1 || + bytes.byteLength > maximumAssetBytes || + bytes.byteLength !== asset.bytes || + sha256(bytes) !== asset.sha256 + ) { + throw new Error(`Reviewed-core build does not match lock: ${asset.path}`); + } + return bytes; +} + +function verifyArchiveEntries(archive, lock) { + const lockedEntries = new Map( + lock.assets.map((asset) => [asset.path, asset]) + ); + const seen = new Set(); + const uncompressed = unzipSync(archive, { + filter: (entry) => { + const relative = assertSafeRelativePath(entry.name); + const locked = lockedEntries.get(relative); + if ( + !locked || + seen.has(relative) || + entry.originalSize !== locked.bytes || + entry.originalSize < 1 || + entry.originalSize > maximumAssetBytes || + entry.size < 1 || + entry.size > 64 * 1024 * 1024 + ) { + throw new Error( + `Reviewed-core archive contains an unexpected entry: ${entry.name}` + ); + } + seen.add(relative); + return true; + }, + }); + if (seen.size !== lockedEntries.size) { + throw new Error('Reviewed-core archive is missing an expected entry.'); + } + for (const asset of lock.assets) { + const bytes = uncompressed[asset.path]; + if ( + !bytes || + bytes.byteLength !== asset.bytes || + sha256(bytes) !== asset.sha256 + ) { + throw new Error( + `Reviewed-core archive entry does not match lock: ${asset.path}` + ); + } + } +} + +const lock = await readLock(); +const buildDriver = await readFile( + path.join(repositoryRoot, ...lock.buildDriver.path.split('/')) +); +if (sha256(buildDriver) !== lock.buildDriver.sha256) { + throw new Error( + 'Reviewed-core build driver does not match its locked identity.' + ); +} +const sourceLock = await readFile( + path.join(repositoryRoot, ...lock.sourceLock.path.split('/')) +); +if (sha256(sourceLock) !== lock.sourceLock.sha256) { + throw new Error('FFmpeg source lock does not match its locked identity.'); +} +const sourceArchive = await readFile( + path.join(repositoryRoot, ...lock.sourceArchive.path.split('/')) +); +if ( + sourceArchive.byteLength !== lock.sourceArchive.bytes || + sha256(sourceArchive) !== lock.sourceArchive.sha256 +) { + throw new Error( + 'Reviewed-core Corresponding Source archive does not match its locked identity.' + ); +} +const artifactRelativePath = assertSafeRelativePath(lock.artifact.path); +const expectedArtifactRelativePath = `vendor/ffmpeg-core-${lock.buildId}.zip`; +if (artifactRelativePath !== expectedArtifactRelativePath) { + throw new Error( + `Reviewed-core artifact path must be ${expectedArtifactRelativePath}.` + ); +} +const artifactPath = path.join( + repositoryRoot, + ...artifactRelativePath.split('/') +); +const entries = {}; +for (const asset of [...lock.assets].sort((left, right) => + left.path.localeCompare(right.path, 'en') +)) { + entries[asset.path] = [ + await readBuildAsset(asset), + { level: 9, mtime: fixedTimestamp }, + ]; +} +const archive = Buffer.from(zipSync(entries, { level: 9 })); +verifyArchiveEntries(archive, lock); +const archiveDigest = sha256(archive); +if ( + archive.byteLength !== lock.artifact.bytes || + archiveDigest !== lock.artifact.sha256 +) { + throw new Error( + `Deterministic reviewed-core archive does not match its locked identity: received ${String(archive.byteLength)} bytes/${archiveDigest}.` + ); +} + +const artifactDirectory = path.dirname(artifactPath); +await requireRealDirectory(repositoryRoot); +await requireRealDirectory(artifactDirectory, true); +const stagingRoot = await mkdtemp( + path.join(artifactDirectory, '.reviewed-core-package-') +); +if ( + !stagingRoot.startsWith( + `${path.join(artifactDirectory, '.reviewed-core-package-')}` + ) +) { + throw new Error(`Unexpected reviewed-core staging path: ${stagingRoot}`); +} +try { + const stagedArtifact = path.join(stagingRoot, path.basename(artifactPath)); + await writeFile(stagedArtifact, archive, { flag: 'wx', mode: 0o644 }); + await rename(stagedArtifact, artifactPath); +} finally { + await rm(stagingRoot, { force: true, recursive: true }); +} + +console.log( + `Packaged ${lock.assets.length} reviewed FFmpeg assets as ${artifactRelativePath} (${archive.byteLength} bytes, ${archiveDigest})` +); diff --git a/scripts/portal-assembly-smoke.mjs b/scripts/portal-assembly-smoke.mjs index f316958..33ff27a 100644 --- a/scripts/portal-assembly-smoke.mjs +++ b/scripts/portal-assembly-smoke.mjs @@ -16,6 +16,7 @@ const repositoryRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..' ); +const reviewedCoreBuildId = '0.12.10-reviewed-stack5m.1'; function consumeValue(argumentsList, index, option) { const value = argumentsList[index + 1]; @@ -179,12 +180,21 @@ export function inspectPortalHeaders(configuration) { 'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.' ); } - if ( - !configuration.includes('vendor/ffmpeg/0\\.12\\.10') && - !configuration.includes('vendor/ffmpeg/0.12.10') - ) { + const immutableCoreRule = configuration + .split(/\r?\n/u) + .find( + (line) => + /vendor\/ffmpeg\//iu.test(line) && + /ffmpeg-core/iu.test(line) && + /max-age=31536000/iu.test(line) && + /immutable/iu.test(line) + ); + const supportsReviewedCore = + immutableCoreRule !== undefined && + immutableCoreRule.replaceAll('\\', '').includes(reviewedCoreBuildId); + if (!supportsReviewedCore) { warnings.push( - 'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.' + `The config does not give reviewed FFmpeg build ${reviewedCoreBuildId} an explicit immutable cache rule.` ); } return { errors, warnings }; @@ -229,11 +239,11 @@ async function verifyAssembly(output, appVersion, digest) { } for (const reference of [ './index.html', - './vendor/ffmpeg/0.12.10/st/ffmpeg-core.js', - './vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm', - './vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js', - './vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm', - './vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js', + './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js', + './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm', + './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js', + './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm', + './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js', ]) { const relative = reference.replace(/^\.\//, ''); await requireRegularFile( diff --git a/scripts/reviewed-ffmpeg-core-lock.json b/scripts/reviewed-ffmpeg-core-lock.json new file mode 100644 index 0000000..044ccac --- /dev/null +++ b/scripts/reviewed-ffmpeg-core-lock.json @@ -0,0 +1,73 @@ +{ + "schemaVersion": 1, + "buildId": "0.12.10-reviewed-stack5m.1", + "profile": "reviewed-stack5m", + "coreVersion": "0.12.10", + "ffmpegVersion": "n5.1.4", + "sourceArchive": { + "path": "release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz", + "bytes": 43050052, + "sha256": "2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a" + }, + "sourceLock": { + "path": "scripts/ffmpeg-source-lock.json", + "sha256": "f521ecab59942f899b117e4570bf3a71710fcab6281fe6d796c9bdd22a1fc986" + }, + "buildDriver": { + "path": "scripts/build-reviewed-ffmpeg-core.sh", + "sha256": "d3551fe12e7883191c41b922850d9bcf7ea093e21ceaa3dec99570db7ea25e2e" + }, + "toolchain": { + "emscriptenVersion": "3.1.40", + "emscriptenCommit": "5c27e79dd0a9c4e27ef2326841698cdd4f6b5784", + "emccSha256": "b0c9551f9155fa9c028efa24591acdfd30f02f32fb247cf9266f5f0f2ede7589", + "cmakeVersion": "3.27.9", + "cmakeBinarySha256": "ade9326f83f4996b17e2cb1bbfe4848df30a237ef5769cd3c00239b9f0d3ec58" + }, + "patch": { + "upstreamCommit": "b409e36475bc21f0451b5b1e1d126fa82871439a", + "stackSizeBytes": 5242880, + "change": "build/ffmpeg-wasm.sh: add -sSTACK_SIZE=5MB" + }, + "opusRegression": { + "fixturePath": "tests/fixtures/generated/tone.wav", + "fixtureBytes": 32102, + "fixtureSha256": "0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c", + "presetId": "audio-opus", + "expectedCodec": "opus", + "singleThread": true, + "multiThread": true + }, + "assets": [ + { + "path": "st/ffmpeg-core.js", + "bytes": 111804, + "sha256": "80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24" + }, + { + "path": "st/ffmpeg-core.wasm", + "bytes": 32232580, + "sha256": "fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc" + }, + { + "path": "mt/ffmpeg-core.js", + "bytes": 128953, + "sha256": "4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62" + }, + { + "path": "mt/ffmpeg-core.wasm", + "bytes": 32718455, + "sha256": "abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645" + }, + { + "path": "mt/ffmpeg-core.worker.js", + "bytes": 2115, + "sha256": "f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3" + } + ], + "artifact": { + "path": "vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip", + "bytes": 21338974, + "sha256": "ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f" + } +} diff --git a/scripts/verify-ffmpeg-assets.mjs b/scripts/verify-ffmpeg-assets.mjs index 0490c70..c9d9335 100644 --- a/scripts/verify-ffmpeg-assets.mjs +++ b/scripts/verify-ffmpeg-assets.mjs @@ -1,80 +1,146 @@ import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; +import { lstat, readFile } from 'node:fs/promises'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; -const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const expectedVersion = '0.12.10'; -const outputRoot = join( +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +const argumentsList = process.argv.slice(2); +const runtimeOnly = argumentsList.includes('--runtime-only'); +if ( + argumentsList.some((argument) => argument !== '--runtime-only') || + (runtimeOnly && argumentsList.length !== 1) +) { + throw new Error( + 'Usage: node scripts/verify-ffmpeg-assets.mjs [--runtime-only]' + ); +} +const lock = JSON.parse( + await readFile( + path.join(repositoryRoot, 'scripts', 'reviewed-ffmpeg-core-lock.json'), + 'utf8' + ) +); +if ( + lock.schemaVersion !== 1 || + typeof lock.buildId !== 'string' || + !/^[a-zA-Z0-9._-]+$/.test(lock.buildId) || + lock.coreVersion !== '0.12.10' || + lock.profile !== 'reviewed-stack5m' || + lock.sourceArchive?.path !== + 'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' || + !Number.isSafeInteger(lock.sourceArchive?.bytes) || + lock.sourceArchive.bytes < 1 || + lock.sourceArchive.bytes > 512 * 1024 * 1024 || + !/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') || + lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' || + !/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') || + lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' || + !/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') || + lock.artifact?.path !== `vendor/ffmpeg-core-${lock.buildId}.zip` || + !Number.isSafeInteger(lock.artifact?.bytes) || + lock.artifact.bytes < 1 || + lock.artifact.bytes > 64 * 1024 * 1024 || + !/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '') || + !Array.isArray(lock.assets) || + lock.assets.length !== 5 +) { + throw new Error('Invalid reviewed FFmpeg core lock.'); +} +const outputRoot = path.join( repositoryRoot, 'public', 'vendor', 'ffmpeg', - expectedVersion + lock.buildId ); -const manifestPath = join(outputRoot, 'version.json'); +const manifestPath = path.join(outputRoot, 'version.json'); const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const safeAssetPath = (value) => { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + path.posix.normalize(value) !== value || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw new Error(`Unsafe FFmpeg asset path: ${String(value)}`); + } + return value; +}; + +const verifyLockedFile = async (record, label) => { + const relative = safeAssetPath(record.path); + const absolute = path.join(repositoryRoot, ...relative.split('/')); + const details = await lstat(absolute).catch((error) => { + throw new Error(`${label} is missing: ${relative}`, { cause: error }); + }); + if (details.isSymbolicLink() || !details.isFile()) { + throw new Error(`${label} must be a regular file: ${relative}`); + } + const bytes = await readFile(absolute); + if ( + (record.bytes !== undefined && bytes.byteLength !== record.bytes) || + sha256(bytes) !== record.sha256 + ) { + throw new Error(`${label} does not match its locked identity: ${relative}`); + } +}; if ( - manifest.schemaVersion !== 1 || - manifest.coreVersion !== expectedVersion || + manifest.schemaVersion !== 2 || + manifest.buildId !== lock.buildId || + manifest.profile !== lock.profile || + manifest.coreVersion !== lock.coreVersion || + manifest.ffmpegVersion !== lock.ffmpegVersion || + !isDeepStrictEqual(manifest.sourceArchive, lock.sourceArchive) || + !isDeepStrictEqual(manifest.sourceLock, lock.sourceLock) || + !isDeepStrictEqual(manifest.buildDriver, lock.buildDriver) || + !isDeepStrictEqual(manifest.toolchain, lock.toolchain) || + !isDeepStrictEqual(manifest.patch, lock.patch) || + !isDeepStrictEqual(manifest.opusRegression, lock.opusRegression) || !Array.isArray(manifest.assets) || - manifest.assets.length !== 5 + manifest.assets.length !== lock.assets.length ) { - throw new Error(`Invalid FFmpeg asset manifest: ${manifestPath}`); + throw new Error(`Invalid reviewed FFmpeg asset manifest: ${manifestPath}`); } -const expectedPaths = new Set([ - 'mt/ffmpeg-core.js', - 'mt/ffmpeg-core.wasm', - 'mt/ffmpeg-core.worker.js', - 'st/ffmpeg-core.js', - 'st/ffmpeg-core.wasm', -]); -const expectedHashes = new Map([ - [ - 'st/ffmpeg-core.js', - '67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3', - ], - [ - 'st/ffmpeg-core.wasm', - '9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7', - ], - [ - 'mt/ffmpeg-core.js', - '270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de', - ], - [ - 'mt/ffmpeg-core.wasm', - 'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a', - ], - [ - 'mt/ffmpeg-core.worker.js', - 'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3', - ], -]); +if (!runtimeOnly) { + await verifyLockedFile(lock.sourceArchive, 'FFmpeg Corresponding Source'); +} +await verifyLockedFile(lock.sourceLock, 'FFmpeg source lock'); +await verifyLockedFile(lock.buildDriver, 'Reviewed-core build driver'); +await verifyLockedFile(lock.artifact, 'Reviewed-core deterministic artifact'); +const lockedAssets = new Map(lock.assets.map((asset) => [asset.path, asset])); +const seen = new Set(); for (const asset of manifest.assets) { - if (!expectedPaths.delete(asset.path)) { - throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`); - } - const bytes = await readFile(join(outputRoot, asset.path)); - const digest = createHash('sha256').update(bytes).digest('hex'); + safeAssetPath(asset.path); + const locked = lockedAssets.get(asset.path); if ( - asset.bytes !== bytes.byteLength || - asset.sha256 !== digest || - expectedHashes.get(asset.path) !== digest + !locked || + seen.has(asset.path) || + asset.bytes !== locked.bytes || + asset.sha256 !== locked.sha256 ) { + throw new Error(`Unexpected reviewed FFmpeg asset: ${asset.path}`); + } + seen.add(asset.path); + const bytes = await readFile(path.join(outputRoot, ...asset.path.split('/'))); + if (bytes.byteLength !== locked.bytes || sha256(bytes) !== locked.sha256) { throw new Error(`FFmpeg asset verification failed: ${asset.path}`); } } - -if (expectedPaths.size > 0) { - throw new Error( - `FFmpeg assets are missing: ${[...expectedPaths].join(', ')}` - ); +if (seen.size !== lockedAssets.size) { + throw new Error('One or more reviewed FFmpeg assets are missing.'); } console.log( - `Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}` + `Verified ${seen.size} FFmpeg ${lock.buildId} assets in ${path.relative(repositoryRoot, outputRoot)}${runtimeOnly ? ' (runtime inputs only)' : ' with Corresponding Source'}` ); diff --git a/src/App.tsx b/src/App.tsx index ca3b2be..873c7f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,10 @@ import { type WorkspaceMode, } from './app/application-state'; import { AppErrorBoundary } from './app/AppErrorBoundary'; +import { + SourceInspectionRegistry, + type SourceInspectionToken, +} from './app/source-inspection-registry'; import { FileDropZone } from './components/FileDropZone'; import { MediaBin } from './components/MediaBin'; import { @@ -79,7 +83,10 @@ import { buildPreviewProxyPlan } from './commands/preview-proxy'; import { buildTimelineExportPlan } from './commands/timeline-export'; import { buildContactSheetPlan, + buildSceneChangeAnalysisPlan, + buildSceneThumbnailSeriesPlan, buildThumbnailSeriesPlan, + parseSceneChangeFrames, } from './commands/thumbnails'; import { buildSplitPlan } from './commands/split'; import { buildTrimPlan } from './commands/trim'; @@ -152,6 +159,11 @@ import { type WaveformPeakData, } from './waveform'; import type { MediaJob, MediaJobStep } from './jobs'; +import { loadBundledContactSheetFont } from './assets/bundled-font'; +import { + FFMPEG_CORE_VERSION, + FFMPEG_OPUS_REGRESSION_VERIFIED, +} from './version'; function timestamp(): string { return new Date().toISOString(); @@ -554,6 +566,7 @@ export function AvToolsApplication() { const projectRef = useRef(project); const sourceProbeTail = useRef>(Promise.resolve()); const sourceProbePendingRef = useRef(0); + const sourceInspectionRegistry = useRef(new SourceInspectionRegistry()); const planExecutionTail = useRef>(Promise.resolve()); const scheduledPlanCountRef = useRef(0); const scheduledWorkRef = useRef(new Map()); @@ -703,7 +716,10 @@ export function AvToolsApplication() { ); useEffect(() => { + const inspectionRegistry = sourceInspectionRegistry.current; return () => { + const cancelActiveInspection = inspectionRegistry.clear(); + if (cancelActiveInspection) void engineManager.cancelActive(); for (const asset of assetsRef.current) { URL.revokeObjectURL(asset.objectUrl); } @@ -751,15 +767,37 @@ export function AvToolsApplication() { ); const inspectAsset = useCallback( - async (asset: ImportedMediaAsset, existingAsset: boolean) => { + async ( + asset: ImportedMediaAsset, + existingAsset: boolean, + token: SourceInspectionToken + ) => { + if (!sourceInspectionRegistry.current.isCurrent(token)) return; updateAssetState(asset.id, { phase: 'probing', error: undefined, }); try { - const probeResult = await runSourceProbe(() => - engineManager.probe(asset.file) - ); + const probeResult = await runSourceProbe(async () => { + if (!sourceInspectionRegistry.current.activate(token)) { + return undefined; + } + try { + return await engineManager.probe( + asset.file, + undefined, + token.signal + ); + } finally { + sourceInspectionRegistry.current.finish(token); + } + }); + if ( + !probeResult || + !sourceInspectionRegistry.current.isCurrent(token) + ) { + return; + } const probe = normalizeFfprobeReport(probeResult.json); const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`; const importedChapters = probe.chapters.map((chapter) => ({ @@ -772,8 +810,15 @@ export function AvToolsApplication() { ? { metadata: chapter.tags } : {}), })); + if (!sourceInspectionRegistry.current.isCurrent(token)) return; updateAssetState(asset.id, { phase: 'ready', probe, rawProbe }); setProject((current) => { + if ( + !sourceInspectionRegistry.current.isCurrent(token) || + !current.assets.some((entry) => entry.id === asset.id) + ) { + return current; + } let next = projectReducer(current, { type: 'asset/update', assetId: asset.id, @@ -823,6 +868,7 @@ export function AvToolsApplication() { return next; }); } catch (error) { + if (!sourceInspectionRegistry.current.isCurrent(token)) return; updateAssetState(asset.id, { phase: 'error', error: @@ -881,6 +927,7 @@ export function AvToolsApplication() { const pending: Array<{ asset: ImportedMediaAsset; existingAsset: boolean; + token: SourceInspectionToken; }> = []; const claimedIds = new Set(assetsRef.current.map((asset) => asset.id)); @@ -916,7 +963,11 @@ export function AvToolsApplication() { if (reattach && match) { asset.id = match.entry.id; claimedIds.add(match.entry.id); - pending.push({ asset, existingAsset: true }); + pending.push({ + asset, + existingAsset: true, + token: sourceInspectionRegistry.current.register(asset.id), + }); } else { setProject((current) => projectReducer(current, { @@ -932,7 +983,11 @@ export function AvToolsApplication() { updatedAt: timestamp(), }) ); - pending.push({ asset, existingAsset: false }); + pending.push({ + asset, + existingAsset: false, + token: sourceInspectionRegistry.current.register(asset.id), + }); } } @@ -943,7 +998,7 @@ export function AvToolsApplication() { }); setSelectedAssetId((current) => current ?? pending[0]?.asset.id); for (const item of pending) { - await inspectAsset(item.asset, item.existingAsset); + await inspectAsset(item.asset, item.existingAsset, item.token); } }, [inspectAsset, project.assets, resourceContext.policy] @@ -953,7 +1008,13 @@ export function AvToolsApplication() { (id: string) => { const asset = assets.find((entry) => entry.id === id); if (asset) URL.revokeObjectURL(asset.objectUrl); - setAssets((current) => current.filter((entry) => entry.id !== id)); + const cancelActiveInspection = + sourceInspectionRegistry.current.remove(id); + setAssets((current) => { + const next = current.filter((entry) => entry.id !== id); + assetsRef.current = next; + return next; + }); setWaveformByAsset((current) => Object.fromEntries( Object.entries(current).filter(([assetId]) => assetId !== id) @@ -982,6 +1043,7 @@ export function AvToolsApplication() { assetId: id, updatedAt: timestamp(), }); + if (cancelActiveInspection) void engineManager.cancelActive(); }, [applyProjectAction, assets, selectedAssetId] ); @@ -1203,7 +1265,7 @@ export function AvToolsApplication() { ? ('multi-thread' as const) : ('single-thread' as const), ffmpegVersion: readyStateAfterRun.capabilities.versionText, - coreVersion: '0.12.10', + coreVersion: FFMPEG_CORE_VERSION, } : {}), }, @@ -2245,6 +2307,9 @@ export function AvToolsApplication() { const resetProject = useCallback(() => { for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); + const cancelActiveInspection = sourceInspectionRegistry.current.clear(); + if (cancelActiveInspection) void engineManager.cancelActive(); + assetsRef.current = []; clearResult(); setAssets([]); setSelectedAssetId(undefined); @@ -2280,6 +2345,9 @@ export function AvToolsApplication() { try { const imported = importProjectDocument(await file.text()); for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); + const cancelActiveInspection = sourceInspectionRegistry.current.clear(); + if (cancelActiveInspection) void engineManager.cancelActive(); + assetsRef.current = []; clearResult(); setAssets([]); setSelectedAssetId(undefined); @@ -2556,6 +2624,7 @@ export function AvToolsApplication() { channelLayout: 'stereo', missingAudioPolicy: request.missingAudioPolicy, subtitlePolicy: request.subtitlePolicy, + crossfadeSeconds: request.crossfadeSeconds, }); })(); await runPlan( @@ -2950,33 +3019,80 @@ export function AvToolsApplication() { if (!asset || duration === undefined) { throw new Error('Select an attached, inspected video source first.'); } - const distribution = - request.distribution.type === 'evenly-spaced' - ? { count: request.distribution.count } - : request.distribution.type === 'fixed-interval' - ? { intervalSeconds: request.distribution.intervalSeconds } - : { timesSeconds: request.distribution.timesSeconds }; - const plan = buildThumbnailSeriesPlan({ - jobId: crypto.randomUUID(), - source: { - id: asset.id, - sourceIndex: 0, - fileName: asset.file.name, - }, - durationSeconds: duration, - ...distribution, - size: { - ...(request.width ? { width: request.width } : {}), - ...(request.height ? { height: request.height } : {}), - }, - format: request.format, - quality: request.quality, - }); - const execution = await runPlan( - plan, - [asset.file], - 'Create thumbnail series' - ); + const source = { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }; + const size = { + ...(request.width ? { width: request.width } : {}), + ...(request.height ? { height: request.height } : {}), + }; + const sceneDistribution = + request.distribution.type === 'scene-changes' + ? request.distribution + : undefined; + const execution = sceneDistribution + ? await runWorkflow( + 'Create scene-change thumbnails', + 2, + async (executeStep) => { + const analysis = await executeStep( + buildSceneChangeAnalysisPlan({ + jobId: crypto.randomUUID(), + source, + durationSeconds: duration, + threshold: sceneDistribution.threshold, + maxFrames: sceneDistribution.count, + }), + [asset.file], + 'Analyze scene changes' + ); + const frames = parseSceneChangeFrames(analysis.logText).slice( + 0, + sceneDistribution.count + ); + if (frames.length === 0) { + throw new Error( + 'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.' + ); + } + return executeStep( + buildSceneThumbnailSeriesPlan({ + jobId: crypto.randomUUID(), + source, + durationSeconds: duration, + frames, + size, + format: request.format, + quality: request.quality, + }), + [asset.file], + 'Extract analyzed scene frames' + ); + } + ) + : await runPlan( + buildThumbnailSeriesPlan({ + jobId: crypto.randomUUID(), + source, + durationSeconds: duration, + ...(request.distribution.type === 'evenly-spaced' + ? { count: request.distribution.count } + : request.distribution.type === 'fixed-interval' + ? { + intervalSeconds: request.distribution.intervalSeconds, + } + : request.distribution.type === 'scene-changes' + ? { count: request.distribution.count } + : { timesSeconds: request.distribution.timesSeconds }), + size, + format: request.format, + quality: request.quality, + }), + [asset.file], + 'Create thumbnail series' + ); try { await Promise.all( execution.results.map((output, index) => @@ -3002,7 +3118,7 @@ export function AvToolsApplication() { ); } }, - [inspectedClipAsset, project.id, runPlan] + [inspectedClipAsset, project.id, runPlan, runWorkflow] ); const handleContactSheet = useCallback( @@ -3012,22 +3128,75 @@ export function AvToolsApplication() { if (!asset || duration === undefined) { throw new Error('Select an attached, inspected video source first.'); } - const plan = buildContactSheetPlan({ - jobId: crypto.randomUUID(), - source: { - id: asset.id, - sourceIndex: 0, - fileName: asset.file.name, - }, - durationSeconds: duration, - ...request, - drawtextAvailable: false, - }); - const execution = await runPlan( - plan, - [asset.file], - 'Create contact sheet' - ); + const source = { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }; + const labelsRequested = request.timestampLabels || request.sourceLabel; + const font = labelsRequested + ? await loadBundledContactSheetFont() + : undefined; + const buildSheet = ( + sceneFrames?: ReturnType + ) => + buildContactSheetPlan({ + jobId: crypto.randomUUID(), + source, + durationSeconds: duration, + ...request, + ...(sceneFrames + ? { frameCount: sceneFrames.length, sceneFrames } + : {}), + drawtextAvailable: Boolean(font), + ...(font + ? { + font: { + id: 'bundled-contact-sheet-font', + sourceIndex: 1, + fileName: font.name, + }, + } + : {}), + }); + const inputs = font ? [asset.file, font] : [asset.file]; + const sceneSelection = + request.selection.type === 'scene-changes' + ? request.selection + : undefined; + const execution = sceneSelection + ? await runWorkflow( + 'Create scene-change contact sheet', + 2, + async (executeStep) => { + const analysis = await executeStep( + buildSceneChangeAnalysisPlan({ + jobId: crypto.randomUUID(), + source, + durationSeconds: duration, + threshold: sceneSelection.threshold, + maxFrames: request.frameCount, + }), + [asset.file], + 'Analyze scene changes' + ); + const frames = parseSceneChangeFrames(analysis.logText).slice( + 0, + request.frameCount + ); + if (frames.length === 0) { + throw new Error( + 'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.' + ); + } + return executeStep( + buildSheet(frames), + inputs, + 'Render scene-change contact sheet' + ); + } + ) + : await runPlan(buildSheet(), inputs, 'Create contact sheet'); const output = execution.results[0]; if (!output) throw new Error('The contact sheet was not returned.'); try { @@ -3045,7 +3214,7 @@ export function AvToolsApplication() { ); } }, - [inspectedClipAsset, project.id, runPlan] + [inspectedClipAsset, project.id, runPlan, runWorkflow] ); const handleMetadataAction = useCallback( @@ -3413,7 +3582,8 @@ export function AvToolsApplication() { status: 'unavailable' as const, reasons: ['Initialize the engine to verify this preset.'], }; - const opusBlocked = preset.audio?.codec === 'libopus'; + const opusBlocked = + preset.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED; return { id: preset.id, name: preset.name, @@ -3423,7 +3593,7 @@ export function AvToolsApplication() { ...(availability.status === 'unavailable' || opusBlocked ? { disabledReason: opusBlocked - ? 'Opus output remains disabled until the pinned core passes its non-empty ST/MT regression smoke.' + ? 'Opus output is disabled until the reviewed core passes the non-empty ST/MT regression encode and re-probe gate.' : availability.reasons.join(' '), } : {}), @@ -3481,6 +3651,7 @@ export function AvToolsApplication() { hasAudio: false, hasVideo: false, hasSubtitles: false, + durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds, }; } const unsupportedStreams = asset.probe.streams.filter( @@ -3517,6 +3688,7 @@ export function AvToolsApplication() { return { id: clip.id, label, + durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds, hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'), hasVideo: asset.probe.streams.some( (stream) => @@ -3622,10 +3794,12 @@ export function AvToolsApplication() { filters: ['fps', 'scale', 'tile'], }), 'contact-sheet-labels': { - available: false, - reason: - 'No reviewed local font is bundled, so labels are omitted instead of fetching a remote font.', + ...capabilityStatus({ filters: ['drawtext'] }), }, + 'scene-change-analysis': capabilityStatus({ + muxers: ['null'], + filters: ['select', 'showinfo'], + }), 'metadata-write': sourceMuxer ? capabilityStatus({ muxers: [sourceMuxer] }) : { available: false, reason: 'Select a source first.' }, @@ -3678,6 +3852,9 @@ export function AvToolsApplication() { }, anyVideoPreset ? undefined : 'No verified video preset is available.' ), + 'concat-crossfade': capabilityStatus({ + filters: ['xfade', ...(structuralHasAnyAudio ? ['acrossfade'] : [])], + }), } as const; const advancedSource = inspectedClipAsset?.probe diff --git a/src/app/source-inspection-registry.ts b/src/app/source-inspection-registry.ts new file mode 100644 index 0000000..eb1ce1f --- /dev/null +++ b/src/app/source-inspection-registry.ts @@ -0,0 +1,76 @@ +export interface SourceInspectionToken { + readonly assetId: string; + readonly generation: number; + readonly signal: AbortSignal; +} + +/** + * Tracks the exact runtime import that owns queued or active inspection work. + * + * Project asset IDs can be reused when a missing source is reattached, so an + * ID-only check is insufficient: a late result from the old File must not be + * applied to the replacement. Tokens are intentionally kept outside React + * state so removal invalidates work synchronously. + */ +export class SourceInspectionRegistry { + readonly #currentByAsset = new Map< + string, + { readonly generation: number; readonly controller: AbortController } + >(); + #nextGeneration = 0; + #activeToken: SourceInspectionToken | undefined; + + register(assetId: string): SourceInspectionToken { + const controller = new AbortController(); + const token = Object.freeze({ + assetId, + generation: ++this.#nextGeneration, + signal: controller.signal, + }); + this.#currentByAsset.get(assetId)?.controller.abort(); + this.#currentByAsset.set(assetId, { + generation: token.generation, + controller, + }); + return token; + } + + isCurrent(token: SourceInspectionToken): boolean { + return ( + this.#currentByAsset.get(token.assetId)?.generation === token.generation + ); + } + + activate(token: SourceInspectionToken): boolean { + if (!this.isCurrent(token)) return false; + this.#activeToken = token; + return true; + } + + finish(token: SourceInspectionToken): void { + if ( + this.#activeToken?.assetId === token.assetId && + this.#activeToken.generation === token.generation + ) { + this.#activeToken = undefined; + } + } + + remove(assetId: string): boolean { + const current = this.#currentByAsset.get(assetId); + current?.controller.abort(); + this.#currentByAsset.delete(assetId); + return ( + this.#activeToken?.assetId === assetId && + this.#activeToken.generation === current?.generation + ); + } + + clear(): boolean { + for (const current of this.#currentByAsset.values()) { + current.controller.abort(); + } + this.#currentByAsset.clear(); + return this.#activeToken !== undefined; + } +} diff --git a/src/assets/bundled-font.ts b/src/assets/bundled-font.ts new file mode 100644 index 0000000..2b613f5 --- /dev/null +++ b/src/assets/bundled-font.ts @@ -0,0 +1,54 @@ +export const BUNDLED_CONTACT_SHEET_FONT = Object.freeze({ + family: 'DejaVu Sans', + fileName: 'DejaVuSans.ttf', + byteLength: 757_076, + sha256: '7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954', + licenseFile: 'LICENSES/DEJAVU_FONTS.txt', +}); + +let fontPromise: Promise | undefined; + +/** + * Loads the same-origin reviewed font and verifies its release identity before + * it is mounted read-only for FFmpeg. A failed check is never silently replaced + * with a system or remote font. + */ +export function loadBundledContactSheetFont(): Promise { + fontPromise ??= loadAndVerifyFont().catch((error: unknown) => { + fontPromise = undefined; + throw error; + }); + return fontPromise; +} + +async function loadAndVerifyFont(): Promise { + const response = await fetch( + `${import.meta.env.BASE_URL}fonts/${BUNDLED_CONTACT_SHEET_FONT.fileName}`, + { + cache: 'force-cache', + credentials: 'same-origin', + } + ); + if (!response.ok) { + throw new Error( + `The bundled contact-sheet font could not be loaded (${response.status}).` + ); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength !== BUNDLED_CONTACT_SHEET_FONT.byteLength) { + throw new Error('The bundled contact-sheet font has an unexpected size.'); + } + const digest = await crypto.subtle.digest('SHA-256', bytes); + const sha256 = [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, '0')) + .join(''); + if (sha256 !== BUNDLED_CONTACT_SHEET_FONT.sha256) { + throw new Error( + 'The bundled contact-sheet font failed its SHA-256 identity check.' + ); + } + return new File([bytes], BUNDLED_CONTACT_SHEET_FONT.fileName, { + type: 'font/ttf', + lastModified: 0, + }); +} diff --git a/src/commands/concatenate.ts b/src/commands/concatenate.ts index 3dc85c9..b70929a 100644 --- a/src/commands/concatenate.ts +++ b/src/commands/concatenate.ts @@ -42,6 +42,8 @@ export interface ConcatSource extends SourceReference { export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject'; const MAX_CONCAT_SOURCES = 100; +export const MIN_CROSSFADE_SECONDS = 0.04; +export const MAX_CROSSFADE_SECONDS = 10; export interface FastConcatOptions { readonly jobId: string; @@ -59,6 +61,11 @@ export interface NormalizedConcatOptions extends FastConcatOptions { readonly channelLayout?: 'mono' | 'stereo' | '5.1'; readonly missingAudioPolicy: MissingAudioPolicy; readonly subtitlePolicy?: 'drop-all' | 'reject'; + /** + * Uniform overlap at every edit. Zero/undefined keeps hard cuts. The command + * layer bounds both the transition and the available material around edits. + */ + readonly crossfadeSeconds?: number; } export function validateConcatCompatibility( @@ -334,6 +341,8 @@ export function buildNormalizedConcatPlan( } const includeAudio = options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean); + const crossfadeSeconds = options.crossfadeSeconds ?? 0; + validateCrossfade(crossfadeSeconds, ranges); const inputs = options.sources.map((source) => createPlannedInput(options.jobId, source) ); @@ -360,12 +369,35 @@ export function buildNormalizedConcatPlan( } } }); - const labels = options.sources - .map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`) - .join(''); - graphParts.push( - `${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}` - ); + if (crossfadeSeconds > 0) { + let videoLabel = 'v0'; + let audioLabel = 'a0'; + let effectiveDuration = ranges[0]?.durationSeconds ?? 0; + for (let index = 1; index < options.sources.length; index += 1) { + const finalTransition = index === options.sources.length - 1; + const nextVideoLabel = finalTransition ? 'vout' : `vxf${index}`; + graphParts.push( + `[${videoLabel}][v${index}]xfade=transition=fade:duration=${normalizeSeconds(crossfadeSeconds)}:offset=${normalizeSeconds(effectiveDuration - crossfadeSeconds)}[${nextVideoLabel}]` + ); + videoLabel = nextVideoLabel; + if (includeAudio) { + const nextAudioLabel = finalTransition ? 'aout' : `axf${index}`; + graphParts.push( + `[${audioLabel}][a${index}]acrossfade=d=${normalizeSeconds(crossfadeSeconds)}:c1=tri:c2=tri[${nextAudioLabel}]` + ); + audioLabel = nextAudioLabel; + } + effectiveDuration += + (ranges[index]?.durationSeconds ?? 0) - crossfadeSeconds; + } + } else { + const labels = options.sources + .map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`) + .join(''); + graphParts.push( + `${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}` + ); + } args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]'); if (includeAudio) { args.push('-map', '[aout]'); @@ -397,10 +429,9 @@ export function buildNormalizedConcatPlan( temporaryFiles: [], args, outputs: [output], - expectedDurationSeconds: ranges.reduce( - (sum, range) => sum + range.durationSeconds, - 0 - ), + expectedDurationSeconds: + ranges.reduce((sum, range) => sum + range.durationSeconds, 0) - + crossfadeSeconds * (ranges.length - 1), requiredCapabilities: mergeRequirements(presetRequirements, { muxers: [options.targetMuxer], filters: [ @@ -411,7 +442,8 @@ export function buildNormalizedConcatPlan( 'setsar', 'fps', 'format', - 'concat', + ...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']), + ...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []), ...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []), ...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence' @@ -423,7 +455,9 @@ export function buildNormalizedConcatPlan( diagnostic( 'normalized-concat', 'info', - 'Clips are normalized to common video and audio properties, then re-encoded.' + crossfadeSeconds > 0 + ? `Clips are normalized and joined with a ${normalizeSeconds(crossfadeSeconds)} second audio/video crossfade at every edit.` + : 'Clips are normalized to common video and audio properties, then re-encoded.' ), ...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence' ? [ @@ -447,6 +481,34 @@ export function buildNormalizedConcatPlan( }); } +function validateCrossfade( + crossfadeSeconds: number, + ranges: readonly { readonly durationSeconds: number }[] +): void { + if (!Number.isFinite(crossfadeSeconds) || crossfadeSeconds < 0) { + throw new RangeError('Crossfade duration must be finite and nonnegative'); + } + if (crossfadeSeconds === 0) { + return; + } + if ( + crossfadeSeconds < MIN_CROSSFADE_SECONDS || + crossfadeSeconds > MAX_CROSSFADE_SECONDS + ) { + throw new RangeError( + `Crossfade duration must be ${MIN_CROSSFADE_SECONDS}–${MAX_CROSSFADE_SECONDS} seconds` + ); + } + for (const [index, range] of ranges.entries()) { + const required = index === 0 || index === ranges.length - 1 ? 1 : 2; + if (range.durationSeconds < crossfadeSeconds * required) { + throw new RangeError( + `Clip ${index + 1} needs at least ${normalizeSeconds(crossfadeSeconds * required)} seconds for the requested crossfade material` + ); + } + } +} + function comparableKeys( kind: ConcatStreamDescription['kind'] ): readonly (keyof ConcatStreamDescription)[] { diff --git a/src/commands/thumbnails.ts b/src/commands/thumbnails.ts index e4a4798..c04b994 100644 --- a/src/commands/thumbnails.ts +++ b/src/commands/thumbnails.ts @@ -14,6 +14,16 @@ import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan'; export type ImageFormat = 'png' | 'jpeg' | 'webp'; +export const MAX_SCENE_CHANGE_FRAMES = 100; +export const MIN_SCENE_CHANGE_THRESHOLD = 0.01; +export const MAX_SCENE_CHANGE_THRESHOLD = 0.95; + +export interface SceneChangeFrame { + /** Exact source presentation timestamp in the source video time base. */ + readonly pts: number; + readonly timeSeconds: number; +} + export interface ThumbnailSize { readonly width?: number; readonly height?: number; @@ -41,6 +51,21 @@ export interface ThumbnailSeriesOptions { readonly quality?: number; } +export interface SceneChangeAnalysisOptions { + readonly jobId: string; + readonly source: SourceReference; + readonly durationSeconds: number; + readonly threshold: number; + readonly maxFrames: number; +} + +export interface SceneThumbnailSeriesOptions extends Omit< + ThumbnailSeriesOptions, + 'timesSeconds' | 'intervalSeconds' | 'count' +> { + readonly frames: readonly SceneChangeFrame[]; +} + export interface ContactSheetOptions { readonly jobId: string; readonly source: SourceReference; @@ -57,6 +82,10 @@ export interface ContactSheetOptions { readonly format: ImageFormat; readonly quality?: number; readonly drawtextAvailable?: boolean; + /** Reviewed local font mounted as a second, read-only job input. */ + readonly font?: SourceReference; + /** Exact frames returned by a preceding bounded scene-change analysis. */ + readonly sceneFrames?: readonly SceneChangeFrame[]; } export function buildSingleThumbnailPlan( @@ -164,6 +193,148 @@ export function buildThumbnailSeriesPlan( }); } +export function buildSceneChangeAnalysisPlan( + options: SceneChangeAnalysisOptions +): FFmpegCommandPlan { + validateSceneAnalysisOptions(options); + const input = createPlannedInput(options.jobId, options.source); + return freezeCommandPlan({ + id: `${options.jobId}:scene-change-analysis`, + operation: 'scene-change-analysis', + inputs: [input], + temporaryFiles: [], + args: [ + '-i', + input.path, + '-an', + '-vf', + `select='gt(scene\\,${normalizedSceneThreshold(options.threshold)})',showinfo`, + '-frames:v', + String(options.maxFrames), + '-f', + 'null', + '-', + ], + outputs: [], + expectedDurationSeconds: options.durationSeconds, + requiredCapabilities: { + muxers: ['null'], + filters: ['select', 'showinfo'], + }, + diagnostics: [ + diagnostic( + 'bounded-scene-analysis', + 'info', + `Scene analysis stops after ${options.maxFrames} qualifying frames and retains no source media.` + ), + ], + }); +} + +export function parseSceneChangeFrames( + logText: string +): readonly SceneChangeFrame[] { + const frames: SceneChangeFrame[] = []; + const seenPts = new Set(); + const linePattern = + /^[^\n]*\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/gimu; + for (const match of logText.matchAll(linePattern)) { + const pts = Number(match[1]); + const timeSeconds = Number(match[2]); + if ( + !Number.isSafeInteger(pts) || + pts < 0 || + !Number.isFinite(timeSeconds) || + timeSeconds < 0 || + seenPts.has(pts) + ) { + continue; + } + seenPts.add(pts); + frames.push({ pts, timeSeconds }); + if (frames.length >= MAX_SCENE_CHANGE_FRAMES) { + break; + } + } + return Object.freeze( + frames + .sort((left, right) => left.timeSeconds - right.timeSeconds) + .map((frame) => Object.freeze({ ...frame })) + ); +} + +export function buildSceneThumbnailSeriesPlan( + options: SceneThumbnailSeriesOptions +): FFmpegCommandPlan { + const frames = validateSceneFrames( + options.frames, + options.durationSeconds, + 'Thumbnail series' + ); + const input = createPlannedInput(options.jobId, options.source); + const extension = extensionForImageFormat(options.format); + const outputs = frames.map((frame, index) => + createPlannedOutput(options.jobId, { + id: `thumbnail-${index + 1}`, + baseName: options.source.fileName, + operation: 'thumbnail', + suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(frame.timeSeconds * 1000)}ms`, + extension, + role: 'thumbnail', + }) + ); + const size = scaleFilter(options.size); + const selected = frames.map((frame) => `eq(pts\\,${frame.pts})`).join('+'); + const splitOutputs = frames + .map((_, index) => `[scene-source-${index}]`) + .join(''); + const filtergraph = [ + `[0:v]select='${selected}',split=${frames.length}${splitOutputs}`, + ...frames.map((_, index) => { + const filters = [ + `select='eq(n\\,${index})'`, + 'setpts=N/FRAME_RATE/TB', + ...(size ? [size] : []), + ]; + return `[scene-source-${index}]${filters.join(',')}[thumb-${index}]`; + }), + ].join(';'); + const outputArguments = outputs.flatMap((output, index) => [ + '-map', + `[thumb-${index}]`, + '-frames:v', + '1', + ...imageQualityArguments(options.format, options.quality), + output.path, + ]); + return freezeCommandPlan({ + id: `${options.jobId}:scene-thumbnail-series`, + operation: 'thumbnail-series', + inputs: [input], + temporaryFiles: [], + args: [ + '-i', + input.path, + '-filter_complex', + filtergraph, + ...outputArguments, + ], + outputs, + expectedDurationSeconds: options.durationSeconds, + requiredCapabilities: { + ...imageRequirements(options.format), + filters: ['select', 'split', 'setpts', ...(size ? ['scale'] : [])], + }, + diagnostics: [ + diagnostic( + 'scene-change-selection', + 'info', + `${frames.length} frames are selected by exact presentation timestamp from a preceding bounded scene analysis.` + ), + ], + }); +} + export function resolveThumbnailTimes( options: Pick< ThumbnailSeriesOptions, @@ -271,7 +442,22 @@ export function buildContactSheetPlan( if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) { throw new TypeError('Background must be a named colour or #RRGGBB'); } + const sceneFrames = options.sceneFrames + ? validateSceneFrames( + options.sceneFrames, + options.durationSeconds, + 'Contact sheet' + ) + : undefined; + if (sceneFrames && sceneFrames.length !== options.frameCount) { + throw new RangeError( + 'Contact sheet frame count must match the analyzed scene selection' + ); + } const input = createPlannedInput(options.jobId, options.source); + const fontInput = options.font + ? createPlannedInput(options.jobId, options.font, 'font') + : undefined; const output = createPlannedOutput(options.jobId, { baseName: options.source.fileName, operation: 'contact-sheet', @@ -280,14 +466,20 @@ export function buildContactSheetPlan( }); const fps = options.frameCount / options.durationSeconds; const filters = [ - `fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`, + ...(sceneFrames + ? [ + `select='${sceneFrames + .map((frame) => `eq(pts\\,${frame.pts})`) + .join('+')}'`, + ] + : [`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`]), options.maintainAspect === false ? `scale=${options.cellWidth}:${options.cellWidth}` : `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`, ]; let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel); const diagnostics = []; - if (labelsEnabled && !options.drawtextAvailable) { + if (labelsEnabled && (!options.drawtextAvailable || !fontInput)) { labelsEnabled = false; diagnostics.push( diagnostic( @@ -302,16 +494,19 @@ export function buildContactSheetPlan( ? `${safePathComponent(options.source.fileName)} %{pts\\:hms}` : '%{pts\\:hms}'; filters.push( - `drawtext=text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6` + `drawtext=fontfile='${fontInput?.path ?? ''}':text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6` ); } + if (sceneFrames) { + filters.push('setpts=N/FRAME_RATE/TB'); + } filters.push( - `tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}` + `tile=${options.columns}x${options.rows}:nb_frames=${options.frameCount}:padding=${spacing}:margin=${spacing}:color=${background}` ); return freezeCommandPlan({ id: `${options.jobId}:contact-sheet`, operation: 'contact-sheet', - inputs: [input], + inputs: [input, ...(fontInput ? [fontInput] : [])], temporaryFiles: [], args: [ '-i', @@ -327,12 +522,101 @@ export function buildContactSheetPlan( expectedDurationSeconds: options.durationSeconds, requiredCapabilities: { ...imageRequirements(options.format), - filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])], + filters: [ + sceneFrames ? 'select' : 'fps', + 'scale', + ...(sceneFrames ? ['setpts'] : []), + 'tile', + ...(labelsEnabled ? ['drawtext'] : []), + ], }, - diagnostics, + diagnostics: [ + ...diagnostics, + ...(sceneFrames + ? [ + diagnostic( + 'scene-change-selection', + 'info', + `${sceneFrames.length} analyzed scene changes populate this contact sheet.` + ), + ] + : []), + ], }); } +function validateSceneAnalysisOptions( + options: Pick< + SceneChangeAnalysisOptions, + 'durationSeconds' | 'threshold' | 'maxFrames' + > +): void { + if ( + !Number.isFinite(options.durationSeconds) || + options.durationSeconds <= 0 + ) { + throw new RangeError('Scene analysis source duration must be positive'); + } + normalizedSceneThreshold(options.threshold); + if ( + !Number.isSafeInteger(options.maxFrames) || + options.maxFrames < 1 || + options.maxFrames > MAX_SCENE_CHANGE_FRAMES + ) { + throw new RangeError( + `Scene analysis frame limit must be an integer from 1 to ${MAX_SCENE_CHANGE_FRAMES}` + ); + } +} + +function normalizedSceneThreshold(value: number): string { + if ( + !Number.isFinite(value) || + value < MIN_SCENE_CHANGE_THRESHOLD || + value > MAX_SCENE_CHANGE_THRESHOLD + ) { + throw new RangeError( + `Scene threshold must be ${MIN_SCENE_CHANGE_THRESHOLD}–${MAX_SCENE_CHANGE_THRESHOLD}` + ); + } + return String(Number(value.toFixed(3))); +} + +function validateSceneFrames( + frames: readonly SceneChangeFrame[], + durationSeconds: number, + label: string +): readonly SceneChangeFrame[] { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + throw new RangeError(`${label} source duration must be positive`); + } + if ( + frames.length < 1 || + frames.length > MAX_SCENE_CHANGE_FRAMES || + frames.some( + (frame) => + !Number.isSafeInteger(frame.pts) || + frame.pts < 0 || + !Number.isFinite(frame.timeSeconds) || + frame.timeSeconds < 0 || + frame.timeSeconds >= durationSeconds + ) + ) { + throw new RangeError( + `${label} scene frames must be bounded, finite points inside the source` + ); + } + const uniquePts = new Set(frames.map((frame) => frame.pts)); + if (uniquePts.size !== frames.length) { + throw new RangeError(`${label} scene frames must have unique timestamps`); + } + return Object.freeze( + [...frames] + .sort((left, right) => left.timeSeconds - right.timeSeconds) + .map((frame) => Object.freeze({ ...frame })) + ); +} + function imageFilterArguments(size?: ThumbnailSize): readonly string[] { const filter = scaleFilter(size); return filter ? ['-vf', filter] : []; diff --git a/src/components/AdvancedOperations.tsx b/src/components/AdvancedOperations.tsx index ca94a38..1987e95 100644 --- a/src/components/AdvancedOperations.tsx +++ b/src/components/AdvancedOperations.tsx @@ -32,6 +32,11 @@ import { GENERATED_OUTPUT_LIMIT_EXPLANATION, MAX_GENERATED_OUTPUT_FILES, } from '../limits'; +import { + MAX_SCENE_CHANGE_FRAMES, + MAX_SCENE_CHANGE_THRESHOLD, + MIN_SCENE_CHANGE_THRESHOLD, +} from '../commands/thumbnails'; import { parseUserPresetJson, serializeUserPreset, @@ -58,6 +63,7 @@ export type AdvancedCapabilityKey = | 'thumbnail-series' | 'contact-sheet' | 'contact-sheet-labels' + | 'scene-change-analysis' | 'metadata-write' | 'chapter-write' | 'subtitle-extract' @@ -181,6 +187,11 @@ export type AdvancedAudioRequest = export type ThumbnailDistribution = | { readonly type: 'evenly-spaced'; readonly count: number } | { readonly type: 'fixed-interval'; readonly intervalSeconds: number } + | { + readonly type: 'scene-changes'; + readonly count: number; + readonly threshold: number; + } | { readonly type: 'chapter-starts' | 'split-markers'; readonly timesSeconds: readonly number[]; @@ -206,6 +217,9 @@ export interface ContactSheetRequest { readonly sourceLabel: boolean; readonly format: ImageFormat; readonly quality: number; + readonly selection: + | { readonly type: 'evenly-spaced' } + | { readonly type: 'scene-changes'; readonly threshold: number }; } export type MetadataExecutionRequest = @@ -556,6 +570,7 @@ export function AdvancedOperations({ chapters={chapters} formatAvailability={imageFormatAvailability} labelAvailability={availability['contact-sheet-labels']} + sceneAvailability={availability['scene-change-analysis']} onThumbnailSeries={onThumbnailSeries} onContactSheet={onContactSheet} action={actionContext} @@ -1442,6 +1457,7 @@ function ImagePanel({ chapters, formatAvailability, labelAvailability, + sceneAvailability, onThumbnailSeries, onContactSheet, action, @@ -1453,6 +1469,7 @@ function ImagePanel({ Readonly> >; readonly labelAvailability?: AdvancedCapabilityStatus; + readonly sceneAvailability?: AdvancedCapabilityStatus; readonly onThumbnailSeries?: RequestHandler; readonly onContactSheet?: RequestHandler; readonly action: ActionContext; @@ -1474,6 +1491,10 @@ function ImagePanel({ const [maintainAspect, setMaintainAspect] = useState(true); const [timestampLabels, setTimestampLabels] = useState(false); const [sourceLabel, setSourceLabel] = useState(false); + const [sceneThreshold, setSceneThreshold] = useState('0.35'); + const [sheetSelection, setSheetSelection] = useState< + 'evenly-spaced' | 'scene-changes' + >('evenly-spaced'); const videoReason = !source ? 'Select a source first.' @@ -1521,6 +1542,29 @@ function ImagePanel({ type: distribution, intervalSeconds: Number(interval), }; + } else if (distribution === 'scene-changes') { + distributionReason = + integerRangeReason( + seriesCount, + 1, + MAX_SCENE_CHANGE_FRAMES, + 'Frame count' + ) ?? + numberRangeReason( + sceneThreshold, + MIN_SCENE_CHANGE_THRESHOLD, + MAX_SCENE_CHANGE_THRESHOLD, + 'Scene threshold' + ) ?? + (sceneAvailability?.available + ? undefined + : (sceneAvailability?.reason ?? + 'Scene-change analysis has not been verified.')); + resolvedDistribution = { + type: distribution, + count: Number(seriesCount), + threshold: Number(sceneThreshold), + }; } else if (distribution === 'chapter-starts') { const times = chapters.map((chapter) => chapter.startSeconds); distributionReason = @@ -1556,7 +1600,12 @@ function ImagePanel({ 'Timestamp and filename labels require verified drawtext and a safe local font.') : undefined; const gridReason = - integerRangeReason(sheetFrames, 1, 500, 'Frame count') ?? + integerRangeReason( + sheetFrames, + 1, + sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500, + 'Frame count' + ) ?? integerRangeReason(sheetRows, 1, 500, 'Rows') ?? integerRangeReason(sheetColumns, 1, 500, 'Columns') ?? integerRangeReason(cellWidth, 32, 4096, 'Cell width') ?? @@ -1571,7 +1620,19 @@ function ImagePanel({ gridReason ?? numberRangeReason(quality, 1, 100, 'Quality') ?? formatReason ?? - labelReason; + labelReason ?? + (sheetSelection === 'scene-changes' + ? (numberRangeReason( + sceneThreshold, + MIN_SCENE_CHANGE_THRESHOLD, + MAX_SCENE_CHANGE_THRESHOLD, + 'Scene threshold' + ) ?? + (sceneAvailability?.available + ? undefined + : (sceneAvailability?.reason ?? + 'Scene-change analysis has not been verified.'))) + : undefined); return (
@@ -1597,6 +1658,12 @@ function ImagePanel({ > + @@ -1605,12 +1672,17 @@ function ImagePanel({ - {distribution === 'evenly-spaced' ? ( + {distribution === 'evenly-spaced' || + distribution === 'scene-changes' ? ( ) : null} @@ -1625,6 +1697,16 @@ function ImagePanel({ onChange={setInterval} /> ) : null} + {distribution === 'scene-changes' ? ( + + ) : null}
+
+ {sheetSelection === 'scene-changes' ? ( + + ) : null}
FFmpeg WASM cores
-
{FFMPEG_CORE_VERSION} · ST & MT
+
{FFMPEG_CORE_BUILD_ID} · ST & MT
ffmpeg.wasm utilities
@@ -156,6 +157,16 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) { GPL text +
  • + DejaVu Sans 2.37 — Bitstream Vera and Arev font terms + + Font licence + +
  • FFmpeg, linked codecs, Toolbox SDK, React and other runtime packages retain their respective terms. diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index 34b2a42..6b633c6 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -3,6 +3,9 @@ import type { ImportedMediaAsset } from '../app/application-state'; import { assessBrowserPlayback, assessGeneratedResultPreview, + isAudioMimeType, + isVideoMimeType, + mimeTypeFromFileName, type MediaProbe, } from '../media'; import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform'; @@ -47,15 +50,31 @@ export function MediaPreview({ const mediaElement = useRef(null); const sourceUrl = result?.objectUrl ?? asset?.objectUrl; const playbackFailed = sourceUrl === failedSourceUrl; + const fileMimeType = asset?.file.type; + const declaredSourceMimeType = + fileMimeType && + (isVideoMimeType(fileMimeType) || isAudioMimeType(fileMimeType)) + ? fileMimeType + : asset + ? mimeTypeFromFileName(asset.file.name) + : undefined; const hasVideo = result ? result.mimeType.startsWith('video/') - : asset?.probe?.streams.some( - (stream) => - stream.type === 'video' && stream.disposition.attached_pic !== true - ) === true; + : asset?.probe + ? asset.probe.streams.some( + (stream) => + stream.type === 'video' && stream.disposition.attached_pic !== true + ) + : declaredSourceMimeType + ? isVideoMimeType(declaredSourceMimeType) + : false; const hasAudio = result ? result.mimeType.startsWith('audio/') - : asset?.probe?.streams.some((stream) => stream.type === 'audio') === true; + : asset?.probe + ? asset.probe.streams.some((stream) => stream.type === 'audio') + : declaredSourceMimeType + ? isAudioMimeType(declaredSourceMimeType) + : false; const hasImage = result?.mimeType.startsWith('image/') === true; const resultPlayback = useMemo(() => { if (!result) return undefined; @@ -186,7 +205,10 @@ export function MediaPreview({ ? resultPlayback?.previewable === false ? `Download only · ${result.name}` : `Generated result · ${result.name}` - : (playback?.reason ?? 'Local source preview')} + : (playback?.reason ?? + (asset?.phase === 'probing' + ? 'Local source preview · Media inspection continues in the background.' + : 'Local source preview'))} {needsProxy && !proxySettings ? (