2 Commits

Author SHA1 Message Date
6c68f9bfbd chore: migrate AV Tools to LocalToolBox 2026-07-27 15:50:55 +02:00
0a4548851c feat: publish av-tools 0.2.0 2026-07-27 01:04:21 +02:00
72 changed files with 4202 additions and 642 deletions

2
.gitignore vendored
View File

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

2
.npmrc
View File

@@ -1 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/

View File

@@ -3,6 +3,42 @@
All notable changes to av-tools are documented here. The project follows
semantic versioning once a first release is legally publishable.
## 0.2.1 — 2026-07-27
- Move the canonical source, manifest schema links, and package registry from
the personal `zemion` namespace to the `lotobo` (LocalToolBox) Gitea
organization.
- Upgrade the Toolbox contract, React shell, and validation testkit to 0.2.3,
republished from the organization-owned registry without functional shell
changes.
- Replace the generic starter favicon with original add·ideas
audio/video file artwork.
## 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
- 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
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util
source.
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2. The published
`@ffmpeg/core` and `@ffmpeg/core-mt` 0.12.10 package manifests declare
`GPL-2.0-or-later`; “or later” comes from those manifests. The legal code
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2 for GPL-enabled FFmpeg
and linked GPL components in the reviewed WebAssembly cores. The legal code
was checked against the standard GPL-2.0 text in the installed Freedesktop
SDK licence corpus and the SPDX licence-list text.
- `CC0-1.0.txt` applies only to generated bytes under

121
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
under Toolbox Portal with shared shell, theme, help and app switching.
> **Release status:** 0.1.0 is licensed GPL-3.0-or-later. Its canonical
> release consists of the exact reviewed `v0.1.0` source, production
> **Release status:** 0.2.1 is licensed GPL-3.0-or-later. Its canonical
> release consists of the exact reviewed `v0.2.1` source, production
> ZIP/checksum and Corresponding Source bundle published together. See
> [Licensing](#licensing).
@@ -23,7 +23,7 @@ Two workflows keep the product intentionally smaller than a nonlinear editor:
- **Edit:** a media bin and sequential non-overlapping clip timeline with
non-destructive settings, one export at a time.
The planned 0.1 line covers typed/capability-gated conversion and remux,
The 0.2 release covers typed/capability-gated conversion and remux,
fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
resize, frame-rate change, audio extraction/stream removal, waveform,
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
@@ -34,10 +34,18 @@ diagnostics and explicit browser export.
The Edit workspace now exposes structural exports directly: fast stream-copy
or preset-backed accurate trim uses the selected clip's timeline range, while
fast concatenate requires explicit compatibility diagnostics and normalized
concatenate requires a preset plus an explicit missing-audio policy.
concatenate requires a preset plus an explicit missing-audio policy. Normalized
joins can apply a uniform, capability-gated 0.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
scheduled operations and one FFmpeg execution at a time.
Thumbnail series and contact sheets support even, interval, chapter, marker or
bounded scene-change selection. Scene analysis records exact source
presentation timestamps before extraction. Contact-sheet labels use only the
bundled, SHA-256-verified DejaVu Sans 2.37 file and remain disabled unless the
active core exposes `drawtext`; there is no remote/system-font fallback.
When a source cannot be played directly, the preview panel can create a
disposable H.264/AAC MP4 proxy. The user can bound it to 1300 seconds and
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/util` 0.12.2;
- `@ffmpeg/core` 0.12.10 (single-thread);
- `@ffmpeg/core-mt` 0.12.10 (multithread).
- reviewed FFmpeg core build `0.12.10-reviewed-stack5m.1` in single-thread and
multithread profiles.
The published cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
The reviewed cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp,
FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their
JS/worker files are copied from exact npm packages and served from the app's
own origin. `npm run build` does not download them.
JS/worker files are verified against a tracked build lock, unpacked from a
deterministic repository artifact and served from the app's own origin.
`npm run build` does not download them.
Every engine load queries actual demuxers, muxers, decoders, encoders and
filters. Presets remain disabled when requirements are absent; an extension or
@@ -92,10 +101,12 @@ WebM/VP8/Vorbis, MP3 and WAV are common preview candidates, but actual codec,
browser and platform support is checked at runtime. Matroska and many other
valid outputs may be download-only. A preview proxy may require re-encoding.
The pinned core has an unresolved Opus encoding regression risk (zero-byte or
out-of-bounds behavior reported around upstream stack-size fixes). Opus
presets must stay unavailable unless a real non-empty, re-probeable ST/MT smoke
passes. Details are in [FFmpeg build](docs/FFMPEG_BUILD.md).
The 2026-07-26 Chromium regression harness ran the real built-in `audio-opus`
plan against both reviewed cores. Each selected the requested ST/MT mode,
produced non-empty Opus and passed codec/duration re-probing. Opus presets are
therefore enabled when runtime capability discovery also succeeds. Exact
commands and evidence boundaries are in
[FFmpeg build](docs/FFMPEG_BUILD.md).
## Execution modes
@@ -156,7 +167,7 @@ the conditional native command-plan integration suite; it is not a production
runtime dependency.
```sh
git clone git@git.add-ideas.de:zemion/av-tools.git
git clone git@git.add-ideas.de:lotobo/av-tools.git
cd av-tools
npm ci
npm run dev
@@ -169,9 +180,14 @@ Core assets are generated local build inputs:
```sh
npm run vendor:ffmpeg
npm run vendor:verify
npm run vendor:verify-runtime
```
The runtime verifier checks the tracked deterministic core artifact, source
lock, build driver and generated same-origin files. The stricter
`npm run vendor:verify` publication gate additionally requires and hashes the
ignored Corresponding Source archive generated by `npm run source:package`.
Regenerate or verify the tiny synthetic fixtures:
```sh
@@ -183,7 +199,7 @@ npm run fixtures:verify
```sh
npm run manifest:check
npm run vendor:verify
npm run vendor:verify-runtime
npm run typecheck
npm run lint
npm run format:check
@@ -191,6 +207,8 @@ npm test
npm run build
npm run toolbox:check
npm run test:browser
npm run evidence:large-preview # set AV_TOOLS_LARGE_MEDIA first
npm run evidence:opus
```
`npm run check` combines the non-browser production checks. Browser tests cover
@@ -198,13 +216,27 @@ the real pinned cores, MT-to-ST fallback, cancellation/recovery, generated
fixtures, nested deployment, Toolbox context and the absence of runtime CDN
traffic.
The latest recorded local Vitest run on 2026-07-24 completed **303 passing
tests and one conditional skip**. It includes broad structured-command
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in case
requires a `subtitles` filter unavailable in that host build.
`npm run evidence:opus` independently contains each ST/MT browser process,
prints one JSON report and exits normally even when the result is unsafe.
`npm run test:opus-regression` runs the same evidence as the positive Opus
enablement gate and exits nonzero unless both modes produce non-empty,
re-probeable Opus. It is a mandatory release gate for the reviewed core
profile.
`AV_TOOLS_LARGE_MEDIA=/absolute/video.mp4 npm run evidence:large-preview`
performs the opt-in large-file regression: the native video element must mount
and advance before bounded inspection finishes, remain the same element after
the authoritative probe completes and produce no browser error.
The latest recorded local Vitest run on 2026-07-26 completed **313 passing
tests and two conditional skips**. It includes broad structured-command
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in and
labeled contact-sheet cases require `subtitles` and `drawtext` filters that are
unavailable in that host build.
The Chromium suite exercises the pinned ST core across conversion,
trim/concat/split, analysis, waveform, image, metadata, chapter, subtitle,
trim/concat/split, bounded crossfades, analysis, waveform, scene-selected image
generation, bundled-font contact-sheet labels, metadata, chapter, subtitle,
queue and recovery workflows. The MT core is directly initialized and queried,
and completes a real non-empty, re-probeable H.264/AAC conversion; the same
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
@@ -243,12 +275,12 @@ repository and the `GPL-3.0-or-later` SPDX licence.
Portal must consume an independently built ZIP; it must not build or import
av-tools source. Immutable Portal revision
`bda9da044d61c109afd9969d357cc27f76648938` has isolation and WASM CSP
permissions but lacks `media-src blob:`, which blocks Blob previews. The
developer's active Toolbox Portal worktree currently contains the narrow local
`media-src 'self' blob:` patch; that uncommitted worktree state does not change
the immutable reference or release evidence. The exact distinction, smallest
change and fallback are documented in
`5c052bc6d1073a66600cfddc44c0e16da34bede8` (`v0.6.0`) provides the required
isolation, narrow WebAssembly CSP, Blob media policy and explicit WASM MIME
mapping. The active Portal worktree additionally extends immutable caching to
the exact reviewed FFmpeg build profile without accepting mutable-looking
suffixes. Its exact reviewed and staged
boundaries are documented in
[Portal requirements](docs/PORTAL_REQUIREMENTS.md).
## Release
@@ -272,11 +304,11 @@ metadata:
```sh
npm run package:release
npm run portal:smoke -- \
--allow-known-header-gap
--portal-root ../toolbox-portal
```
The header-gap override remains development-only until the Portal CSP change is
committed. Full commands and safety behavior are in
The smoke harness clones the selected Portal `HEAD`; it never consumes
uncommitted Portal files silently. Full commands and safety behavior are in
[release documentation](docs/RELEASE.md).
## Licensing
@@ -286,11 +318,11 @@ and licensed **GPL-3.0-or-later**. See [LICENSE](LICENSE). The application
provides a visible licence, source and no-warranty notice in its Help dialog.
Third-party components retain their copyright and licence terms.
The wrapper/util are MIT. Core/core-mt 0.12.10 declare
GPL-2.0-or-later and statically include FFmpeg/external codec code with
additional terms. The branch-based x264/LAME inputs have been resolved to
immutable commits in the source record. A binary publication must include
equivalent access to the reviewed Corresponding Source and notices. See:
The wrapper/util are MIT. The reviewed 0.12.10 cores statically include
GPL-enabled FFmpeg/external codec code with additional terms. The branch-based
x264/LAME inputs have been resolved to immutable commits in the source record.
A binary publication must include equivalent access to the reviewed
Corresponding Source and notices. See:
- [third-party notices](THIRD_PARTY_NOTICES.md)
- [source record](SOURCE.md)
@@ -317,19 +349,18 @@ The auditable list is in
- Metadata removal is best effort, not an anonymity guarantee.
- Browser preview supports fewer combinations than FFmpeg.
- MT depends on isolation; ST can still hit memory/time limits.
- Opus output is release-gated for the pinned core.
- Opus enablement is supported by Chromium ST/MT fixture evidence, not a
cross-browser or arbitrary-input guarantee.
- Projects do not contain source bytes by default and require reattachment.
- Cross-browser/mobile and exhaustive MT performance matrices remain
unrecorded; published support claims must stay within the tested evidence.
## Roadmap
1. Complete Quick Convert and structural-export browser verification with the
real pinned ST/MT cores, including fallback and queued execution.
2. Verify waveform, loudness normalization and combined filters in browsers.
3. Verify metadata/chapter/subtitle round trips in browsers.
4. Verify bounded thumbnails/contact sheets, projects, derivative-cache reuse
and export in browsers.
5. Complete accessibility, mobile, performance and nested Portal matrices.
6. Commit/tag exact source, publish its reviewed Corresponding Source, assemble
the exact production ZIP through Portal and verify deployed headers.
1. Extend the evidence matrix beyond Chromium to Firefox, Safari and mobile;
broaden MT operation parity and replace the one-run performance observation
with repeated cold/warm measurements.
2. Evaluate optional editing breadth without weakening local-first guarantees:
additional reviewed transition shapes, richer scene-ranking controls and
explicit transition audio-curve choices beyond the current bounded uniform
crossfade.

View File

@@ -1,17 +1,17 @@
# 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.1. It is not a
substitute for satisfying a component's licence or for delivering complete
Corresponding Source with an object-code release.
## av-tools
- Canonical repository:
`git@git.add-ideas.de:zemion/av-tools.git`
- Browser source: `https://git.add-ideas.de/zemion/av-tools`
- Version: `0.1.0`
- Release source identity: immutable annotated tag `v0.1.0`
(`refs/tags/v0.1.0`) in the canonical repository.
`git@git.add-ideas.de:lotobo/av-tools.git`
- Browser source: `https://git.add-ideas.de/lotobo/av-tools`
- Version: `0.2.1`
- Release source identity: immutable annotated tag `v0.2.1`
(`refs/tags/v0.2.1`) in the canonical repository.
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
`LICENSE`.
- Copyright: Copyright © 2026 Albrecht Degering.
@@ -22,10 +22,10 @@ the tag exists, is annotated, points to the reviewed release commit and is
available without authentication:
```sh
test "$(git cat-file -t v0.1.0)" = tag
git rev-parse v0.1.0^{commit}
test "$(git cat-file -t v0.2.1)" = tag
git rev-parse v0.2.1^{commit}
git ls-remote --tags \
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.1.0
https://git.add-ideas.de/lotobo/av-tools.git refs/tags/v0.2.1
```
The Toolbox manifest names the canonical repository and SPDX licence. Publish
@@ -40,34 +40,66 @@ the tag/source archive beside the binary release and checksum.
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` |
| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` |
Install exact packages and recreate the copied same-origin assets without a
Install exact packages and recreate the verified same-origin assets without a
network fetch during the production build:
```sh
npm ci
npm run vendor:ffmpeg
npm run vendor:verify
npm run vendor:verify-runtime
npm run build
```
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the
installed core packages; it does not download them. The copied bytes are:
`scripts/copy-ffmpeg-assets.mjs` fails closed against
`scripts/reviewed-ffmpeg-core-lock.json`, verifies the deterministic tracked
ZIP and unpacks only its five declared entries. It does not download them or
fall back to published core packages. The reviewed bytes are:
| Release path | Bytes | SHA-256 |
| ------------------------------------------------ | -------: | ------------------------------------------------------------------ |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
For a publication check, generate the build-qualified Corresponding Source
archive and run `npm run vendor:verify`; that stricter gate hashes the archive
as well as every runtime input.
| Release path | Bytes | SHA-256 |
| ---------------------------------------------------------------------------- | -------: | ------------------------------------------------------------------ |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
| `vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip` (deterministic artifact) | 21338974 | `ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f` |
## Bundled contact-sheet font
Contact-sheet labels use one unmodified DejaVu Sans 2.37 TrueType file. It is
served same-origin and mounted read-only for the duration of a labelled job;
the browser verifies its size and SHA-256 before use.
| Release path | Bytes | SHA-256 |
| --------------------------- | ------: | ------------------------------------------------------------------ |
| `fonts/DejaVuSans.ttf` | 757,076 | `7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954` |
| `LICENSES/DEJAVU_FONTS.txt` | n/a | licence text embedded in the reviewed font and official archive |
The byte-for-byte source is `ttf/DejaVuSans.ttf` in the official
`dejavu-fonts-ttf-2.37.tar.bz2` release:
- project download record: `https://dejavu-fonts.github.io/Download.html`;
- archive URL:
`https://downloads.sourceforge.net/project/dejavu/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2`;
- archive SHA-256:
`fa9ca4d13871dd122f61258a80d01751d603b4d3ee14095d65453b4e846e17d7`;
- corresponding upstream tag `version_2_37`, commit
`0eda8a319c08835009849583cd090bb5b141ce25`.
The release archive and the bundled file were independently hashed and the
font's internal version string is `Version 2.37`. The file is not modified or
subsetted. Its Bitstream Vera/Arev permission terms are reproduced verbatim in
`LICENSES/DEJAVU_FONTS.txt`.
## Corresponding ffmpeg.wasm and FFmpeg source
- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15,
`@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10:
- Base ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15 and the
source configuration for core 0.12.10:
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
@@ -88,6 +120,14 @@ The upstream build control files are `Dockerfile`, `Makefile`,
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
`make prd-mt` builds multithread.
The shipped profile is rebuilt by
`scripts/build-reviewed-ffmpeg-core.sh`. It applies upstream ffmpeg.wasm commit
`b409e36475bc21f0451b5b1e1d126fa82871439a` (`-sSTACK_SIZE=5MB`), uses exact
Emscripten 3.1.40 and CMake 3.27.9 identities, makes upstream container-stage
isolation explicit and refuses maintainer-local paths in output. The exact
source archive, toolchain, patch, asset and Opus-gate identities are locked in
`scripts/reviewed-ffmpeg-core-lock.json`.
The Dockerfile uses branch names for two inputs. Repository history resolves
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
changed since:
@@ -121,10 +161,13 @@ npm run source:package
```
The command writes
`release/ffmpeg-core-0.12.10-corresponding-source.tar.xz` and its
`release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz`
and its
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
The npm packages contain the resulting binaries but not every linked
component's source and legal file, so publish this reviewed bundle and the
The reviewed archive for this build profile is 43,050,052 bytes with SHA-256
`2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a`.
The reviewed object-code archive does not contain every linked component's
source and legal file, so publish this Corresponding Source bundle and the
notices beside the object code. A homepage link alone is not asserted to
satisfy GPL source obligations.

View File

@@ -1,6 +1,6 @@
# Third-party notices
av-tools 0.1.0 is Copyright © 2026 Albrecht Degering and licensed
av-tools 0.2.1 is Copyright © 2026 Albrecht Degering and licensed
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
inputs. It is informational, not legal advice, and does not replace their
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
@@ -9,30 +9,33 @@ receives licence files shipped by installed runtime npm packages under
## Browser runtime
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
| -------------------------------- | ------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
| `@ffmpeg/core` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | single-thread FFmpeg JavaScript and WebAssembly | shipped verbatim as self-hosted assets |
| `@ffmpeg/core-mt` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | multithread FFmpeg JavaScript, WebAssembly and pthread worker | shipped verbatim as self-hosted assets |
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
| -------------------------------- | -------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | `https://git.add-ideas.de/lotobo/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | `https://git.add-ideas.de/lotobo/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
| reviewed FFmpeg WASM cores | 0.12.10-reviewed-stack5m.1 | GPL-enabled combined work | `https://github.com/ffmpegwasm/ffmpeg.wasm` | ST/MT FFmpeg JavaScript, WebAssembly and pthread worker | rebuilt from locked source and shipped as verified assets |
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
| DejaVu Sans | 2.37 | Bitstream Vera/Arev terms | `https://dejavu-fonts.github.io/` | deterministic local contact-sheet labels | shipped unmodified as reviewed TTF |
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
The MIT text inspected in the ffmpeg.wasm source is
`LICENSES/ffmpeg.wasm-MIT.txt`. The core npm tarballs do not contain a licence
file, but their package manifests declare `GPL-2.0-or-later`;
`LICENSES/GPL-2.0-or-later.txt` reproduces GPL version 2. These notices do not
make the complete application “MIT-only.”
`LICENSES/ffmpeg.wasm-MIT.txt`. The reviewed cores enable GPL FFmpeg features
and statically link GPL components; `LICENSES/GPL-2.0-or-later.txt` reproduces
GPL version 2. These notices do not make the complete application “MIT-only.”
The reviewed DejaVu file is identified byte-for-byte in `SOURCE.md`; its full
embedded terms are in `LICENSES/DEJAVU_FONTS.txt`. No remote or system font is
used as a runtime fallback.
## Native components linked into the WebAssembly cores
The following list is derived from the `v12.15` ffmpeg.wasm Dockerfile, the
configuration embedded in the copied 0.12.10 WASM and the resolved histories
configuration embedded in the reviewed 0.12.10 WASM and the resolved histories
of its source repositories. Branch-based inputs are recorded below as exact
commits. The combined core is GPL-enabled. Individual projects retain their
own terms, notices, patent considerations and corresponding-source
@@ -70,7 +73,7 @@ shipped in the static ZIP:
| Package | Exact version | Licence | Role |
| ----------------------------- | ------------- | ---------- | ------------------------------------- |
| `@add-ideas/toolbox-testkit` | 0.2.2 | Apache-2.0 | manifest and nested-path test helpers |
| `@add-ideas/toolbox-testkit` | 0.2.3 | Apache-2.0 | manifest and nested-path test helpers |
| `@eslint/js` | 10.0.1 | MIT | lint rules |
| `@playwright/test` | 1.61.1 | Apache-2.0 | browser tests |
| `@testing-library/jest-dom` | 6.9.1 | MIT | DOM assertions |

View File

@@ -14,7 +14,7 @@ tools.example.test {
@wasm path_regexp wasm \.wasm$
header @wasm Content-Type "application/wasm"
@immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$
@immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$
header @immutable Cache-Control "public, max-age=31536000, immutable"
redir /av /av/ 308

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
names. The files under the exact version directory
`vendor/ffmpeg/0.12.10/{st,mt}/` are content-verified and may use:
`vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` are content-verified and may use:
```text
Cache-Control: public, max-age=31536000, immutable
@@ -55,7 +55,7 @@ For a host with a `_headers`-style configuration, adapt:
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Cache-Control: no-cache
/av/vendor/ffmpeg/0.12.10/*
/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/*
Cache-Control: public, max-age=31536000, immutable
/av/assets/*
@@ -68,7 +68,7 @@ real response rather than relying only on configuration:
```sh
curl -sSI https://tools.example.test/av/ | sed -n '1,30p'
curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm | sed -n '1,30p'
curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm | sed -n '1,30p'
```
In browser developer tools, also verify `window.crossOriginIsolated === true`,

View File

@@ -3,7 +3,7 @@
map $uri $av_tools_cache_control {
default "no-cache";
"~^/(?:.*/)?vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable";
"~^/(?:.*/)?vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable";
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
}

View File

@@ -1,13 +1,13 @@
# FFmpeg WebAssembly build record
av-tools ships the published ESM assets from `@ffmpeg/core` and
`@ffmpeg/core-mt` **0.12.10**. The browser wrapper is `@ffmpeg/ffmpeg`
**0.12.15** and helpers are `@ffmpeg/util` **0.12.2**. All versions are exact,
not ranges.
av-tools ships the locally rebuilt, reviewed core profile
`0.12.10-reviewed-stack5m.1` in single-thread and multithread forms. The
browser wrapper is `@ffmpeg/ffmpeg` **0.12.15** and helpers are
`@ffmpeg/util` **0.12.2**. All versions and build inputs are exact, not ranges.
## Underlying build
The matching ffmpeg.wasm upstream release is `v12.15` at
The base ffmpeg.wasm upstream release is `v12.15` at
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. Its Dockerfile selects:
- FFmpeg **n5.1.4** (libavcodec 59.37.100);
@@ -15,6 +15,14 @@ The matching ffmpeg.wasm upstream release is `v12.15` at
- production `-O3 -msimd128`;
- pthread flags for the MT build and explicit thread disabling for ST.
The reviewed profile then applies upstream ffmpeg.wasm commit
`b409e36475bc21f0451b5b1e1d126fa82871439a` to select a 5 MiB Emscripten stack.
That upstream fix addresses the libopus zero-byte/out-of-bounds failures
reported in issues `#591` and `#775`. The local driver additionally makes the
upstream container-stage dependency isolation explicit, disables native x265
assembly for the wasm32 build, bounds parallel jobs and refuses output that
contains the maintainer's repository or SDK paths.
The build configuration embedded in both copied WASM files is:
```text
@@ -35,8 +43,8 @@ The build configuration embedded in both copied WASM files is:
--cxx=em++
--objcc=emcc
--dep-cc=emcc
--extra-cflags='-I/opt/include -O3 -msimd128'
--extra-cxxflags='-I/opt/include -O3 -msimd128'
--extra-cflags='-I../../../../install/include -O3 -msimd128'
--extra-cxxflags='-I../../../../install/include -O3 -msimd128'
--enable-gpl
--enable-libx264
--enable-libx265
@@ -76,16 +84,26 @@ respective branch tips. `SOURCE.md` records retrieval commands.
| Mode | File | Bytes | SHA-256 |
| ---- | -------------------------- | -------: | ------------------------------------------------------------------ |
| ST | `st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
| ST | `st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
| MT | `mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
| MT | `mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
| ST | `st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
| ST | `st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
| MT | `mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
| MT | `mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
| MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
`npm run vendor:ffmpeg` copies these files from `node_modules` to
`public/vendor/ffmpeg/0.12.10/`; `npm run vendor:verify` checks versions,
sizes, paths and digests. Vite then copies them unchanged into `dist`. A normal
production build performs no network download.
The deterministic five-file repository artifact is 21,338,974 bytes with
SHA-256
`ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f`.
`npm run vendor:ffmpeg` verifies and unpacks the deterministic tracked core
artifact to
`public/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/`;
`npm run vendor:verify-runtime` checks the build profile,
source/toolchain/patch identity, tracked artifact, generated paths, sizes and
digests without requiring a release-directory download. The stricter
`npm run vendor:verify` release gate also hashes the build-qualified
Corresponding Source archive. Vite then copies the verified bytes into `dist`.
A normal production build performs no network download and cannot fall back
to the older published npm core bytes.
## Multithread Chromium guardrail
@@ -118,26 +136,42 @@ This is a conservative browser-memory guardrail, not a guarantee that sixteen
large jobs or three large contact sheets fit in memory. Cancellation and
failed filesystem-cleanup recovery continue to recreate the core immediately.
## Opus regression risk
## Opus regression evidence
Pinned core 0.12.10 predates upstream ffmpeg.wasm fixes associated with stack
size and Opus zero-byte/out-of-bounds failures (including upstream issues
`#591` and `#775`). Therefore:
Published core 0.12.10 predates the upstream stack-size fix and stalled in the
real Opus preset path even though it advertised the `opus` muxer and
`libopus` encoder. Capability discovery alone was therefore not accepted as
enablement evidence.
- decoder/encoder listing is not enough to certify Opus;
- real ST and MT fixture encodes must produce a non-empty, re-probeable output;
- the Opus audio and VP9/Opus presets must remain disabled with a diagnostic
if that smoke fails;
- a core upgrade requires new checksums, capability snapshots, legal review
and regression tests.
On 2026-07-26 the Chromium harness used the checked
`tests/fixtures/generated/tone.wav` fixture (32,102 bytes, SHA-256
`0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c`)
and the real built-in `audio-opus` preset/command planner. After rebuilding
with the reviewed stack profile, the isolated ST and MT runs each:
This is a known pinned-core risk, not a claim that every Opus job fails.
- loaded the requested mode rather than a fallback;
- mapped PCM audio to `opus (libopus)`;
- opened the declared Opus output and reported its libopus encoder;
- returned non-empty bytes within its independent containment limit;
- re-probed the result as positive-duration Opus audio.
```sh
# Record JSON evidence.
npm run evidence:opus
# Positive Opus enablement/release gate.
npm run test:opus-regression
```
The Opus audio and VP9/Opus presets are enabled only when the reviewed-core
lock records both passing modes and runtime discovery finds every preset
requirement. This is exact Chromium fixture evidence, not a claim about every
browser, input or possible Opus build.
## Licence consequence
The core packages declare GPL-2.0-or-later and the actual build enables GPL and
links external codecs. The wrapper/util are MIT. av-tools is
GPL-3.0-or-later, and the distributed application must not be described as
MIT-only. Publish the reviewed Corresponding Source and external notices with
the binary release. See `LICENSE`, `LICENSES/`, `THIRD_PARTY_NOTICES.md` and
`SOURCE.md`.
The actual core build enables GPL and links external codecs. The wrapper/util
are MIT. av-tools is GPL-3.0-or-later, and the distributed application must
not be described as MIT-only. Publish the reviewed Corresponding Source and
external notices with the binary release. See `LICENSE`, `LICENSES/`,
`THIRD_PARTY_NOTICES.md` and `SOURCE.md`.

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.
Browser playback is a separate capability checked with MIME/media APIs.
## 0.1.0 candidate fixture matrix
## 0.2.1 release fixture matrix
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
preparation and pass the checksum/probe verifier. That proves fixture
@@ -41,20 +41,20 @@ browser may still be unable to preview the result.
Preset availability always depends on detected runtime requirements:
| Preset family | Required examples | Browser result preview |
| ------------------ | ---------------------------------------- | ------------------------------------------------------ |
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | browser-dependent; Opus core regression gate applies |
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
| MP3 | `mp3`, `libmp3lame` | commonly playable |
| M4A/AAC | `ipod`, `aac` | commonly playable |
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies |
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
| FLAC | `flac`, `flac` | browser-dependent |
| Preset family | Required examples | Browser result preview |
| ------------------ | ---------------------------------------- | -------------------------------------------------------- |
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | enabled when the loaded core advertises all requirements |
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
| MP3 | `mp3`, `libmp3lame` | commonly playable |
| M4A/AAC | `ipod`, `aac` | commonly playable |
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
| Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded |
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
| FLAC | `flac`, `flac` | browser-dependent |
“Lossless” applies only to a lossless audio codec such as PCM/FLAC relative to
its decoded samples. It does not make a lossy source lossless. Stream-copy
@@ -77,11 +77,30 @@ remux avoids re-encoding compatible streams but may change container metadata.
- Normalized concat uses an explicit export preset and requires an explicit
insert-silence, drop-all-audio or reject policy when clips have mismatched
audio presence.
- Normalized concat can overlap every edit with one uniform 0.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
contact sheets require specific filters discovered at runtime.
- Even-dimension correction is applied when the selected pixel format/encoder
requires it and is reported in the plan/result.
## Images and scene selection
Thumbnail/contact-sheet scene mode first performs a bounded full-source
analysis with `select`/`showinfo`, stopping after at most 100 qualifying
frames. Extraction then selects those exact source PTS values; no guessed time
rounding or remote analysis is used. If no frame meets the 0.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
The text fixtures cover SRT, WebVTT and ASS import with explicit time shift.
@@ -115,8 +134,9 @@ elements do not reliably expose them, so ffprobe round-trip is authoritative.
- frame-accurate stream-copy trimming;
- complete metadata erasure/anonymity;
- browser preview of every FFmpeg-readable result;
- Opus presets until real non-empty ST/MT encoding smoke passes for core
0.12.10;
- Opus on browsers, inputs or deployments outside the exact reviewed
Chromium regression: both ST and MT pass the non-empty encode/re-probe gate,
but runtime capability discovery and normal per-job limits still apply;
- any capability omitted by the active core's runtime query.
Update this file only from re-probed generated/public-domain fixtures and

View File

@@ -1,6 +1,6 @@
# Known limitations
This is the conservative 0.1.0 development baseline. A capability appearing in
This is the conservative 0.2.1 release baseline. A capability appearing in
FFmpeg output does not remove these product/browser/resource limits.
## Functional
@@ -13,6 +13,9 @@ FFmpeg output does not remove these product/browser/resource limits.
frame-accurate. Accurate mode requires decoding/re-encoding.
- Stream-copy concatenate works only when selected streams and codec/container
parameters are compatible. Other input needs normalized re-encoding.
- Crossfades are uniform fade/triangular-curve transitions on normalized
sequential joins, not arbitrary transition effects or overlapping timeline
tracks. They require enough material on both sides of every internal edit.
- Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
cannot restore information already lost in a source.
- Metadata removal is best effort. Muxers may add structural/encoder fields,
@@ -26,6 +29,12 @@ FFmpeg output does not remove these product/browser/resource limits.
page discards queued work.
- A file-generating batch and its managed result collection are bounded to 255
outputs, leaving the 256th ZIP entry available for an optional export report.
- Scene analysis scans the source until it finds the requested changes or
reaches the end, retains at most 100 exact PTS results and can therefore take
about as long as a full decode. It does not begin an export implicitly when
no frame meets the chosen threshold.
- Labelled contact sheets require a core with `drawtext`; the reviewed local
font does not make that filter available on a core that omits it.
- Preview proxies are bounded, lossy H.264/AAC derivatives for local playback;
they do not replace the source or guarantee that every browser can play the
result.
@@ -38,8 +47,9 @@ FFmpeg output does not remove these product/browser/resource limits.
support fewer container/codec combinations.
- Browser support, codec policy and memory differ across Chromium, Firefox and
Safari and across desktop/mobile versions.
- Core 0.12.10 has an Opus regression risk. Opus remains release-gated until
non-empty, re-probeable ST and MT fixture outputs pass.
- The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe
gate in isolated Chromium ST and MT runs. That evidence is not a
cross-browser or every-input Opus guarantee.
- MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works
without isolation but is not a guarantee that a large job fits.
- Top-level navigation is supported; iframe embedding is not.
@@ -69,7 +79,7 @@ FFmpeg output does not remove these product/browser/resource limits.
verification against the live container.
- The application is GPL-3.0-or-later and its native legal files and pinned
source identities are included in the release record. Publish the production
ZIP/checksum, exact `v0.1.0` source and FFmpeg Corresponding Source bundle
ZIP/checksum, exact `v0.2.1` source and FFmpeg Corresponding Source bundle
together; development-only smoke artifacts must not be distributed.
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context

View File

@@ -6,12 +6,13 @@ output at once. No hardware encoder is used.
## Fixed download footprint
Uncompressed self-hosted core assets for 0.12.10:
Uncompressed self-hosted core assets for
`0.12.10-reviewed-stack5m.1`:
| Mode | JavaScript/worker | WASM | Total |
| ------------- | ------------------: | -----------: | -----------: |
| single-thread | 111,804 B | 32,232,419 B | 32,344,223 B |
| multithread | 128,947 B + 2,115 B | 32,718,323 B | 32,849,385 B |
| single-thread | 111,804 B | 32,232,580 B | 32,344,384 B |
| multithread | 128,953 B + 2,115 B | 32,718,455 B | 32,849,523 B |
Only the selected mode is loaded. HTTP compression may reduce JavaScript
transfer but not necessarily WASM to the same degree; report transfer and
@@ -72,9 +73,18 @@ Estimated output warns at 512 MiB and is blocked at 1 GiB. FFmpeg jobs are
serial; the application retains at most eight scheduled operations in its
in-memory queue. Timeline clips are capped at 100, streams per input at 64,
images at 8192×8192/67,108,864 pixels, generated output files at 255,
contact-sheet cells at 500 and runtime at four hours. The documented
ffmpeg.wasm input ceiling is 2 GiB, but real limits can be substantially lower
on mobile or memory-constrained browsers.
contact-sheet cells at 500, scene-change results at 100, crossfades at 10
seconds and runtime at four hours. Scene selection is a bounded two-step
workflow but its analysis step can still decode the complete source when few
frames meet the threshold. The documented ffmpeg.wasm input ceiling is 2 GiB,
but real limits can be substantially lower on mobile or memory-constrained
browsers.
Source inspection has a bounded, size-aware watchdog: 30 seconds for small
files, a two-MiB-per-second scan budget for larger files, and a five-minute
ceiling. Recognized local audio/video can begin browser playback from its
temporary object URL while that worker inspection continues; completed probe
metadata remains authoritative for editing and export.
Lowering resolution, duration, frame rate, passes and output count reduces
work. MT can be faster but consumes more memory/CPU and requires isolation; ST

View File

@@ -18,7 +18,7 @@ X-Content-Type-Options: nosniff
Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME
type and JSON as `application/json`. Test real responses under
`/apps/av/vendor/ffmpeg/0.12.10/...`; a file-extension allowlist alone does not
`/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/...`; a file-extension allowlist alone does not
prove the MIME type.
Suggested CSP:
@@ -43,45 +43,36 @@ form-action 'none';
`'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created
worker path. `media-src blob:` permits local source/result previews.
## Immutable reference and active-worktree state
## Reviewed Portal state
Portal revision `bda9da044d61c109afd9969d357cc27f76648938` already sends COOP
`same-origin`, COEP `require-corp`, CORP `same-origin`, `script-src 'self'
'wasm-unsafe-eval'`, and `worker-src 'self' blob:`. Its packaged nginx CSP does
**not** declare `media-src`; `default-src 'self'` therefore blocks local
`blob:` audio/video URLs. The expected browser diagnostic is equivalent to:
Portal revision `5c052bc6d1073a66600cfddc44c0e16da34bede8` (tag `v0.6.0`)
already sends COOP `same-origin`, COEP `require-corp`, CORP `same-origin`,
`script-src 'self' 'wasm-unsafe-eval'`, `worker-src 'self' blob:` and
`media-src 'self' blob:`. Its nginx profile also maps `.wasm` explicitly to
`application/wasm`. Local source and result Blob previews therefore require no
CSP exception beyond that reviewed narrow media directive.
```text
Refused to load media from 'blob:…' because it violates
"default-src 'self'".
```
As inspected on 2026-07-26, the active
`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` has an additional worktree
patch that extends the immutable FFmpeg cache rule from plain semantic versions
to the one exact reviewed build profile
`0.12.10-reviewed-stack5m.1`. The corresponding deployment tests accept the
five reviewed core files and `version.json` while rejecting mutable-looking
suffixes, malformed versions and unexpected paths. A clean temporary clone
containing that exact patch passed the AV release assembly smoke without a
known-header-gap override.
Smallest required Portal change: append exactly
`media-src 'self' blob:;` to that CSP. This permits only same-origin and
in-memory Blob media; it does not permit remote media hosts, inline script or
eval. If the Portal is not changed, conversion/download can remain available
but Blob source/result preview must show a clear disabled reason.
As inspected on 2026-07-24, the active
`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` contains that exact directive
as a local worktree patch. The worktree still resolves to the immutable
revision above, so the patch is useful for local deployment testing but is not
a committed Portal revision and must not be cited as published release
evidence. The clean-reference assembly smoke therefore continues to require
`--allow-known-header-gap` until a reviewed Portal commit contains the fix.
The nginx profile includes `.wasm` in its static allowlist/gzip types but
relies on the base image `mime.types`. Verify `Content-Type:
application/wasm` against the pinned image; add an explicit mapping if the
response differs.
Until the cache extension is committed, cite it only as staged integration
evidence, not as a published Portal revision. The clean `v0.6.0` rule leaves
the reviewed profile at `no-cache`; that affects performance, not loading,
MIME type, isolation or preview correctness.
## Caching
The immutable Portal reference's cache map makes Vite content-hashed assets
immutable but leaves versioned FFmpeg cores at `no-cache`. Correctness is
unaffected.
For performance, the exact directory
`apps/av/vendor/ffmpeg/0.12.10/{st,mt}/` may receive:
The staged Portal cache map makes Vite content-hashed assets, plain semantic
versions and the explicitly reviewed profile immutable. For performance, the
exact directory
`apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` may receive:
```text
Cache-Control: public, max-age=31536000, immutable

View File

@@ -1,28 +1,31 @@
# Reference implementations
Inspection date: **2026-07-24**.
Inspection dates: **2026-07-24** (initial implementation) and
**2026-07-26** (remaining-slice integration).
The references were cloned beside av-tools under
`/mnt/DATA/git/references/`; no reference repository was nested in this
working tree. Revisions are full immutable commit ids.
| Repository | Revision inspected | Used for | Copied or adapted |
| -------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| av-tools, `git@git.add-ideas.de:zemion/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed |
| Toolbox SDK, `https://git.add-ideas.de/zemion/toolbox-sdk.git` | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` (SDK 0.2.2) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.2 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the documented artifact contract informed an independent release packager and smoke harness |
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree code was copied; exact published npm packages are dependencies and their five core assets are copied verbatim by the vendor script |
| Repository | Revision inspected | Used for | Copied or adapted |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| av-tools, `git@git.add-ideas.de:lotobo/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/lotobo/toolbox-sdk.git` | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a` (SDK 0.2.3) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.3 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
| Toolbox Portal, `https://git.add-ideas.de/lotobo/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` initially; `5c052bc6d1073a66600cfddc44c0e16da34bede8` for final integration | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the AV smoke harness consumes only an exact cloned Portal revision; the separate Portal worktree contains its own reviewed integration patch |
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc`; pinned release baseline `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree source was copied; wrapper/util remain exact npm dependencies, while the five core assets are rebuilt from the recorded source/toolchain/patch profile and verified against the build lock |
For the **pinned** package set, the more specific upstream release tag
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It
contains `@ffmpeg/ffmpeg` 0.12.15 and core/core-mt 0.12.10. That revision's
build selects FFmpeg n5.1.4.
contains `@ffmpeg/ffmpeg` 0.12.15 and the source configuration for core
0.12.10. That revision's build selects FFmpeg n5.1.4. The shipped
`0.12.10-reviewed-stack5m.1` assets additionally apply upstream ffmpeg.wasm
commit `b409e36475bc21f0451b5b1e1d126fa82871439a`; exact build and source
identities are in `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
The user's active `/mnt/DATA/git/toolbox-portal` worktree still has Portal
revision `bda9da044d61c109afd9969d357cc27f76648938` as `HEAD`, but currently
contains a local `media-src 'self' blob:` nginx CSP patch. That mutable
worktree state is documented for deployment testing only; it does not replace
the immutable reference in the table.
The active `/mnt/DATA/git/toolbox-portal` worktree has immutable revision
`5c052bc6d1073a66600cfddc44c0e16da34bede8` as `HEAD`. Its staged cache-rule
extension is documented separately and is not represented as a published
revision.
Official behavior references:

View File

@@ -11,10 +11,10 @@ The repository already declares the application licence and immutable
third-party source identities. Before any distribution:
1. commit the reviewed GPL-3.0-or-later source;
2. create and verify annotated tag `v0.1.0` at that exact commit;
2. create and verify annotated tag `v0.2.1` at that exact commit;
3. push the commit and tag to the canonical public repository;
4. assemble, review and publish corresponding source and exact external
notices for both core packages and linked libraries;
notices for both reviewed core profiles and linked libraries;
5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned.
`SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not
@@ -44,6 +44,7 @@ Normal packaging validates the root GPL version 3 text and matching
```sh
npm run package:release
npm run source:package
npm run vendor:verify
```
The legacy development-only escape hatch is retained only for checking an
@@ -56,19 +57,19 @@ npm run package:release -- --allow-unlicensed-development-smoke
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
```text
release/av-tools-0.1.0.zip
release/av-tools-0.1.0.zip.sha256
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256
release/av-tools-0.2.1.zip
release/av-tools-0.2.1.zip.sha256
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
```
Verify independently:
```sh
node scripts/checksum-release.mjs
(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256)
unzip -l release/av-tools-0.1.0.zip
tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
unzip -l release/av-tools-0.2.1.zip
tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
```
The packager:
@@ -90,13 +91,13 @@ When used against a fixture without an application licence, the escape hatch add
## 4. Portal assembly smoke
Use the clean reference clone beside av-tools, never the user's active Portal
worktree:
Use a reviewed Portal Git repository beside av-tools. The harness reads its
exact `HEAD` and clones that revision into a temporary directory:
```sh
npm run portal:smoke -- \
--artifact release/av-tools-0.1.0.zip \
--allow-known-header-gap
--artifact release/av-tools-0.2.1.zip \
--portal-root ../toolbox-portal
```
The smoke harness clones the exact Portal HEAD to a temporary directory,
@@ -106,19 +107,23 @@ then verifies the generated catalogue/release records and copied FFmpeg assets.
The temporary directory is removed unless `--keep-temp` is explicitly passed.
No machine-specific path is committed and no Portal worktree is changed.
Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP
omission of `media-src blob:` is a failing deployment check. The override still
reports the gap and is only for testing archive assembly. See
`docs/PORTAL_REQUIREMENTS.md`.
Portal `v0.6.0`
(`5c052bc6d1073a66600cfddc44c0e16da34bede8`) already passes the isolation,
Blob-media CSP and explicit WASM MIME checks. Its plain-semver cache rule does
not match the reviewed core's build-profile suffix, so the harness reports a
cache warning. The staged Portal extension accepts only the exact reviewed
profile in addition to plain semantic versions and rejects malformed or
mutable-looking suffixes; a temporary clean commit containing that patch passed
this smoke without warnings.
The user's active `/mnt/DATA/git/toolbox-portal` worktree contains the narrow
directive as a local patch, but the smoke intentionally does not consume that
mutable worktree. Until the patch exists in a reviewed immutable Portal
revision, retain the clean-reference failure/override behavior above.
`--allow-known-header-gap` remains available only for deliberately testing an
older Portal revision with a known required-header error. It is unnecessary
for `v0.6.0` and must not suppress review of new gaps. See
`docs/PORTAL_REQUIREMENTS.md`.
## 5. Publish only reviewed bytes
From a clean `v0.1.0` checkout, rerun all checks, create the ZIP once, verify
From a clean `v0.2.1` checkout, rerun all checks, create the ZIP once, verify
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never

View File

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

View File

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

52
package-lock.json generated
View File

@@ -1,18 +1,16 @@
{
"name": "av-tools",
"version": "0.1.0",
"version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "av-tools",
"version": "0.1.0",
"version": "0.2.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2",
"@add-ideas/toolbox-shell-react": "0.2.2",
"@ffmpeg/core": "0.12.10",
"@ffmpeg/core-mt": "0.12.10",
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@ffmpeg/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2",
"fflate": "0.8.3",
@@ -20,7 +18,7 @@
"react-dom": "19.2.7"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.2",
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",
@@ -46,18 +44,18 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.2",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.2/toolbox-contract-0.2.2.tgz",
"integrity": "sha512-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==",
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.2",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.2/toolbox-shell-react-0.2.2.tgz",
"integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2"
"@add-ideas/toolbox-contract": "0.2.3"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -65,13 +63,13 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.2",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.2/toolbox-testkit-0.2.2.tgz",
"integrity": "sha512-ZxrRYiJI/j5aXwcd3mQItgDl1iOJ6N26+MpIG18qVZNwKKFCTCfSTzgzRooPt8rRc1MQYTPo4c+l/QpOWuTXxA==",
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2"
"@add-ideas/toolbox-contract": "0.2.3"
},
"bin": {
"toolbox-check": "dist/cli.js"
@@ -718,24 +716,6 @@
}
}
},
"node_modules/@ffmpeg/core": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
"license": "GPL-2.0-or-later",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/core-mt": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz",
"integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==",
"license": "GPL-2.0-or-later",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.15",
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",

View File

@@ -1,17 +1,17 @@
{
"name": "av-tools",
"private": true,
"version": "0.1.0",
"version": "0.2.1",
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/zemion/av-tools.git"
"url": "git+https://git.add-ideas.de/lotobo/av-tools.git"
},
"homepage": "https://git.add-ideas.de/zemion/av-tools",
"homepage": "https://git.add-ideas.de/lotobo/av-tools",
"bugs": {
"url": "https://git.add-ideas.de/zemion/av-tools/issues"
"url": "https://git.add-ideas.de/lotobo/av-tools/issues"
},
"type": "module",
"engines": {
@@ -22,6 +22,8 @@
"dev": "vite",
"vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs",
"vendor:verify": "node scripts/verify-ffmpeg-assets.mjs",
"vendor:verify-runtime": "node scripts/verify-ffmpeg-assets.mjs --runtime-only",
"vendor:package": "node scripts/package-reviewed-ffmpeg-core.mjs",
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
"prebuild": "npm run vendor:ffmpeg && npm run manifest:generate",
@@ -34,6 +36,9 @@
"test": "vitest run",
"test:watch": "vitest",
"test:browser": "playwright test",
"evidence:large-preview": "playwright test tests/browser/large-media-preview.spec.ts --project=chromium --workers=1",
"evidence:opus": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs",
"test:opus-regression": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs --require-pass",
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
"fixtures:generate": "sh scripts/generate-fixtures.sh",
"fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS",
@@ -42,14 +47,12 @@
"source:package": "node scripts/package-ffmpeg-source.mjs",
"checksum:release": "node scripts/checksum-release.mjs",
"portal:smoke": "node scripts/portal-assembly-smoke.mjs",
"check": "npm run manifest:check && npm run vendor:verify && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
"release:artifact": "npm run check && npm run test:browser && npm run package:release"
"check": "npm run manifest:check && npm run vendor:verify-runtime && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
"release:artifact": "npm run check && npm run vendor:verify && npm run test:opus-regression && npm run test:browser && npm run package:release"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2",
"@add-ideas/toolbox-shell-react": "0.2.2",
"@ffmpeg/core": "0.12.10",
"@ffmpeg/core-mt": "0.12.10",
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@ffmpeg/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2",
"fflate": "0.8.3",
@@ -57,7 +60,7 @@
"react-dom": "19.2.7"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.2",
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View File

@@ -1,9 +1,9 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.av-tools",
"name": "Audio & Video Tools",
"version": "0.1.0",
"version": "0.2.1",
"description": "Convert and lightly edit audio and video locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -44,21 +44,21 @@
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/zemion/av-tools",
"repository": "https://git.add-ideas.de/lotobo/av-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/zemion/av-tools"
"url": "https://git.add-ideas.de/lotobo/av-tools"
}
],
"assets": [
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
]
}

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 { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { unzipSync } from 'fflate';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const expectedVersion = '0.12.10';
const outputRoot = join(
repositoryRoot,
'public',
'vendor',
'ffmpeg',
expectedVersion
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const packages = [
{
packageName: '@ffmpeg/core',
mode: 'st',
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'],
},
{
packageName: '@ffmpeg/core-mt',
mode: 'mt',
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'],
},
];
const expectedHashes = new Map([
[
'@ffmpeg/core:ffmpeg-core.js',
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
],
[
'@ffmpeg/core:ffmpeg-core.wasm',
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
],
[
'@ffmpeg/core-mt:ffmpeg-core.js',
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
],
[
'@ffmpeg/core-mt:ffmpeg-core.wasm',
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
],
[
'@ffmpeg/core-mt:ffmpeg-core.worker.js',
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
],
const lockPath = path.join(
repositoryRoot,
'scripts',
'reviewed-ffmpeg-core-lock.json'
);
const maximumArchiveBytes = 64 * 1024 * 1024;
const maximumAssetBytes = 64 * 1024 * 1024;
const requiredAssetPaths = new Set([
'st/ffmpeg-core.js',
'st/ffmpeg-core.wasm',
'mt/ffmpeg-core.js',
'mt/ffmpeg-core.wasm',
'mt/ffmpeg-core.worker.js',
]);
async function loadPackageVersion(packageName) {
const packagePath = join(
repositoryRoot,
'node_modules',
...packageName.split('/'),
'package.json'
);
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
if (parsed.version !== expectedVersion) {
throw new Error(
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
);
}
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
async function copyAsset(packageName, mode, fileName) {
const source = join(
repositoryRoot,
'node_modules',
...packageName.split('/'),
'dist',
'esm',
fileName
);
const destination = join(outputRoot, mode, fileName);
const sourceBytes = await readFile(source).catch((error) => {
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
cause: error,
});
function assertSafeRelativePath(value) {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.includes('\\') ||
value.includes('\0') ||
value.startsWith('/') ||
path.posix.normalize(value) !== value ||
value.split('/').some((part) => !part || part === '.' || part === '..')
) {
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
}
return value;
}
async function requireRealDirectory(directory, create = false) {
let details = await lstat(directory).catch((error) => {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return undefined;
}
throw error;
});
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
if (!expectedDigest || sourceDigest !== expectedDigest) {
throw new Error(
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
);
if (!details && create) {
await mkdir(directory);
details = await lstat(directory);
}
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, {
dereference: true,
errorOnExist: false,
force: true,
preserveTimestamps: false,
});
const destinationBytes = await readFile(destination);
if (!sourceBytes.equals(destinationBytes)) {
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
}
return {
path: relative(outputRoot, destination).replaceAll('\\', '/'),
bytes: destinationBytes.byteLength,
sha256: sourceDigest,
};
}
await rm(outputRoot, { force: true, recursive: true });
const assets = [];
for (const entry of packages) {
await loadPackageVersion(entry.packageName);
for (const fileName of entry.files) {
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
throw new Error(`Required directory is unsafe: ${directory}`);
}
}
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
const versionManifest = {
schemaVersion: 1,
coreVersion: expectedVersion,
packages: {
'@ffmpeg/core': expectedVersion,
'@ffmpeg/core-mt': expectedVersion,
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
const lockedAssetPaths = new Set(
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
);
if (
lock.schemaVersion !== 1 ||
typeof lock.buildId !== 'string' ||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
lock.coreVersion !== '0.12.10' ||
lock.profile !== 'reviewed-stack5m' ||
lock.sourceArchive?.path !==
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
lock.patch?.upstreamCommit !== 'b409e36475bc21f0451b5b1e1d126fa82871439a' ||
lock.patch?.stackSizeBytes !== 5 * 1024 * 1024 ||
typeof lock.opusRegression?.singleThread !== 'boolean' ||
typeof lock.opusRegression?.multiThread !== 'boolean' ||
!Array.isArray(lock.assets) ||
lock.assets.length !== 5 ||
lock.assets.some(
(asset) =>
!requiredAssetPaths.has(asset.path) ||
!Number.isSafeInteger(asset.bytes) ||
asset.bytes < 1 ||
asset.bytes > maximumAssetBytes ||
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
) ||
lockedAssetPaths.size !== requiredAssetPaths.size ||
[...requiredAssetPaths].some((assetPath) => !lockedAssetPaths.has(assetPath))
) {
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
}
const artifactRelativePath = assertSafeRelativePath(lock.artifact?.path);
if (artifactRelativePath !== `vendor/ffmpeg-core-${lock.buildId}.zip`) {
throw new Error('Reviewed-core artifact path and build identity disagree.');
}
const artifact = await readFile(
path.join(repositoryRoot, ...artifactRelativePath.split('/'))
);
if (
artifact.byteLength < 1 ||
artifact.byteLength > maximumArchiveBytes ||
artifact.byteLength !== lock.artifact.bytes ||
sha256(artifact) !== lock.artifact.sha256
) {
throw new Error('Reviewed FFmpeg core archive failed locked verification.');
}
const lockedArchiveEntries = new Map(
lock.assets.map((asset) => [asset.path, asset])
);
const archiveEntries = new Set();
const uncompressed = unzipSync(artifact, {
filter: (entry) => {
const relative = assertSafeRelativePath(entry.name);
const locked = lockedArchiveEntries.get(relative);
if (
!locked ||
archiveEntries.has(relative) ||
entry.originalSize !== locked.bytes ||
entry.originalSize < 1 ||
entry.originalSize > maximumAssetBytes ||
entry.size < 1 ||
entry.size > maximumArchiveBytes
) {
throw new Error(
`Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
);
}
archiveEntries.add(relative);
return true;
},
assets,
};
});
if (archiveEntries.size !== lockedArchiveEntries.size) {
throw new Error('Reviewed FFmpeg core archive is missing an expected entry.');
}
const vendorRoot = path.join(repositoryRoot, 'public', 'vendor', 'ffmpeg');
const outputRoot = path.join(vendorRoot, lock.buildId);
await requireRealDirectory(repositoryRoot);
await requireRealDirectory(path.join(repositoryRoot, 'public'));
await requireRealDirectory(path.join(repositoryRoot, 'public', 'vendor'), true);
const existingVendorRoot = await lstat(vendorRoot).catch((error) => {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return undefined;
}
throw error;
});
if (
existingVendorRoot &&
(existingVendorRoot.isSymbolicLink() || !existingVendorRoot.isDirectory())
) {
throw new Error(`Generated FFmpeg vendor root is unsafe: ${vendorRoot}`);
}
await rm(vendorRoot, { force: true, recursive: true });
for (const asset of lock.assets) {
const relativeAssetPath = assertSafeRelativePath(asset.path);
const bytes = uncompressed[relativeAssetPath];
if (
!bytes ||
bytes.byteLength < 1 ||
bytes.byteLength > maximumAssetBytes ||
bytes.byteLength !== asset.bytes ||
sha256(bytes) !== asset.sha256
) {
throw new Error(
`Reviewed FFmpeg core entry failed locked verification: ${asset.path}`
);
}
const destination = path.join(outputRoot, ...relativeAssetPath.split('/'));
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, bytes, { flag: 'wx', mode: 0o644 });
}
const versionManifest = {
schemaVersion: 2,
buildId: lock.buildId,
profile: lock.profile,
coreVersion: lock.coreVersion,
ffmpegVersion: lock.ffmpegVersion,
sourceArchive: lock.sourceArchive,
sourceLock: lock.sourceLock,
buildDriver: lock.buildDriver,
toolchain: lock.toolchain,
patch: lock.patch,
opusRegression: lock.opusRegression,
assets: lock.assets,
};
await writeFile(
join(outputRoot, 'version.json'),
`${JSON.stringify(versionManifest, null, 2)}\n`
path.join(outputRoot, 'version.json'),
`${JSON.stringify(versionManifest, null, 2)}\n`,
{ flag: 'wx', mode: 0o644 }
);
console.log(
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
`Verified and unpacked ${lock.assets.length} reviewed FFmpeg assets to ${path.relative(repositoryRoot, outputRoot)}`
);

View File

@@ -1,7 +1,9 @@
# FFmpeg WebAssembly Corresponding Source
This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt`
0.12.10 object code distributed by av-tools 0.1.0.
This archive accompanies the reviewed single-thread and multithread FFmpeg
WebAssembly core profile distributed by av-tools. The profile retains the
upstream core version 0.12.10 and FFmpeg n5.1.4 identities, but it is rebuilt
from source rather than copied from the published `@ffmpeg/core` packages.
`SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and
every external library selected by the upstream v12.15 build. The source trees
@@ -18,10 +20,15 @@ The original build-control files are in `sources/ffmpeg.wasm/`, including:
- `build/ffmpeg-wasm.sh`;
- the other dependency scripts under `build/`.
The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools
does not modify the five published JavaScript/WebAssembly core assets. Their
exact sizes, hashes and embedded configure arguments are recorded in the
application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
The build uses Emscripten SDK 3.1.40 and FFmpeg n5.1.4. Its one source patch
applies upstream ffmpeg.wasm commit
`b409e36475bc21f0451b5b1e1d126fa82871439a`, which raises the Emscripten stack
to 5 MiB and fixes the libopus encode failure reported upstream. The tracked
`scripts/build-reviewed-ffmpeg-core.sh` driver in the matching av-tools source
release also isolates optional host dependencies and records the exact
toolchain identities. The generated assets' exact sizes, hashes and embedded
configure arguments are recorded in the application's `SOURCE.md`,
`scripts/reviewed-ffmpeg-core-lock.json` and `docs/FFMPEG_BUILD.md`.
The source archive preserves each component's own licence and copyright
notices. av-tools itself is distributed separately under
@@ -37,14 +44,11 @@ test "$(git -C source-check rev-parse FETCH_HEAD)" = "<revision>"
```
The included ffmpeg.wasm Dockerfile documents the historical upstream build,
but still names remote tags and branches. It is not a turnkey offline or
byte-for-byte reproducible build recipe. To reconstruct a pinned build,
adapt each remote `ADD` to use the corresponding bundled source tree. Replace
the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/`
tree, which already contains its pinned GoogleTest submodule at
`test/extra/googletest/`. Keep the included build scripts and Emscripten
version unchanged. The upstream targets are `make prd` for single-thread and
`make prd-mt` for multithread.
but still names remote tags and branches. For the distributed reviewed
profile, use this archive together with the matching av-tools source release
and invoke its `scripts/build-reviewed-ffmpeg-core.sh` driver. The bundled
`sources/zimg/` tree already contains its pinned GoogleTest submodule at
`test/extra/googletest/`.
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
3.1.40 container image is not copied into this archive.

View File

@@ -23,7 +23,7 @@ if (source.version !== packageJson.version) {
}
if (
source.source?.repository !== 'https://git.add-ideas.de/zemion/av-tools' ||
source.source?.repository !== 'https://git.add-ideas.de/lotobo/av-tools' ||
source.source?.license !== 'GPL-3.0-or-later'
) {
throw new Error(

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 lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
const reviewedLockPath = join(
repositoryRoot,
'scripts',
'reviewed-ffmpeg-core-lock.json'
);
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
const releaseDirectory = join(repositoryRoot, 'release');
const force = process.argv.includes('--force');
@@ -204,7 +209,19 @@ async function publishSourcePair({
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
assertLock(lock);
const archiveName = `ffmpeg-core-${lock.coreVersion}-corresponding-source.tar.xz`;
const reviewedLock = JSON.parse(await readFile(reviewedLockPath, 'utf8'));
if (
reviewedLock?.schemaVersion !== 1 ||
reviewedLock.coreVersion !== lock.coreVersion ||
typeof reviewedLock.buildId !== 'string' ||
!/^[a-zA-Z0-9._-]+$/u.test(reviewedLock.buildId) ||
!reviewedLock.buildId.startsWith(`${lock.coreVersion}-`)
) {
throw new Error(
'The reviewed-core lock does not identify a build-qualified source bundle.'
);
}
const archiveName = `ffmpeg-core-${reviewedLock.buildId}-corresponding-source.tar.xz`;
const archivePath = join(releaseDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
await ensureReleaseDirectory();

View File

@@ -23,9 +23,9 @@ const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const maximumEntryBytes = 256 * 1024 * 1024;
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
const applicationLicenseSpdx = 'GPL-3.0-or-later';
const canonicalRepository = 'https://git.add-ideas.de/zemion/av-tools';
const canonicalRepository = 'https://git.add-ideas.de/lotobo/av-tools';
const canonicalPackageRepository =
'git+https://git.add-ideas.de/zemion/av-tools.git';
'git+https://git.add-ideas.de/lotobo/av-tools.git';
const requiredNativeLegalFiles = [
'LICENSES/native/FFmpeg-LICENSE.md',
'LICENSES/native/FreeType-LICENSE.txt',
@@ -52,8 +52,6 @@ const ffmpegLicenseCoverage = new Map([
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'],
['@ffmpeg/core', 'LICENSES/GPL-2.0-or-later.txt'],
['@ffmpeg/core-mt', 'LICENSES/GPL-2.0-or-later.txt'],
]);
function compareText(left, right) {
@@ -479,7 +477,8 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
}
}
const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json';
const vendorBuildId = '0.12.10-reviewed-stack5m.1';
const vendorManifestPath = `vendor/ffmpeg/${vendorBuildId}/version.json`;
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
if (!vendorManifestEntry) {
throw new Error(
@@ -488,10 +487,12 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
}
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
if (
vendorManifest.schemaVersion !== 1 ||
vendorManifest.schemaVersion !== 2 ||
vendorManifest.buildId !== vendorBuildId ||
vendorManifest.profile !== 'reviewed-stack5m' ||
vendorManifest.coreVersion !== '0.12.10' ||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' ||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' ||
vendorManifest.opusRegression?.singleThread !== true ||
vendorManifest.opusRegression?.multiThread !== true ||
!Array.isArray(vendorManifest.assets) ||
vendorManifest.assets.length !== 5
) {
@@ -511,7 +512,7 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
);
}
const assetPath = safeArchivePath(
path.posix.join('vendor/ffmpeg/0.12.10', asset.path)
path.posix.join(`vendor/ffmpeg/${vendorBuildId}`, asset.path)
);
if (verifiedVendorPaths.has(assetPath)) {
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);

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)),
'..'
);
const reviewedCoreBuildId = '0.12.10-reviewed-stack5m.1';
function consumeValue(argumentsList, index, option) {
const value = argumentsList[index + 1];
@@ -179,12 +180,21 @@ export function inspectPortalHeaders(configuration) {
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
);
}
if (
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') &&
!configuration.includes('vendor/ffmpeg/0.12.10')
) {
const immutableCoreRule = configuration
.split(/\r?\n/u)
.find(
(line) =>
/vendor\/ffmpeg\//iu.test(line) &&
/ffmpeg-core/iu.test(line) &&
/max-age=31536000/iu.test(line) &&
/immutable/iu.test(line)
);
const supportsReviewedCore =
immutableCoreRule !== undefined &&
immutableCoreRule.replaceAll('\\', '').includes(reviewedCoreBuildId);
if (!supportsReviewedCore) {
warnings.push(
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
`The config does not give reviewed FFmpeg build ${reviewedCoreBuildId} an explicit immutable cache rule.`
);
}
return { errors, warnings };
@@ -229,11 +239,11 @@ async function verifyAssembly(output, appVersion, digest) {
}
for (const reference of [
'./index.html',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
]) {
const relative = reference.replace(/^\.\//, '');
await requireRegularFile(

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 { readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { lstat, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const expectedVersion = '0.12.10';
const outputRoot = join(
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const argumentsList = process.argv.slice(2);
const runtimeOnly = argumentsList.includes('--runtime-only');
if (
argumentsList.some((argument) => argument !== '--runtime-only') ||
(runtimeOnly && argumentsList.length !== 1)
) {
throw new Error(
'Usage: node scripts/verify-ffmpeg-assets.mjs [--runtime-only]'
);
}
const lock = JSON.parse(
await readFile(
path.join(repositoryRoot, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
'utf8'
)
);
if (
lock.schemaVersion !== 1 ||
typeof lock.buildId !== 'string' ||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
lock.coreVersion !== '0.12.10' ||
lock.profile !== 'reviewed-stack5m' ||
lock.sourceArchive?.path !==
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
!Number.isSafeInteger(lock.sourceArchive?.bytes) ||
lock.sourceArchive.bytes < 1 ||
lock.sourceArchive.bytes > 512 * 1024 * 1024 ||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
lock.artifact?.path !== `vendor/ffmpeg-core-${lock.buildId}.zip` ||
!Number.isSafeInteger(lock.artifact?.bytes) ||
lock.artifact.bytes < 1 ||
lock.artifact.bytes > 64 * 1024 * 1024 ||
!/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '') ||
!Array.isArray(lock.assets) ||
lock.assets.length !== 5
) {
throw new Error('Invalid reviewed FFmpeg core lock.');
}
const outputRoot = path.join(
repositoryRoot,
'public',
'vendor',
'ffmpeg',
expectedVersion
lock.buildId
);
const manifestPath = join(outputRoot, 'version.json');
const manifestPath = path.join(outputRoot, 'version.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const safeAssetPath = (value) => {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.includes('\\') ||
value.includes('\0') ||
value.startsWith('/') ||
path.posix.normalize(value) !== value ||
value.split('/').some((part) => !part || part === '.' || part === '..')
) {
throw new Error(`Unsafe FFmpeg asset path: ${String(value)}`);
}
return value;
};
const verifyLockedFile = async (record, label) => {
const relative = safeAssetPath(record.path);
const absolute = path.join(repositoryRoot, ...relative.split('/'));
const details = await lstat(absolute).catch((error) => {
throw new Error(`${label} is missing: ${relative}`, { cause: error });
});
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`${label} must be a regular file: ${relative}`);
}
const bytes = await readFile(absolute);
if (
(record.bytes !== undefined && bytes.byteLength !== record.bytes) ||
sha256(bytes) !== record.sha256
) {
throw new Error(`${label} does not match its locked identity: ${relative}`);
}
};
if (
manifest.schemaVersion !== 1 ||
manifest.coreVersion !== expectedVersion ||
manifest.schemaVersion !== 2 ||
manifest.buildId !== lock.buildId ||
manifest.profile !== lock.profile ||
manifest.coreVersion !== lock.coreVersion ||
manifest.ffmpegVersion !== lock.ffmpegVersion ||
!isDeepStrictEqual(manifest.sourceArchive, lock.sourceArchive) ||
!isDeepStrictEqual(manifest.sourceLock, lock.sourceLock) ||
!isDeepStrictEqual(manifest.buildDriver, lock.buildDriver) ||
!isDeepStrictEqual(manifest.toolchain, lock.toolchain) ||
!isDeepStrictEqual(manifest.patch, lock.patch) ||
!isDeepStrictEqual(manifest.opusRegression, lock.opusRegression) ||
!Array.isArray(manifest.assets) ||
manifest.assets.length !== 5
manifest.assets.length !== lock.assets.length
) {
throw new Error(`Invalid FFmpeg asset manifest: ${manifestPath}`);
throw new Error(`Invalid reviewed FFmpeg asset manifest: ${manifestPath}`);
}
const expectedPaths = new Set([
'mt/ffmpeg-core.js',
'mt/ffmpeg-core.wasm',
'mt/ffmpeg-core.worker.js',
'st/ffmpeg-core.js',
'st/ffmpeg-core.wasm',
]);
const expectedHashes = new Map([
[
'st/ffmpeg-core.js',
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
],
[
'st/ffmpeg-core.wasm',
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
],
[
'mt/ffmpeg-core.js',
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
],
[
'mt/ffmpeg-core.wasm',
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
],
[
'mt/ffmpeg-core.worker.js',
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
],
]);
if (!runtimeOnly) {
await verifyLockedFile(lock.sourceArchive, 'FFmpeg Corresponding Source');
}
await verifyLockedFile(lock.sourceLock, 'FFmpeg source lock');
await verifyLockedFile(lock.buildDriver, 'Reviewed-core build driver');
await verifyLockedFile(lock.artifact, 'Reviewed-core deterministic artifact');
const lockedAssets = new Map(lock.assets.map((asset) => [asset.path, asset]));
const seen = new Set();
for (const asset of manifest.assets) {
if (!expectedPaths.delete(asset.path)) {
throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`);
}
const bytes = await readFile(join(outputRoot, asset.path));
const digest = createHash('sha256').update(bytes).digest('hex');
safeAssetPath(asset.path);
const locked = lockedAssets.get(asset.path);
if (
asset.bytes !== bytes.byteLength ||
asset.sha256 !== digest ||
expectedHashes.get(asset.path) !== digest
!locked ||
seen.has(asset.path) ||
asset.bytes !== locked.bytes ||
asset.sha256 !== locked.sha256
) {
throw new Error(`Unexpected reviewed FFmpeg asset: ${asset.path}`);
}
seen.add(asset.path);
const bytes = await readFile(path.join(outputRoot, ...asset.path.split('/')));
if (bytes.byteLength !== locked.bytes || sha256(bytes) !== locked.sha256) {
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
}
}
if (expectedPaths.size > 0) {
throw new Error(
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
);
if (seen.size !== lockedAssets.size) {
throw new Error('One or more reviewed FFmpeg assets are missing.');
}
console.log(
`Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}`
`Verified ${seen.size} FFmpeg ${lock.buildId} assets in ${path.relative(repositoryRoot, outputRoot)}${runtimeOnly ? ' (runtime inputs only)' : ' with Corresponding Source'}`
);

View File

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

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';
const MAX_CONCAT_SOURCES = 100;
export const MIN_CROSSFADE_SECONDS = 0.04;
export const MAX_CROSSFADE_SECONDS = 10;
export interface FastConcatOptions {
readonly jobId: string;
@@ -59,6 +61,11 @@ export interface NormalizedConcatOptions extends FastConcatOptions {
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy?: 'drop-all' | 'reject';
/**
* Uniform overlap at every edit. Zero/undefined keeps hard cuts. The command
* layer bounds both the transition and the available material around edits.
*/
readonly crossfadeSeconds?: number;
}
export function validateConcatCompatibility(
@@ -334,6 +341,8 @@ export function buildNormalizedConcatPlan(
}
const includeAudio =
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
const crossfadeSeconds = options.crossfadeSeconds ?? 0;
validateCrossfade(crossfadeSeconds, ranges);
const inputs = options.sources.map((source) =>
createPlannedInput(options.jobId, source)
);
@@ -360,12 +369,35 @@ export function buildNormalizedConcatPlan(
}
}
});
const labels = options.sources
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
.join('');
graphParts.push(
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
);
if (crossfadeSeconds > 0) {
let videoLabel = 'v0';
let audioLabel = 'a0';
let effectiveDuration = ranges[0]?.durationSeconds ?? 0;
for (let index = 1; index < options.sources.length; index += 1) {
const finalTransition = index === options.sources.length - 1;
const nextVideoLabel = finalTransition ? 'vout' : `vxf${index}`;
graphParts.push(
`[${videoLabel}][v${index}]xfade=transition=fade:duration=${normalizeSeconds(crossfadeSeconds)}:offset=${normalizeSeconds(effectiveDuration - crossfadeSeconds)}[${nextVideoLabel}]`
);
videoLabel = nextVideoLabel;
if (includeAudio) {
const nextAudioLabel = finalTransition ? 'aout' : `axf${index}`;
graphParts.push(
`[${audioLabel}][a${index}]acrossfade=d=${normalizeSeconds(crossfadeSeconds)}:c1=tri:c2=tri[${nextAudioLabel}]`
);
audioLabel = nextAudioLabel;
}
effectiveDuration +=
(ranges[index]?.durationSeconds ?? 0) - crossfadeSeconds;
}
} else {
const labels = options.sources
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
.join('');
graphParts.push(
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
);
}
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
if (includeAudio) {
args.push('-map', '[aout]');
@@ -397,10 +429,9 @@ export function buildNormalizedConcatPlan(
temporaryFiles: [],
args,
outputs: [output],
expectedDurationSeconds: ranges.reduce(
(sum, range) => sum + range.durationSeconds,
0
),
expectedDurationSeconds:
ranges.reduce((sum, range) => sum + range.durationSeconds, 0) -
crossfadeSeconds * (ranges.length - 1),
requiredCapabilities: mergeRequirements(presetRequirements, {
muxers: [options.targetMuxer],
filters: [
@@ -411,7 +442,8 @@ export function buildNormalizedConcatPlan(
'setsar',
'fps',
'format',
'concat',
...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']),
...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []),
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
...(mixedAudioPresence &&
options.missingAudioPolicy === 'insert-silence'
@@ -423,7 +455,9 @@ export function buildNormalizedConcatPlan(
diagnostic(
'normalized-concat',
'info',
'Clips are normalized to common video and audio properties, then re-encoded.'
crossfadeSeconds > 0
? `Clips are normalized and joined with a ${normalizeSeconds(crossfadeSeconds)} second audio/video crossfade at every edit.`
: 'Clips are normalized to common video and audio properties, then re-encoded.'
),
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
? [
@@ -447,6 +481,34 @@ export function buildNormalizedConcatPlan(
});
}
function validateCrossfade(
crossfadeSeconds: number,
ranges: readonly { readonly durationSeconds: number }[]
): void {
if (!Number.isFinite(crossfadeSeconds) || crossfadeSeconds < 0) {
throw new RangeError('Crossfade duration must be finite and nonnegative');
}
if (crossfadeSeconds === 0) {
return;
}
if (
crossfadeSeconds < MIN_CROSSFADE_SECONDS ||
crossfadeSeconds > MAX_CROSSFADE_SECONDS
) {
throw new RangeError(
`Crossfade duration must be ${MIN_CROSSFADE_SECONDS}${MAX_CROSSFADE_SECONDS} seconds`
);
}
for (const [index, range] of ranges.entries()) {
const required = index === 0 || index === ranges.length - 1 ? 1 : 2;
if (range.durationSeconds < crossfadeSeconds * required) {
throw new RangeError(
`Clip ${index + 1} needs at least ${normalizeSeconds(crossfadeSeconds * required)} seconds for the requested crossfade material`
);
}
}
}
function comparableKeys(
kind: ConcatStreamDescription['kind']
): readonly (keyof ConcatStreamDescription)[] {

View File

@@ -14,6 +14,16 @@ import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type ImageFormat = 'png' | 'jpeg' | 'webp';
export const MAX_SCENE_CHANGE_FRAMES = 100;
export const MIN_SCENE_CHANGE_THRESHOLD = 0.01;
export const MAX_SCENE_CHANGE_THRESHOLD = 0.95;
export interface SceneChangeFrame {
/** Exact source presentation timestamp in the source video time base. */
readonly pts: number;
readonly timeSeconds: number;
}
export interface ThumbnailSize {
readonly width?: number;
readonly height?: number;
@@ -41,6 +51,21 @@ export interface ThumbnailSeriesOptions {
readonly quality?: number;
}
export interface SceneChangeAnalysisOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly threshold: number;
readonly maxFrames: number;
}
export interface SceneThumbnailSeriesOptions extends Omit<
ThumbnailSeriesOptions,
'timesSeconds' | 'intervalSeconds' | 'count'
> {
readonly frames: readonly SceneChangeFrame[];
}
export interface ContactSheetOptions {
readonly jobId: string;
readonly source: SourceReference;
@@ -57,6 +82,10 @@ export interface ContactSheetOptions {
readonly format: ImageFormat;
readonly quality?: number;
readonly drawtextAvailable?: boolean;
/** Reviewed local font mounted as a second, read-only job input. */
readonly font?: SourceReference;
/** Exact frames returned by a preceding bounded scene-change analysis. */
readonly sceneFrames?: readonly SceneChangeFrame[];
}
export function buildSingleThumbnailPlan(
@@ -164,6 +193,148 @@ export function buildThumbnailSeriesPlan(
});
}
export function buildSceneChangeAnalysisPlan(
options: SceneChangeAnalysisOptions
): FFmpegCommandPlan {
validateSceneAnalysisOptions(options);
const input = createPlannedInput(options.jobId, options.source);
return freezeCommandPlan({
id: `${options.jobId}:scene-change-analysis`,
operation: 'scene-change-analysis',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-an',
'-vf',
`select='gt(scene\\,${normalizedSceneThreshold(options.threshold)})',showinfo`,
'-frames:v',
String(options.maxFrames),
'-f',
'null',
'-',
],
outputs: [],
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: {
muxers: ['null'],
filters: ['select', 'showinfo'],
},
diagnostics: [
diagnostic(
'bounded-scene-analysis',
'info',
`Scene analysis stops after ${options.maxFrames} qualifying frames and retains no source media.`
),
],
});
}
export function parseSceneChangeFrames(
logText: string
): readonly SceneChangeFrame[] {
const frames: SceneChangeFrame[] = [];
const seenPts = new Set<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(
options: Pick<
ThumbnailSeriesOptions,
@@ -271,7 +442,22 @@ export function buildContactSheetPlan(
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
throw new TypeError('Background must be a named colour or #RRGGBB');
}
const sceneFrames = options.sceneFrames
? validateSceneFrames(
options.sceneFrames,
options.durationSeconds,
'Contact sheet'
)
: undefined;
if (sceneFrames && sceneFrames.length !== options.frameCount) {
throw new RangeError(
'Contact sheet frame count must match the analyzed scene selection'
);
}
const input = createPlannedInput(options.jobId, options.source);
const fontInput = options.font
? createPlannedInput(options.jobId, options.font, 'font')
: undefined;
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'contact-sheet',
@@ -280,14 +466,20 @@ export function buildContactSheetPlan(
});
const fps = options.frameCount / options.durationSeconds;
const filters = [
`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`,
...(sceneFrames
? [
`select='${sceneFrames
.map((frame) => `eq(pts\\,${frame.pts})`)
.join('+')}'`,
]
: [`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`]),
options.maintainAspect === false
? `scale=${options.cellWidth}:${options.cellWidth}`
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
];
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
const diagnostics = [];
if (labelsEnabled && !options.drawtextAvailable) {
if (labelsEnabled && (!options.drawtextAvailable || !fontInput)) {
labelsEnabled = false;
diagnostics.push(
diagnostic(
@@ -302,16 +494,19 @@ export function buildContactSheetPlan(
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
: '%{pts\\:hms}';
filters.push(
`drawtext=text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
`drawtext=fontfile='${fontInput?.path ?? ''}':text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
);
}
if (sceneFrames) {
filters.push('setpts=N/FRAME_RATE/TB');
}
filters.push(
`tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}`
`tile=${options.columns}x${options.rows}:nb_frames=${options.frameCount}:padding=${spacing}:margin=${spacing}:color=${background}`
);
return freezeCommandPlan({
id: `${options.jobId}:contact-sheet`,
operation: 'contact-sheet',
inputs: [input],
inputs: [input, ...(fontInput ? [fontInput] : [])],
temporaryFiles: [],
args: [
'-i',
@@ -327,12 +522,101 @@ export function buildContactSheetPlan(
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: {
...imageRequirements(options.format),
filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])],
filters: [
sceneFrames ? 'select' : 'fps',
'scale',
...(sceneFrames ? ['setpts'] : []),
'tile',
...(labelsEnabled ? ['drawtext'] : []),
],
},
diagnostics,
diagnostics: [
...diagnostics,
...(sceneFrames
? [
diagnostic(
'scene-change-selection',
'info',
`${sceneFrames.length} analyzed scene changes populate this contact sheet.`
),
]
: []),
],
});
}
function validateSceneAnalysisOptions(
options: Pick<
SceneChangeAnalysisOptions,
'durationSeconds' | 'threshold' | 'maxFrames'
>
): void {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Scene analysis source duration must be positive');
}
normalizedSceneThreshold(options.threshold);
if (
!Number.isSafeInteger(options.maxFrames) ||
options.maxFrames < 1 ||
options.maxFrames > MAX_SCENE_CHANGE_FRAMES
) {
throw new RangeError(
`Scene analysis frame limit must be an integer from 1 to ${MAX_SCENE_CHANGE_FRAMES}`
);
}
}
function normalizedSceneThreshold(value: number): string {
if (
!Number.isFinite(value) ||
value < MIN_SCENE_CHANGE_THRESHOLD ||
value > MAX_SCENE_CHANGE_THRESHOLD
) {
throw new RangeError(
`Scene threshold must be ${MIN_SCENE_CHANGE_THRESHOLD}${MAX_SCENE_CHANGE_THRESHOLD}`
);
}
return String(Number(value.toFixed(3)));
}
function validateSceneFrames(
frames: readonly SceneChangeFrame[],
durationSeconds: number,
label: string
): readonly SceneChangeFrame[] {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
throw new RangeError(`${label} source duration must be positive`);
}
if (
frames.length < 1 ||
frames.length > MAX_SCENE_CHANGE_FRAMES ||
frames.some(
(frame) =>
!Number.isSafeInteger(frame.pts) ||
frame.pts < 0 ||
!Number.isFinite(frame.timeSeconds) ||
frame.timeSeconds < 0 ||
frame.timeSeconds >= durationSeconds
)
) {
throw new RangeError(
`${label} scene frames must be bounded, finite points inside the source`
);
}
const uniquePts = new Set(frames.map((frame) => frame.pts));
if (uniquePts.size !== frames.length) {
throw new RangeError(`${label} scene frames must have unique timestamps`);
}
return Object.freeze(
[...frames]
.sort((left, right) => left.timeSeconds - right.timeSeconds)
.map((frame) => Object.freeze({ ...frame }))
);
}
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
const filter = scaleFilter(size);
return filter ? ['-vf', filter] : [];

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
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 gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?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 {
APP_VERSION,
FFMPEG_CORE_VERSION,
FFMPEG_CORE_BUILD_ID,
FFMPEG_ENGINE_VERSION,
FFMPEG_UTIL_VERSION,
FFMPEG_WRAPPER_VERSION,
@@ -101,7 +102,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
</div>
<div>
<dt>FFmpeg WASM cores</dt>
<dd>{FFMPEG_CORE_VERSION} · ST &amp; MT</dd>
<dd>{FFMPEG_CORE_BUILD_ID} · ST &amp; MT</dd>
</div>
<div>
<dt>ffmpeg.wasm utilities</dt>
@@ -156,6 +157,16 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
GPL text
</a>
</li>
<li>
DejaVu Sans 2.37 Bitstream Vera and Arev font terms
<a
href={dejavuFontLicenseUrl}
target="_blank"
rel="noreferrer"
>
Font licence
</a>
</li>
<li>
FFmpeg, linked codecs, Toolbox SDK, React and other runtime
packages retain their respective terms.
@@ -185,7 +196,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
</aside>
<footer>
<a
href="https://git.add-ideas.de/zemion/av-tools"
href="https://git.add-ideas.de/lotobo/av-tools"
target="_blank"
rel="noreferrer"
>

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,11 @@ import { useId, useState } from 'react';
import './StructuralOperations.css';
export type StructuralCapabilityKey =
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized';
| 'trim-fast'
| 'trim-accurate'
| 'concat-fast'
| 'concat-normalized'
| 'concat-crossfade';
export interface StructuralCapabilityStatus {
readonly available: boolean;
@@ -35,6 +39,7 @@ export interface StructuralTimelineClip {
readonly hasAudio: boolean;
readonly hasVideo: boolean;
readonly hasSubtitles: boolean;
readonly durationSeconds?: number;
}
export interface StructuralCompatibilityDiagnostic {
@@ -92,6 +97,7 @@ export type StructuralConcatRequest =
readonly presetId: string;
readonly missingAudioPolicy: MissingAudioPolicy;
readonly subtitlePolicy: NormalizedSubtitlePolicy;
readonly crossfadeSeconds: number;
};
type RequestHandler<T> = (request: T) => void | Promise<void>;
@@ -135,6 +141,7 @@ export function StructuralOperations({
const [subtitlePolicy, setSubtitlePolicy] = useState<
NormalizedSubtitlePolicy | ''
>('');
const [crossfadeSeconds, setCrossfadeSeconds] = useState('0');
const [pending, setPending] = useState(false);
const [status, setStatus] = useState<string>();
@@ -156,6 +163,7 @@ export function StructuralOperations({
preset: concatPreset,
missingAudioPolicy,
subtitlePolicy,
crossfadeSeconds,
availability,
connected: Boolean(onConcat),
busy: busy || pending,
@@ -445,6 +453,30 @@ export function StructuralOperations({
)}
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,
missingAudioPolicy,
subtitlePolicy,
crossfadeSeconds: Number(crossfadeSeconds),
},
'Normalized concat queued.'
);
@@ -856,6 +889,7 @@ function reasonForConcat({
preset,
missingAudioPolicy,
subtitlePolicy,
crossfadeSeconds,
availability,
connected,
busy,
@@ -865,6 +899,7 @@ function reasonForConcat({
readonly preset: StructuralExportPresetOption | undefined;
readonly missingAudioPolicy: MissingAudioPolicy | '';
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
readonly crossfadeSeconds: string;
readonly availability: StructuralOperationsProps['availability'];
readonly connected: 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.';
}
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) {
return 'This action has not been connected to the job runner.';
}
return capabilityReason(
const baseReason = capabilityReason(
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
);
if (baseReason || mode === 'fast' || Number(crossfadeSeconds) === 0) {
return baseReason;
}
return capabilityReason(availability?.['concat-crossfade']);
}
function capabilityReason(

View File

@@ -32,6 +32,9 @@ import {
type FFmpegFactory = () => Promise<FFmpegAdapter>;
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;
/**
* 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> {
const { FFmpeg } = await import('@ffmpeg/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();
if (signal?.aborted) {
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
const engine = this.#engine;
const mode = this.#mode;
if (!engine || !mode || this.#state.status !== 'ready') {
@@ -537,6 +563,10 @@ export class EngineManager {
const id = crypto.randomUUID();
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 inputPath = mounted.inputPaths[0];
if (!probeOutput || !inputPath) {
@@ -565,7 +595,15 @@ export class EngineManager {
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
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 {
const exitCode = await engine.ffprobe([
'-v',
@@ -630,6 +668,13 @@ export class EngineManager {
};
} catch (error) {
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.', {
cause: error,
});
@@ -641,6 +686,7 @@ export class EngineManager {
details: this.#logs.text(),
});
} finally {
signal?.removeEventListener('abort', cancelOnAbort);
clearTimeout(watchdog);
this.#activeJobId = null;
this.#machineProgressLines = [];

View File

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

View File

@@ -1,9 +1,9 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.av-tools",
"name": "Audio & Video Tools",
"version": "0.1.0",
"version": "0.2.1",
"description": "Convert and lightly edit audio and video locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -37,21 +37,21 @@
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/zemion/av-tools",
"repository": "https://git.add-ideas.de/lotobo/av-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/zemion/av-tools"
"url": "https://git.add-ideas.de/lotobo/av-tools"
}
],
"assets": [
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
]
}

View File

@@ -1,5 +1,7 @@
export const APP_VERSION = '0.1.0';
export const APP_VERSION = '0.2.1';
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
export const FFMPEG_UTIL_VERSION = '0.12.2';
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_OPUS_REGRESSION_VERIFIED = true;

View File

@@ -191,12 +191,12 @@ test.describe('nested static deployment and Toolbox context', () => {
const coreScript = runtime.responses.find((response) =>
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) =>
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({

View File

@@ -112,7 +112,7 @@ export async function startNestedStaticServer(): Promise<NestedStaticServer> {
};
const catalog = {
$schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
schemaVersion: 1,
id: 'de.add-ideas.browser-test-toolbox',
name: 'Browser Test Toolbox',

View File

@@ -75,9 +75,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
);
const expectedAssets = [
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
];
for (const suffix of expectedAssets) {
expect(
@@ -87,11 +87,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
).toBe(true);
}
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(
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);
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
@@ -119,8 +123,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
page,
}) => {
const runtime = observeRuntime(page);
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) =>
route.abort('failed')
await page.route(
'**/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/**',
(route) => route.abort('failed')
);
await page.goto('/');
const environment = await page.evaluate(() => ({
@@ -147,13 +152,17 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
)
).toBeVisible();
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);
expect(
runtime.responses.some(
(response) =>
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);
expect(runtime.pageErrors).toEqual([]);
@@ -247,7 +256,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
expect(
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);
assertLocalOnly(runtime);
@@ -303,7 +314,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
).toHaveCount(4);
expect(
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);
assertLocalOnly(runtime);
@@ -356,4 +369,43 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
await expect(page.getByRole('alert')).toHaveCount(0);
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>;
disposition?: Record<string, number>;
}>;
format?: { tags?: Record<string, string> };
format?: { duration?: string; tags?: Record<string, string> };
chapters?: Array<{ tags?: Record<string, string> }>;
} {
return JSON.parse(
@@ -547,6 +547,31 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
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');
@@ -687,6 +712,72 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
.size
).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 () => {
@@ -988,7 +1079,9 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
expect(
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);
assertLocalOnly(runtime);

View File

@@ -28,6 +28,8 @@ import {
buildPeakNormalizationPlan,
buildPreviewProxyPlan,
buildRemuxPlan,
buildSceneChangeAnalysisPlan,
buildSceneThumbnailSeriesPlan,
buildSingleThumbnailPlan,
buildSplitPlan,
buildStaticWaveformPlan,
@@ -44,6 +46,7 @@ import {
createConcatListEntries,
parseLoudnessMeasurement,
parsePeakMeasurement,
parseSceneChangeFrames,
resolveThumbnailTimes,
streamMapArguments,
type ConcatSource,
@@ -63,8 +66,16 @@ const NATIVE_SUBTITLES_FILTER_AVAILABLE =
encoding: 'utf8',
}).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 itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip;
const itNativeDrawtext = NATIVE_DRAWTEXT_FILTER_AVAILABLE ? it : it.skip;
interface NativeExecution {
readonly outputPaths: readonly string[];
@@ -752,6 +763,33 @@ describeNative('native execution of structured command plans', () => {
Number(probe(normalized.outputPaths[0]!).format?.duration)
).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');
nativeCommand(
[
@@ -788,6 +826,7 @@ describeNative('native execution of structured command plans', () => {
sampleRate: 16_000,
channelLayout: 'mono',
missingAudioPolicy: 'insert-silence',
crossfadeSeconds: 0.2,
}),
{ first: fixture('compatible-a.mp4'), 'video-only': videoOnly }
);
@@ -1185,8 +1224,80 @@ describeNative('native execution of structured command plans', () => {
codec_name: 'png',
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);
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', () => {
const execution = runNative(
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 () => {
const user = userEvent.setup();
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,
buildPeakNormalizationFilter,
buildPeakNormalizationPlan,
buildSceneChangeAnalysisPlan,
buildSceneThumbnailSeriesPlan,
buildSubtitleBurnPlan,
buildSubtitleExtractionPlan,
buildSubtitleMuxPlan,
@@ -25,6 +27,7 @@ import {
parseChapterJson,
parseLoudnessMeasurement,
parsePeakMeasurement,
parseSceneChangeFrames,
parseMetadataJson,
resolveThumbnailTimes,
serializeChapterJson,
@@ -360,6 +363,65 @@ describe('thumbnail and contact sheet plans', () => {
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', () => {
const plan = buildContactSheetPlan({
jobId: 'sheet',
@@ -381,6 +443,49 @@ describe('thumbnail and contact sheet plans', () => {
);
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', () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
buildConvertPlan,
buildFastConcatPlan,
buildNormalizedConcatPlan,
buildPreviewProxyPlan,
buildRemuxPlan,
buildSplitPlan,
@@ -276,6 +277,64 @@ describe('concat and transform validation', () => {
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', () => {
expect(
validateCrop({

View File

@@ -16,7 +16,9 @@ describe('HelpDialog', () => {
).toBeInTheDocument();
expect(screen.getByText('n5.1.4')).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(
screen.getByText(/copyright © 2026 Albrecht Degering/i)
).toBeInTheDocument();
@@ -29,6 +31,9 @@ describe('HelpDialog', () => {
expect(
screen.getByRole('link', { name: 'GPL text', hidden: true })
).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(
screen.getByRole('link', {
name: 'Complete inventory',

View File

@@ -26,6 +26,41 @@ afterEach(() => {
});
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 () => {
const user = userEvent.setup();
const onCreateProxy = vi.fn();

View File

@@ -138,6 +138,28 @@ describe('QuickConvert', () => {
).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 () => {
const user = userEvent.setup();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();

View File

@@ -274,6 +274,7 @@ describe('StructuralOperations', () => {
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'reject',
crossfadeSeconds: 0,
})
);
});
@@ -328,6 +329,55 @@ describe('StructuralOperations', () => {
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
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 {
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
EngineManager,
MAX_CONTACT_SHEETS_PER_ENGINE,
MAX_EXECUTIONS_PER_ENGINE,
MAX_PROBE_TIMEOUT_MILLISECONDS,
MIN_PROBE_TIMEOUT_MILLISECONDS,
MULTITHREAD_CODEC_THREAD_LIMIT,
engineJobTimeoutMilliseconds,
prepareEngineArguments,
probeTimeoutMilliseconds,
shouldRecycleExecutionCore,
} 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', () => {
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(

View File

@@ -20,8 +20,12 @@ describe('FFmpeg runtime utilities', () => {
'multithread',
(path) => `https://tools.example/deep/${path}`
);
expect(mt.coreURL).toContain('/0.12.10/mt/ffmpeg-core.js');
expect(mt.workerURL).toContain('/0.12.10/mt/ffmpeg-core.worker.js');
expect(mt.coreURL).toContain(
'/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', () => {

View File

@@ -165,30 +165,30 @@ describe('licence release gate', () => {
describe('release checksum', () => {
it('formats and parses the portable sidecar form', () => {
const digest = 'a'.repeat(64);
const line = checksumLine(digest, 'av-tools-0.1.0.zip');
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
const line = checksumLine(digest, 'av-tools-0.2.1.zip');
expect(line).toBe(`${digest} av-tools-0.2.1.zip\n`);
expect(parseChecksumLine(line)).toEqual({
archiveName: 'av-tools-0.1.0.zip',
archiveName: 'av-tools-0.2.1.zip',
digest,
});
});
it('writes and verifies a checksum sidecar', async () => {
const directory = await temporaryDirectory();
const archive = path.join(directory, 'av-tools-0.1.0.zip');
const archive = path.join(directory, 'av-tools-0.2.1.zip');
await writeFile(archive, 'deterministic bytes');
const result = await writeChecksum(archive);
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
result.digest
);
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
' av-tools-0.1.0.zip'
' av-tools-0.2.1.zip'
);
});
it('rejects checksum mismatches', async () => {
const directory = await temporaryDirectory();
const archive = path.join(directory, 'av-tools-0.1.0.zip');
const archive = path.join(directory, 'av-tools-0.2.1.zip');
const sidecar = `${archive}.sha256`;
await writeFile(archive, 'changed');
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 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; }
# 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', () => {
@@ -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', () => {
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',
css: true,
restoreMocks: true,
exclude: ['tests/browser/**', 'node_modules/**', 'dist/**'],
exclude: [
'tests/browser/**',
'node_modules/**',
'dist/**',
'.core-build/**',
],
},
});