1 Commits

Author SHA1 Message Date
0a4548851c feat: publish av-tools 0.2.0 2026-07-27 01:04:21 +02:00
68 changed files with 4130 additions and 605 deletions

2
.gitignore vendored
View File

@@ -11,10 +11,12 @@ node_modules
dist dist
dist-ssr dist-ssr
release release
.core-build
test-results test-results
playwright-report playwright-report
coverage coverage
public/vendor/ffmpeg public/vendor/ffmpeg
vendor/*.tmp
*.local *.local
# Editor directories and files # Editor directories and files

View File

@@ -3,6 +3,31 @@
All notable changes to av-tools are documented here. The project follows All notable changes to av-tools are documented here. The project follows
semantic versioning once a first release is legally publishable. 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.0410 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 ## 0.1.0 — 2026-07-24
- Scaffold a local-first React/Vite audio and video toolbox application. - Scaffold a local-first React/Vite audio and video toolbox application.

92
LICENSES/DEJAVU_FONTS.txt Normal file
View File

@@ -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.

View File

@@ -13,9 +13,8 @@ or patent considerations of bundled third-party components.
`v12.15` source at revision `v12.15` source at revision
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util
source. source.
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2. The published - `GPL-2.0-or-later.txt` reproduces GNU GPL version 2 for GPL-enabled FFmpeg
`@ffmpeg/core` and `@ffmpeg/core-mt` 0.12.10 package manifests declare and linked GPL components in the reviewed WebAssembly cores. The legal code
`GPL-2.0-or-later`; “or later” comes from those manifests. The legal code
was checked against the standard GPL-2.0 text in the installed Freedesktop was checked against the standard GPL-2.0 text in the installed Freedesktop
SDK licence corpus and the SPDX licence-list text. SDK licence corpus and the SPDX licence-list text.
- `CC0-1.0.txt` applies only to generated bytes under - `CC0-1.0.txt` applies only to generated bytes under

119
README.md
View File

@@ -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 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. 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 status:** 0.2.0 is licensed GPL-3.0-or-later. Its canonical
> release consists of the exact reviewed `v0.1.0` source, production > release consists of the exact reviewed `v0.2.0` source, production
> ZIP/checksum and Corresponding Source bundle published together. See > ZIP/checksum and Corresponding Source bundle published together. See
> [Licensing](#licensing). > [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 - **Edit:** a media bin and sequential non-overlapping clip timeline with
non-destructive settings, one export at a time. 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, fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
resize, frame-rate change, audio extraction/stream removal, waveform, resize, frame-rate change, audio extraction/stream removal, waveform,
peak/two-pass loudness normalization, gain, audio/video fades, metadata, 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 The Edit workspace now exposes structural exports directly: fast stream-copy
or preset-backed accurate trim uses the selected clip's timeline range, while or preset-backed accurate trim uses the selected clip's timeline range, while
fast concatenate requires explicit compatibility diagnostics and normalized 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.0410 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 Operations enter a bounded, in-memory application queue with at most eight
scheduled operations and one FFmpeg execution at a time. 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 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 1300 seconds and disposable H.264/AAC MP4 proxy. The user can bound it to 1300 seconds and
choose a maximum width from 4801920 pixels. A proxy is reused from the local choose a maximum width from 4801920 pixels. A proxy is reused from the local
@@ -53,14 +61,15 @@ Exact runtime packages:
- `@ffmpeg/ffmpeg` 0.12.15; - `@ffmpeg/ffmpeg` 0.12.15;
- `@ffmpeg/util` 0.12.2; - `@ffmpeg/util` 0.12.2;
- `@ffmpeg/core` 0.12.10 (single-thread); - reviewed FFmpeg core build `0.12.10-reviewed-stack5m.1` in single-thread and
- `@ffmpeg/core-mt` 0.12.10 (multithread). 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, with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp,
FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their 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 JS/worker files are verified against a tracked build lock, unpacked from a
own origin. `npm run build` does not download them. 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 Every engine load queries actual demuxers, muxers, decoders, encoders and
filters. Presets remain disabled when requirements are absent; an extension or 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 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. 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 The 2026-07-26 Chromium regression harness ran the real built-in `audio-opus`
out-of-bounds behavior reported around upstream stack-size fixes). Opus plan against both reviewed cores. Each selected the requested ST/MT mode,
presets must stay unavailable unless a real non-empty, re-probeable ST/MT smoke produced non-empty Opus and passed codec/duration re-probing. Opus presets are
passes. Details are in [FFmpeg build](docs/FFMPEG_BUILD.md). therefore enabled when runtime capability discovery also succeeds. Exact
commands and evidence boundaries are in
[FFmpeg build](docs/FFMPEG_BUILD.md).
## Execution modes ## Execution modes
@@ -169,9 +180,14 @@ Core assets are generated local build inputs:
```sh ```sh
npm run vendor:ffmpeg 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: Regenerate or verify the tiny synthetic fixtures:
```sh ```sh
@@ -183,7 +199,7 @@ npm run fixtures:verify
```sh ```sh
npm run manifest:check npm run manifest:check
npm run vendor:verify npm run vendor:verify-runtime
npm run typecheck npm run typecheck
npm run lint npm run lint
npm run format:check npm run format:check
@@ -191,6 +207,8 @@ npm test
npm run build npm run build
npm run toolbox:check npm run toolbox:check
npm run test:browser 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 `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 fixtures, nested deployment, Toolbox context and the absence of runtime CDN
traffic. traffic.
The latest recorded local Vitest run on 2026-07-24 completed **303 passing `npm run evidence:opus` independently contains each ST/MT browser process,
tests and one conditional skip**. It includes broad structured-command prints one JSON report and exits normally even when the result is unsafe.
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in case `npm run test:opus-regression` runs the same evidence as the positive Opus
requires a `subtitles` filter unavailable in that host build. 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, 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, 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 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 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 Portal must consume an independently built ZIP; it must not build or import
av-tools source. Immutable Portal revision av-tools source. Immutable Portal revision
`bda9da044d61c109afd9969d357cc27f76648938` has isolation and WASM CSP `5c052bc6d1073a66600cfddc44c0e16da34bede8` (`v0.6.0`) provides the required
permissions but lacks `media-src blob:`, which blocks Blob previews. The isolation, narrow WebAssembly CSP, Blob media policy and explicit WASM MIME
developer's active Toolbox Portal worktree currently contains the narrow local mapping. The active Portal worktree additionally extends immutable caching to
`media-src 'self' blob:` patch; that uncommitted worktree state does not change the exact reviewed FFmpeg build profile without accepting mutable-looking
the immutable reference or release evidence. The exact distinction, smallest suffixes. Its exact reviewed and staged
change and fallback are documented in boundaries are documented in
[Portal requirements](docs/PORTAL_REQUIREMENTS.md). [Portal requirements](docs/PORTAL_REQUIREMENTS.md).
## Release ## Release
@@ -272,11 +304,11 @@ metadata:
```sh ```sh
npm run package:release npm run package:release
npm run portal:smoke -- \ 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 The smoke harness clones the selected Portal `HEAD`; it never consumes
committed. Full commands and safety behavior are in uncommitted Portal files silently. Full commands and safety behavior are in
[release documentation](docs/RELEASE.md). [release documentation](docs/RELEASE.md).
## Licensing ## 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. provides a visible licence, source and no-warranty notice in its Help dialog.
Third-party components retain their copyright and licence terms. Third-party components retain their copyright and licence terms.
The wrapper/util are MIT. Core/core-mt 0.12.10 declare The wrapper/util are MIT. The reviewed 0.12.10 cores statically include
GPL-2.0-or-later and statically include FFmpeg/external codec code with GPL-enabled FFmpeg/external codec code with additional terms. The branch-based
additional terms. The branch-based x264/LAME inputs have been resolved to x264/LAME inputs have been resolved to immutable commits in the source record.
immutable commits in the source record. A binary publication must include A binary publication must include equivalent access to the reviewed
equivalent access to the reviewed Corresponding Source and notices. See: Corresponding Source and notices. See:
- [third-party notices](THIRD_PARTY_NOTICES.md) - [third-party notices](THIRD_PARTY_NOTICES.md)
- [source record](SOURCE.md) - [source record](SOURCE.md)
@@ -317,19 +349,18 @@ The auditable list is in
- Metadata removal is best effort, not an anonymity guarantee. - Metadata removal is best effort, not an anonymity guarantee.
- Browser preview supports fewer combinations than FFmpeg. - Browser preview supports fewer combinations than FFmpeg.
- MT depends on isolation; ST can still hit memory/time limits. - 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. - Projects do not contain source bytes by default and require reattachment.
- Cross-browser/mobile and exhaustive MT performance matrices remain - Cross-browser/mobile and exhaustive MT performance matrices remain
unrecorded; published support claims must stay within the tested evidence. unrecorded; published support claims must stay within the tested evidence.
## Roadmap ## Roadmap
1. Complete Quick Convert and structural-export browser verification with the 1. Extend the evidence matrix beyond Chromium to Firefox, Safari and mobile;
real pinned ST/MT cores, including fallback and queued execution. broaden MT operation parity and replace the one-run performance observation
2. Verify waveform, loudness normalization and combined filters in browsers. with repeated cold/warm measurements.
3. Verify metadata/chapter/subtitle round trips in browsers. 2. Evaluate optional editing breadth without weakening local-first guarantees:
4. Verify bounded thumbnails/contact sheets, projects, derivative-cache reuse additional reviewed transition shapes, richer scene-ranking controls and
and export in browsers. explicit transition audio-curve choices beyond the current bounded uniform
5. Complete accessibility, mobile, performance and nested Portal matrices. crossfade.
6. Commit/tag exact source, publish its reviewed Corresponding Source, assemble
the exact production ZIP through Portal and verify deployed headers.

View File

@@ -1,6 +1,6 @@
# Source and reproducibility record # 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 substitute for satisfying a component's licence or for delivering complete
Corresponding Source with an object-code release. Corresponding Source with an object-code release.
@@ -9,9 +9,9 @@ Corresponding Source with an object-code release.
- Canonical repository: - Canonical repository:
`git@git.add-ideas.de:zemion/av-tools.git` `git@git.add-ideas.de:zemion/av-tools.git`
- Browser source: `https://git.add-ideas.de/zemion/av-tools` - Browser source: `https://git.add-ideas.de/zemion/av-tools`
- Version: `0.1.0` - Version: `0.2.0`
- Release source identity: immutable annotated tag `v0.1.0` - Release source identity: immutable annotated tag `v0.2.0`
(`refs/tags/v0.1.0`) in the canonical repository. (`refs/tags/v0.2.0`) in the canonical repository.
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in - Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
`LICENSE`. `LICENSE`.
- Copyright: Copyright © 2026 Albrecht Degering. - 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: available without authentication:
```sh ```sh
test "$(git cat-file -t v0.1.0)" = tag test "$(git cat-file -t v0.2.0)" = tag
git rev-parse v0.1.0^{commit} git rev-parse v0.2.0^{commit}
git ls-remote --tags \ 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 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/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` | | `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` | | `@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: network fetch during the production build:
```sh ```sh
npm ci npm ci
npm run vendor:ffmpeg npm run vendor:ffmpeg
npm run vendor:verify npm run vendor:verify-runtime
npm run build npm run build
``` ```
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the `scripts/copy-ffmpeg-assets.mjs` fails closed against
installed core packages; it does not download them. The copied bytes are: `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:
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 | | Release path | Bytes | SHA-256 |
| ------------------------------------------------ | -------: | ------------------------------------------------------------------ | | ---------------------------------------------------------------------------- | -------: | ------------------------------------------------------------------ |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` | | `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` | | `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` | | `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` | | `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` | | `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 ## Corresponding ffmpeg.wasm and FFmpeg source
- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15, - Base ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15 and the
`@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10: source configuration for core 0.12.10:
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. `v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`, - FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`. 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; `build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
`make prd-mt` builds multithread. `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 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 those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
changed since: changed since:
@@ -121,10 +161,13 @@ npm run source:package
``` ```
The command writes 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. `.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
The npm packages contain the resulting binaries but not every linked The reviewed archive for this build profile is 43,050,052 bytes with SHA-256
component's source and legal file, so publish this reviewed bundle and the `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 notices beside the object code. A homepage link alone is not asserted to
satisfy GPL source obligations. satisfy GPL source obligations.

View File

@@ -1,6 +1,6 @@
# Third-party notices # 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 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 inputs. It is informational, not legal advice, and does not replace their
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
@@ -10,29 +10,32 @@ receives licence files shipped by installed runtime npm packages under
## Browser runtime ## Browser runtime
| Package | Version | Licence declared upstream | Project source | Role | Distribution | | 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-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 | | `@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/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/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/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 | | 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 |
| `@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 | | `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` | 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 | | `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 | | `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 The MIT text inspected in the ffmpeg.wasm source is
`LICENSES/ffmpeg.wasm-MIT.txt`. The core npm tarballs do not contain a licence `LICENSES/ffmpeg.wasm-MIT.txt`. The reviewed cores enable GPL FFmpeg features
file, but their package manifests declare `GPL-2.0-or-later`; and statically link GPL components; `LICENSES/GPL-2.0-or-later.txt` reproduces
`LICENSES/GPL-2.0-or-later.txt` reproduces GPL version 2. These notices do not GPL version 2. These notices do not make the complete application “MIT-only.”
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 ## Native components linked into the WebAssembly cores
The following list is derived from the `v12.15` ffmpeg.wasm Dockerfile, the 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 of its source repositories. Branch-based inputs are recorded below as exact
commits. The combined core is GPL-enabled. Individual projects retain their commits. The combined core is GPL-enabled. Individual projects retain their
own terms, notices, patent considerations and corresponding-source own terms, notices, patent considerations and corresponding-source

View File

@@ -14,7 +14,7 @@ tools.example.test {
@wasm path_regexp wasm \.wasm$ @wasm path_regexp wasm \.wasm$
header @wasm Content-Type "application/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" header @immutable Cache-Control "public, max-age=31536000, immutable"
redir /av /av/ 308 redir /av /av/ 308

View File

@@ -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 Use `no-cache` for `index.html`, `toolbox-app.json`, notices and other stable
names. The files under the exact version directory 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 ```text
Cache-Control: public, max-age=31536000, immutable 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' 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 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 Cache-Control: public, max-age=31536000, immutable
/av/assets/* /av/assets/*
@@ -68,7 +68,7 @@ real response rather than relying only on configuration:
```sh ```sh
curl -sSI https://tools.example.test/av/ | sed -n '1,30p' 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`, In browser developer tools, also verify `window.crossOriginIsolated === true`,

View File

@@ -3,7 +3,7 @@
map $uri $av_tools_cache_control { map $uri $av_tools_cache_control {
default "no-cache"; 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"; "~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
} }

View File

@@ -1,13 +1,13 @@
# FFmpeg WebAssembly build record # FFmpeg WebAssembly build record
av-tools ships the published ESM assets from `@ffmpeg/core` and av-tools ships the locally rebuilt, reviewed core profile
`@ffmpeg/core-mt` **0.12.10**. The browser wrapper is `@ffmpeg/ffmpeg` `0.12.10-reviewed-stack5m.1` in single-thread and multithread forms. The
**0.12.15** and helpers are `@ffmpeg/util` **0.12.2**. All versions are exact, browser wrapper is `@ffmpeg/ffmpeg` **0.12.15** and helpers are
not ranges. `@ffmpeg/util` **0.12.2**. All versions and build inputs are exact, not ranges.
## Underlying build ## 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: `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. Its Dockerfile selects:
- FFmpeg **n5.1.4** (libavcodec 59.37.100); - 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`; - production `-O3 -msimd128`;
- pthread flags for the MT build and explicit thread disabling for ST. - 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: The build configuration embedded in both copied WASM files is:
```text ```text
@@ -35,8 +43,8 @@ The build configuration embedded in both copied WASM files is:
--cxx=em++ --cxx=em++
--objcc=emcc --objcc=emcc
--dep-cc=emcc --dep-cc=emcc
--extra-cflags='-I/opt/include -O3 -msimd128' --extra-cflags='-I../../../../install/include -O3 -msimd128'
--extra-cxxflags='-I/opt/include -O3 -msimd128' --extra-cxxflags='-I../../../../install/include -O3 -msimd128'
--enable-gpl --enable-gpl
--enable-libx264 --enable-libx264
--enable-libx265 --enable-libx265
@@ -76,16 +84,26 @@ respective branch tips. `SOURCE.md` records retrieval commands.
| Mode | File | Bytes | SHA-256 | | Mode | File | Bytes | SHA-256 |
| ---- | -------------------------- | -------: | ------------------------------------------------------------------ | | ---- | -------------------------- | -------: | ------------------------------------------------------------------ |
| ST | `st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` | | ST | `st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
| ST | `st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` | | ST | `st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
| MT | `mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` | | MT | `mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
| MT | `mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` | | MT | `mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
| MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` | | MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
`npm run vendor:ffmpeg` copies these files from `node_modules` to The deterministic five-file repository artifact is 21,338,974 bytes with
`public/vendor/ffmpeg/0.12.10/`; `npm run vendor:verify` checks versions, SHA-256
sizes, paths and digests. Vite then copies them unchanged into `dist`. A normal `ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f`.
production build performs no network download.
`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 ## 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 large jobs or three large contact sheets fit in memory. Cancellation and
failed filesystem-cleanup recovery continue to recreate the core immediately. 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 Published core 0.12.10 predates the upstream stack-size fix and stalled in the
size and Opus zero-byte/out-of-bounds failures (including upstream issues real Opus preset path even though it advertised the `opus` muxer and
`#591` and `#775`). Therefore: `libopus` encoder. Capability discovery alone was therefore not accepted as
enablement evidence.
- decoder/encoder listing is not enough to certify Opus; On 2026-07-26 the Chromium harness used the checked
- real ST and MT fixture encodes must produce a non-empty, re-probeable output; `tests/fixtures/generated/tone.wav` fixture (32,102 bytes, SHA-256
- the Opus audio and VP9/Opus presets must remain disabled with a diagnostic `0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c`)
if that smoke fails; and the real built-in `audio-opus` preset/command planner. After rebuilding
- a core upgrade requires new checksums, capability snapshots, legal review with the reviewed stack profile, the isolated ST and MT runs each:
and regression tests.
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 ## Licence consequence
The core packages declare GPL-2.0-or-later and the actual build enables GPL and The actual core build enables GPL and links external codecs. The wrapper/util
links external codecs. The wrapper/util are MIT. av-tools is are MIT. av-tools is GPL-3.0-or-later, and the distributed application must
GPL-3.0-or-later, and the distributed application must not be described as not be described as MIT-only. Publish the reviewed Corresponding Source and
MIT-only. Publish the reviewed Corresponding Source and external notices with external notices with the binary release. See `LICENSE`, `LICENSES/`,
the binary release. See `LICENSE`, `LICENSES/`, `THIRD_PARTY_NOTICES.md` and `THIRD_PARTY_NOTICES.md` and `SOURCE.md`.
`SOURCE.md`.

View File

@@ -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. encoders and filters; a preset is disabled with missing components listed.
Browser playback is a separate capability checked with MIME/media APIs. 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 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 preparation and pass the checksum/probe verifier. That proves fixture
@@ -42,9 +42,9 @@ browser may still be unable to preview the result.
Preset availability always depends on detected runtime requirements: Preset availability always depends on detected runtime requirements:
| Preset family | Required examples | Browser result preview | | Preset family | Required examples | Browser result preview |
| ------------------ | ---------------------------------------- | ------------------------------------------------------ | | ------------------ | ---------------------------------------- | -------------------------------------------------------- |
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output | | 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 VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | enabled when the loaded core advertises all requirements |
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox | | WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers | | Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview | | animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
@@ -52,7 +52,7 @@ Preset availability always depends on detected runtime requirements:
| MP3 | `mp3`, `libmp3lame` | commonly playable | | MP3 | `mp3`, `libmp3lame` | commonly playable |
| M4A/AAC | `ipod`, `aac` | commonly playable | | M4A/AAC | `ipod`, `aac` | commonly playable |
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent | | Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies | | Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded |
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable | | WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
| FLAC | `flac`, `flac` | browser-dependent | | FLAC | `flac`, `flac` | browser-dependent |
@@ -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 - Normalized concat uses an explicit export preset and requires an explicit
insert-silence, drop-all-audio or reject policy when clips have mismatched insert-silence, drop-all-audio or reject policy when clips have mismatched
audio presence. audio presence.
- Normalized concat can overlap every edit with one uniform 0.0410 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 - Crop, resize, frame-rate conversion, fades, normalization, burn-in and
contact sheets require specific filters discovered at runtime. contact sheets require specific filters discovered at runtime.
- Even-dimension correction is applied when the selected pixel format/encoder - Even-dimension correction is applied when the selected pixel format/encoder
requires it and is reported in the plan/result. 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.010.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 ## Subtitles
The text fixtures cover SRT, WebVTT and ASS import with explicit time shift. 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; - frame-accurate stream-copy trimming;
- complete metadata erasure/anonymity; - complete metadata erasure/anonymity;
- browser preview of every FFmpeg-readable result; - browser preview of every FFmpeg-readable result;
- Opus presets until real non-empty ST/MT encoding smoke passes for core - Opus on browsers, inputs or deployments outside the exact reviewed
0.12.10; 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. - any capability omitted by the active core's runtime query.
Update this file only from re-probed generated/public-domain fixtures and Update this file only from re-probed generated/public-domain fixtures and

View File

@@ -1,6 +1,6 @@
# Known limitations # 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. FFmpeg output does not remove these product/browser/resource limits.
## Functional ## Functional
@@ -13,6 +13,9 @@ FFmpeg output does not remove these product/browser/resource limits.
frame-accurate. Accurate mode requires decoding/re-encoding. frame-accurate. Accurate mode requires decoding/re-encoding.
- Stream-copy concatenate works only when selected streams and codec/container - Stream-copy concatenate works only when selected streams and codec/container
parameters are compatible. Other input needs normalized re-encoding. 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 - Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
cannot restore information already lost in a source. cannot restore information already lost in a source.
- Metadata removal is best effort. Muxers may add structural/encoder fields, - 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. page discards queued work.
- A file-generating batch and its managed result collection are bounded to 255 - 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. 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; - 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 they do not replace the source or guarantee that every browser can play the
result. result.
@@ -38,8 +47,9 @@ FFmpeg output does not remove these product/browser/resource limits.
support fewer container/codec combinations. support fewer container/codec combinations.
- Browser support, codec policy and memory differ across Chromium, Firefox and - Browser support, codec policy and memory differ across Chromium, Firefox and
Safari and across desktop/mobile versions. Safari and across desktop/mobile versions.
- Core 0.12.10 has an Opus regression risk. Opus remains release-gated until - The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe
non-empty, re-probeable ST and MT fixture outputs pass. 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 - MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works
without isolation but is not a guarantee that a large job fits. without isolation but is not a guarantee that a large job fits.
- Top-level navigation is supported; iframe embedding is not. - 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. verification against the live container.
- The application is GPL-3.0-or-later and its native legal files and pinned - 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 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. together; development-only smoke artifacts must not be distributed.
- Chromium covers a broad real pinned-core ST operation matrix, a real MT - 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 H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context

View File

@@ -6,12 +6,13 @@ output at once. No hardware encoder is used.
## Fixed download footprint ## 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 | | Mode | JavaScript/worker | WASM | Total |
| ------------- | ------------------: | -----------: | -----------: | | ------------- | ------------------: | -----------: | -----------: |
| single-thread | 111,804 B | 32,232,419 B | 32,344,223 B | | single-thread | 111,804 B | 32,232,580 B | 32,344,384 B |
| multithread | 128,947 B + 2,115 B | 32,718,323 B | 32,849,385 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 Only the selected mode is loaded. HTTP compression may reduce JavaScript
transfer but not necessarily WASM to the same degree; report transfer and 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 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, 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, 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 contact-sheet cells at 500, scene-change results at 100, crossfades at 10
ffmpeg.wasm input ceiling is 2 GiB, but real limits can be substantially lower seconds and runtime at four hours. Scene selection is a bounded two-step
on mobile or memory-constrained browsers. 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 Lowering resolution, duration, frame rate, passes and output count reduces
work. MT can be faster but consumes more memory/CPU and requires isolation; ST work. MT can be faster but consumes more memory/CPU and requires isolation; ST

View File

@@ -18,7 +18,7 @@ X-Content-Type-Options: nosniff
Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME
type and JSON as `application/json`. Test real responses under 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. prove the MIME type.
Suggested CSP: Suggested CSP:
@@ -43,45 +43,36 @@ form-action 'none';
`'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created `'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created
worker path. `media-src blob:` permits local source/result previews. 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 Portal revision `5c052bc6d1073a66600cfddc44c0e16da34bede8` (tag `v0.6.0`)
`same-origin`, COEP `require-corp`, CORP `same-origin`, `script-src 'self' already sends COOP `same-origin`, COEP `require-corp`, CORP `same-origin`,
'wasm-unsafe-eval'`, and `worker-src 'self' blob:`. Its packaged nginx CSP does `script-src 'self' 'wasm-unsafe-eval'`, `worker-src 'self' blob:` and
**not** declare `media-src`; `default-src 'self'` therefore blocks local `media-src 'self' blob:`. Its nginx profile also maps `.wasm` explicitly to
`blob:` audio/video URLs. The expected browser diagnostic is equivalent to: `application/wasm`. Local source and result Blob previews therefore require no
CSP exception beyond that reviewed narrow media directive.
```text As inspected on 2026-07-26, the active
Refused to load media from 'blob:…' because it violates `/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` has an additional worktree
"default-src 'self'". 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 Until the cache extension is committed, cite it only as staged integration
`media-src 'self' blob:;` to that CSP. This permits only same-origin and evidence, not as a published Portal revision. The clean `v0.6.0` rule leaves
in-memory Blob media; it does not permit remote media hosts, inline script or the reviewed profile at `no-cache`; that affects performance, not loading,
eval. If the Portal is not changed, conversion/download can remain available MIME type, isolation or preview correctness.
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.
## Caching ## Caching
The immutable Portal reference's cache map makes Vite content-hashed assets The staged Portal cache map makes Vite content-hashed assets, plain semantic
immutable but leaves versioned FFmpeg cores at `no-cache`. Correctness is versions and the explicitly reviewed profile immutable. For performance, the
unaffected. exact directory
For performance, the exact directory `apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` may receive:
`apps/av/vendor/ffmpeg/0.12.10/{st,mt}/` may receive:
```text ```text
Cache-Control: public, max-age=31536000, immutable Cache-Control: public, max-age=31536000, immutable

View File

@@ -1,28 +1,31 @@
# Reference implementations # 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 The references were cloned beside av-tools under
`/mnt/DATA/git/references/`; no reference repository was nested in this `/mnt/DATA/git/references/`; no reference repository was nested in this
working tree. Revisions are full immutable commit ids. working tree. Revisions are full immutable commit ids.
| Repository | Revision inspected | Used for | Copied or adapted | | 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 | | 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 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 | | 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` | 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 | | 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 For the **pinned** package set, the more specific upstream release tag
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It `v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It
contains `@ffmpeg/ffmpeg` 0.12.15 and core/core-mt 0.12.10. That revision's contains `@ffmpeg/ffmpeg` 0.12.15 and the source configuration for core
build selects FFmpeg n5.1.4. 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 The active `/mnt/DATA/git/toolbox-portal` worktree has immutable revision
revision `bda9da044d61c109afd9969d357cc27f76648938` as `HEAD`, but currently `5c052bc6d1073a66600cfddc44c0e16da34bede8` as `HEAD`. Its staged cache-rule
contains a local `media-src 'self' blob:` nginx CSP patch. That mutable extension is documented separately and is not represented as a published
worktree state is documented for deployment testing only; it does not replace revision.
the immutable reference in the table.
Official behavior references: Official behavior references:

View File

@@ -11,10 +11,10 @@ The repository already declares the application licence and immutable
third-party source identities. Before any distribution: third-party source identities. Before any distribution:
1. commit the reviewed GPL-3.0-or-later source; 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; 3. push the commit and tag to the canonical public repository;
4. assemble, review and publish corresponding source and exact external 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. 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 `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 ```sh
npm run package:release npm run package:release
npm run source:package npm run source:package
npm run vendor:verify
``` ```
The legacy development-only escape hatch is retained only for checking an 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: Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
```text ```text
release/av-tools-0.1.0.zip release/av-tools-0.2.0.zip
release/av-tools-0.1.0.zip.sha256 release/av-tools-0.2.0.zip.sha256
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256 release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
``` ```
Verify independently: Verify independently:
```sh ```sh
node scripts/checksum-release.mjs node scripts/checksum-release.mjs
(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256) (cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
unzip -l release/av-tools-0.1.0.zip unzip -l release/av-tools-0.2.0.zip
tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
``` ```
The packager: The packager:
@@ -90,13 +91,13 @@ When used against a fixture without an application licence, the escape hatch add
## 4. Portal assembly smoke ## 4. Portal assembly smoke
Use the clean reference clone beside av-tools, never the user's active Portal Use a reviewed Portal Git repository beside av-tools. The harness reads its
worktree: exact `HEAD` and clones that revision into a temporary directory:
```sh ```sh
npm run portal:smoke -- \ npm run portal:smoke -- \
--artifact release/av-tools-0.1.0.zip \ --artifact release/av-tools-0.2.0.zip \
--allow-known-header-gap --portal-root ../toolbox-portal
``` ```
The smoke harness clones the exact Portal HEAD to a temporary directory, 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. The temporary directory is removed unless `--keep-temp` is explicitly passed.
No machine-specific path is committed and no Portal worktree is changed. No machine-specific path is committed and no Portal worktree is changed.
Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP Portal `v0.6.0`
omission of `media-src blob:` is a failing deployment check. The override still (`5c052bc6d1073a66600cfddc44c0e16da34bede8`) already passes the isolation,
reports the gap and is only for testing archive assembly. See Blob-media CSP and explicit WASM MIME checks. Its plain-semver cache rule does
`docs/PORTAL_REQUIREMENTS.md`. 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 `--allow-known-header-gap` remains available only for deliberately testing an
directive as a local patch, but the smoke intentionally does not consume that older Portal revision with a known required-header error. It is unnecessary
mutable worktree. Until the patch exists in a reviewed immutable Portal for `v0.6.0` and must not suppress review of new gaps. See
revision, retain the clean-reference failure/override behavior above. `docs/PORTAL_REQUIREMENTS.md`.
## 5. Publish only reviewed bytes ## 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 the sidecar, assemble that exact ZIP through Portal, and publish the immutable
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never its exact lowercase digest/version/id/target in a reviewed Portal lock. Never

View File

@@ -39,9 +39,10 @@ exportable diagnostics.
Metadata is rendered as React text, never `dangerouslySetInnerHTML`. Subtitle Metadata is rendered as React text, never `dangerouslySetInnerHTML`. Subtitle
text is parsed and rendered or sent through a generated local file; it is not 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 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 links from media automatically. No remote font is fetched. Contact-sheet labels
packaged/system-generic font configuration only when the required local mount only the same-origin, size/SHA-256-verified DejaVu asset and fail closed
capability is available. 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 Preview/download URLs are `blob:` values made from local bytes. They are
revoked on replacement, project close and unmount. Browser support for a revoked on replacement, project close and unmount. Browser support for a

View File

@@ -13,6 +13,7 @@ export default tseslint.config(
'test-results', 'test-results',
'playwright-report', 'playwright-report',
'public/vendor/ffmpeg', 'public/vendor/ffmpeg',
'.core-build',
], ],
}, },
{ {

24
package-lock.json generated
View File

@@ -1,18 +1,16 @@
{ {
"name": "av-tools", "name": "av-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "av-tools", "name": "av-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.2", "@add-ideas/toolbox-contract": "0.2.2",
"@add-ideas/toolbox-shell-react": "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/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2", "@ffmpeg/util": "0.12.2",
"fflate": "0.8.3", "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": { "node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.15", "version": "0.12.15",
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "av-tools", "name": "av-tools",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.", "description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -22,6 +22,8 @@
"dev": "vite", "dev": "vite",
"vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs", "vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs",
"vendor:verify": "node scripts/verify-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:generate": "node scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check", "manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
"prebuild": "npm run vendor:ffmpeg && npm run manifest:generate", "prebuild": "npm run vendor:ffmpeg && npm run manifest:generate",
@@ -34,6 +36,9 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:browser": "playwright test", "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", "benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
"fixtures:generate": "sh scripts/generate-fixtures.sh", "fixtures:generate": "sh scripts/generate-fixtures.sh",
"fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS", "fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS",
@@ -42,14 +47,12 @@
"source:package": "node scripts/package-ffmpeg-source.mjs", "source:package": "node scripts/package-ffmpeg-source.mjs",
"checksum:release": "node scripts/checksum-release.mjs", "checksum:release": "node scripts/checksum-release.mjs",
"portal:smoke": "node scripts/portal-assembly-smoke.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", "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 test:browser && npm run package:release" "release:artifact": "npm run check && npm run vendor:verify && npm run test:opus-regression && npm run test:browser && npm run package:release"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.2", "@add-ideas/toolbox-contract": "0.2.2",
"@add-ideas/toolbox-shell-react": "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/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2", "@ffmpeg/util": "0.12.2",
"fflate": "0.8.3", "fflate": "0.8.3",

BIN
public/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.av-tools", "id": "de.add-ideas.av-tools",
"name": "Audio & Video 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.", "description": "Convert and lightly edit audio and video locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -55,10 +55,10 @@
} }
], ],
"assets": [ "assets": [
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js" "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
] ]
} }

View File

@@ -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"

View File

@@ -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

View File

@@ -1,138 +1,203 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { unzipSync } from 'fflate';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const repositoryRoot = path.resolve(
const expectedVersion = '0.12.10'; path.dirname(fileURLToPath(import.meta.url)),
const outputRoot = join( '..'
repositoryRoot,
'public',
'vendor',
'ffmpeg',
expectedVersion
); );
const lockPath = path.join(
const packages = [ repositoryRoot,
{ 'scripts',
packageName: '@ffmpeg/core', 'reviewed-ffmpeg-core-lock.json'
mode: 'st', );
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'], const maximumArchiveBytes = 64 * 1024 * 1024;
}, const maximumAssetBytes = 64 * 1024 * 1024;
{ const requiredAssetPaths = new Set([
packageName: '@ffmpeg/core-mt', 'st/ffmpeg-core.js',
mode: 'mt', 'st/ffmpeg-core.wasm',
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'], 'mt/ffmpeg-core.js',
}, 'mt/ffmpeg-core.wasm',
]; 'mt/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',
],
]); ]);
async function loadPackageVersion(packageName) { function sha256(bytes) {
const packagePath = join( return createHash('sha256').update(bytes).digest('hex');
repositoryRoot, }
'node_modules',
...packageName.split('/'), function assertSafeRelativePath(value) {
'package.json' if (
); typeof value !== 'string' ||
const parsed = JSON.parse(await readFile(packagePath, 'utf8')); value.length === 0 ||
if (parsed.version !== expectedVersion) { 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}`);
}
}
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( throw new Error(
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}` `Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
); );
} }
} archiveEntries.add(relative);
return true;
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,
});
});
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}`
);
}
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));
}
}
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
const versionManifest = {
schemaVersion: 1,
coreVersion: expectedVersion,
packages: {
'@ffmpeg/core': expectedVersion,
'@ffmpeg/core-mt': expectedVersion,
}, },
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( await writeFile(
join(outputRoot, 'version.json'), path.join(outputRoot, 'version.json'),
`${JSON.stringify(versionManifest, null, 2)}\n` `${JSON.stringify(versionManifest, null, 2)}\n`,
{ flag: 'wx', mode: 0o644 }
); );
console.log( 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)}`
); );

View File

@@ -1,7 +1,9 @@
# FFmpeg WebAssembly Corresponding Source # FFmpeg WebAssembly Corresponding Source
This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt` This archive accompanies the reviewed single-thread and multithread FFmpeg
0.12.10 object code distributed by av-tools 0.1.0. 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 `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 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`; - `build/ffmpeg-wasm.sh`;
- the other dependency scripts under `build/`. - the other dependency scripts under `build/`.
The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools The build uses Emscripten SDK 3.1.40 and FFmpeg n5.1.4. Its one source patch
does not modify the five published JavaScript/WebAssembly core assets. Their applies upstream ffmpeg.wasm commit
exact sizes, hashes and embedded configure arguments are recorded in the `b409e36475bc21f0451b5b1e1d126fa82871439a`, which raises the Emscripten stack
application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`. 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 The source archive preserves each component's own licence and copyright
notices. av-tools itself is distributed separately under notices. av-tools itself is distributed separately under
@@ -37,14 +44,11 @@ test "$(git -C source-check rev-parse FETCH_HEAD)" = "<revision>"
``` ```
The included ffmpeg.wasm Dockerfile documents the historical upstream build, The included ffmpeg.wasm Dockerfile documents the historical upstream build,
but still names remote tags and branches. It is not a turnkey offline or but still names remote tags and branches. For the distributed reviewed
byte-for-byte reproducible build recipe. To reconstruct a pinned build, profile, use this archive together with the matching av-tools source release
adapt each remote `ADD` to use the corresponding bundled source tree. Replace and invoke its `scripts/build-reviewed-ffmpeg-core.sh` driver. The bundled
the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/` `sources/zimg/` tree already contains its pinned GoogleTest submodule at
tree, which already contains its pinned GoogleTest submodule at `test/extra/googletest/`.
`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.
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
3.1.40 container image is not copied into this archive. 3.1.40 container image is not copied into this archive.

522
scripts/opus-regression.mjs Normal file
View File

@@ -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;
}

View File

@@ -18,6 +18,11 @@ import { createReadStream } from 'node:fs';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json'); 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 readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
const releaseDirectory = join(repositoryRoot, 'release'); const releaseDirectory = join(repositoryRoot, 'release');
const force = process.argv.includes('--force'); const force = process.argv.includes('--force');
@@ -204,7 +209,19 @@ async function publishSourcePair({
const lock = JSON.parse(await readFile(lockPath, 'utf8')); const lock = JSON.parse(await readFile(lockPath, 'utf8'));
assertLock(lock); 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 archivePath = join(releaseDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`; const checksumPath = `${archivePath}.sha256`;
await ensureReleaseDirectory(); await ensureReleaseDirectory();

View File

@@ -52,8 +52,6 @@ const ffmpegLicenseCoverage = new Map([
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'], ['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'], ['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/types', '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) { 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); const vendorManifestEntry = entriesByName.get(vendorManifestPath);
if (!vendorManifestEntry) { if (!vendorManifestEntry) {
throw new Error( throw new Error(
@@ -488,10 +487,12 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
} }
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8')); const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
if ( if (
vendorManifest.schemaVersion !== 1 || vendorManifest.schemaVersion !== 2 ||
vendorManifest.buildId !== vendorBuildId ||
vendorManifest.profile !== 'reviewed-stack5m' ||
vendorManifest.coreVersion !== '0.12.10' || vendorManifest.coreVersion !== '0.12.10' ||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' || vendorManifest.opusRegression?.singleThread !== true ||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' || vendorManifest.opusRegression?.multiThread !== true ||
!Array.isArray(vendorManifest.assets) || !Array.isArray(vendorManifest.assets) ||
vendorManifest.assets.length !== 5 vendorManifest.assets.length !== 5
) { ) {
@@ -511,7 +512,7 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
); );
} }
const assetPath = safeArchivePath( 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)) { if (verifiedVendorPaths.has(assetPath)) {
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`); throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);

View File

@@ -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})`
);

View File

@@ -16,6 +16,7 @@ const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
'..' '..'
); );
const reviewedCoreBuildId = '0.12.10-reviewed-stack5m.1';
function consumeValue(argumentsList, index, option) { function consumeValue(argumentsList, index, option) {
const value = argumentsList[index + 1]; 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.' 'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
); );
} }
if ( const immutableCoreRule = configuration
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') && .split(/\r?\n/u)
!configuration.includes('vendor/ffmpeg/0.12.10') .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( 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 }; return { errors, warnings };
@@ -229,11 +239,11 @@ async function verifyAssembly(output, appVersion, digest) {
} }
for (const reference of [ for (const reference of [
'./index.html', './index.html',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js', './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm', './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js', './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm', './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js', './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
]) { ]) {
const relative = reference.replace(/^\.\//, ''); const relative = reference.replace(/^\.\//, '');
await requireRegularFile( await requireRegularFile(

View File

@@ -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"
}
}

View File

@@ -1,80 +1,146 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises'; import { lstat, readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const repositoryRoot = path.resolve(
const expectedVersion = '0.12.10'; path.dirname(fileURLToPath(import.meta.url)),
const outputRoot = join( '..'
);
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, repositoryRoot,
'public', 'public',
'vendor', 'vendor',
'ffmpeg', '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 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 ( if (
manifest.schemaVersion !== 1 || manifest.schemaVersion !== 2 ||
manifest.coreVersion !== expectedVersion || 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) || !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([ if (!runtimeOnly) {
'mt/ffmpeg-core.js', await verifyLockedFile(lock.sourceArchive, 'FFmpeg Corresponding Source');
'mt/ffmpeg-core.wasm', }
'mt/ffmpeg-core.worker.js', await verifyLockedFile(lock.sourceLock, 'FFmpeg source lock');
'st/ffmpeg-core.js', await verifyLockedFile(lock.buildDriver, 'Reviewed-core build driver');
'st/ffmpeg-core.wasm', await verifyLockedFile(lock.artifact, 'Reviewed-core deterministic artifact');
]);
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',
],
]);
const lockedAssets = new Map(lock.assets.map((asset) => [asset.path, asset]));
const seen = new Set();
for (const asset of manifest.assets) { for (const asset of manifest.assets) {
if (!expectedPaths.delete(asset.path)) { safeAssetPath(asset.path);
throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`); const locked = lockedAssets.get(asset.path);
}
const bytes = await readFile(join(outputRoot, asset.path));
const digest = createHash('sha256').update(bytes).digest('hex');
if ( if (
asset.bytes !== bytes.byteLength || !locked ||
asset.sha256 !== digest || seen.has(asset.path) ||
expectedHashes.get(asset.path) !== digest 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}`); throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
} }
} }
if (seen.size !== lockedAssets.size) {
if (expectedPaths.size > 0) { throw new Error('One or more reviewed FFmpeg assets are missing.');
throw new Error(
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
);
} }
console.log( 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'}`
); );

View File

@@ -9,6 +9,10 @@ import {
type WorkspaceMode, type WorkspaceMode,
} from './app/application-state'; } from './app/application-state';
import { AppErrorBoundary } from './app/AppErrorBoundary'; import { AppErrorBoundary } from './app/AppErrorBoundary';
import {
SourceInspectionRegistry,
type SourceInspectionToken,
} from './app/source-inspection-registry';
import { FileDropZone } from './components/FileDropZone'; import { FileDropZone } from './components/FileDropZone';
import { MediaBin } from './components/MediaBin'; import { MediaBin } from './components/MediaBin';
import { import {
@@ -79,7 +83,10 @@ import { buildPreviewProxyPlan } from './commands/preview-proxy';
import { buildTimelineExportPlan } from './commands/timeline-export'; import { buildTimelineExportPlan } from './commands/timeline-export';
import { import {
buildContactSheetPlan, buildContactSheetPlan,
buildSceneChangeAnalysisPlan,
buildSceneThumbnailSeriesPlan,
buildThumbnailSeriesPlan, buildThumbnailSeriesPlan,
parseSceneChangeFrames,
} from './commands/thumbnails'; } from './commands/thumbnails';
import { buildSplitPlan } from './commands/split'; import { buildSplitPlan } from './commands/split';
import { buildTrimPlan } from './commands/trim'; import { buildTrimPlan } from './commands/trim';
@@ -152,6 +159,11 @@ import {
type WaveformPeakData, type WaveformPeakData,
} from './waveform'; } from './waveform';
import type { MediaJob, MediaJobStep } from './jobs'; 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 { function timestamp(): string {
return new Date().toISOString(); return new Date().toISOString();
@@ -554,6 +566,7 @@ export function AvToolsApplication() {
const projectRef = useRef(project); const projectRef = useRef(project);
const sourceProbeTail = useRef<Promise<void>>(Promise.resolve()); const sourceProbeTail = useRef<Promise<void>>(Promise.resolve());
const sourceProbePendingRef = useRef(0); const sourceProbePendingRef = useRef(0);
const sourceInspectionRegistry = useRef(new SourceInspectionRegistry());
const planExecutionTail = useRef<Promise<void>>(Promise.resolve()); const planExecutionTail = useRef<Promise<void>>(Promise.resolve());
const scheduledPlanCountRef = useRef(0); const scheduledPlanCountRef = useRef(0);
const scheduledWorkRef = useRef(new Map<string, ScheduledWorkDefinition>()); const scheduledWorkRef = useRef(new Map<string, ScheduledWorkDefinition>());
@@ -703,7 +716,10 @@ export function AvToolsApplication() {
); );
useEffect(() => { useEffect(() => {
const inspectionRegistry = sourceInspectionRegistry.current;
return () => { return () => {
const cancelActiveInspection = inspectionRegistry.clear();
if (cancelActiveInspection) void engineManager.cancelActive();
for (const asset of assetsRef.current) { for (const asset of assetsRef.current) {
URL.revokeObjectURL(asset.objectUrl); URL.revokeObjectURL(asset.objectUrl);
} }
@@ -751,15 +767,37 @@ export function AvToolsApplication() {
); );
const inspectAsset = useCallback( const inspectAsset = useCallback(
async (asset: ImportedMediaAsset, existingAsset: boolean) => { async (
asset: ImportedMediaAsset,
existingAsset: boolean,
token: SourceInspectionToken
) => {
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
updateAssetState(asset.id, { updateAssetState(asset.id, {
phase: 'probing', phase: 'probing',
error: undefined, error: undefined,
}); });
try { try {
const probeResult = await runSourceProbe(() => const probeResult = await runSourceProbe(async () => {
engineManager.probe(asset.file) 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 probe = normalizeFfprobeReport(probeResult.json);
const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`; const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`;
const importedChapters = probe.chapters.map((chapter) => ({ const importedChapters = probe.chapters.map((chapter) => ({
@@ -772,8 +810,15 @@ export function AvToolsApplication() {
? { metadata: chapter.tags } ? { metadata: chapter.tags }
: {}), : {}),
})); }));
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
updateAssetState(asset.id, { phase: 'ready', probe, rawProbe }); updateAssetState(asset.id, { phase: 'ready', probe, rawProbe });
setProject((current) => { setProject((current) => {
if (
!sourceInspectionRegistry.current.isCurrent(token) ||
!current.assets.some((entry) => entry.id === asset.id)
) {
return current;
}
let next = projectReducer(current, { let next = projectReducer(current, {
type: 'asset/update', type: 'asset/update',
assetId: asset.id, assetId: asset.id,
@@ -823,6 +868,7 @@ export function AvToolsApplication() {
return next; return next;
}); });
} catch (error) { } catch (error) {
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
updateAssetState(asset.id, { updateAssetState(asset.id, {
phase: 'error', phase: 'error',
error: error:
@@ -881,6 +927,7 @@ export function AvToolsApplication() {
const pending: Array<{ const pending: Array<{
asset: ImportedMediaAsset; asset: ImportedMediaAsset;
existingAsset: boolean; existingAsset: boolean;
token: SourceInspectionToken;
}> = []; }> = [];
const claimedIds = new Set(assetsRef.current.map((asset) => asset.id)); const claimedIds = new Set(assetsRef.current.map((asset) => asset.id));
@@ -916,7 +963,11 @@ export function AvToolsApplication() {
if (reattach && match) { if (reattach && match) {
asset.id = match.entry.id; asset.id = match.entry.id;
claimedIds.add(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 { } else {
setProject((current) => setProject((current) =>
projectReducer(current, { projectReducer(current, {
@@ -932,7 +983,11 @@ export function AvToolsApplication() {
updatedAt: timestamp(), 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); setSelectedAssetId((current) => current ?? pending[0]?.asset.id);
for (const item of pending) { for (const item of pending) {
await inspectAsset(item.asset, item.existingAsset); await inspectAsset(item.asset, item.existingAsset, item.token);
} }
}, },
[inspectAsset, project.assets, resourceContext.policy] [inspectAsset, project.assets, resourceContext.policy]
@@ -953,7 +1008,13 @@ export function AvToolsApplication() {
(id: string) => { (id: string) => {
const asset = assets.find((entry) => entry.id === id); const asset = assets.find((entry) => entry.id === id);
if (asset) URL.revokeObjectURL(asset.objectUrl); 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) => setWaveformByAsset((current) =>
Object.fromEntries( Object.fromEntries(
Object.entries(current).filter(([assetId]) => assetId !== id) Object.entries(current).filter(([assetId]) => assetId !== id)
@@ -982,6 +1043,7 @@ export function AvToolsApplication() {
assetId: id, assetId: id,
updatedAt: timestamp(), updatedAt: timestamp(),
}); });
if (cancelActiveInspection) void engineManager.cancelActive();
}, },
[applyProjectAction, assets, selectedAssetId] [applyProjectAction, assets, selectedAssetId]
); );
@@ -1203,7 +1265,7 @@ export function AvToolsApplication() {
? ('multi-thread' as const) ? ('multi-thread' as const)
: ('single-thread' as const), : ('single-thread' as const),
ffmpegVersion: readyStateAfterRun.capabilities.versionText, ffmpegVersion: readyStateAfterRun.capabilities.versionText,
coreVersion: '0.12.10', coreVersion: FFMPEG_CORE_VERSION,
} }
: {}), : {}),
}, },
@@ -2245,6 +2307,9 @@ export function AvToolsApplication() {
const resetProject = useCallback(() => { const resetProject = useCallback(() => {
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
if (cancelActiveInspection) void engineManager.cancelActive();
assetsRef.current = [];
clearResult(); clearResult();
setAssets([]); setAssets([]);
setSelectedAssetId(undefined); setSelectedAssetId(undefined);
@@ -2280,6 +2345,9 @@ export function AvToolsApplication() {
try { try {
const imported = importProjectDocument(await file.text()); const imported = importProjectDocument(await file.text());
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
if (cancelActiveInspection) void engineManager.cancelActive();
assetsRef.current = [];
clearResult(); clearResult();
setAssets([]); setAssets([]);
setSelectedAssetId(undefined); setSelectedAssetId(undefined);
@@ -2556,6 +2624,7 @@ export function AvToolsApplication() {
channelLayout: 'stereo', channelLayout: 'stereo',
missingAudioPolicy: request.missingAudioPolicy, missingAudioPolicy: request.missingAudioPolicy,
subtitlePolicy: request.subtitlePolicy, subtitlePolicy: request.subtitlePolicy,
crossfadeSeconds: request.crossfadeSeconds,
}); });
})(); })();
await runPlan( await runPlan(
@@ -2950,30 +3019,77 @@ export function AvToolsApplication() {
if (!asset || duration === undefined) { if (!asset || duration === undefined) {
throw new Error('Select an attached, inspected video source first.'); throw new Error('Select an attached, inspected video source first.');
} }
const distribution = const source = {
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, id: asset.id,
sourceIndex: 0, sourceIndex: 0,
fileName: asset.file.name, fileName: asset.file.name,
}, };
durationSeconds: duration, const size = {
...distribution,
size: {
...(request.width ? { width: request.width } : {}), ...(request.width ? { width: request.width } : {}),
...(request.height ? { height: request.height } : {}), ...(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, format: request.format,
quality: request.quality, quality: request.quality,
}); }),
const execution = await runPlan( [asset.file],
plan, '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], [asset.file],
'Create thumbnail series' 'Create thumbnail series'
); );
@@ -3002,7 +3118,7 @@ export function AvToolsApplication() {
); );
} }
}, },
[inspectedClipAsset, project.id, runPlan] [inspectedClipAsset, project.id, runPlan, runWorkflow]
); );
const handleContactSheet = useCallback( const handleContactSheet = useCallback(
@@ -3012,22 +3128,75 @@ export function AvToolsApplication() {
if (!asset || duration === undefined) { if (!asset || duration === undefined) {
throw new Error('Select an attached, inspected video source first.'); throw new Error('Select an attached, inspected video source first.');
} }
const plan = buildContactSheetPlan({ const source = {
jobId: crypto.randomUUID(),
source: {
id: asset.id, id: asset.id,
sourceIndex: 0, sourceIndex: 0,
fileName: asset.file.name, fileName: asset.file.name,
}, };
const labelsRequested = request.timestampLabels || request.sourceLabel;
const font = labelsRequested
? await loadBundledContactSheetFont()
: undefined;
const buildSheet = (
sceneFrames?: ReturnType<typeof parseSceneChangeFrames>
) =>
buildContactSheetPlan({
jobId: crypto.randomUUID(),
source,
durationSeconds: duration, durationSeconds: duration,
...request, ...request,
drawtextAvailable: false, ...(sceneFrames
? { frameCount: sceneFrames.length, sceneFrames }
: {}),
drawtextAvailable: Boolean(font),
...(font
? {
font: {
id: 'bundled-contact-sheet-font',
sourceIndex: 1,
fileName: font.name,
},
}
: {}),
}); });
const execution = await runPlan( const inputs = font ? [asset.file, font] : [asset.file];
plan, 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], [asset.file],
'Create contact sheet' '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]; const output = execution.results[0];
if (!output) throw new Error('The contact sheet was not returned.'); if (!output) throw new Error('The contact sheet was not returned.');
try { try {
@@ -3045,7 +3214,7 @@ export function AvToolsApplication() {
); );
} }
}, },
[inspectedClipAsset, project.id, runPlan] [inspectedClipAsset, project.id, runPlan, runWorkflow]
); );
const handleMetadataAction = useCallback( const handleMetadataAction = useCallback(
@@ -3413,7 +3582,8 @@ export function AvToolsApplication() {
status: 'unavailable' as const, status: 'unavailable' as const,
reasons: ['Initialize the engine to verify this preset.'], 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 { return {
id: preset.id, id: preset.id,
name: preset.name, name: preset.name,
@@ -3423,7 +3593,7 @@ export function AvToolsApplication() {
...(availability.status === 'unavailable' || opusBlocked ...(availability.status === 'unavailable' || opusBlocked
? { ? {
disabledReason: 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(' '), : availability.reasons.join(' '),
} }
: {}), : {}),
@@ -3481,6 +3651,7 @@ export function AvToolsApplication() {
hasAudio: false, hasAudio: false,
hasVideo: false, hasVideo: false,
hasSubtitles: false, hasSubtitles: false,
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
}; };
} }
const unsupportedStreams = asset.probe.streams.filter( const unsupportedStreams = asset.probe.streams.filter(
@@ -3517,6 +3688,7 @@ export function AvToolsApplication() {
return { return {
id: clip.id, id: clip.id,
label, label,
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'), hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'),
hasVideo: asset.probe.streams.some( hasVideo: asset.probe.streams.some(
(stream) => (stream) =>
@@ -3622,10 +3794,12 @@ export function AvToolsApplication() {
filters: ['fps', 'scale', 'tile'], filters: ['fps', 'scale', 'tile'],
}), }),
'contact-sheet-labels': { 'contact-sheet-labels': {
available: false, ...capabilityStatus({ filters: ['drawtext'] }),
reason:
'No reviewed local font is bundled, so labels are omitted instead of fetching a remote font.',
}, },
'scene-change-analysis': capabilityStatus({
muxers: ['null'],
filters: ['select', 'showinfo'],
}),
'metadata-write': sourceMuxer 'metadata-write': sourceMuxer
? capabilityStatus({ muxers: [sourceMuxer] }) ? capabilityStatus({ muxers: [sourceMuxer] })
: { available: false, reason: 'Select a source first.' }, : { available: false, reason: 'Select a source first.' },
@@ -3678,6 +3852,9 @@ export function AvToolsApplication() {
}, },
anyVideoPreset ? undefined : 'No verified video preset is available.' anyVideoPreset ? undefined : 'No verified video preset is available.'
), ),
'concat-crossfade': capabilityStatus({
filters: ['xfade', ...(structuralHasAnyAudio ? ['acrossfade'] : [])],
}),
} as const; } as const;
const advancedSource = inspectedClipAsset?.probe const advancedSource = inspectedClipAsset?.probe

View File

@@ -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;
}
}

View File

@@ -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<File> | 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<File> {
fontPromise ??= loadAndVerifyFont().catch((error: unknown) => {
fontPromise = undefined;
throw error;
});
return fontPromise;
}
async function loadAndVerifyFont(): Promise<File> {
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,
});
}

View File

@@ -42,6 +42,8 @@ export interface ConcatSource extends SourceReference {
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject'; export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
const MAX_CONCAT_SOURCES = 100; const MAX_CONCAT_SOURCES = 100;
export const MIN_CROSSFADE_SECONDS = 0.04;
export const MAX_CROSSFADE_SECONDS = 10;
export interface FastConcatOptions { export interface FastConcatOptions {
readonly jobId: string; readonly jobId: string;
@@ -59,6 +61,11 @@ export interface NormalizedConcatOptions extends FastConcatOptions {
readonly channelLayout?: 'mono' | 'stereo' | '5.1'; readonly channelLayout?: 'mono' | 'stereo' | '5.1';
readonly missingAudioPolicy: MissingAudioPolicy; readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy?: 'drop-all' | 'reject'; 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( export function validateConcatCompatibility(
@@ -334,6 +341,8 @@ export function buildNormalizedConcatPlan(
} }
const includeAudio = const includeAudio =
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean); options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
const crossfadeSeconds = options.crossfadeSeconds ?? 0;
validateCrossfade(crossfadeSeconds, ranges);
const inputs = options.sources.map((source) => const inputs = options.sources.map((source) =>
createPlannedInput(options.jobId, source) createPlannedInput(options.jobId, source)
); );
@@ -360,12 +369,35 @@ export function buildNormalizedConcatPlan(
} }
} }
}); });
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 const labels = options.sources
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`) .map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
.join(''); .join('');
graphParts.push( graphParts.push(
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}` `${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
); );
}
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]'); args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
if (includeAudio) { if (includeAudio) {
args.push('-map', '[aout]'); args.push('-map', '[aout]');
@@ -397,10 +429,9 @@ export function buildNormalizedConcatPlan(
temporaryFiles: [], temporaryFiles: [],
args, args,
outputs: [output], outputs: [output],
expectedDurationSeconds: ranges.reduce( expectedDurationSeconds:
(sum, range) => sum + range.durationSeconds, ranges.reduce((sum, range) => sum + range.durationSeconds, 0) -
0 crossfadeSeconds * (ranges.length - 1),
),
requiredCapabilities: mergeRequirements(presetRequirements, { requiredCapabilities: mergeRequirements(presetRequirements, {
muxers: [options.targetMuxer], muxers: [options.targetMuxer],
filters: [ filters: [
@@ -411,7 +442,8 @@ export function buildNormalizedConcatPlan(
'setsar', 'setsar',
'fps', 'fps',
'format', 'format',
'concat', ...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']),
...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []),
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []), ...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
...(mixedAudioPresence && ...(mixedAudioPresence &&
options.missingAudioPolicy === 'insert-silence' options.missingAudioPolicy === 'insert-silence'
@@ -423,7 +455,9 @@ export function buildNormalizedConcatPlan(
diagnostic( diagnostic(
'normalized-concat', 'normalized-concat',
'info', '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' ...(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( function comparableKeys(
kind: ConcatStreamDescription['kind'] kind: ConcatStreamDescription['kind']
): readonly (keyof ConcatStreamDescription)[] { ): readonly (keyof ConcatStreamDescription)[] {

View File

@@ -14,6 +14,16 @@ import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type ImageFormat = 'png' | 'jpeg' | 'webp'; 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 { export interface ThumbnailSize {
readonly width?: number; readonly width?: number;
readonly height?: number; readonly height?: number;
@@ -41,6 +51,21 @@ export interface ThumbnailSeriesOptions {
readonly quality?: number; 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 { export interface ContactSheetOptions {
readonly jobId: string; readonly jobId: string;
readonly source: SourceReference; readonly source: SourceReference;
@@ -57,6 +82,10 @@ export interface ContactSheetOptions {
readonly format: ImageFormat; readonly format: ImageFormat;
readonly quality?: number; readonly quality?: number;
readonly drawtextAvailable?: boolean; 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( 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<number>();
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( export function resolveThumbnailTimes(
options: Pick< options: Pick<
ThumbnailSeriesOptions, ThumbnailSeriesOptions,
@@ -271,7 +442,22 @@ export function buildContactSheetPlan(
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) { if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
throw new TypeError('Background must be a named colour or #RRGGBB'); 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 input = createPlannedInput(options.jobId, options.source);
const fontInput = options.font
? createPlannedInput(options.jobId, options.font, 'font')
: undefined;
const output = createPlannedOutput(options.jobId, { const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName, baseName: options.source.fileName,
operation: 'contact-sheet', operation: 'contact-sheet',
@@ -280,14 +466,20 @@ export function buildContactSheetPlan(
}); });
const fps = options.frameCount / options.durationSeconds; const fps = options.frameCount / options.durationSeconds;
const filters = [ 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 options.maintainAspect === false
? `scale=${options.cellWidth}:${options.cellWidth}` ? `scale=${options.cellWidth}:${options.cellWidth}`
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`, : `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
]; ];
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel); let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
const diagnostics = []; const diagnostics = [];
if (labelsEnabled && !options.drawtextAvailable) { if (labelsEnabled && (!options.drawtextAvailable || !fontInput)) {
labelsEnabled = false; labelsEnabled = false;
diagnostics.push( diagnostics.push(
diagnostic( diagnostic(
@@ -302,16 +494,19 @@ export function buildContactSheetPlan(
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}` ? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
: '%{pts\\:hms}'; : '%{pts\\:hms}';
filters.push( 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( 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({ return freezeCommandPlan({
id: `${options.jobId}:contact-sheet`, id: `${options.jobId}:contact-sheet`,
operation: 'contact-sheet', operation: 'contact-sheet',
inputs: [input], inputs: [input, ...(fontInput ? [fontInput] : [])],
temporaryFiles: [], temporaryFiles: [],
args: [ args: [
'-i', '-i',
@@ -327,12 +522,101 @@ export function buildContactSheetPlan(
expectedDurationSeconds: options.durationSeconds, expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: { requiredCapabilities: {
...imageRequirements(options.format), ...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[] { function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
const filter = scaleFilter(size); const filter = scaleFilter(size);
return filter ? ['-vf', filter] : []; return filter ? ['-vf', filter] : [];

View File

@@ -32,6 +32,11 @@ import {
GENERATED_OUTPUT_LIMIT_EXPLANATION, GENERATED_OUTPUT_LIMIT_EXPLANATION,
MAX_GENERATED_OUTPUT_FILES, MAX_GENERATED_OUTPUT_FILES,
} from '../limits'; } from '../limits';
import {
MAX_SCENE_CHANGE_FRAMES,
MAX_SCENE_CHANGE_THRESHOLD,
MIN_SCENE_CHANGE_THRESHOLD,
} from '../commands/thumbnails';
import { import {
parseUserPresetJson, parseUserPresetJson,
serializeUserPreset, serializeUserPreset,
@@ -58,6 +63,7 @@ export type AdvancedCapabilityKey =
| 'thumbnail-series' | 'thumbnail-series'
| 'contact-sheet' | 'contact-sheet'
| 'contact-sheet-labels' | 'contact-sheet-labels'
| 'scene-change-analysis'
| 'metadata-write' | 'metadata-write'
| 'chapter-write' | 'chapter-write'
| 'subtitle-extract' | 'subtitle-extract'
@@ -181,6 +187,11 @@ export type AdvancedAudioRequest =
export type ThumbnailDistribution = export type ThumbnailDistribution =
| { readonly type: 'evenly-spaced'; readonly count: number } | { readonly type: 'evenly-spaced'; readonly count: number }
| { readonly type: 'fixed-interval'; readonly intervalSeconds: 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 type: 'chapter-starts' | 'split-markers';
readonly timesSeconds: readonly number[]; readonly timesSeconds: readonly number[];
@@ -206,6 +217,9 @@ export interface ContactSheetRequest {
readonly sourceLabel: boolean; readonly sourceLabel: boolean;
readonly format: ImageFormat; readonly format: ImageFormat;
readonly quality: number; readonly quality: number;
readonly selection:
| { readonly type: 'evenly-spaced' }
| { readonly type: 'scene-changes'; readonly threshold: number };
} }
export type MetadataExecutionRequest = export type MetadataExecutionRequest =
@@ -556,6 +570,7 @@ export function AdvancedOperations({
chapters={chapters} chapters={chapters}
formatAvailability={imageFormatAvailability} formatAvailability={imageFormatAvailability}
labelAvailability={availability['contact-sheet-labels']} labelAvailability={availability['contact-sheet-labels']}
sceneAvailability={availability['scene-change-analysis']}
onThumbnailSeries={onThumbnailSeries} onThumbnailSeries={onThumbnailSeries}
onContactSheet={onContactSheet} onContactSheet={onContactSheet}
action={actionContext} action={actionContext}
@@ -1442,6 +1457,7 @@ function ImagePanel({
chapters, chapters,
formatAvailability, formatAvailability,
labelAvailability, labelAvailability,
sceneAvailability,
onThumbnailSeries, onThumbnailSeries,
onContactSheet, onContactSheet,
action, action,
@@ -1453,6 +1469,7 @@ function ImagePanel({
Readonly<Record<ImageFormat, AdvancedCapabilityStatus>> Readonly<Record<ImageFormat, AdvancedCapabilityStatus>>
>; >;
readonly labelAvailability?: AdvancedCapabilityStatus; readonly labelAvailability?: AdvancedCapabilityStatus;
readonly sceneAvailability?: AdvancedCapabilityStatus;
readonly onThumbnailSeries?: RequestHandler<ThumbnailSeriesRequest>; readonly onThumbnailSeries?: RequestHandler<ThumbnailSeriesRequest>;
readonly onContactSheet?: RequestHandler<ContactSheetRequest>; readonly onContactSheet?: RequestHandler<ContactSheetRequest>;
readonly action: ActionContext; readonly action: ActionContext;
@@ -1474,6 +1491,10 @@ function ImagePanel({
const [maintainAspect, setMaintainAspect] = useState(true); const [maintainAspect, setMaintainAspect] = useState(true);
const [timestampLabels, setTimestampLabels] = useState(false); const [timestampLabels, setTimestampLabels] = useState(false);
const [sourceLabel, setSourceLabel] = 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 const videoReason = !source
? 'Select a source first.' ? 'Select a source first.'
@@ -1521,6 +1542,29 @@ function ImagePanel({
type: distribution, type: distribution,
intervalSeconds: Number(interval), 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') { } else if (distribution === 'chapter-starts') {
const times = chapters.map((chapter) => chapter.startSeconds); const times = chapters.map((chapter) => chapter.startSeconds);
distributionReason = distributionReason =
@@ -1556,7 +1600,12 @@ function ImagePanel({
'Timestamp and filename labels require verified drawtext and a safe local font.') 'Timestamp and filename labels require verified drawtext and a safe local font.')
: undefined; : undefined;
const gridReason = 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(sheetRows, 1, 500, 'Rows') ??
integerRangeReason(sheetColumns, 1, 500, 'Columns') ?? integerRangeReason(sheetColumns, 1, 500, 'Columns') ??
integerRangeReason(cellWidth, 32, 4096, 'Cell width') ?? integerRangeReason(cellWidth, 32, 4096, 'Cell width') ??
@@ -1571,7 +1620,19 @@ function ImagePanel({
gridReason ?? gridReason ??
numberRangeReason(quality, 1, 100, 'Quality') ?? numberRangeReason(quality, 1, 100, 'Quality') ??
formatReason ?? 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 ( return (
<div className="advanced-operations__section-grid"> <div className="advanced-operations__section-grid">
@@ -1597,6 +1658,12 @@ function ImagePanel({
> >
<option value="evenly-spaced">Evenly spaced</option> <option value="evenly-spaced">Evenly spaced</option>
<option value="fixed-interval">Fixed interval</option> <option value="fixed-interval">Fixed interval</option>
<option
value="scene-changes"
disabled={!sceneAvailability?.available}
>
Scene changes
</option>
<option value="chapter-starts" disabled={chapters.length === 0}> <option value="chapter-starts" disabled={chapters.length === 0}>
Chapter starts Chapter starts
</option> </option>
@@ -1605,12 +1672,17 @@ function ImagePanel({
</option> </option>
</select> </select>
</label> </label>
{distribution === 'evenly-spaced' ? ( {distribution === 'evenly-spaced' ||
distribution === 'scene-changes' ? (
<NumberField <NumberField
label="Number of frames" label="Number of frames"
value={seriesCount} value={seriesCount}
min={1} min={1}
max={MAX_GENERATED_OUTPUT_FILES} max={
distribution === 'scene-changes'
? MAX_SCENE_CHANGE_FRAMES
: MAX_GENERATED_OUTPUT_FILES
}
onChange={setSeriesCount} onChange={setSeriesCount}
/> />
) : null} ) : null}
@@ -1625,6 +1697,16 @@ function ImagePanel({
onChange={setInterval} onChange={setInterval}
/> />
) : null} ) : null}
{distribution === 'scene-changes' ? (
<NumberField
label="Scene sensitivity threshold"
value={sceneThreshold}
min={MIN_SCENE_CHANGE_THRESHOLD}
max={MAX_SCENE_CHANGE_THRESHOLD}
step={0.01}
onChange={setSceneThreshold}
/>
) : null}
<ImageSettings <ImageSettings
width={width} width={width}
height={height} height={height}
@@ -1673,11 +1755,32 @@ function ImagePanel({
</div> </div>
</header> </header>
<div className="advanced-form-grid advanced-form-grid--three"> <div className="advanced-form-grid advanced-form-grid--three">
<label className="advanced-field">
<span>Frame selection</span>
<select
value={sheetSelection}
onChange={(event) =>
setSheetSelection(
event.currentTarget.value as 'evenly-spaced' | 'scene-changes'
)
}
>
<option value="evenly-spaced">Evenly spaced</option>
<option
value="scene-changes"
disabled={!sceneAvailability?.available}
>
Scene changes
</option>
</select>
</label>
<NumberField <NumberField
label="Frames" label="Frames"
value={sheetFrames} value={sheetFrames}
min={1} min={1}
max={500} max={
sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500
}
onChange={setSheetFrames} onChange={setSheetFrames}
/> />
<NumberField <NumberField
@@ -1716,6 +1819,16 @@ function ImagePanel({
onChange={setBackground} onChange={setBackground}
/> />
</div> </div>
{sheetSelection === 'scene-changes' ? (
<NumberField
label="Contact-sheet scene threshold"
value={sceneThreshold}
min={MIN_SCENE_CHANGE_THRESHOLD}
max={MAX_SCENE_CHANGE_THRESHOLD}
step={0.01}
onChange={setSceneThreshold}
/>
) : null}
<div className="advanced-check-grid"> <div className="advanced-check-grid">
<CheckField <CheckField
label="Maintain aspect ratio" label="Maintain aspect ratio"
@@ -1769,6 +1882,13 @@ function ImagePanel({
sourceLabel, sourceLabel,
format, format,
quality: Number(quality), quality: Number(quality),
selection:
sheetSelection === 'scene-changes'
? {
type: 'scene-changes',
threshold: Number(sceneThreshold),
}
: { type: 'evenly-spaced' },
}, },
'Contact sheet added to the local queue.' 'Contact sheet added to the local queue.'
) )

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import applicationLicenseUrl from '../../LICENSE?url&no-inline'; import applicationLicenseUrl from '../../LICENSE?url&no-inline';
import dejavuFontLicenseUrl from '../../LICENSES/DEJAVU_FONTS.txt?url&no-inline';
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline'; import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline'; import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline'; import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
@@ -7,7 +8,7 @@ import sourceRecordUrl from '../../SOURCE.md?url&no-inline';
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline'; import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
import { import {
APP_VERSION, APP_VERSION,
FFMPEG_CORE_VERSION, FFMPEG_CORE_BUILD_ID,
FFMPEG_ENGINE_VERSION, FFMPEG_ENGINE_VERSION,
FFMPEG_UTIL_VERSION, FFMPEG_UTIL_VERSION,
FFMPEG_WRAPPER_VERSION, FFMPEG_WRAPPER_VERSION,
@@ -101,7 +102,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
</div> </div>
<div> <div>
<dt>FFmpeg WASM cores</dt> <dt>FFmpeg WASM cores</dt>
<dd>{FFMPEG_CORE_VERSION} · ST &amp; MT</dd> <dd>{FFMPEG_CORE_BUILD_ID} · ST &amp; MT</dd>
</div> </div>
<div> <div>
<dt>ffmpeg.wasm utilities</dt> <dt>ffmpeg.wasm utilities</dt>
@@ -156,6 +157,16 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
GPL text GPL text
</a> </a>
</li> </li>
<li>
DejaVu Sans 2.37 Bitstream Vera and Arev font terms
<a
href={dejavuFontLicenseUrl}
target="_blank"
rel="noreferrer"
>
Font licence
</a>
</li>
<li> <li>
FFmpeg, linked codecs, Toolbox SDK, React and other runtime FFmpeg, linked codecs, Toolbox SDK, React and other runtime
packages retain their respective terms. packages retain their respective terms.

View File

@@ -3,6 +3,9 @@ import type { ImportedMediaAsset } from '../app/application-state';
import { import {
assessBrowserPlayback, assessBrowserPlayback,
assessGeneratedResultPreview, assessGeneratedResultPreview,
isAudioMimeType,
isVideoMimeType,
mimeTypeFromFileName,
type MediaProbe, type MediaProbe,
} from '../media'; } from '../media';
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform'; import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
@@ -47,15 +50,31 @@ export function MediaPreview({
const mediaElement = useRef<HTMLMediaElement>(null); const mediaElement = useRef<HTMLMediaElement>(null);
const sourceUrl = result?.objectUrl ?? asset?.objectUrl; const sourceUrl = result?.objectUrl ?? asset?.objectUrl;
const playbackFailed = sourceUrl === failedSourceUrl; 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 const hasVideo = result
? result.mimeType.startsWith('video/') ? result.mimeType.startsWith('video/')
: asset?.probe?.streams.some( : asset?.probe
? asset.probe.streams.some(
(stream) => (stream) =>
stream.type === 'video' && stream.disposition.attached_pic !== true stream.type === 'video' && stream.disposition.attached_pic !== true
) === true; )
: declaredSourceMimeType
? isVideoMimeType(declaredSourceMimeType)
: false;
const hasAudio = result const hasAudio = result
? result.mimeType.startsWith('audio/') ? 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 hasImage = result?.mimeType.startsWith('image/') === true;
const resultPlayback = useMemo(() => { const resultPlayback = useMemo(() => {
if (!result) return undefined; if (!result) return undefined;
@@ -186,7 +205,10 @@ export function MediaPreview({
? resultPlayback?.previewable === false ? resultPlayback?.previewable === false
? `Download only · ${result.name}` ? `Download only · ${result.name}`
: `Generated result · ${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'))}
</span> </span>
{needsProxy && !proxySettings ? ( {needsProxy && !proxySettings ? (
<button type="button" className="text-button" onClick={onCreateProxy}> <button type="button" className="text-button" onClick={onCreateProxy}>

View File

@@ -16,6 +16,7 @@ import {
presetAvailability, presetAvailability,
} from '../presets/preset-registry'; } from '../presets/preset-registry';
import { formatBytes, formatDuration } from '../app/application-state'; import { formatBytes, formatDuration } from '../app/application-state';
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
import { Icon } from './Icon'; import { Icon } from './Icon';
export type QuickOperation = 'convert' | 'remux'; export type QuickOperation = 'convert' | 'remux';
@@ -139,7 +140,8 @@ export function QuickConvert({
engineState.capabilities engineState.capabilities
) )
: undefined; : undefined;
const opusNeedsRegressionTest = preset?.audio?.codec === 'libopus'; const opusBlockedByPinnedCore =
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
const presetNeedsMissingAudio = const presetNeedsMissingAudio =
preset?.kind === 'audio' && effectiveSelection.audio.length === 0; preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) => const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
@@ -186,7 +188,7 @@ export function QuickConvert({
? !remuxMuxerMissing && remuxErrors.length === 0 ? !remuxMuxerMissing && remuxErrors.length === 0
: preset !== undefined && : preset !== undefined &&
availability?.status !== 'unavailable' && availability?.status !== 'unavailable' &&
!opusNeedsRegressionTest && !opusBlockedByPinnedCore &&
!presetNeedsMissingAudio); !presetNeedsMissingAudio);
const presetOption = (entry: ExportPreset) => { const presetOption = (entry: ExportPreset) => {
@@ -199,8 +201,8 @@ export function QuickConvert({
? presetAvailability({ requirements }, engineState.capabilities) ? presetAvailability({ requirements }, engineState.capabilities)
: undefined; : undefined;
const reason = const reason =
entry.audio?.codec === 'libopus' entry.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED
? 'Opus awaits a pinned-core regression test' ? 'the reviewed core has not passed the ST/MT Opus regression encode'
: entryAvailability?.status === 'unavailable' : entryAvailability?.status === 'unavailable'
? entryAvailability.reasons.join(', ') ? entryAvailability.reasons.join(', ')
: undefined; : undefined;
@@ -455,11 +457,11 @@ export function QuickConvert({
Select at least one output stream. Select at least one output stream.
</p> </p>
) : null} ) : null}
{opusNeedsRegressionTest ? ( {opusBlockedByPinnedCore ? (
<p className="notice notice--warning"> <p className="notice notice--warning">
<Icon name="warning" /> <Icon name="warning" />
Opus export is held back until the pinned core passes its browser Opus export is disabled until the reviewed core passes its non-empty
regression test. encode and re-probe gate in both engine modes.
</p> </p>
) : null} ) : null}

View File

@@ -244,7 +244,8 @@
font-weight: 740; font-weight: 740;
} }
.structural-field select { .structural-field select,
.structural-field input {
width: 100%; width: 100%;
min-height: 2.5rem; min-height: 2.5rem;
padding: 0.48rem 0.62rem; padding: 0.48rem 0.62rem;
@@ -402,6 +403,7 @@
} }
.structural-field select:focus-visible, .structural-field select:focus-visible,
.structural-field input:focus-visible,
.structural-operations input:focus-visible, .structural-operations input:focus-visible,
.structural-operations button:focus-visible { .structural-operations button:focus-visible {
outline: 3px solid var(--toolbox-focus); outline: 3px solid var(--toolbox-focus);

View File

@@ -2,7 +2,11 @@ import { useId, useState } from 'react';
import './StructuralOperations.css'; import './StructuralOperations.css';
export type StructuralCapabilityKey = export type StructuralCapabilityKey =
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized'; | 'trim-fast'
| 'trim-accurate'
| 'concat-fast'
| 'concat-normalized'
| 'concat-crossfade';
export interface StructuralCapabilityStatus { export interface StructuralCapabilityStatus {
readonly available: boolean; readonly available: boolean;
@@ -35,6 +39,7 @@ export interface StructuralTimelineClip {
readonly hasAudio: boolean; readonly hasAudio: boolean;
readonly hasVideo: boolean; readonly hasVideo: boolean;
readonly hasSubtitles: boolean; readonly hasSubtitles: boolean;
readonly durationSeconds?: number;
} }
export interface StructuralCompatibilityDiagnostic { export interface StructuralCompatibilityDiagnostic {
@@ -92,6 +97,7 @@ export type StructuralConcatRequest =
readonly presetId: string; readonly presetId: string;
readonly missingAudioPolicy: MissingAudioPolicy; readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy: NormalizedSubtitlePolicy; readonly subtitlePolicy: NormalizedSubtitlePolicy;
readonly crossfadeSeconds: number;
}; };
type RequestHandler<T> = (request: T) => void | Promise<void>; type RequestHandler<T> = (request: T) => void | Promise<void>;
@@ -135,6 +141,7 @@ export function StructuralOperations({
const [subtitlePolicy, setSubtitlePolicy] = useState< const [subtitlePolicy, setSubtitlePolicy] = useState<
NormalizedSubtitlePolicy | '' NormalizedSubtitlePolicy | ''
>(''); >('');
const [crossfadeSeconds, setCrossfadeSeconds] = useState('0');
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [status, setStatus] = useState<string>(); const [status, setStatus] = useState<string>();
@@ -156,6 +163,7 @@ export function StructuralOperations({
preset: concatPreset, preset: concatPreset,
missingAudioPolicy, missingAudioPolicy,
subtitlePolicy, subtitlePolicy,
crossfadeSeconds,
availability, availability,
connected: Boolean(onConcat), connected: Boolean(onConcat),
busy: busy || pending, busy: busy || pending,
@@ -445,6 +453,30 @@ export function StructuralOperations({
)} )}
onChange={setSubtitlePolicy} onChange={setSubtitlePolicy}
/> />
<label
className="structural-field"
htmlFor={`${componentId}-crossfade`}
>
<span>Crossfade duration</span>
<input
id={`${componentId}-crossfade`}
type="number"
aria-label="Crossfade duration"
min="0"
max="10"
step="0.04"
inputMode="decimal"
value={crossfadeSeconds}
disabled={busy || pending}
onChange={(event) =>
setCrossfadeSeconds(event.currentTarget.value)
}
/>
<small>
0 keeps hard cuts. A value from 0.0410 seconds overlaps video
and, when retained, audio at every edit.
</small>
</label>
</> </>
)} )}
@@ -488,6 +520,7 @@ export function StructuralOperations({
presetId: concatPreset.id, presetId: concatPreset.id,
missingAudioPolicy, missingAudioPolicy,
subtitlePolicy, subtitlePolicy,
crossfadeSeconds: Number(crossfadeSeconds),
}, },
'Normalized concat queued.' 'Normalized concat queued.'
); );
@@ -856,6 +889,7 @@ function reasonForConcat({
preset, preset,
missingAudioPolicy, missingAudioPolicy,
subtitlePolicy, subtitlePolicy,
crossfadeSeconds,
availability, availability,
connected, connected,
busy, busy,
@@ -865,6 +899,7 @@ function reasonForConcat({
readonly preset: StructuralExportPresetOption | undefined; readonly preset: StructuralExportPresetOption | undefined;
readonly missingAudioPolicy: MissingAudioPolicy | ''; readonly missingAudioPolicy: MissingAudioPolicy | '';
readonly subtitlePolicy: NormalizedSubtitlePolicy | ''; readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
readonly crossfadeSeconds: string;
readonly availability: StructuralOperationsProps['availability']; readonly availability: StructuralOperationsProps['availability'];
readonly connected: boolean; readonly connected: boolean;
readonly busy: boolean; readonly busy: boolean;
@@ -924,13 +959,39 @@ function reasonForConcat({
) { ) {
return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.'; return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.';
} }
const crossfade = Number(crossfadeSeconds);
if (
!Number.isFinite(crossfade) ||
crossfade < 0 ||
(crossfade > 0 && (crossfade < 0.04 || crossfade > 10))
) {
return 'Crossfade duration must be 0, or a value from 0.0410 seconds.';
}
if (crossfade > 0) {
for (const [index, clip] of timeline.clips.entries()) {
if (clip.durationSeconds === undefined) {
continue;
}
const required =
index === 0 || index === timeline.clips.length - 1
? crossfade
: crossfade * 2;
if (clip.durationSeconds < required) {
return `Clip ${index + 1} is too short for ${crossfade} second crossfades at its edit boundaries.`;
}
}
}
} }
if (!connected) { if (!connected) {
return 'This action has not been connected to the job runner.'; return 'This action has not been connected to the job runner.';
} }
return capabilityReason( const baseReason = capabilityReason(
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized'] availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
); );
if (baseReason || mode === 'fast' || Number(crossfadeSeconds) === 0) {
return baseReason;
}
return capabilityReason(availability?.['concat-crossfade']);
} }
function capabilityReason( function capabilityReason(

View File

@@ -32,6 +32,9 @@ import {
type FFmpegFactory = () => Promise<FFmpegAdapter>; type FFmpegFactory = () => Promise<FFmpegAdapter>;
export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000; export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000;
export const MIN_PROBE_TIMEOUT_MILLISECONDS = 30_000;
export const MAX_PROBE_TIMEOUT_MILLISECONDS = 5 * 60_000;
export const PROBE_BUDGET_BYTES_PER_SECOND = 2 * 1024 * 1024;
export const MULTITHREAD_CODEC_THREAD_LIMIT = 2; export const MULTITHREAD_CODEC_THREAD_LIMIT = 2;
/** /**
* The pinned Emscripten core can retain native heap state across heterogeneous * The pinned Emscripten core can retain native heap state across heterogeneous
@@ -55,6 +58,19 @@ export function shouldRecycleExecutionCore(
); );
} }
export function probeTimeoutMilliseconds(inputBytes: number): number {
if (!Number.isFinite(inputBytes) || inputBytes <= 0) {
return MIN_PROBE_TIMEOUT_MILLISECONDS;
}
const sizeAwareBudget = Math.ceil(
(inputBytes / PROBE_BUDGET_BYTES_PER_SECOND) * 1_000
);
return Math.min(
MAX_PROBE_TIMEOUT_MILLISECONDS,
Math.max(MIN_PROBE_TIMEOUT_MILLISECONDS, sizeAwareBudget)
);
}
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> { async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
const { FFmpeg } = await import('@ffmpeg/ffmpeg'); const { FFmpeg } = await import('@ffmpeg/ffmpeg');
return new FFmpeg(); return new FFmpeg();
@@ -521,8 +537,18 @@ export class EngineManager {
}; };
} }
async probe(file: File, timeoutMilliseconds = 30_000): Promise<ProbeResult> { async probe(
file: File,
timeoutMilliseconds = probeTimeoutMilliseconds(file.size),
signal?: AbortSignal
): Promise<ProbeResult> {
if (signal?.aborted) {
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
await this.initialize(); await this.initialize();
if (signal?.aborted) {
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
const engine = this.#engine; const engine = this.#engine;
const mode = this.#mode; const mode = this.#mode;
if (!engine || !mode || this.#state.status !== 'ready') { if (!engine || !mode || this.#state.status !== 'ready') {
@@ -537,6 +563,10 @@ export class EngineManager {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']); const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
if (signal?.aborted) {
await mounted.cleanup();
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
const probeOutput = mounted.outputPaths[0]; const probeOutput = mounted.outputPaths[0];
const inputPath = mounted.inputPaths[0]; const inputPath = mounted.inputPaths[0];
if (!probeOutput || !inputPath) { if (!probeOutput || !inputPath) {
@@ -565,7 +595,15 @@ export class EngineManager {
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr', type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
message: describeMountedInputAccess(mounted), message: describeMountedInputAccess(mounted),
}); });
const watchdog = setTimeout(() => this.cancelActive(), timeoutMilliseconds); let timedOut = false;
const watchdog = setTimeout(() => {
timedOut = true;
void this.cancelActive();
}, timeoutMilliseconds);
const cancelOnAbort = () => {
void this.cancelActive();
};
signal?.addEventListener('abort', cancelOnAbort, { once: true });
try { try {
const exitCode = await engine.ffprobe([ const exitCode = await engine.ffprobe([
'-v', '-v',
@@ -630,6 +668,13 @@ export class EngineManager {
}; };
} catch (error) { } catch (error) {
if (this.#cancelledJobIds.delete(activeProbeId)) { if (this.#cancelledJobIds.delete(activeProbeId)) {
if (timedOut) {
throw new EngineError(
'probe-failed',
`Media inspection exceeded its ${String(Math.ceil(timeoutMilliseconds / 1_000))}-second safety limit.`,
{ cause: error }
);
}
throw new EngineError('cancelled', 'The media probe was cancelled.', { throw new EngineError('cancelled', 'The media probe was cancelled.', {
cause: error, cause: error,
}); });
@@ -641,6 +686,7 @@ export class EngineManager {
details: this.#logs.text(), details: this.#logs.text(),
}); });
} finally { } finally {
signal?.removeEventListener('abort', cancelOnAbort);
clearTimeout(watchdog); clearTimeout(watchdog);
this.#activeJobId = null; this.#activeJobId = null;
this.#machineProgressLines = []; this.#machineProgressLines = [];

View File

@@ -1,5 +1,5 @@
import type { EngineMode } from './ffmpeg.types'; import type { EngineMode } from './ffmpeg.types';
import { FFMPEG_CORE_VERSION } from '../version'; import { FFMPEG_CORE_BUILD_ID } from '../version';
export function resolveFromAppBase( export function resolveFromAppBase(
relativePath: string, relativePath: string,
@@ -22,8 +22,8 @@ export function getCoreAssetUrls(
): CoreAssetUrls { ): CoreAssetUrls {
const directory = const directory =
mode === 'multithread' mode === 'multithread'
? `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/mt/` ? `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/mt/`
: `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/st/`; : `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/st/`;
const base = resolver(directory); const base = resolver(directory);
const urls: CoreAssetUrls = { const urls: CoreAssetUrls = {
coreURL: new URL('ffmpeg-core.js', base).href, coreURL: new URL('ffmpeg-core.js', base).href,

View File

@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.av-tools", "id": "de.add-ideas.av-tools",
"name": "Audio & Video 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.", "description": "Convert and lightly edit audio and video locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -48,10 +48,10 @@
} }
], ],
"assets": [ "assets": [
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm", "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js" "./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
] ]
} }

View File

@@ -1,5 +1,7 @@
export const APP_VERSION = '0.1.0'; export const APP_VERSION = '0.2.0';
export const FFMPEG_WRAPPER_VERSION = '0.12.15'; export const FFMPEG_WRAPPER_VERSION = '0.12.15';
export const FFMPEG_UTIL_VERSION = '0.12.2'; export const FFMPEG_UTIL_VERSION = '0.12.2';
export const FFMPEG_CORE_VERSION = '0.12.10'; export const FFMPEG_CORE_VERSION = '0.12.10';
export const FFMPEG_CORE_BUILD_ID = '0.12.10-reviewed-stack5m.1';
export const FFMPEG_ENGINE_VERSION = 'n5.1.4'; export const FFMPEG_ENGINE_VERSION = 'n5.1.4';
export const FFMPEG_OPUS_REGRESSION_VERIFIED = true;

View File

@@ -191,12 +191,12 @@ test.describe('nested static deployment and Toolbox context', () => {
const coreScript = runtime.responses.find((response) => const coreScript = runtime.responses.find((response) =>
response.url.endsWith( response.url.endsWith(
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js' '/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js'
) )
); );
const coreWasm = runtime.responses.find((response) => const coreWasm = runtime.responses.find((response) =>
response.url.endsWith( response.url.endsWith(
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm' '/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
) )
); );
expect(coreScript).toMatchObject({ expect(coreScript).toMatchObject({

View File

@@ -75,9 +75,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
); );
const expectedAssets = [ const expectedAssets = [
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js', '/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm', '/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js', '/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
]; ];
for (const suffix of expectedAssets) { for (const suffix of expectedAssets) {
expect( expect(
@@ -87,11 +87,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
).toBe(true); ).toBe(true);
} }
const wasm = runtime.responses.find((response) => const wasm = runtime.responses.find((response) =>
response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm') response.url.endsWith(
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm'
)
); );
expect(wasm?.contentType).toContain('application/wasm'); expect(wasm?.contentType).toContain('application/wasm');
expect( expect(
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/')) runtime.requests.some((url) =>
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/')
)
).toBe(false); ).toBe(false);
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE); await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
@@ -119,8 +123,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
page, page,
}) => { }) => {
const runtime = observeRuntime(page); const runtime = observeRuntime(page);
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) => await page.route(
route.abort('failed') '**/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/**',
(route) => route.abort('failed')
); );
await page.goto('/'); await page.goto('/');
const environment = await page.evaluate(() => ({ const environment = await page.evaluate(() => ({
@@ -147,13 +152,17 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
) )
).toBeVisible(); ).toBeVisible();
expect( expect(
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/')) runtime.requests.some((url) =>
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/')
)
).toBe(true); ).toBe(true);
expect( expect(
runtime.responses.some( runtime.responses.some(
(response) => (response) =>
response.status === 200 && response.status === 200 &&
response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') response.url.endsWith(
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
)
) )
).toBe(true); ).toBe(true);
expect(runtime.pageErrors).toEqual([]); expect(runtime.pageErrors).toEqual([]);
@@ -247,7 +256,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
expect( expect(
runtime.requests.some((url) => runtime.requests.some((url) =>
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') url.endsWith(
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
)
) )
).toBe(true); ).toBe(true);
assertLocalOnly(runtime); assertLocalOnly(runtime);
@@ -303,7 +314,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
).toHaveCount(4); ).toHaveCount(4);
expect( expect(
runtime.requests.filter((url) => runtime.requests.filter((url) =>
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') url.endsWith(
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
)
) )
).toHaveLength(2); ).toHaveLength(2);
assertLocalOnly(runtime); assertLocalOnly(runtime);
@@ -356,4 +369,43 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0);
assertLocalOnly(runtime); assertLocalOnly(runtime);
}); });
test('removes active and queued inspections without applying stale probe results', async ({
page,
}) => {
const runtime = observeRuntime(page);
await page.goto('/');
await selectSingleThread(page);
await mediaInput(page).setInputFiles([
cancellationFixture.filePath,
PRIMARY_FIXTURE,
]);
const activeName = basename(cancellationFixture.filePath);
const activeCard = page.locator('.media-card').filter({
hasText: activeName,
});
const queuedCard = page.locator('.media-card').filter({
hasText: 'pattern-av.mp4',
});
await expect(activeCard.locator('.phase--probing')).toBeVisible({
timeout: 30_000,
});
await expect(queuedCard.locator('.phase--queued')).toBeVisible();
await page.getByRole('button', { name: 'Remove pattern-av.mp4' }).click();
await page.getByRole('button', { name: `Remove ${activeName}` }).click();
await expect(page.locator('.media-card')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Choose files' })
).toBeEnabled({ timeout: 120_000 });
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
{ timeout: 120_000 }
);
await expect(page.getByRole('alert')).toHaveCount(0);
assertLocalOnly(runtime);
});
}); });

View File

@@ -0,0 +1,61 @@
/// <reference types="node" />
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { expect, test } from '@playwright/test';
const mediaPath = process.env.AV_TOOLS_LARGE_MEDIA;
test('plays a recognized large local video while inspection continues', async ({
page,
}) => {
test.skip(
!mediaPath || !existsSync(mediaPath),
'Set AV_TOOLS_LARGE_MEDIA to a local video for this opt-in evidence run.'
);
test.setTimeout(360_000);
await page.goto('/');
const startedAt = Date.now();
await page
.locator('input[type="file"][accept*="audio"]')
.first()
.setInputFiles(mediaPath as string);
const name = basename(mediaPath as string);
const card = page.locator('.media-card').filter({ hasText: name });
const preview = page.locator('.preview video');
await expect(preview).toBeVisible({ timeout: 15_000 });
const previewVisibleMilliseconds = Date.now() - startedAt;
expect(previewVisibleMilliseconds).toBeLessThan(15_000);
await expect(card.locator('.phase--probing')).toBeVisible();
const elementIdentity = await preview.evaluate((element) => {
element.dataset.evidenceIdentity = crypto.randomUUID();
return element.dataset.evidenceIdentity;
});
await preview.evaluate(async (element) => {
const video = element as HTMLVideoElement;
video.muted = true;
await video.play();
});
await expect
.poll(
() =>
preview.evaluate(
(element) => (element as HTMLVideoElement).currentTime
),
{
timeout: 15_000,
}
)
.toBeGreaterThan(0);
await expect(card.locator('.phase--ready')).toBeVisible({
timeout: 330_000,
});
await expect(preview).toHaveAttribute(
'data-evidence-identity',
elementIdentity
);
await expect(page.getByRole('alert')).toHaveCount(0);
});

View File

@@ -149,7 +149,7 @@ function probe(filePath: string): {
tags?: Record<string, string>; tags?: Record<string, string>;
disposition?: Record<string, number>; disposition?: Record<string, number>;
}>; }>;
format?: { tags?: Record<string, string> }; format?: { duration?: string; tags?: Record<string, string> };
chapters?: Array<{ tags?: Record<string, string> }>; chapters?: Array<{ tags?: Record<string, string> }>;
} { } {
return JSON.parse( return JSON.parse(
@@ -547,6 +547,31 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }), expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
]) ])
); );
await structural.getByLabel('Crossfade duration').fill('0.2');
const crossfadeAction = structural.getByRole('button', {
name: 'Export normalized concat',
});
if (await crossfadeAction.isEnabled()) {
const [crossfadeCard] = await runForResults(
page,
() => crossfadeAction.click(),
['compatible-a-concat.mp4'],
{ verifyMedia: true }
);
const crossfadeDuration = Number(
probe((await downloadResult(page, crossfadeCard as Locator)).filePath)
.format?.duration
);
expect(crossfadeDuration).toBeGreaterThan(1.35);
expect(crossfadeDuration).toBeLessThan(1.75);
} else {
await expect(
structural.getByText(
/required browser and FFmpeg capabilities have not been verified|filter (?:x|a)fade/iu
)
).toBeVisible();
}
}); });
const advanced = page.locator('.advanced-operations'); const advanced = page.locator('.advanced-operations');
@@ -687,6 +712,72 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
statSync((await downloadResult(page, sheetCard as Locator)).filePath) statSync((await downloadResult(page, sheetCard as Locator)).filePath)
.size .size
).toBeGreaterThan(500); ).toBeGreaterThan(500);
await importMedia(page, ['pattern-av.mp4']);
const patternClip = page
.locator('.timeline-clip')
.filter({ hasText: 'pattern-av.mp4' });
await patternClip.locator('.timeline-clip__main').click();
const imageSelection = advanced.getByLabel('Frame selection').first();
const sceneOption = imageSelection.locator(
'option[value="scene-changes"]'
);
if (!(await sceneOption.isDisabled())) {
await imageSelection.selectOption('scene-changes');
await advanced.getByLabel('Number of frames').fill('2');
await advanced.getByLabel('Scene sensitivity threshold').fill('0.01');
await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Generate thumbnail series' })
.click(),
[
'pattern-av-thumbnail-001-83ms.jpg',
'pattern-av-thumbnail-002-1000ms.jpg',
]
);
const contactSelection = advanced.getByLabel('Frame selection').nth(1);
await contactSelection.selectOption('scene-changes');
await advanced.getByLabel('Contact-sheet scene threshold').fill('0.01');
await advanced
.getByRole('spinbutton', { name: 'Frames', exact: true })
.fill('2');
await advanced
.getByRole('spinbutton', { name: 'Rows', exact: true })
.fill('1');
await advanced
.getByRole('spinbutton', { name: 'Columns', exact: true })
.fill('2');
const timestampLabels = advanced.getByLabel('Timestamp labels');
if (await timestampLabels.isEnabled()) {
await timestampLabels.check();
await advanced.getByLabel('Source filename label').check();
}
const [sceneSheetCard] = await runForResults(
page,
() =>
advanced
.getByRole('button', { name: 'Generate contact sheet' })
.click(),
['pattern-av-contact-sheet.jpg']
);
expect(
statSync(
(await downloadResult(page, sceneSheetCard as Locator)).filePath
).size
).toBeGreaterThan(500);
}
await patternClip.getByRole('button', { name: 'Remove clip' }).click();
await expect(patternClip).toHaveCount(0);
await page
.locator('.timeline-clip')
.filter({ hasText: 'compatible-a.mp4' })
.first()
.locator('.timeline-clip__main')
.click();
}); });
await test.step('single frame and a transformed timeline with audio/video fades', async () => { await test.step('single frame and a transformed timeline with audio/video fades', async () => {
@@ -988,7 +1079,9 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
expect( expect(
runtime.requests.some((url) => runtime.requests.some((url) =>
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') url.endsWith(
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
)
) )
).toBe(true); ).toBe(true);
assertLocalOnly(runtime); assertLocalOnly(runtime);

View File

@@ -28,6 +28,8 @@ import {
buildPeakNormalizationPlan, buildPeakNormalizationPlan,
buildPreviewProxyPlan, buildPreviewProxyPlan,
buildRemuxPlan, buildRemuxPlan,
buildSceneChangeAnalysisPlan,
buildSceneThumbnailSeriesPlan,
buildSingleThumbnailPlan, buildSingleThumbnailPlan,
buildSplitPlan, buildSplitPlan,
buildStaticWaveformPlan, buildStaticWaveformPlan,
@@ -44,6 +46,7 @@ import {
createConcatListEntries, createConcatListEntries,
parseLoudnessMeasurement, parseLoudnessMeasurement,
parsePeakMeasurement, parsePeakMeasurement,
parseSceneChangeFrames,
resolveThumbnailTimes, resolveThumbnailTimes,
streamMapArguments, streamMapArguments,
type ConcatSource, type ConcatSource,
@@ -63,8 +66,16 @@ const NATIVE_SUBTITLES_FILTER_AVAILABLE =
encoding: 'utf8', encoding: 'utf8',
}).stdout ?? '' }).stdout ?? ''
); );
const NATIVE_DRAWTEXT_FILTER_AVAILABLE =
NATIVE_FFMPEG_AVAILABLE &&
/\bdrawtext\b/u.test(
spawnSync('ffmpeg', ['-hide_banner', '-filters'], {
encoding: 'utf8',
}).stdout ?? ''
);
const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip; const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip;
const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip; const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip;
const itNativeDrawtext = NATIVE_DRAWTEXT_FILTER_AVAILABLE ? it : it.skip;
interface NativeExecution { interface NativeExecution {
readonly outputPaths: readonly string[]; readonly outputPaths: readonly string[];
@@ -752,6 +763,33 @@ describeNative('native execution of structured command plans', () => {
Number(probe(normalized.outputPaths[0]!).format?.duration) Number(probe(normalized.outputPaths[0]!).format?.duration)
).toBeCloseTo(1.6, 1); ).toBeCloseTo(1.6, 1);
const crossfaded = runNative(
buildNormalizedConcatPlan({
jobId: 'crossfade-concat-native',
sources: [
concatSource('first', 0, 'compatible-a.mp4', 128, 72),
concatSource('second', 1, 'compatible-b.mp4', 128, 72),
],
targetExtension: 'mp4',
targetMuxer: 'mp4',
preset: preset('mp4-h264-balanced'),
width: 128,
height: 72,
frameRate: 10,
sampleRate: 16_000,
channelLayout: 'mono',
missingAudioPolicy: 'reject',
crossfadeSeconds: 0.2,
}),
{
first: fixture('compatible-a.mp4'),
second: fixture('compatible-b.mp4'),
}
);
expect(
Number(probe(crossfaded.outputPaths[0]!).format?.duration)
).toBeCloseTo(1.8, 1);
const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4'); const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4');
nativeCommand( nativeCommand(
[ [
@@ -788,6 +826,7 @@ describeNative('native execution of structured command plans', () => {
sampleRate: 16_000, sampleRate: 16_000,
channelLayout: 'mono', channelLayout: 'mono',
missingAudioPolicy: 'insert-silence', missingAudioPolicy: 'insert-silence',
crossfadeSeconds: 0.2,
}), }),
{ first: fixture('compatible-a.mp4'), 'video-only': videoOnly } { first: fixture('compatible-a.mp4'), 'video-only': videoOnly }
); );
@@ -1185,8 +1224,80 @@ describeNative('native execution of structured command plans', () => {
codec_name: 'png', codec_name: 'png',
width: 166, width: 166,
}); });
const sceneAnalysis = runNative(
buildSceneChangeAnalysisPlan({
jobId: 'scene-analysis-native',
source: media,
durationSeconds: 2,
threshold: 0.01,
maxFrames: 4,
}),
{ media: mediaFile },
'info'
);
const sceneFrames = parseSceneChangeFrames(sceneAnalysis.stderr);
expect(sceneFrames.length).toBeGreaterThan(0);
const sceneSeries = runNative(
buildSceneThumbnailSeriesPlan({
jobId: 'scene-series-native',
source: media,
durationSeconds: 2,
frames: sceneFrames,
size: { width: 80 },
format: 'png',
}),
{ media: mediaFile }
);
expect(sceneSeries.outputPaths).toHaveLength(sceneFrames.length);
const sceneSheet = runNative(
buildContactSheetPlan({
jobId: 'scene-contact-sheet-native',
source: media,
durationSeconds: 2,
frameCount: sceneFrames.length,
rows: 1,
columns: sceneFrames.length,
cellWidth: 80,
spacing: 2,
sceneFrames,
format: 'png',
}),
{ media: mediaFile }
);
expect(statSync(sceneSheet.outputPaths[0]!).size).toBeGreaterThan(500);
}, 120_000); }, 120_000);
itNativeDrawtext(
'executes contact-sheet labels with the reviewed bundled font',
() => {
const media = source('media', 0, 'pattern-av.mp4');
const labeledSheet = runNative(
buildContactSheetPlan({
jobId: 'labeled-contact-sheet-native',
source: media,
durationSeconds: 2,
frameCount: 2,
rows: 1,
columns: 2,
cellWidth: 120,
timestampLabels: true,
sourceLabel: true,
drawtextAvailable: true,
font: source('font', 1, 'DejaVuSans.ttf'),
format: 'png',
}),
{
media: fixture('pattern-av.mp4'),
font: resolve(process.cwd(), 'public/fonts/DejaVuSans.ttf'),
}
);
expect(statSync(labeledSheet.outputPaths[0]!).size).toBeGreaterThan(500);
},
120_000
);
it('executes a transformed, faded, normalized sequential timeline', () => { it('executes a transformed, faded, normalized sequential timeline', () => {
const execution = runNative( const execution = runNative(
buildTimelineExportPlan({ buildTimelineExportPlan({

View File

@@ -283,6 +283,72 @@ describe('AdvancedOperations', () => {
); );
}); });
it('emits bounded scene analysis settings for series and contact sheets', async () => {
const user = userEvent.setup();
const onThumbnailSeries = vi.fn();
const onContactSheet = vi.fn();
render(
<AdvancedOperations
source={SOURCE}
availability={capabilities(
'thumbnail-series',
'contact-sheet',
'scene-change-analysis',
'contact-sheet-labels'
)}
imageFormatAvailability={{ jpeg: AVAILABLE }}
onThumbnailSeries={onThumbnailSeries}
onContactSheet={onContactSheet}
/>
);
await user.click(
screen.getByRole('tab', {
name: 'Thumbnails and contact sheets',
})
);
const selectionFields = screen.getAllByLabelText('Frame selection');
await user.selectOptions(selectionFields[0]!, 'scene-changes');
await user.clear(screen.getByLabelText('Scene sensitivity threshold'));
await user.type(
screen.getByLabelText('Scene sensitivity threshold'),
'0.42'
);
await user.click(
screen.getByRole('button', { name: 'Generate thumbnail series' })
);
await waitFor(() =>
expect(onThumbnailSeries).toHaveBeenCalledWith({
distribution: {
type: 'scene-changes',
count: 12,
threshold: 0.42,
},
width: 480,
format: 'jpeg',
quality: 85,
})
);
await user.selectOptions(selectionFields[1]!, 'scene-changes');
await user.clear(screen.getByLabelText('Contact-sheet scene threshold'));
await user.type(
screen.getByLabelText('Contact-sheet scene threshold'),
'0.3'
);
await user.click(
screen.getByRole('button', { name: 'Generate contact sheet' })
);
await waitFor(() =>
expect(onContactSheet).toHaveBeenCalledWith(
expect.objectContaining({
frameCount: 12,
selection: { type: 'scene-changes', threshold: 0.3 },
})
)
);
});
it('classifies bitmap subtitles conservatively before enabling extraction', async () => { it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSubtitleAction = vi.fn(); const onSubtitleAction = vi.fn();

View File

@@ -0,0 +1,31 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { BUNDLED_CONTACT_SHEET_FONT } from '../../src/assets/bundled-font';
describe('bundled contact-sheet font', () => {
it('matches its reviewed byte identity and ships the embedded licence', () => {
const bytes = readFileSync(
resolve(
process.cwd(),
'public/fonts',
BUNDLED_CONTACT_SHEET_FONT.fileName
)
);
expect(bytes.byteLength).toBe(BUNDLED_CONTACT_SHEET_FONT.byteLength);
expect(createHash('sha256').update(bytes).digest('hex')).toBe(
BUNDLED_CONTACT_SHEET_FONT.sha256
);
const licence = readFileSync(
resolve(process.cwd(), BUNDLED_CONTACT_SHEET_FONT.licenseFile),
'utf8'
);
expect(licence).toContain('Bitstream Vera Fonts Copyright');
expect(licence).toContain('Arev Fonts Copyright');
expect(licence).toContain(
'The above copyright and trademark notices and this permission notice'
);
});
});

View File

@@ -9,6 +9,8 @@ import {
buildPeakAnalysisPlan, buildPeakAnalysisPlan,
buildPeakNormalizationFilter, buildPeakNormalizationFilter,
buildPeakNormalizationPlan, buildPeakNormalizationPlan,
buildSceneChangeAnalysisPlan,
buildSceneThumbnailSeriesPlan,
buildSubtitleBurnPlan, buildSubtitleBurnPlan,
buildSubtitleExtractionPlan, buildSubtitleExtractionPlan,
buildSubtitleMuxPlan, buildSubtitleMuxPlan,
@@ -25,6 +27,7 @@ import {
parseChapterJson, parseChapterJson,
parseLoudnessMeasurement, parseLoudnessMeasurement,
parsePeakMeasurement, parsePeakMeasurement,
parseSceneChangeFrames,
parseMetadataJson, parseMetadataJson,
resolveThumbnailTimes, resolveThumbnailTimes,
serializeChapterJson, serializeChapterJson,
@@ -360,6 +363,65 @@ describe('thumbnail and contact sheet plans', () => {
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false); expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
}); });
it('analyzes a bounded number of scene changes and parses exact timestamps', () => {
const plan = buildSceneChangeAnalysisPlan({
jobId: 'scenes',
source: SOURCE,
durationSeconds: 10,
threshold: 0.35,
maxFrames: 12,
});
expect(plan.outputs).toEqual([]);
expect(plan.args).toEqual(
expect.arrayContaining([
"select='gt(scene\\,0.35)',showinfo",
'-frames:v',
'12',
'-f',
'null',
])
);
expect(plan.requiredCapabilities.filters).toEqual(['select', 'showinfo']);
expect(
parseSceneChangeFrames(`
[Parsed_showinfo_1 @ 0x1] n: 0 pts: 4096 pts_time:0.25 duration:512
n: 1 pts: 16384 pts_time:1 duration:512
[Parsed_showinfo_1 @ 0x1] n: 2 pts: 16384 pts_time:1 duration:512
invalid showinfo n: 3 pts: nope pts_time:2
`)
).toEqual([
{ pts: 4096, timeSeconds: 0.25 },
{ pts: 16384, timeSeconds: 1 },
]);
});
it('builds exact scene-selected thumbnails from analyzed PTS values', () => {
const plan = buildSceneThumbnailSeriesPlan({
jobId: 'scene-thumbs',
source: SOURCE,
durationSeconds: 10,
frames: [
{ pts: 4096, timeSeconds: 0.25 },
{ pts: 16384, timeSeconds: 1 },
],
format: 'png',
size: { width: 320 },
});
expect(plan.outputs.map((output) => output.fileName)).toEqual([
'source-thumbnail-001-250ms.png',
'source-thumbnail-002-1000ms.png',
]);
expect(plan.args.join(' ')).toContain(
"select='eq(pts\\,4096)+eq(pts\\,16384)',split=2"
);
expect(plan.requiredCapabilities.filters).toEqual([
'select',
'split',
'setpts',
'scale',
]);
});
it('degrades contact-sheet labels when drawtext is unavailable', () => { it('degrades contact-sheet labels when drawtext is unavailable', () => {
const plan = buildContactSheetPlan({ const plan = buildContactSheetPlan({
jobId: 'sheet', jobId: 'sheet',
@@ -381,6 +443,49 @@ describe('thumbnail and contact sheet plans', () => {
); );
expect(plan.requiredCapabilities.filters).not.toContain('drawtext'); expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
}); });
it('uses a mounted reviewed font and exact scene frames for labels', () => {
const plan = buildContactSheetPlan({
jobId: 'scene-sheet',
source: SOURCE,
durationSeconds: 10,
frameCount: 2,
rows: 1,
columns: 2,
cellWidth: 240,
timestampLabels: true,
sourceLabel: true,
format: 'jpeg',
drawtextAvailable: true,
font: {
id: 'bundled-font',
sourceIndex: 1,
fileName: 'DejaVuSans.ttf',
},
sceneFrames: [
{ pts: 4096, timeSeconds: 0.25 },
{ pts: 16384, timeSeconds: 1 },
],
});
expect(plan.inputs[1]).toMatchObject({
kind: 'font',
sourceIndex: 1,
});
expect(plan.args.join(' ')).toContain(
"select='eq(pts\\,4096)+eq(pts\\,16384)'"
);
expect(plan.args.join(' ')).toContain(
"drawtext=fontfile='/input/job-scene-sheet/source-1.ttf'"
);
expect(plan.args.join(' ')).toContain('nb_frames=2');
expect(plan.requiredCapabilities.filters).toEqual([
'select',
'scale',
'setpts',
'tile',
'drawtext',
]);
});
}); });
describe('composed visual operations', () => { describe('composed visual operations', () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
buildConvertPlan, buildConvertPlan,
buildFastConcatPlan, buildFastConcatPlan,
buildNormalizedConcatPlan,
buildPreviewProxyPlan, buildPreviewProxyPlan,
buildRemuxPlan, buildRemuxPlan,
buildSplitPlan, buildSplitPlan,
@@ -276,6 +277,64 @@ describe('concat and transform validation', () => {
expect(plan.expectedDurationSeconds).toBe(5); expect(plan.expectedDurationSeconds).toBe(5);
}); });
it('builds bounded chained audio/video crossfades for normalized concat', () => {
const preset = findBuiltInPreset('mp4-h264-balanced');
const sources = [
{ ...SOURCES[0]!, durationSeconds: 4 },
{ ...SOURCES[1]!, durationSeconds: 5 },
{
...SOURCES[1]!,
id: 'three',
sourceIndex: 2,
fileName: 'three.mp4',
durationSeconds: 3,
},
];
const plan = buildNormalizedConcatPlan({
jobId: 'crossfade',
sources,
targetExtension: 'mp4',
targetMuxer: 'mp4',
preset: preset!,
width: 640,
height: 360,
frameRate: 25,
sampleRate: 48_000,
channelLayout: 'stereo',
missingAudioPolicy: 'reject',
subtitlePolicy: 'reject',
crossfadeSeconds: 0.5,
});
const graph = plan.args[plan.args.indexOf('-filter_complex') + 1] ?? '';
expect(graph).toContain(
'[v0][v1]xfade=transition=fade:duration=0.5:offset=3.5[vxf1]'
);
expect(graph).toContain(
'[vxf1][v2]xfade=transition=fade:duration=0.5:offset=8[vout]'
);
expect(graph).toContain('[a0][a1]acrossfade=d=0.5:c1=tri:c2=tri[axf1]');
expect(graph).not.toContain('concat=n=3');
expect(plan.expectedDurationSeconds).toBe(11);
expect(plan.requiredCapabilities.filters).toEqual(
expect.arrayContaining(['xfade', 'acrossfade'])
);
expect(() =>
buildNormalizedConcatPlan({
jobId: 'too-long-crossfade',
sources: SOURCES,
targetExtension: 'mp4',
targetMuxer: 'mp4',
preset: preset!,
width: 640,
height: 360,
frameRate: 25,
missingAudioPolicy: 'reject',
subtitlePolicy: 'reject',
crossfadeSeconds: 2.5,
})
).toThrow(/Clip 1 needs at least/iu);
});
it('corrects crop to even bounds and calculates aspect-preserving resize', () => { it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
expect( expect(
validateCrop({ validateCrop({

View File

@@ -16,7 +16,9 @@ describe('HelpDialog', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText('n5.1.4')).toBeInTheDocument(); expect(screen.getByText('n5.1.4')).toBeInTheDocument();
expect(screen.getByText('0.12.15')).toBeInTheDocument(); expect(screen.getByText('0.12.15')).toBeInTheDocument();
expect(screen.getByText('0.12.10 · ST & MT')).toBeInTheDocument(); expect(
screen.getByText('0.12.10-reviewed-stack5m.1 · ST & MT')
).toBeInTheDocument();
expect( expect(
screen.getByText(/copyright © 2026 Albrecht Degering/i) screen.getByText(/copyright © 2026 Albrecht Degering/i)
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -29,6 +31,9 @@ describe('HelpDialog', () => {
expect( expect(
screen.getByRole('link', { name: 'GPL text', hidden: true }) screen.getByRole('link', { name: 'GPL text', hidden: true })
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later')); ).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
expect(
screen.getByRole('link', { name: 'Font licence', hidden: true })
).toHaveAttribute('href', expect.stringContaining('DEJAVU_FONTS'));
expect( expect(
screen.getByRole('link', { screen.getByRole('link', {
name: 'Complete inventory', name: 'Complete inventory',

View File

@@ -26,6 +26,41 @@ afterEach(() => {
}); });
describe('MediaPreview', () => { describe('MediaPreview', () => {
it('starts declared local video playback while inspection continues and preserves the element', () => {
const probingAsset: ImportedMediaAsset = {
id: 'asset-probing-video',
file: new File(['video'], 'lecture.mp4', {
type: 'application/octet-stream',
}),
objectUrl: 'blob:probing-video',
phase: 'probing',
};
const { container, rerender } = render(
<MediaPreview asset={probingAsset} />
);
const progressiveVideo = requiredElement(container.querySelector('video'));
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
expect(
screen.getByText(
'Local source preview · Media inspection continues in the background.'
)
).toBeVisible();
rerender(
<MediaPreview
asset={{
...videoAsset(),
id: probingAsset.id,
file: probingAsset.file,
objectUrl: probingAsset.objectUrl,
}}
/>
);
expect(container.querySelector('video')).toBe(progressiveVideo);
});
it('offers bounded configurable proxy settings after playback fails', async () => { it('offers bounded configurable proxy settings after playback fails', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onCreateProxy = vi.fn(); const onCreateProxy = vi.fn();

View File

@@ -138,6 +138,28 @@ describe('QuickConvert', () => {
).toBeDisabled(); ).toBeDisabled();
}); });
it('enables Opus after the reviewed core passed both regression modes', () => {
const engineState: EngineState = {
status: 'ready',
mode: 'single-thread',
capabilities: {
...capabilities(['mp4', 'opus']),
encoders: new Set(['libx264', 'aac', 'libopus']),
},
};
render(
<QuickConvert
asset={createAsset('asset-a', 'source-a.mp4')}
engineState={engineState}
busy={false}
onExport={vi.fn()}
/>
);
expect(screen.getByRole('option', { name: 'Opus' })).toBeEnabled();
});
it('exposes a validated user preset and returns it unchanged on export', async () => { it('exposes a validated user preset and returns it unchanged on export', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>(); const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();

View File

@@ -274,6 +274,7 @@ describe('StructuralOperations', () => {
presetId: 'video-ready', presetId: 'video-ready',
missingAudioPolicy: 'insert-silence', missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'reject', subtitlePolicy: 'reject',
crossfadeSeconds: 0,
}) })
); );
}); });
@@ -328,6 +329,55 @@ describe('StructuralOperations', () => {
presetId: 'video-ready', presetId: 'video-ready',
missingAudioPolicy: 'insert-silence', missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'drop-all', subtitlePolicy: 'drop-all',
crossfadeSeconds: 0,
})
);
});
it('emits a bounded crossfade only after its filters are verified', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={{
...TIMELINE,
clips: TIMELINE.clips.map((clip) => ({
...clip,
durationSeconds: 3,
})),
}}
exportPresets={PRESETS}
availability={capabilities('concat-normalized', 'concat-crossfade')}
onConcat={onConcat}
/>
);
await user.click(
screen.getByRole('radio', { name: /Normalized re-encode/iu })
);
await user.selectOptions(
screen.getByLabelText('Normalized concat preset'),
'video-ready'
);
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
await user.click(
screen.getByRole('radio', { name: /Reject subtitle input/iu })
);
await user.clear(screen.getByLabelText(/^Crossfade duration/iu));
await user.type(screen.getByLabelText(/^Crossfade duration/iu), '0.5');
await user.click(
screen.getByRole('button', { name: 'Export normalized concat' })
);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'normalized',
clipIds: ['clip-a', 'clip-b'],
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'reject',
crossfadeSeconds: 0.5,
}) })
); );
}); });

View File

@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { import {
DEFAULT_JOB_TIMEOUT_MILLISECONDS, DEFAULT_JOB_TIMEOUT_MILLISECONDS,
EngineManager,
MAX_CONTACT_SHEETS_PER_ENGINE, MAX_CONTACT_SHEETS_PER_ENGINE,
MAX_EXECUTIONS_PER_ENGINE, MAX_EXECUTIONS_PER_ENGINE,
MAX_PROBE_TIMEOUT_MILLISECONDS,
MIN_PROBE_TIMEOUT_MILLISECONDS,
MULTITHREAD_CODEC_THREAD_LIMIT, MULTITHREAD_CODEC_THREAD_LIMIT,
engineJobTimeoutMilliseconds, engineJobTimeoutMilliseconds,
prepareEngineArguments, prepareEngineArguments,
probeTimeoutMilliseconds,
shouldRecycleExecutionCore, shouldRecycleExecutionCore,
} from '../../src/ffmpeg/EngineManager'; } from '../../src/ffmpeg/EngineManager';
@@ -90,6 +94,39 @@ describe('engineJobTimeoutMilliseconds', () => {
}); });
}); });
describe('probeTimeoutMilliseconds', () => {
it('keeps small probes responsive, scales real media, and caps the watchdog', () => {
expect(probeTimeoutMilliseconds(34_057)).toBe(
MIN_PROBE_TIMEOUT_MILLISECONDS
);
expect(probeTimeoutMilliseconds(153_019_068)).toBe(72_966);
expect(probeTimeoutMilliseconds(2 * 1024 * 1024 * 1024)).toBe(
MAX_PROBE_TIMEOUT_MILLISECONDS
);
});
it('rejects an already-cancelled probe before loading an engine', async () => {
const factory = vi.fn(async () => {
throw new Error('The engine factory must not be called.');
});
const manager = new EngineManager(factory);
const controller = new AbortController();
controller.abort();
await expect(
manager.probe(
new File(['media'], 'cancelled.mp4', { type: 'video/mp4' }),
undefined,
controller.signal
)
).rejects.toMatchObject({
code: 'cancelled',
message: 'The media probe was cancelled.',
});
expect(factory).not.toHaveBeenCalled();
});
});
describe('execution core recycling policy', () => { describe('execution core recycling policy', () => {
it('refreshes only after the bounded idle-core job budget is exhausted', () => { it('refreshes only after the bounded idle-core job budget is exhausted', () => {
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe( expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(

View File

@@ -20,8 +20,12 @@ describe('FFmpeg runtime utilities', () => {
'multithread', 'multithread',
(path) => `https://tools.example/deep/${path}` (path) => `https://tools.example/deep/${path}`
); );
expect(mt.coreURL).toContain('/0.12.10/mt/ffmpeg-core.js'); expect(mt.coreURL).toContain(
expect(mt.workerURL).toContain('/0.12.10/mt/ffmpeg-core.worker.js'); '/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js'
);
expect(mt.workerURL).toContain(
'/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js'
);
}); });
it('clamps experimental progress and removes invalid numbers', () => { it('clamps experimental progress and removes invalid numbers', () => {

View File

@@ -165,30 +165,30 @@ describe('licence release gate', () => {
describe('release checksum', () => { describe('release checksum', () => {
it('formats and parses the portable sidecar form', () => { it('formats and parses the portable sidecar form', () => {
const digest = 'a'.repeat(64); const digest = 'a'.repeat(64);
const line = checksumLine(digest, 'av-tools-0.1.0.zip'); const line = checksumLine(digest, 'av-tools-0.2.0.zip');
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`); expect(line).toBe(`${digest} av-tools-0.2.0.zip\n`);
expect(parseChecksumLine(line)).toEqual({ expect(parseChecksumLine(line)).toEqual({
archiveName: 'av-tools-0.1.0.zip', archiveName: 'av-tools-0.2.0.zip',
digest, digest,
}); });
}); });
it('writes and verifies a checksum sidecar', async () => { it('writes and verifies a checksum sidecar', async () => {
const directory = await temporaryDirectory(); const directory = await temporaryDirectory();
const archive = path.join(directory, 'av-tools-0.1.0.zip'); const archive = path.join(directory, 'av-tools-0.2.0.zip');
await writeFile(archive, 'deterministic bytes'); await writeFile(archive, 'deterministic bytes');
const result = await writeChecksum(archive); const result = await writeChecksum(archive);
expect(await verifyChecksum(archive, result.sidecarPath)).toBe( expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
result.digest result.digest
); );
expect(await readFile(result.sidecarPath, 'utf8')).toContain( expect(await readFile(result.sidecarPath, 'utf8')).toContain(
' av-tools-0.1.0.zip' ' av-tools-0.2.0.zip'
); );
}); });
it('rejects checksum mismatches', async () => { it('rejects checksum mismatches', async () => {
const directory = await temporaryDirectory(); const directory = await temporaryDirectory();
const archive = path.join(directory, 'av-tools-0.1.0.zip'); const archive = path.join(directory, 'av-tools-0.2.0.zip');
const sidecar = `${archive}.sha256`; const sidecar = `${archive}.sha256`;
await writeFile(archive, 'changed'); await writeFile(archive, 'changed');
await mkdir(path.dirname(sidecar), { recursive: true }); await mkdir(path.dirname(sidecar), { recursive: true });
@@ -207,7 +207,7 @@ describe('portal assembly smoke validation', () => {
add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
types { application/wasm wasm; } types { application/wasm wasm; }
# vendor/ffmpeg/0.12.10 "~^/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/(?:st|mt)/ffmpeg-core\\.(?:js|wasm)$" "public, max-age=31536000, immutable";
`; `;
it('accepts the required isolation, CSP and MIME profile', () => { it('accepts the required isolation, CSP and MIME profile', () => {
@@ -226,6 +226,15 @@ describe('portal assembly smoke validation', () => {
); );
}); });
it('does not mistake a plain-semver cache rule for reviewed-build coverage', () => {
const inspection = inspectPortalHeaders(
isolatedHeaders.replace('0.12.10-reviewed-stack5m.1', '0.12.10')
);
expect(inspection.warnings).toContainEqual(
expect.stringMatching(/reviewed FFmpeg build/u)
);
});
it('parses only explicit smoke overrides and paths', () => { it('parses only explicit smoke overrides and paths', () => {
const parsed = parsePortalArguments( const parsed = parsePortalArguments(
[ [

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { SourceInspectionRegistry } from '../../src/app/source-inspection-registry';
describe('SourceInspectionRegistry', () => {
it('invalidates queued work synchronously and does not request cancellation', () => {
const registry = new SourceInspectionRegistry();
const token = registry.register('queued');
expect(registry.remove('queued')).toBe(false);
expect(registry.isCurrent(token)).toBe(false);
expect(registry.activate(token)).toBe(false);
expect(token.signal.aborted).toBe(true);
});
it('requests cancellation for the active asset and rejects its late result', () => {
const registry = new SourceInspectionRegistry();
const token = registry.register('active');
expect(registry.activate(token)).toBe(true);
expect(registry.remove('active')).toBe(true);
expect(registry.isCurrent(token)).toBe(false);
expect(token.signal.aborted).toBe(true);
registry.finish(token);
expect(registry.remove('active')).toBe(false);
});
it('does not let a late generation overwrite a replacement with the same ID', () => {
const registry = new SourceInspectionRegistry();
const stale = registry.register('reattached');
expect(registry.activate(stale)).toBe(true);
const replacement = registry.register('reattached');
expect(registry.isCurrent(stale)).toBe(false);
expect(registry.isCurrent(replacement)).toBe(true);
expect(stale.signal.aborted).toBe(true);
expect(replacement.signal.aborted).toBe(false);
registry.finish(stale);
expect(registry.activate(replacement)).toBe(true);
});
it('invalidates all queued work and identifies an active probe on clear', () => {
const registry = new SourceInspectionRegistry();
const active = registry.register('active');
const queued = registry.register('queued');
registry.activate(active);
expect(registry.clear()).toBe(true);
expect(registry.isCurrent(active)).toBe(false);
expect(registry.isCurrent(queued)).toBe(false);
});
});

Binary file not shown.

View File

@@ -24,6 +24,11 @@ export default defineConfig({
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',
css: true, css: true,
restoreMocks: true, restoreMocks: true,
exclude: ['tests/browser/**', 'node_modules/**', 'dist/**'], exclude: [
'tests/browser/**',
'node_modules/**',
'dist/**',
'.core-build/**',
],
}, },
}); });