4 Commits

87 changed files with 6309 additions and 1025 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,59 @@
All notable changes to av-tools are documented here. The project follows
semantic versioning once a first release is legally publishable.
## 0.3.0 — 2026-07-27
- Make imported media and browser-native duration/dimensions available without
loading FFmpeg, so recognized files can start playback immediately.
- Move authoritative codec, stream, chapter and tag inspection to explicit or
operation-driven demand; keep playback usable after inspection failure and
expose bounded retry controls.
- Inspect automatically when Quick Convert, preview proxies, timeline renders
or advanced tools require authoritative details, while preserving
deterministic multi-file timeline order.
- Recover Firefox probes with a fresh same-mode core, safe MT-to-ST fallback
and operation reservations that prevent concurrent mounts or preference
changes.
- Add Chromium and Firefox evidence proving the supplied 153 MB, 43-minute
lecture plays before detailed inspection and retains the same media element
afterward.
## 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

137
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.3.0 is licensed GPL-3.0-or-later. Its canonical
> release consists of the exact reviewed `v0.3.0` source, production
> ZIP/checksum and Corresponding Source bundle published together. See
> [Licensing](#licensing).
@@ -17,13 +17,13 @@ under Toolbox Portal with shared shell, theme, help and app switching.
Two workflows keep the product intentionally smaller than a nonlinear editor:
- **Quick Convert:** drop/import media, inspect streams, choose a
capability-checked preset/streams, convert, preview when the browser can, and
download.
- **Quick Convert:** drop/import media, play it and read browser-native file
information immediately, then inspect streams on demand when an export needs
authoritative details.
- **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.3 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,20 +216,38 @@ 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 while detailed inspection remains idle, browser-native
duration/dimensions must appear without loading FFmpeg, and the same element
must survive a subsequently requested authoritative probe. The command runs
that evidence in both Chromium and Firefox.
The latest recorded local Vitest run on 2026-07-27 completed **332 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
static/Toolbox-context loading is also covered. This evidence is
Chromium-specific: Firefox, Safari, mobile and exhaustive MT operation parity
remain unrecorded. The exact one-run performance record and its limitations are
in [performance observations](docs/PERFORMANCE.md).
Chromium-specific. Firefox now covers the opt-in large-file
play-before-inspection regression and its explicit authoritative-probe
transition; broad Firefox operation parity, Safari, mobile and exhaustive MT
operation parity remain unrecorded. The exact one-run performance record and
its limitations are in [performance observations](docs/PERFORMANCE.md).
## Deployment
@@ -243,12 +279,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 +308,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 +322,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 +353,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.3.0. 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.3.0`
- Release source identity: immutable annotated tag `v0.3.0`
(`refs/tags/v0.3.0`) in the canonical repository.
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
`LICENSE`.
- Copyright: Copyright © 2026 Albrecht Degering.
@@ -22,10 +22,10 @@ the tag exists, is annotated, points to the reviewed release commit and is
available without authentication:
```sh
test "$(git cat-file -t v0.1.0)" = tag
git rev-parse v0.1.0^{commit}
test "$(git cat-file -t v0.3.0)" = tag
git rev-parse v0.3.0^{commit}
git ls-remote --tags \
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.1.0
https://git.add-ideas.de/lotobo/av-tools.git refs/tags/v0.3.0
```
The Toolbox manifest names the canonical repository and SPDX licence. Publish
@@ -40,34 +40,66 @@ the tag/source archive beside the binary release and checksum.
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` |
| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` |
Install exact packages and recreate the copied same-origin assets without a
Install exact packages and recreate the verified same-origin assets without a
network fetch during the production build:
```sh
npm ci
npm run vendor:ffmpeg
npm run vendor:verify
npm run vendor:verify-runtime
npm run build
```
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the
installed core packages; it does not download them. The copied bytes are:
`scripts/copy-ffmpeg-assets.mjs` fails closed against
`scripts/reviewed-ffmpeg-core-lock.json`, verifies the deterministic tracked
ZIP and unpacks only its five declared entries. It does not download them or
fall back to published core packages. The reviewed bytes are:
| Release path | Bytes | SHA-256 |
| ------------------------------------------------ | -------: | ------------------------------------------------------------------ |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
For a publication check, generate the build-qualified Corresponding Source
archive and run `npm run vendor:verify`; that stricter gate hashes the archive
as well as every runtime input.
| Release path | Bytes | SHA-256 |
| ---------------------------------------------------------------------------- | -------: | ------------------------------------------------------------------ |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
| `vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip` (deterministic artifact) | 21338974 | `ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f` |
## Bundled contact-sheet font
Contact-sheet labels use one unmodified DejaVu Sans 2.37 TrueType file. It is
served same-origin and mounted read-only for the duration of a labelled job;
the browser verifies its size and SHA-256 before use.
| Release path | Bytes | SHA-256 |
| --------------------------- | ------: | ------------------------------------------------------------------ |
| `fonts/DejaVuSans.ttf` | 757,076 | `7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954` |
| `LICENSES/DEJAVU_FONTS.txt` | n/a | licence text embedded in the reviewed font and official archive |
The byte-for-byte source is `ttf/DejaVuSans.ttf` in the official
`dejavu-fonts-ttf-2.37.tar.bz2` release:
- project download record: `https://dejavu-fonts.github.io/Download.html`;
- archive URL:
`https://downloads.sourceforge.net/project/dejavu/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2`;
- archive SHA-256:
`fa9ca4d13871dd122f61258a80d01751d603b4d3ee14095d65453b4e846e17d7`;
- corresponding upstream tag `version_2_37`, commit
`0eda8a319c08835009849583cd090bb5b141ce25`.
The release archive and the bundled file were independently hashed and the
font's internal version string is `Version 2.37`. The file is not modified or
subsetted. Its Bitstream Vera/Arev permission terms are reproduced verbatim in
`LICENSES/DEJAVU_FONTS.txt`.
## Corresponding ffmpeg.wasm and FFmpeg source
- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15,
`@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10:
- Base ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15 and the
source configuration for core 0.12.10:
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
@@ -88,6 +120,14 @@ The upstream build control files are `Dockerfile`, `Makefile`,
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
`make prd-mt` builds multithread.
The shipped profile is rebuilt by
`scripts/build-reviewed-ffmpeg-core.sh`. It applies upstream ffmpeg.wasm commit
`b409e36475bc21f0451b5b1e1d126fa82871439a` (`-sSTACK_SIZE=5MB`), uses exact
Emscripten 3.1.40 and CMake 3.27.9 identities, makes upstream container-stage
isolation explicit and refuses maintainer-local paths in output. The exact
source archive, toolchain, patch, asset and Opus-gate identities are locked in
`scripts/reviewed-ffmpeg-core-lock.json`.
The Dockerfile uses branch names for two inputs. Repository history resolves
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
changed since:
@@ -121,10 +161,13 @@ npm run source:package
```
The command writes
`release/ffmpeg-core-0.12.10-corresponding-source.tar.xz` and its
`release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz`
and its
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
The npm packages contain the resulting binaries but not every linked
component's source and legal file, so publish this reviewed bundle and the
The reviewed archive for this build profile is 43,050,052 bytes with SHA-256
`2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a`.
The reviewed object-code archive does not contain every linked component's
source and legal file, so publish this Corresponding Source bundle and the
notices beside the object code. A homepage link alone is not asserted to
satisfy GPL source obligations.

View File

@@ -1,6 +1,6 @@
# Third-party notices
av-tools 0.1.0 is Copyright © 2026 Albrecht Degering and licensed
av-tools 0.3.0 is Copyright © 2026 Albrecht Degering and licensed
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
inputs. It is informational, not legal advice, and does not replace their
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
@@ -9,30 +9,33 @@ receives licence files shipped by installed runtime npm packages under
## Browser runtime
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
| -------------------------------- | ------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
| `@ffmpeg/core` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | single-thread FFmpeg JavaScript and WebAssembly | shipped verbatim as self-hosted assets |
| `@ffmpeg/core-mt` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | multithread FFmpeg JavaScript, WebAssembly and pthread worker | shipped verbatim as self-hosted assets |
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
| -------------------------------- | -------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.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

@@ -27,8 +27,10 @@ Browser storage boundary
```
- `src/toolbox/` owns the one typed manifest and suite integration.
- `src/media/` normalizes ffprobe JSON, duration/timecode, stream selection,
playback MIME decisions and safe names.
- `src/media/` reads lightweight browser-native metadata, normalizes
authoritative ffprobe JSON, and owns duration/timecode, stream-selection,
playback-MIME and safe-name decisions. Browser metadata and probe data are
deliberately separate models.
- `src/project/` owns the versioned, non-destructive project document and
migrations. A source is represented by identity/metadata and must be
reattached after load; the current application does not persist source bytes.
@@ -51,20 +53,35 @@ raw command field.
## FFmpeg lifecycle
1. Inspect secure-context, worker, `crossOriginIsolated` and
1. Import creates an object URL immediately. A detached native media element
reads duration/dimensions where supported; playback and file identity do not
initialize FFmpeg.
2. Detailed inspection remains idle until the user requests it or an operation
requires codecs, complete streams, chapters, tags or dispositions.
3. Inspect secure-context, worker, `crossOriginIsolated` and
`SharedArrayBuffer` availability.
2. Select MT only when all MT prerequisites are true (or ST when explicitly
4. Select MT only when all MT prerequisites are true (or ST when explicitly
selected). A failed MT load is terminated and retried once with ST.
3. Load wrapper/core assets via base-relative, same-origin URLs.
4. Query version/build configuration/formats/codecs/encoders/filters
5. Load wrapper/core assets via base-relative, same-origin URLs.
6. Query version/build configuration/formats/codecs/encoders/filters
sequentially and publish immutable capability state.
5. For one job, validate resource estimates and structured settings, allocate
7. For one job, validate resource estimates and structured settings, allocate
a generated directory, mount/write inputs, run a generated argument array,
read only declared outputs, and re-probe applicable results.
6. Return result bytes and an export report, then unmount/delete virtual files.
7. Retain the idle engine for the next sequential job; terminate it on mode
8. Return result bytes and an export report, then unmount/delete virtual files.
9. Retain the idle engine for the next sequential job; terminate it on mode
changes, unrecoverable errors or cancellation.
A detailed-probe failure changes only the inspection state. The File, object
URL, browser metadata and provisional timeline clip remain available, and the
user can retry. An authoritative probe supersedes browser duration/dimensions
and reconciles an untouched provisional full-source clip.
The reviewed Emscripten runtime can stall when Firefox probes on a core that
has retained prior application state. Before an explicit Firefox probe, the
manager recreates the identical ST/MT core and reuses only that mode's already
validated capability inventory. Chromium retains the normal warm-core path.
The FFmpeg instance is a single process. `maxConcurrentFFmpegJobs` is one and
no probe/export operations overlap on it. The application-facing scheduler is
also bounded: it retains at most eight scheduled operations in memory,

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.3.0 release fixture matrix
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
preparation and pass the checksum/probe verifier. That proves fixture
@@ -41,20 +41,20 @@ browser may still be unable to preview the result.
Preset availability always depends on detected runtime requirements:
| Preset family | Required examples | Browser result preview |
| ------------------ | ---------------------------------------- | ------------------------------------------------------ |
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | browser-dependent; Opus core regression gate applies |
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
| MP3 | `mp3`, `libmp3lame` | commonly playable |
| M4A/AAC | `ipod`, `aac` | commonly playable |
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies |
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
| FLAC | `flac`, `flac` | browser-dependent |
| Preset family | Required examples | Browser result preview |
| ------------------ | ---------------------------------------- | -------------------------------------------------------- |
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | enabled when the loaded core advertises all requirements |
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
| MP3 | `mp3`, `libmp3lame` | commonly playable |
| M4A/AAC | `ipod`, `aac` | commonly playable |
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
| Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded |
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
| FLAC | `flac`, `flac` | browser-dependent |
“Lossless” applies only to a lossless audio codec such as PCM/FLAC relative to
its decoded samples. It does not make a lossy source lossless. Stream-copy
@@ -77,11 +77,30 @@ remux avoids re-encoding compatible streams but may change container metadata.
- Normalized concat uses an explicit export preset and requires an explicit
insert-silence, drop-all-audio or reject policy when clips have mismatched
audio presence.
- Normalized concat can overlap every edit with one uniform 0.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.3.0 release baseline. A capability appearing in
FFmpeg output does not remove these product/browser/resource limits.
## Functional
@@ -13,6 +13,9 @@ FFmpeg output does not remove these product/browser/resource limits.
frame-accurate. Accurate mode requires decoding/re-encoding.
- Stream-copy concatenate works only when selected streams and codec/container
parameters are compatible. Other input needs normalized re-encoding.
- Crossfades are uniform fade/triangular-curve transitions on normalized
sequential joins, not arbitrary transition effects or overlapping timeline
tracks. They require enough material on both sides of every internal edit.
- Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
cannot restore information already lost in a source.
- Metadata removal is best effort. Muxers may add structural/encoder fields,
@@ -26,6 +29,12 @@ FFmpeg output does not remove these product/browser/resource limits.
page discards queued work.
- A file-generating batch and its managed result collection are bounded to 255
outputs, leaving the 256th ZIP entry available for an optional export report.
- Scene analysis scans the source until it finds the requested changes or
reaches the end, retains at most 100 exact PTS results and can therefore take
about as long as a full decode. It does not begin an export implicitly when
no frame meets the chosen threshold.
- Labelled contact sheets require a core with `drawtext`; the reviewed local
font does not make that filter available on a core that omits it.
- Preview proxies are bounded, lossy H.264/AAC derivatives for local playback;
they do not replace the source or guarantee that every browser can play the
result.
@@ -36,10 +45,14 @@ FFmpeg output does not remove these product/browser/resource limits.
presets from runtime formats/codecs/encoders/filters.
- FFmpeg-readable output may be download-only because browser media elements
support fewer container/codec combinations.
- Browser-native basic information intentionally omits codecs, complete stream
lists, chapters, tags, subtitles, attachments and dispositions. Features
that need those details request an authoritative local FFprobe pass.
- 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,13 +82,15 @@ 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.3.0` source and FFmpeg Corresponding Source bundle
together; development-only smoke artifacts must not be distributed.
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
loading and one two-size ST/MT performance baseline. Firefox, Safari, mobile,
exhaustive MT feature parity and repeated performance measurements remain
unrecorded. Live Portal container/header verification is a deployment gate.
loading and one two-size ST/MT performance baseline. Firefox covers the
opt-in large-file browser-metadata/playback-to-authoritative-probe
regression. Broad Firefox operation parity, Safari, mobile, exhaustive MT
feature parity and repeated performance measurements remain unrecorded. Live
Portal container/header verification is a deployment gate.
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.

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,22 @@ 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.
Import does not start FFmpeg. Recognized local audio/video can begin browser
playback from its temporary object URL while a native media element reads
lightweight duration/dimensions. Detailed FFprobe inspection starts only when
requested or required for an export and has a bounded, size-aware watchdog:
30 seconds for small files, a two-MiB-per-second budget for larger files, and a
five-minute ceiling. A detailed-inspection timeout is nonfatal; browser
playback and basic file information remain available. Completed probe metadata
remains authoritative for stream mapping, remux safety, chapters, tags,
subtitles/attachments and export validation.
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.3.0` at that exact commit;
3. push the commit and tag to the canonical public repository;
4. assemble, review and publish corresponding source and exact external
notices for both core packages and linked libraries;
notices for both reviewed core profiles and linked libraries;
5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned.
`SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not
@@ -44,6 +44,7 @@ Normal packaging validates the root GPL version 3 text and matching
```sh
npm run package:release
npm run source:package
npm run vendor:verify
```
The legacy development-only escape hatch is retained only for checking an
@@ -56,19 +57,19 @@ npm run package:release -- --allow-unlicensed-development-smoke
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
```text
release/av-tools-0.1.0.zip
release/av-tools-0.1.0.zip.sha256
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256
release/av-tools-0.3.0.zip
release/av-tools-0.3.0.zip.sha256
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
```
Verify independently:
```sh
node scripts/checksum-release.mjs
(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256)
unzip -l release/av-tools-0.1.0.zip
tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
unzip -l release/av-tools-0.3.0.zip
tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
```
The packager:
@@ -90,13 +91,13 @@ When used against a fixture without an application licence, the escape hatch add
## 4. Portal assembly smoke
Use the clean reference clone beside av-tools, never the user's active Portal
worktree:
Use a reviewed Portal Git repository beside av-tools. The harness reads its
exact `HEAD` and clones that revision into a temporary directory:
```sh
npm run portal:smoke -- \
--artifact release/av-tools-0.1.0.zip \
--allow-known-header-gap
--artifact release/av-tools-0.3.0.zip \
--portal-root ../toolbox-portal
```
The smoke harness clones the exact Portal HEAD to a temporary directory,
@@ -106,19 +107,23 @@ then verifies the generated catalogue/release records and copied FFmpeg assets.
The temporary directory is removed unless `--keep-temp` is explicitly passed.
No machine-specific path is committed and no Portal worktree is changed.
Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP
omission of `media-src blob:` is a failing deployment check. The override still
reports the gap and is only for testing archive assembly. See
`docs/PORTAL_REQUIREMENTS.md`.
Portal `v0.6.0`
(`5c052bc6d1073a66600cfddc44c0e16da34bede8`) already passes the isolation,
Blob-media CSP and explicit WASM MIME checks. Its plain-semver cache rule does
not match the reviewed core's build-profile suffix, so the harness reports a
cache warning. The staged Portal extension accepts only the exact reviewed
profile in addition to plain semantic versions and rejects malformed or
mutable-looking suffixes; a temporary clean commit containing that patch passed
this smoke without warnings.
The user's active `/mnt/DATA/git/toolbox-portal` worktree contains the narrow
directive as a local patch, but the smoke intentionally does not consume that
mutable worktree. Until the patch exists in a reviewed immutable Portal
revision, retain the clean-reference failure/override behavior above.
`--allow-known-header-gap` remains available only for deliberately testing an
older Portal revision with a known required-header error. It is unnecessary
for `v0.6.0` and must not suppress review of new gaps. See
`docs/PORTAL_REQUIREMENTS.md`.
## 5. Publish only reviewed bytes
From a clean `v0.1.0` checkout, rerun all checks, create the ZIP once, verify
From a clean `v0.3.0` checkout, rerun all checks, create the ZIP once, verify
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never

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.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "av-tools",
"version": "0.1.0",
"version": "0.3.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2",
"@add-ideas/toolbox-shell-react": "0.2.2",
"@ffmpeg/core": "0.12.10",
"@ffmpeg/core-mt": "0.12.10",
"@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.3.0",
"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 --project=firefox-large-media --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",

View File

@@ -21,5 +21,10 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox-large-media',
testMatch: 'large-media-preview.spec.ts',
use: { ...devices['Desktop Firefox'] },
},
],
});

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.3.0",
"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'}`
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,21 @@
import type { MediaProbe } from '../media';
import type { BrowserMediaMetadata, MediaProbe } from '../media';
export type AssetPhase = 'queued' | 'probing' | 'ready' | 'error';
/**
* State of the optional authoritative FFprobe inspection.
*
* The File and its object URL are usable in every phase. In particular,
* `error` means only that detailed metadata is unavailable; it never marks the
* local source or browser playback as failed.
*/
export type AssetPhase = 'idle' | 'probing' | 'ready' | 'error';
export interface ImportedMediaAsset {
id: string;
file: File;
objectUrl: string;
phase: AssetPhase;
browserMetadata?: BrowserMediaMetadata;
browserMetadataError?: string;
probe?: MediaProbe;
rawProbe?: string;
error?: string;
@@ -29,10 +38,47 @@ export function createAsset(file: File): ImportedMediaAsset {
id: crypto.randomUUID(),
file,
objectUrl: URL.createObjectURL(file),
phase: 'queued',
phase: 'idle',
};
}
export function assetDurationSeconds(
asset: ImportedMediaAsset | undefined
): number | undefined {
return (
asset?.probe?.durationSeconds ?? asset?.browserMetadata?.durationSeconds
);
}
export function assetHasVideo(
asset: ImportedMediaAsset | undefined
): boolean | undefined {
if (asset?.probe) {
return asset.probe.streams.some(
(stream) =>
stream.type === 'video' && stream.disposition.attached_pic !== true
);
}
if (asset?.browserMetadata?.hasVideo !== undefined) {
return asset.browserMetadata.hasVideo;
}
if (asset?.browserMetadata?.kind === 'video') return true;
if (asset?.browserMetadata?.kind === 'audio') return false;
return undefined;
}
export function assetHasAudio(
asset: ImportedMediaAsset | undefined
): boolean | undefined {
if (asset?.probe) {
return asset.probe.streams.some((stream) => stream.type === 'audio');
}
if (asset?.browserMetadata?.hasAudio !== undefined) {
return asset.browserMetadata.hasAudio;
}
return asset?.browserMetadata?.kind === 'audio' ? true : undefined;
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
if (bytes < 1_000) return `${bytes} B`;

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

@@ -12,6 +12,7 @@ export interface CapabilityPanelProps {
preference: EnginePreference;
resourcePolicy: ResourcePolicy;
deviceMemoryGiB?: number;
disabled?: boolean;
onPreferenceChange: (preference: EnginePreference) => void;
onInitialize: () => void;
}
@@ -21,6 +22,7 @@ export function CapabilityPanel({
preference,
resourcePolicy,
deviceMemoryGiB,
disabled = false,
onPreferenceChange,
onInitialize,
}: CapabilityPanelProps) {
@@ -58,7 +60,12 @@ export function CapabilityPanel({
Engine mode
<select
value={preference}
disabled={state.status === 'running'}
disabled={
disabled ||
state.status === 'loading' ||
state.status === 'recovering' ||
state.status === 'running'
}
onChange={(event) =>
onPreferenceChange(event.currentTarget.value as EnginePreference)
}
@@ -188,7 +195,11 @@ export function CapabilityPanel({
</p>
</section>
{state.status === 'idle' ? (
<button className="button button--secondary" onClick={onInitialize}>
<button
className="button button--secondary"
disabled={disabled}
onClick={onInitialize}
>
Initialize engine
</button>
) : null}

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,
@@ -59,10 +60,9 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
<span className="step-number">1</span>
<h3>Add media</h3>
<p>
Pick or drop local files. The browser gives FFmpeg direct,
read-only access when WORKERFS is available. If it must use the
bounded memory-copy fallback, the job log reports the exact copied
byte count.
Pick or drop local files. Playback, file identity, duration and
dimensions use the browser first; FFmpeg stays unloaded until
detailed streams or an export are requested.
</p>
</section>
<section>
@@ -70,7 +70,8 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
<h3>Choose an operation</h3>
<p>
Quick Convert produces one output. Edit arranges non-destructive
clips on a sequential timeline.
clips on a sequential timeline. Detailed inspection adds codecs,
complete streams, chapters and tags only when needed.
</p>
</section>
<section>
@@ -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

@@ -1,6 +1,11 @@
import { useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import { formatBytes, formatDuration } from '../app/application-state';
import {
assetDurationSeconds,
assetHasVideo,
formatBytes,
formatDuration,
} from '../app/application-state';
import type {
AvProjectV1,
FadeCurve,
@@ -39,6 +44,7 @@ export interface InspectorProps {
onChaptersChange: (chapters: ProjectChapter[]) => void;
onSubtitlesChange: (tracks: ProjectSubtitleTrack[]) => void;
onSubtitleImport?: (file: File) => void;
onInspectDetails?: (assetId: string) => void;
onThumbnail: (request: ThumbnailRequest) => void;
currentPreviewTimeSeconds?: number;
disabled?: boolean;
@@ -74,6 +80,7 @@ export function Inspector({
onChaptersChange,
onSubtitlesChange,
onSubtitleImport,
onInspectDetails,
onThumbnail,
currentPreviewTimeSeconds,
disabled = false,
@@ -119,7 +126,13 @@ export function Inspector({
))}
</div>
<div className="inspector__body">
{tab === 'media' ? <MediaInspector asset={asset} /> : null}
{tab === 'media' ? (
<MediaInspector
asset={asset}
disabled={disabled}
onInspectDetails={onInspectDetails}
/>
) : null}
{tab === 'transform' ? (
<TransformInspector
clip={clip}
@@ -159,7 +172,7 @@ export function Inspector({
) : null}
{tab === 'thumbnails' ? (
<ThumbnailInspector
duration={asset?.probe?.durationSeconds}
duration={assetDurationSeconds(asset)}
currentPreviewTimeSeconds={currentPreviewTimeSeconds}
disabled={disabled || !asset}
onCreate={onThumbnail}
@@ -170,20 +183,30 @@ export function Inspector({
);
}
function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
function MediaInspector({
asset,
disabled,
onInspectDetails,
}: {
asset?: ImportedMediaAsset;
disabled: boolean;
onInspectDetails?: (assetId: string) => void;
}) {
if (!asset) return <p className="panel-empty">Select a media source.</p>;
const probe = asset.probe;
const durationSeconds = assetDurationSeconds(asset);
const hasVideo = assetHasVideo(asset) === true;
const width =
probe?.streams.find((stream) => stream.type === 'video')?.width ??
asset.browserMetadata?.width;
const height =
probe?.streams.find((stream) => stream.type === 'video')?.height ??
asset.browserMetadata?.height;
return (
<div className="inspector-section">
<div className="inspector-file">
<span>
<Icon
name={
probe?.streams.some((stream) => stream.type === 'video')
? 'video'
: 'audio'
}
/>
<Icon name={hasVideo ? 'video' : 'audio'} />
</span>
<div>
<strong>{asset.file.name}</strong>
@@ -191,6 +214,18 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
</div>
</div>
<dl className="detail-list">
<div>
<dt>File type</dt>
<dd>
{asset.browserMetadata?.file.declaredMimeType &&
asset.browserMetadata.file.declaredMimeType !==
'application/octet-stream'
? asset.browserMetadata.file.declaredMimeType
: (asset.browserMetadata?.file.inferredMimeType ??
asset.browserMetadata?.file.declaredMimeType ??
(asset.file.type || '—'))}
</dd>
</div>
<div>
<dt>Container</dt>
<dd>
@@ -199,8 +234,21 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
</div>
<div>
<dt>Duration</dt>
<dd>{formatDuration(probe?.durationSeconds)}</dd>
<dd>
{formatDuration(durationSeconds)}
{!probe && durationSeconds !== undefined ? ' · browser' : ''}
</dd>
</div>
{hasVideo ? (
<div>
<dt>Dimensions</dt>
<dd>
{width !== undefined && height !== undefined
? `${width}×${height}${probe ? '' : ' · browser'}`
: '—'}
</dd>
</div>
) : null}
<div>
<dt>Bit rate</dt>
<dd>
@@ -210,6 +258,41 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
</dd>
</div>
</dl>
{!probe ? (
<div
className={`notice ${
asset.phase === 'error' ? 'notice--warning' : ''
}`}
role="status"
>
<Icon name={asset.phase === 'error' ? 'warning' : 'info'} />
<div>
<strong>
{asset.phase === 'probing'
? 'Inspecting detailed media information…'
: asset.phase === 'error'
? 'Detailed inspection is unavailable.'
: 'Detailed information has not been inspected.'}
</strong>
<small>
{asset.phase === 'error'
? asset.error
: 'Codecs, complete streams, chapters and tags are loaded only when needed.'}
</small>
</div>
{asset.phase !== 'probing' && onInspectDetails ? (
<button
type="button"
className="button button--secondary"
disabled={disabled}
onClick={() => onInspectDetails(asset.id)}
>
<Icon name="info" />
{asset.phase === 'error' ? 'Retry inspection' : 'Inspect details'}
</button>
) : null}
</div>
) : null}
{probe && Object.keys(probe.tags).length > 0 ? (
<>
<h3>Container tags</h3>
@@ -223,66 +306,69 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
</dl>
</>
) : null}
<h3>Streams</h3>
<ol className="inspector-streams">
{probe?.streams.map((stream) => (
<li key={stream.index}>
<span className="stream-kind">{stream.type}</span>
<div>
<strong>
#{stream.index} · {stream.codecName ?? 'unknown'}
</strong>
{stream.type === 'video' ? (
<>
{probe ? <h3>Streams</h3> : null}
{probe ? (
<ol className="inspector-streams">
{probe?.streams.map((stream) => (
<li key={stream.index}>
<span className="stream-kind">{stream.type}</span>
<div>
<strong>
#{stream.index} · {stream.codecName ?? 'unknown'}
</strong>
{stream.type === 'video' ? (
<>
<small>
Encoded pixels {stream.width ?? '?'}×
{stream.height ?? '?'}
{stream.codedWidth !== undefined &&
stream.codedHeight !== undefined
? ` · coded ${stream.codedWidth}×${stream.codedHeight}`
: ''}
{' · '}
{stream.frameRate?.toFixed(3) ?? '?'} fps
</small>
<small>
SAR {stream.sampleAspectRatio ?? '—'} · DAR{' '}
{stream.displayAspectRatio ?? '—'} · rotation{' '}
{stream.rotationDegrees === undefined
? '—'
: `${stream.rotationDegrees}°`}
</small>
</>
) : (
<small>
Encoded pixels {stream.width ?? '?'}×{stream.height ?? '?'}
{stream.codedWidth !== undefined &&
stream.codedHeight !== undefined
? ` · coded ${stream.codedWidth}×${stream.codedHeight}`
: ''}
{' · '}
{stream.frameRate?.toFixed(3) ?? '?'} fps
{stream.type === 'audio'
? `${stream.sampleRate ?? '?'} Hz · ${stream.channelLayout ?? `${stream.channels ?? '?'} channels`}`
: (stream.language ??
stream.title ??
'No additional details')}
</small>
)}
{Object.keys(stream.disposition).length > 0 ? (
<small>
SAR {stream.sampleAspectRatio ?? '—'} · DAR{' '}
{stream.displayAspectRatio ?? '—'} · rotation{' '}
{stream.rotationDegrees === undefined
? '—'
: `${stream.rotationDegrees}°`}
Disposition:{' '}
{Object.entries(stream.disposition)
.filter(([, enabled]) => enabled)
.map(([name]) => name)
.join(', ') || 'none'}
</small>
</>
) : (
<small>
{stream.type === 'audio'
? `${stream.sampleRate ?? '?'} Hz · ${stream.channelLayout ?? `${stream.channels ?? '?'} channels`}`
: (stream.language ??
stream.title ??
'No additional details')}
</small>
)}
{Object.keys(stream.disposition).length > 0 ? (
<small>
Disposition:{' '}
{Object.entries(stream.disposition)
.filter(([, enabled]) => enabled)
.map(([name]) => name)
.join(', ') || 'none'}
</small>
) : null}
{Object.keys(stream.tags).length > 0 ? (
<dl className="stream-tag-list">
{Object.entries(stream.tags).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
) : null}
</div>
</li>
)) ?? <li>Waiting for inspection.</li>}
</ol>
) : null}
{Object.keys(stream.tags).length > 0 ? (
<dl className="stream-tag-list">
{Object.entries(stream.tags).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
) : null}
</div>
</li>
))}
</ol>
) : null}
{probe && probe.chapters.length > 0 ? (
<>
<h3>Source chapters</h3>
@@ -333,8 +419,8 @@ function TransformInspector({
}) {
if (!clip) return <p className="panel-empty">Select a timeline clip.</p>;
const video = asset?.probe?.streams.find((stream) => stream.type === 'video');
const sourceWidth = video?.width;
const sourceHeight = video?.height;
const sourceWidth = video?.width ?? asset?.browserMetadata?.width;
const sourceHeight = video?.height ?? asset?.browserMetadata?.height;
const canShowVisualCrop =
asset !== undefined &&
isPositiveSafeInteger(sourceWidth) &&

View File

@@ -1,11 +1,19 @@
import type { ImportedMediaAsset } from '../app/application-state';
import { formatBytes, formatDuration } from '../app/application-state';
import {
assetDurationSeconds,
assetHasVideo,
formatBytes,
formatDuration,
} from '../app/application-state';
import { Icon } from './Icon';
export interface MediaBinProps {
assets: readonly ImportedMediaAsset[];
selectedId?: string;
onSelect: (id: string) => void;
onInspect: (id: string) => void;
inspectDisabled?: boolean;
removeDisabled?: boolean;
onRemove: (id: string) => void;
}
@@ -13,6 +21,9 @@ export function MediaBin({
assets,
selectedId,
onSelect,
onInspect,
inspectDisabled = false,
removeDisabled = false,
onRemove,
}: MediaBinProps) {
if (assets.length === 0) {
@@ -27,9 +38,13 @@ export function MediaBin({
return (
<ul className="media-list" aria-label="Imported media">
{assets.map((asset) => {
const hasVideo = assetHasVideo(asset) === true;
const video = asset.probe?.streams.find(
(stream) => stream.type === 'video'
);
const durationSeconds = assetDurationSeconds(asset);
const width = video?.width ?? asset.browserMetadata?.width;
const height = video?.height ?? asset.browserMetadata?.height;
const audioCount =
asset.probe?.streams.filter((stream) => stream.type === 'audio')
.length ?? 0;
@@ -43,34 +58,67 @@ export function MediaBin({
]
.filter(Boolean)
.join(' ')}
aria-pressed={selectedId === asset.id}
onClick={() => onSelect(asset.id)}
>
<span className="media-card__type">
<Icon name={video ? 'video' : 'audio'} />
<Icon name={hasVideo ? 'video' : 'audio'} />
</span>
<span className="media-card__content">
<strong title={asset.file.name}>{asset.file.name}</strong>
<small>
{formatBytes(asset.file.size)}
{asset.probe?.durationSeconds !== undefined
? ` · ${formatDuration(asset.probe.durationSeconds)}`
{durationSeconds !== undefined
? ` · ${formatDuration(durationSeconds)}`
: ''}
</small>
<small className={`phase phase--${asset.phase}`}>
{asset.phase === 'probing'
? 'Inspecting locally…'
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Inspecting details…`
: asset.phase === 'ready'
? `${video ? `${video.width ?? '?'}×${video.height ?? '?'}` : 'Audio'} · ${audioCount} audio`
? `${hasVideo ? `${width ?? '?'}×${height ?? '?'}` : 'Audio'} · ${audioCount} audio`
: asset.phase === 'error'
? asset.error
: 'Queued'}
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Details unavailable`
: asset.browserMetadataError
? 'File ready · Basic metadata unavailable'
: asset.browserMetadata
? 'Ready to play · Details on demand'
: 'File ready · Reading basic metadata…'}
</small>
</span>
</button>
{asset.phase !== 'ready' ? (
<button
className="icon-action"
type="button"
aria-label={`${asset.phase === 'error' ? 'Retry detailed inspection for' : 'Inspect details for'} ${asset.file.name}`}
title={
asset.phase === 'error'
? asset.error
: 'Read codecs, streams, chapters and tags'
}
disabled={inspectDisabled || asset.phase === 'probing'}
{...(asset.error
? { 'aria-describedby': `inspection-error-${asset.id}` }
: {})}
onClick={() => onInspect(asset.id)}
>
<Icon name={asset.phase === 'probing' ? 'queue' : 'info'} />
</button>
) : null}
{asset.error ? (
<span
className="visually-hidden"
id={`inspection-error-${asset.id}`}
>
{asset.error}
</span>
) : null}
<button
className="icon-action"
type="button"
aria-label={`Remove ${asset.file.name}`}
disabled={removeDisabled}
onClick={() => onRemove(asset.id)}
>
<Icon name="trash" />

View File

@@ -1,8 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import { assetHasAudio, assetHasVideo } from '../app/application-state';
import {
assessBrowserPlayback,
assessGeneratedResultPreview,
isAudioMimeType,
isVideoMimeType,
mimeTypeFromFileName,
type MediaProbe,
} from '../media';
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
@@ -47,15 +51,28 @@ 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;
: assetHasVideo(asset) !== undefined
? assetHasVideo(asset) === true
: declaredSourceMimeType
? isVideoMimeType(declaredSourceMimeType)
: false;
const hasAudio = result
? result.mimeType.startsWith('audio/')
: asset?.probe?.streams.some((stream) => stream.type === 'audio') === true;
: assetHasAudio(asset) !== undefined
? assetHasAudio(asset) === true
: declaredSourceMimeType
? isAudioMimeType(declaredSourceMimeType)
: false;
const hasImage = result?.mimeType.startsWith('image/') === true;
const resultPlayback = useMemo(() => {
if (!result) return undefined;
@@ -186,7 +203,12 @@ 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 · Detailed inspection runs separately.'
: asset?.phase === 'error'
? 'Local source preview · Detailed inspection unavailable.'
: 'Local source preview · Basic browser metadata'))}
</span>
{needsProxy && !proxySettings ? (
<button type="button" className="text-button" onClick={onCreateProxy}>

View File

@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import { assetDurationSeconds, assetHasVideo } from '../app/application-state';
import type { EngineState } from '../ffmpeg/ffmpeg.types';
import type { MediaStreamProbe } from '../media';
import type { StreamSelection } from '../commands/command-utils';
import {
validateRemuxCompatibility,
@@ -16,9 +16,14 @@ import {
presetAvailability,
} from '../presets/preset-registry';
import { formatBytes, formatDuration } from '../app/application-state';
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
import { Icon } from './Icon';
import {
quickStreamUnavailableReason,
type QuickOperation,
} from './quick-convert-selection';
export type QuickOperation = 'convert' | 'remux';
export type { QuickOperation } from './quick-convert-selection';
export interface QuickExportConfiguration {
operation: QuickOperation;
@@ -27,6 +32,11 @@ export interface QuickExportConfiguration {
streamSelection: StreamSelection;
removeMetadata: boolean;
removeChapters: boolean;
/**
* The source was not authoritatively inspected when the user submitted the
* form. Resolve a safe default selection from the on-demand probe.
*/
selectDetectedStreams?: true;
}
export interface QuickConvertProps {
@@ -35,6 +45,7 @@ export interface QuickConvertProps {
busy: boolean;
queueing?: boolean;
presets?: readonly ExportPreset[];
onInspect?: () => void;
onExport: (configuration: QuickExportConfiguration) => void;
}
@@ -44,6 +55,7 @@ export function QuickConvert({
busy,
queueing = false,
presets = BUILT_IN_PRESETS,
onInspect,
onExport,
}: QuickConvertProps) {
const [operation, setOperation] = useState<QuickOperation>('convert');
@@ -58,6 +70,7 @@ export function QuickConvert({
}>();
const preset = presets.find((entry) => entry.id === presetId);
const hasDetailedInformation = asset?.probe !== undefined;
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
const requestedStreams = useMemo(
() =>
@@ -139,9 +152,12 @@ 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;
hasDetailedInformation &&
preset?.kind === 'audio' &&
effectiveSelection.audio.length === 0;
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
stream.type === 'video' ||
stream.type === 'audio' ||
@@ -157,16 +173,17 @@ export function QuickConvert({
]
: []
);
const remuxDiagnostics = remuxContainer
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
remuxContainer,
effectiveSelection
)
: [];
const remuxDiagnostics =
remuxContainer && hasDetailedInformation
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
remuxContainer,
effectiveSelection
)
: [];
const remuxErrors = remuxDiagnostics.filter(
(diagnostic) => diagnostic.severity === 'error'
);
@@ -177,16 +194,16 @@ export function QuickConvert({
const remuxUnavailableReason = remuxMuxerMissing
? `The loaded core is missing muxer ${remuxContainer}.`
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
const hasSelectedStream = selectedStreams.size > 0;
const hasSelectedStream = !hasDetailedInformation || selectedStreams.size > 0;
const canExport =
asset?.phase === 'ready' &&
asset !== undefined &&
!busy &&
hasSelectedStream &&
(operation === 'remux'
? !remuxMuxerMissing && remuxErrors.length === 0
: preset !== undefined &&
availability?.status !== 'unavailable' &&
!opusNeedsRegressionTest &&
!opusBlockedByPinnedCore &&
!presetNeedsMissingAudio);
const presetOption = (entry: ExportPreset) => {
@@ -199,8 +216,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;
@@ -226,27 +243,23 @@ export function QuickConvert({
{asset ? (
<div className="source-summary">
<span className="source-summary__icon">
<Icon
name={
asset.probe?.streams.some((stream) => stream.type === 'video')
? 'video'
: 'audio'
}
/>
<Icon name={assetHasVideo(asset) === true ? 'video' : 'audio'} />
</span>
<div>
<strong>{asset.file.name}</strong>
<span>
{formatBytes(asset.file.size)} ·{' '}
{formatDuration(asset.probe?.durationSeconds)}
{formatDuration(assetDurationSeconds(asset))}
</span>
</div>
<span className={`phase phase--${asset.phase}`}>
{asset.phase === 'ready'
? 'Ready'
: asset.phase === 'error'
? 'Could not inspect'
: 'Preparing'}
? 'Details ready'
: asset.phase === 'probing'
? 'Inspecting details'
: asset.phase === 'error'
? 'Ready to play'
: 'Ready to play'}
</span>
</div>
) : (
@@ -338,14 +351,16 @@ export function QuickConvert({
['ogg', 'Ogg (.ogg)'],
] as const
).map(([container, label]) => {
const diagnostics = validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
container,
effectiveSelection
);
const diagnostics = hasDetailedInformation
? validateRemuxCompatibility(
{
streams: remuxStreams,
hasChapters: Boolean(asset?.probe?.chapters.length),
},
container,
effectiveSelection
)
: [];
const missingMuxer =
engineState.status === 'ready' &&
!engineState.capabilities.muxers.has(container);
@@ -408,7 +423,25 @@ export function QuickConvert({
);
})
) : (
<p>Streams appear after local inspection.</p>
<div className="stream-picker__empty">
<p>
Streams will be identified only when you inspect details or
start this export.
</p>
{asset && asset.phase !== 'probing' && onInspect ? (
<button
type="button"
className="button button--secondary"
disabled={busy}
onClick={onInspect}
>
<Icon name="info" />
{asset.phase === 'error'
? 'Retry inspection'
: 'Inspect details now'}
</button>
) : null}
</div>
)}
</fieldset>
@@ -449,17 +482,24 @@ export function QuickConvert({
Select at least one audio stream for an audio-only preset.
</p>
) : null}
{asset?.phase === 'error' && !asset.probe ? (
<p className="notice notice--warning">
<Icon name="warning" />
Playback and basic file information remain available. Detailed
inspection can be retried when an export needs it.
</p>
) : null}
{!hasSelectedStream && streams.length > 0 ? (
<p className="notice notice--error">
<Icon name="warning" />
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}
@@ -472,7 +512,11 @@ export function QuickConvert({
? 'Validated user preset'
: 'Stream copy'}
</strong>
<span>Resource estimates are checked before allocation.</span>
<span>
{hasDetailedInformation
? 'Resource estimates are checked before allocation.'
: 'Detailed stream inspection will run before this export.'}
</span>
</div>
<button
className="button button--primary button--large"
@@ -486,6 +530,9 @@ export function QuickConvert({
streamSelection: effectiveSelection,
removeMetadata,
removeChapters,
...(!hasDetailedInformation
? { selectDetectedStreams: true as const }
: {}),
})
}
>
@@ -495,47 +542,14 @@ export function QuickConvert({
: queueing
? 'Add to queue'
: operation === 'remux'
? 'Remux'
: 'Convert'}
? hasDetailedInformation
? 'Remux'
: 'Inspect & remux'
: hasDetailedInformation
? 'Convert'
: 'Inspect & convert'}
</button>
</footer>
</section>
);
}
function quickStreamUnavailableReason(
stream: MediaStreamProbe,
operation: QuickOperation,
preset: ExportPreset | undefined
): string | undefined {
if (stream.type === 'unknown') {
return 'unknown stream kinds cannot be mapped safely.';
}
if (operation === 'remux') {
return undefined;
}
if (!preset) {
return 'choose an export preset first.';
}
if (stream.type === 'video' && !preset.video) {
return 'the selected preset creates audio only.';
}
if (stream.type === 'audio' && !preset.audio) {
return 'the selected preset creates video or images without audio.';
}
if (stream.type === 'subtitle') {
if (preset.subtitlePolicy === 'none') {
return 'the selected preset explicitly omits subtitles.';
}
if (preset.subtitlePolicy === 'burn-in') {
return 'burn-in requires an explicitly attached subtitle source.';
}
}
if (
(stream.type === 'attachment' || stream.type === 'data') &&
preset.container !== 'matroska'
) {
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
}
return undefined;
}

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

@@ -0,0 +1,70 @@
import type { StreamSelection } from '../commands/command-utils';
import type { MediaProbe, MediaStreamProbe } from '../media';
import type { ExportPreset } from '../commands/convert';
export type QuickOperation = 'convert' | 'remux';
export function defaultQuickStreamSelection(
probe: MediaProbe,
operation: QuickOperation,
preset: ExportPreset | undefined
): StreamSelection {
const selected = probe.streams.filter(
(stream) =>
quickStreamUnavailableReason(stream, operation, preset) === undefined
);
return {
video: selected
.filter((stream) => stream.type === 'video')
.map((stream) => stream.index),
audio: selected
.filter((stream) => stream.type === 'audio')
.map((stream) => stream.index),
subtitles: selected
.filter((stream) => stream.type === 'subtitle')
.map((stream) => stream.index),
attachments: selected
.filter((stream) => stream.type === 'attachment')
.map((stream) => stream.index),
data: selected
.filter((stream) => stream.type === 'data')
.map((stream) => stream.index),
};
}
export function quickStreamUnavailableReason(
stream: MediaStreamProbe,
operation: QuickOperation,
preset: ExportPreset | undefined
): string | undefined {
if (stream.type === 'unknown') {
return 'unknown stream kinds cannot be mapped safely.';
}
if (operation === 'remux') {
return undefined;
}
if (!preset) {
return 'choose an export preset first.';
}
if (stream.type === 'video' && !preset.video) {
return 'the selected preset creates audio only.';
}
if (stream.type === 'audio' && !preset.audio) {
return 'the selected preset creates video or images without audio.';
}
if (stream.type === 'subtitle') {
if (preset.subtitlePolicy === 'none') {
return 'the selected preset explicitly omits subtitles.';
}
if (preset.subtitlePolicy === 'burn-in') {
return 'burn-in requires an explicitly attached subtitle source.';
}
}
if (
(stream.type === 'attachment' || stream.type === 'data') &&
preset.container !== 'matroska'
) {
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
}
return undefined;
}

View File

@@ -23,7 +23,11 @@ import type {
import { createLoadConfig } from './load-config';
import { LogBuffer } from './log-buffer';
import { normalizeProgress } from './progress-parser';
import { describeMountedInputAccess, mountJobFiles } from './virtual-fs';
import {
describeMountedInputAccess,
mountJobFiles,
type MountedJobFiles,
} from './virtual-fs';
import {
parseFFmpegProgress,
progressUpdateFromFFmpeg,
@@ -32,6 +36,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 +62,38 @@ 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)
);
}
/**
* Firefox can leave this reviewed Emscripten core in a state where a later
* ffprobe call stalls after otherwise successful FFmpeg executions. A fresh
* worker/core completes the same probe promptly. Keep the workaround narrow
* so other browsers retain their warm-core startup path.
*/
export function shouldRefreshCoreBeforeProbe(userAgent?: string): boolean {
const resolvedUserAgent =
userAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent);
return /(?:^|\s)(?:Firefox|FxiOS)\//u.test(resolvedUserAgent);
}
export function canReuseProbeCapabilities(
loadedMode: EngineMode,
cachedMode: EngineMode
): boolean {
return loadedMode === cachedMode;
}
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
return new FFmpeg();
@@ -82,6 +121,7 @@ export class EngineManager {
#warning: Extract<EngineState, { status: 'ready' }>['warning'];
#loadPromise: Promise<void> | null = null;
#activeJobId: string | null = null;
#preparingOperation = false;
#activeExpectedDurationSeconds: number | undefined;
#activeStartedAtMilliseconds = 0;
#machineProgressLines: string[] = [];
@@ -92,35 +132,40 @@ export class EngineManager {
#generation = 0;
readonly #handleLog = (event: LogEvent): void => {
// Capability discovery emits thousands of individual lines. Forwarding
// each one into React while the engine is still loading can starve
// Firefox's worker message queue long enough for the following probe to
// hit its watchdog. queryCapabilities installs its own short-lived
// listener, so application job logs only need to be retained/published
// while an operation is running.
if (this.#state.status !== 'running') return;
this.#logs.append(event);
if (this.#state.status === 'running') {
for (const line of event.message.split(/\r?\n/gu)) {
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
this.#machineProgressLines.push(line);
}
if (/^progress=(?:continue|end)$/u.test(line)) {
const parsed = parseFFmpegProgress(
this.#machineProgressLines.join('\n')
);
const update = progressUpdateFromFFmpeg(
parsed,
this.#activeExpectedDurationSeconds
);
this.#machineProgressLines = [];
this.#machineProgressSeen = true;
this.#setState({
...this.#state,
...(update.progress !== undefined
? { progress: update.progress }
: {}),
...(update.speed !== undefined ? { speed: update.speed } : {}),
...(update.frame !== undefined ? { frame: update.frame } : {}),
elapsedSeconds: Math.max(
0,
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
),
});
}
for (const line of event.message.split(/\r?\n/gu)) {
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
this.#machineProgressLines.push(line);
}
if (/^progress=(?:continue|end)$/u.test(line)) {
const parsed = parseFFmpegProgress(
this.#machineProgressLines.join('\n')
);
const update = progressUpdateFromFFmpeg(
parsed,
this.#activeExpectedDurationSeconds
);
this.#machineProgressLines = [];
this.#machineProgressSeen = true;
this.#setState({
...this.#state,
...(update.progress !== undefined
? { progress: update.progress }
: {}),
...(update.speed !== undefined ? { speed: update.speed } : {}),
...(update.frame !== undefined ? { frame: update.frame } : {}),
elapsedSeconds: Math.max(
0,
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
),
});
}
}
for (const listener of this.#logListeners) listener(event);
@@ -158,10 +203,16 @@ export class EngineManager {
}
setPreference(preference: EnginePreference): void {
if (this.#state.status === 'running') {
if (
this.#activeJobId ||
this.#preparingOperation ||
this.#state.status === 'loading' ||
this.#state.status === 'recovering' ||
this.#state.status === 'running'
) {
throw new EngineError(
'busy',
'Engine mode cannot be changed while a job is running.'
'Engine mode cannot be changed while another operation is active.'
);
}
if (preference !== this.#preference) {
@@ -187,7 +238,11 @@ export class EngineManager {
}
async initialize(): Promise<void> {
if (this.#activeJobId || this.#state.status === 'running') {
if (
this.#activeJobId ||
this.#preparingOperation ||
this.#state.status === 'running'
) {
throw new EngineError(
'busy',
'The FFmpeg engine cannot be initialized while another operation is active.'
@@ -274,7 +329,11 @@ export class EngineManager {
async #loadMode(
mode: EngineMode,
warning?: Extract<EngineState, { status: 'ready' }>['warning']
warning?: Extract<EngineState, { status: 'ready' }>['warning'],
cached?: {
readonly mode: EngineMode;
readonly capabilities: FFmpegCapabilities;
}
): Promise<void> {
const generation = ++this.#generation;
this.#setState({
@@ -301,9 +360,15 @@ export class EngineManager {
status: 'loading',
mode,
progress: 0.75,
stage: 'Inspecting codec capabilities',
stage:
cached && canReuseProbeCapabilities(mode, cached.mode)
? 'Restoring codec capabilities'
: 'Inspecting codec capabilities',
});
const capabilities = await queryCapabilities(engine);
const capabilities =
cached && canReuseProbeCapabilities(mode, cached.mode)
? cached.capabilities
: await queryCapabilities(engine);
if (generation !== this.#generation) return;
this.#capabilities = capabilities;
this.#executionsSinceLoad = 0;
@@ -313,27 +378,54 @@ export class EngineManager {
}
async runJob(request: EngineJobRequest): Promise<EngineJobResult> {
if (this.#activeJobId) {
if (this.#activeJobId || this.#preparingOperation) {
throw new EngineError(
'busy',
'Another FFmpeg operation is already running.'
);
}
await this.initialize();
const coreWorkload = request.coreWorkload ?? request.operation;
await this.#recycleExecutionCoreIfNeeded(coreWorkload);
const engine = this.#engine;
const mode = this.#mode;
if (!engine || !mode || this.#state.status !== 'ready') {
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
if (this.#activeJobId || this.#preparingOperation) {
throw new EngineError(
'busy',
'Another FFmpeg operation is already running.'
);
}
const mounted = await mountJobFiles(
engine,
request.id,
request.inputs,
request.outputs.map(({ name }) => name)
);
let prepared:
| {
readonly engine: FFmpegAdapter;
readonly mode: EngineMode;
readonly mounted: MountedJobFiles;
}
| undefined;
const coreWorkload = request.coreWorkload ?? request.operation;
this.#preparingOperation = true;
try {
await this.#recycleExecutionCoreIfNeeded(coreWorkload, true);
const engine = this.#engine;
const mode = this.#mode;
if (!engine || !mode || this.#state.status !== 'ready') {
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
}
const mounted = await mountJobFiles(
engine,
request.id,
request.inputs,
request.outputs.map(({ name }) => name)
);
this.#activeJobId = request.id;
prepared = { engine, mode, mounted };
} finally {
this.#preparingOperation = false;
}
if (!prepared) {
throw new EngineError(
'filesystem-failed',
'Could not prepare the FFmpeg job.'
);
}
const { engine, mode, mounted } = prepared;
const generation = this.#generation;
const temporaryPaths = (request.temporaryFiles ?? []).map(
(temporary, index) => {
@@ -342,7 +434,6 @@ export class EngineManager {
return `${mounted.workDirectory}/temporary-${index}.${extension}`;
}
);
this.#activeJobId = request.id;
this.#activeExpectedDurationSeconds = request.expectedDurationSeconds;
this.#activeStartedAtMilliseconds = performance.now();
this.#machineProgressLines = [];
@@ -521,8 +612,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') {
@@ -535,18 +636,79 @@ export class EngineManager {
);
}
const id = crypto.randomUUID();
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
if (this.#activeJobId || this.#preparingOperation) {
throw new EngineError(
'busy',
'Another FFmpeg operation is already running.'
);
}
let prepared:
| {
readonly engine: FFmpegAdapter;
readonly mode: EngineMode;
readonly mounted: MountedJobFiles;
}
| undefined;
this.#preparingOperation = true;
try {
await this.#refreshCoreBeforeProbe(signal);
if (signal?.aborted) {
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
const refreshedEngine = this.#engine;
const refreshedMode = this.#mode;
if (
!refreshedEngine ||
!refreshedMode ||
this.#state.status !== 'ready'
) {
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
}
const id = crypto.randomUUID();
const mounted = await mountJobFiles(
refreshedEngine,
id,
[file],
['probe.json']
);
if (signal?.aborted) {
await mounted.cleanup();
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
this.#activeJobId = `probe-${id}`;
prepared = {
engine: refreshedEngine,
mode: refreshedMode,
mounted,
};
} finally {
this.#preparingOperation = false;
}
if (!prepared) {
throw new EngineError(
'filesystem-failed',
'Could not prepare the media probe.'
);
}
const { engine: refreshedEngine, mode: refreshedMode, mounted } = prepared;
const probeOutput = mounted.outputPaths[0];
const inputPath = mounted.inputPaths[0];
if (!probeOutput || !inputPath) {
this.#activeJobId = null;
await mounted.cleanup();
throw new EngineError(
'filesystem-failed',
'Could not allocate probe paths.'
);
}
const activeProbeId = `probe-${id}`;
this.#activeJobId = activeProbeId;
const activeProbeId = this.#activeJobId;
if (!activeProbeId) {
throw new EngineError(
'filesystem-failed',
'Could not reserve the media probe.'
);
}
this.#activeExpectedDurationSeconds = undefined;
this.#activeStartedAtMilliseconds = performance.now();
this.#machineProgressLines = [];
@@ -554,7 +716,7 @@ export class EngineManager {
this.#logs.clear();
this.#setState({
status: 'running',
mode,
mode: refreshedMode,
jobId: this.#activeJobId,
operation: 'Inspect media',
inputMode: mounted.inputMode,
@@ -565,9 +727,17 @@ 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([
const exitCode = await refreshedEngine.ffprobe([
'-v',
'error',
'-show_format',
@@ -582,7 +752,7 @@ export class EngineManager {
let parsed: unknown;
try {
parsed = JSON.parse(
textFromFileData(await engine.readFile(probeOutput))
textFromFileData(await refreshedEngine.readFile(probeOutput))
) as unknown;
} catch (readError) {
throw new EngineError(
@@ -630,6 +800,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,20 +818,24 @@ export class EngineManager {
details: this.#logs.text(),
});
} finally {
signal?.removeEventListener('abort', cancelOnAbort);
clearTimeout(watchdog);
this.#activeJobId = null;
this.#machineProgressLines = [];
this.#machineProgressSeen = false;
if (this.#engine === engine) {
if (this.#engine === refreshedEngine) {
const cleanupSucceeded = await mounted.cleanup();
if (!cleanupSucceeded && this.#engine === engine) {
if (!cleanupSucceeded && this.#engine === refreshedEngine) {
this.#logs.append({
type: 'stderr',
message:
'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.',
});
this.#terminateEngine();
this.#setState({ status: 'recovering', previousMode: mode });
this.#setState({
status: 'recovering',
previousMode: refreshedMode,
});
try {
await this.initialize();
} catch {
@@ -665,7 +846,7 @@ export class EngineManager {
if (capabilities) {
this.#setState({
status: 'ready',
mode,
mode: refreshedMode,
capabilities,
warning: this.#warning,
});
@@ -675,6 +856,71 @@ export class EngineManager {
}
}
async #refreshCoreBeforeProbe(signal?: AbortSignal): Promise<void> {
if (!shouldRefreshCoreBeforeProbe()) return;
if (signal?.aborted) {
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
const mode = this.#mode;
const capabilities = this.#capabilities;
const warning = this.#warning;
if (!mode || !capabilities || this.#state.status !== 'ready') {
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
}
this.#terminateEngine();
this.#setState({ status: 'recovering', previousMode: mode, warning });
try {
await this.#loadMode(mode, warning, { mode, capabilities });
} catch (firstError) {
if (mode !== 'multithread') {
this.#terminateEngine();
const loadError = new EngineError(
'load-failed',
'The FFmpeg core could not be refreshed for media inspection.',
{
cause: firstError,
details: describeInitializationFailure(firstError),
}
);
this.#setState({ status: 'error', error: loadError });
throw loadError;
}
this.#terminateEngine();
const fallbackWarning = {
code: 'multithread-load-failed' as const,
message:
'The multithread core failed to reload. The app recovered with the single-thread compatibility core.',
};
this.#setState({
status: 'recovering',
previousMode: 'multithread',
warning: fallbackWarning,
});
try {
// Capabilities are mode-specific; discover a fresh single-thread set.
await this.#loadMode('single-thread', fallbackWarning);
} catch (fallbackError) {
const details = [
`Multithread core: ${describeInitializationFailure(firstError)}`,
`Single-thread core: ${describeInitializationFailure(fallbackError)}`,
].join('\n');
this.#terminateEngine();
const loadError = new EngineError(
'load-failed',
'Neither FFmpeg execution core could be refreshed for media inspection.',
{ cause: fallbackError, details }
);
this.#setState({ status: 'error', error: loadError });
throw loadError;
}
}
if (signal?.aborted) {
throw new EngineError('cancelled', 'The media probe was cancelled.');
}
}
async cancelActive(): Promise<void> {
const activeJobId = this.#activeJobId;
const previousMode = this.#mode;
@@ -714,7 +960,10 @@ export class EngineManager {
this.#contactSheetsSinceLoad = 0;
}
async #recycleExecutionCoreIfNeeded(nextOperation: string): Promise<void> {
async #recycleExecutionCoreIfNeeded(
nextOperation: string,
operationReserved = false
): Promise<void> {
if (
!shouldRecycleExecutionCore(
this.#executionsSinceLoad,
@@ -743,7 +992,14 @@ export class EngineManager {
});
this.#terminateEngine();
this.#setState({ status: 'recovering', previousMode });
await this.initialize();
if (operationReserved) {
this.#loadPromise = this.#initializeInternal().finally(() => {
this.#loadPromise = null;
});
await this.#loadPromise;
} else {
await this.initialize();
}
}
#setState(state: EngineState): void {

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

@@ -561,12 +561,16 @@ select:disabled {
color: #2c7d63 !important;
}
.phase--idle {
color: #2c7d63 !important;
}
.phase--probing {
color: #8b6a20 !important;
}
.phase--error {
color: var(--toolbox-danger) !important;
color: #8b6a20 !important;
}
.media-bin__empty {
@@ -779,6 +783,18 @@ select:disabled {
font-size: 0.68rem;
}
.stream-picker__empty {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.stream-picker__empty .button {
flex: none;
}
.stream-kind {
display: inline-flex;
align-items: center;
@@ -1759,6 +1775,15 @@ progress::-moz-progress-bar {
padding: 0.8rem;
}
.inspector-section > .notice {
align-items: center;
margin: 0;
}
.inspector-section > .notice > div {
flex: 1;
}
.inspector-section h3 {
margin: 0.45rem 0 0;
}

View File

@@ -0,0 +1,282 @@
import {
isAudioMimeType,
isVideoMimeType,
mimeTypeFromFileName,
} from './mime-types';
export type BrowserMediaKind = 'audio' | 'video' | 'unknown';
export type BrowserMetadataErrorCode = 'aborted' | 'load-error' | 'timeout';
export interface BrowserMediaFileInfo {
readonly name: string;
readonly sizeBytes: number;
readonly declaredMimeType?: string;
readonly inferredMimeType?: string;
readonly lastModified: number;
}
/**
* Lightweight metadata exposed by the browser's native media element.
*
* This intentionally does not pretend to replace ffprobe: codec names,
* complete stream lists, chapters, tags and dispositions remain unknown.
*/
export interface BrowserMediaMetadata {
readonly source: 'browser';
readonly file: BrowserMediaFileInfo;
readonly kind: BrowserMediaKind;
readonly durationSeconds?: number;
readonly width?: number;
readonly height?: number;
readonly hasAudio?: boolean;
readonly hasVideo?: boolean;
}
export interface BrowserMetadataOptions {
readonly signal?: AbortSignal;
readonly timeoutMilliseconds?: number;
/**
* Test seam for the DOM-owned media element. Production callers normally
* leave this unset.
*/
readonly createMediaElement?: (
kind: Exclude<BrowserMediaKind, 'unknown'>
) => HTMLMediaElement;
}
export class BrowserMetadataError extends Error {
readonly code: BrowserMetadataErrorCode;
constructor(code: BrowserMetadataErrorCode, message: string) {
super(message);
this.name = 'BrowserMetadataError';
this.code = code;
}
}
const DEFAULT_BROWSER_METADATA_TIMEOUT_MILLISECONDS = 15_000;
interface MediaElementWithAudioHints extends HTMLMediaElement {
readonly audioTracks?: { readonly length: number };
readonly mozHasAudio?: boolean;
}
export function browserMediaFileInfo(file: File): BrowserMediaFileInfo {
return {
name: file.name,
sizeBytes: file.size,
declaredMimeType: normalizeMimeType(file.type),
inferredMimeType: mimeTypeFromFileName(file.name),
lastModified: file.lastModified,
};
}
export function inferBrowserMediaKind(file: File): BrowserMediaKind {
const declaredMimeType = normalizeMimeType(file.type);
const inferredMimeType = mimeTypeFromFileName(file.name);
const mimeType =
declaredMimeType !== undefined &&
(isAudioMimeType(declaredMimeType) || isVideoMimeType(declaredMimeType))
? declaredMimeType
: inferredMimeType;
if (mimeType === undefined) return 'unknown';
if (isVideoMimeType(mimeType)) return 'video';
if (isAudioMimeType(mimeType)) return 'audio';
return 'unknown';
}
/**
* Reads only metadata the browser can expose without FFmpeg. The supplied
* object URL remains owned by the caller and is never revoked here.
*/
export function inspectBrowserMediaMetadata(
file: File,
objectUrl: string,
options: BrowserMetadataOptions = {}
): Promise<BrowserMediaMetadata> {
const declaredKind = inferBrowserMediaKind(file);
const elementKind = declaredKind === 'audio' ? 'audio' : 'video';
const createMediaElement =
options.createMediaElement ??
((kind: 'audio' | 'video') => document.createElement(kind));
const element = createMediaElement(elementKind);
const timeoutMilliseconds =
options.timeoutMilliseconds ??
DEFAULT_BROWSER_METADATA_TIMEOUT_MILLISECONDS;
if (options.signal?.aborted === true) {
return Promise.reject(abortedError());
}
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timeoutId);
element.removeEventListener('loadedmetadata', handleLoadedMetadata);
element.removeEventListener('error', handleError);
element.removeEventListener('abort', handleElementAbort);
options.signal?.removeEventListener('abort', handleSignalAbort);
element.removeAttribute('src');
try {
element.load();
} catch {
// The caller owns the object URL; releasing this detached element is
// best-effort and must not turn successful metadata into a failure.
}
};
const finish = (
outcome:
| { readonly ok: true; readonly value: BrowserMediaMetadata }
| { readonly ok: false; readonly error: BrowserMetadataError }
) => {
if (settled) return;
settled = true;
cleanup();
if (outcome.ok) resolve(outcome.value);
else reject(outcome.error);
};
const handleLoadedMetadata = () => {
finish({
ok: true,
value: metadataFromElement(file, declaredKind, element),
});
};
const handleError = () => {
finish({
ok: false,
error: new BrowserMetadataError(
'load-error',
`The browser could not read metadata from ${file.name}.`
),
});
};
const handleElementAbort = () => {
finish({
ok: false,
error: new BrowserMetadataError(
'load-error',
`The browser stopped reading metadata from ${file.name}.`
),
});
};
const handleSignalAbort = () => {
finish({ ok: false, error: abortedError() });
};
element.addEventListener('loadedmetadata', handleLoadedMetadata);
element.addEventListener('error', handleError);
element.addEventListener('abort', handleElementAbort);
options.signal?.addEventListener('abort', handleSignalAbort, {
once: true,
});
const timeoutId = setTimeout(
() => {
finish({
ok: false,
error: new BrowserMetadataError(
'timeout',
`Browser metadata loading exceeded ${String(
Math.ceil(timeoutMilliseconds / 1_000)
)} seconds.`
),
});
},
Math.max(0, timeoutMilliseconds)
);
element.preload = 'metadata';
element.src = objectUrl;
try {
element.load();
} catch {
finish({
ok: false,
error: new BrowserMetadataError(
'load-error',
`The browser could not start reading metadata from ${file.name}.`
),
});
}
});
}
function metadataFromElement(
file: File,
declaredKind: BrowserMediaKind,
element: HTMLMediaElement
): BrowserMediaMetadata {
const isVideoElement = element instanceof HTMLVideoElement;
const width = isVideoElement
? positiveFiniteNumber(element.videoWidth)
: undefined;
const height = isVideoElement
? positiveFiniteNumber(element.videoHeight)
: undefined;
// A zero-sized <video> can still be the correct, playable element for an
// audio-only MP4 or another video container. Treat missing dimensions as
// inconclusive instead of claiming that no video-style player is usable.
const hasVideo = isVideoElement
? width !== undefined
? true
: undefined
: false;
const hasAudio = audioPresence(element, isVideoElement);
const kind =
hasVideo === true
? 'video'
: hasAudio === true || element instanceof HTMLAudioElement
? 'audio'
: declaredKind;
return {
source: 'browser',
file: browserMediaFileInfo(file),
kind,
durationSeconds: nonNegativeFiniteNumber(element.duration),
width,
height,
hasAudio,
hasVideo,
};
}
function audioPresence(
element: HTMLMediaElement,
isVideoElement: boolean
): boolean | undefined {
if (!isVideoElement) return true;
const withAudioHints = element as MediaElementWithAudioHints;
if (withAudioHints.audioTracks !== undefined) {
return withAudioHints.audioTracks.length > 0;
}
if (typeof withAudioHints.mozHasAudio === 'boolean') {
return withAudioHints.mozHasAudio;
}
return undefined;
}
function normalizeMimeType(mimeType: string): string | undefined {
const normalized = mimeType.trim().toLowerCase().split(';')[0]?.trim();
return normalized === undefined || normalized.length === 0
? undefined
: normalized;
}
function positiveFiniteNumber(value: number): number | undefined {
return Number.isFinite(value) && value > 0 ? value : undefined;
}
function nonNegativeFiniteNumber(value: number): number | undefined {
return Number.isFinite(value) && value >= 0 ? value : undefined;
}
function abortedError(): BrowserMetadataError {
return new BrowserMetadataError(
'aborted',
'Browser metadata loading was cancelled.'
);
}

View File

@@ -1,4 +1,5 @@
export * from './browser-playback';
export * from './browser-metadata';
export * from './chapter-time-base';
export * from './duration';
export * from './media.types';

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.3.0",
"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.3.0';
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

@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import {
observeRuntime,
PRIMARY_FIXTURE,
startNestedStaticServer,
type NestedStaticServer,
} from './browser-helpers';
@@ -119,6 +120,38 @@ test.describe('static application and Toolbox shell', () => {
await expect(inspectorDrawer).toBeHidden();
await expect(inspector).toBeFocused();
});
test('shows native preview metadata without loading the FFmpeg runtime', async ({
page,
}) => {
const runtime = observeRuntime(page);
await page.goto('/');
await page
.locator('input[type="file"][accept*="audio"]')
.first()
.setInputFiles(PRIMARY_FIXTURE);
await expect(page.locator('.quick-preview video')).toBeVisible();
const card = page
.locator('.media-card')
.filter({ hasText: 'pattern-av.mp4' });
await expect(card.locator('.phase--idle')).toContainText(
'Ready to play · Details on demand'
);
await expect(card).toContainText(/\d+:\d{2}\.\d{2}/u);
expect(
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
).toBe(false);
await page.getByRole('tab', { name: 'Edit' }).click();
await expect(
page.getByRole('button', { name: 'Inspect & render timeline' })
).toBeEnabled();
expect(
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
).toBe(false);
});
});
test.describe('nested static deployment and Toolbox context', () => {
@@ -191,12 +224,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

@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
import { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
import { expect } from '@playwright/test';
export const REPOSITORY_ROOT = resolve(
dirname(fileURLToPath(import.meta.url)),
@@ -19,6 +20,20 @@ export const GENERATED_FIXTURES = join(
);
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
export async function inspectMediaDetails(
page: Page,
fileName: string
): Promise<void> {
const card = page.locator('.media-card').filter({ hasText: fileName });
const inspect = page.getByRole('button', {
name: `Inspect details for ${fileName}`,
});
if ((await inspect.count()) > 0) await inspect.click();
await expect(card.locator('.phase--ready')).toBeVisible({
timeout: 120_000,
});
}
const NESTED_PREFIX = '/deep/nested/av/';
const SECURITY_HEADERS = {
'Cross-Origin-Embedder-Policy': 'require-corp',
@@ -112,7 +127,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

@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
import {
createCancellationFixture,
GENERATED_FIXTURES,
inspectMediaDetails,
observeRuntime,
PRIMARY_FIXTURE,
type TemporaryMediaFixture,
@@ -75,9 +76,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,16 +88,21 @@ 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);
await inspectMediaDetails(page, 'pattern-av.mp4');
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
'Details ready',
{ timeout: 120_000 }
);
await page.getByRole('button', { name: 'Convert', exact: true }).click();
@@ -119,8 +125,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 +154,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([]);
@@ -175,8 +186,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
).toBe(false);
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
await inspectMediaDetails(page, 'pattern-av.mp4');
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
'Details ready',
{ timeout: 120_000 }
);
await expect(page.locator('.source-summary')).toContainText(
@@ -247,7 +259,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);
@@ -264,6 +278,7 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
await mediaInput(page)
.first()
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
await inspectMediaDetails(page, 'compatible-a.mp4');
await expect(
page
.locator('.media-card')
@@ -303,7 +318,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);
@@ -316,8 +333,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
await page.goto('/');
await selectSingleThread(page);
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
await inspectMediaDetails(page, basename(cancellationFixture.filePath));
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
'Details ready',
{ timeout: 120_000 }
);
@@ -344,8 +362,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
})
.click();
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
await inspectMediaDetails(page, 'pattern-av.mp4');
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
'Details ready',
{ timeout: 120_000 }
);
await page.getByRole('button', { name: 'Convert', exact: true }).click();
@@ -356,4 +375,49 @@ 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 page
.getByRole('button', {
name: `Inspect details for ${activeName}`,
})
.click();
await expect(activeCard.locator('.phase--probing')).toBeVisible({
timeout: 30_000,
});
await expect(queuedCard.locator('.phase--idle')).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 inspectMediaDetails(page, 'pattern-av.mp4');
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Details ready',
{ timeout: 120_000 }
);
await expect(page.getByRole('alert')).toHaveCount(0);
assertLocalOnly(runtime);
});
});

View File

@@ -4,6 +4,7 @@ import { join } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import {
GENERATED_FIXTURES,
inspectMediaDetails,
observeRuntime,
PRIMARY_FIXTURE,
type RuntimeObservation,
@@ -51,8 +52,9 @@ test.describe
await mediaInput(page).setInputFiles(
join(GENERATED_FIXTURES, 'attached-cover.mp3')
);
await inspectMediaDetails(page, 'attached-cover.mp3');
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
'Details ready',
{ timeout: 60_000 }
);
await expect(page.locator('.source-summary')).toContainText(
@@ -108,13 +110,23 @@ test.describe
await mediaInput(page).setInputFiles(
join(GENERATED_FIXTURES, 'truncated.mp4')
);
await page
.getByRole('button', {
name: 'Inspect details for truncated.mp4',
})
.click();
const malformedCard = page
.locator('.media-card')
.filter({ hasText: 'truncated.mp4' });
await expect(malformedCard.locator('.phase--error')).toContainText(
'ffprobe did not find any media streams.',
'File ready · Details unavailable',
{ timeout: 30_000 }
);
await expect(
page.getByRole('button', {
name: 'Retry detailed inspection for truncated.mp4',
})
).toHaveAttribute('title', 'ffprobe did not find any media streams.');
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
await expect(
page
@@ -123,6 +135,7 @@ test.describe
).toBeVisible();
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
await inspectMediaDetails(page, 'pattern-av.mp4');
const validCard = page
.locator('.media-card')
.filter({ hasText: 'pattern-av.mp4' });

View File

@@ -0,0 +1,71 @@
/// <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 before detailed inspection', 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--idle')).toContainText('Details on demand');
await expect(card).toContainText(/43:42\.\d{2}/u, { timeout: 15_000 });
expect(
await page.evaluate(() =>
performance
.getEntriesByType('resource')
.some((entry) => entry.name.includes('/vendor/ffmpeg/'))
)
).toBe(false);
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 page
.getByRole('button', { name: `Inspect details for ${name}` })
.click();
await expect(card.locator('.phase--probing')).toBeVisible();
await expect(card.locator('.phase--ready')).toBeVisible({ timeout: 120_000 });
await expect(preview).toHaveAttribute(
'data-evidence-identity',
elementIdentity
);
await expect(page.getByRole('alert')).toHaveCount(0);
});

View File

@@ -31,6 +31,9 @@ async function importMedia(
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
for (const name of names) {
const card = page.locator('.media-card').filter({ hasText: name });
await page
.getByRole('button', { name: `Inspect details for ${name}` })
.click();
await expect(card.locator('.phase--ready')).toBeVisible({
timeout: 120_000,
});
@@ -149,7 +152,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(
@@ -210,6 +213,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
const sourceName = basename(cancellationFixture.filePath);
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
await page
.getByRole('button', {
name: `Inspect details for ${sourceName}`,
})
.click();
await expect(
page
.locator('.media-card')
@@ -547,6 +555,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 +720,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 () => {
@@ -964,6 +1063,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
mimeType: 'video/x-matroska',
buffer: readFileSync(muxed.filePath),
});
await page
.getByRole('button', {
name: 'Inspect details for subtitled-source.mkv',
})
.click();
await expect(
page
.locator('.media-card')
@@ -988,7 +1092,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

@@ -103,8 +103,9 @@ async function runMode(
const probeStarted = await monotonicNow(page);
await mediaInput(page).setInputFiles(fixturePath);
await page.getByRole('button', { name: /^Inspect details for /u }).click();
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
'Details ready',
{ timeout: 120_000 }
);
const probeMs = (await monotonicNow(page)) - probeStarted;

View File

@@ -21,8 +21,11 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
await page.getByLabel('Engine mode').selectOption('force-single-thread');
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
await page
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
.click();
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Ready',
'Details ready',
{ timeout: 120_000 }
);
await expect(page.locator('.quick-preview video')).toHaveAttribute(
@@ -47,6 +50,9 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
await page.getByRole('tab', { name: 'Edit' }).click();
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
await mediaInput(page).setInputFiles(compatibleB);
await page
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
.click();
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
timeout: 120_000,
});
@@ -127,6 +133,12 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
page.on('dialog', (dialog) => void dialog.accept());
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
await page
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
.click();
await page
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
.click();
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
timeout: 120_000,
});

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

@@ -0,0 +1,93 @@
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ImportedMediaAsset } from '../../../src/app/application-state';
import { MediaBin } from '../../../src/components/MediaBin';
afterEach(cleanup);
describe('MediaBin', () => {
it('shows browser metadata and offers optional detailed inspection', async () => {
const user = userEvent.setup();
const onInspect = vi.fn();
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
const asset: ImportedMediaAsset = {
id: 'lecture',
file,
objectUrl: 'blob:lecture',
phase: 'idle',
browserMetadata: {
source: 'browser',
file: {
name: file.name,
sizeBytes: file.size,
declaredMimeType: file.type,
inferredMimeType: file.type,
lastModified: file.lastModified,
},
kind: 'video',
durationSeconds: 2_622.48,
width: 640,
height: 480,
hasVideo: true,
},
};
render(
<MediaBin
assets={[asset]}
selectedId={asset.id}
onSelect={vi.fn()}
onInspect={onInspect}
onRemove={vi.fn()}
/>
);
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
expect(screen.getByText('Ready to play · Details on demand')).toBeVisible();
expect(screen.getByText('lecture.mp4').closest('button')).toHaveAttribute(
'aria-pressed',
'true'
);
await user.click(
screen.getByRole('button', {
name: 'Inspect details for lecture.mp4',
})
);
expect(onInspect).toHaveBeenCalledWith(asset.id);
});
it('keeps a source ready to play after detailed inspection fails', () => {
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
const asset: ImportedMediaAsset = {
id: 'lecture',
file,
objectUrl: 'blob:lecture',
phase: 'error',
error: 'Media inspection exceeded its safety limit.',
};
render(
<MediaBin
assets={[asset]}
onSelect={vi.fn()}
onInspect={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.getByText('File ready · Details unavailable')).toBeVisible();
expect(
screen.getByRole('button', {
name: 'Retry detailed inspection for lecture.mp4',
})
).toBeEnabled();
expect(
screen.getByRole('button', {
name: 'Retry detailed inspection for lecture.mp4',
})
).toHaveAccessibleDescription(
'Media inspection exceeded its safety limit.'
);
});
});

View File

@@ -26,6 +26,99 @@ 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 · Detailed inspection runs separately.'
)
).toBeVisible();
rerender(
<MediaPreview
asset={{
...videoAsset(),
id: probingAsset.id,
file: probingAsset.file,
objectUrl: probingAsset.objectUrl,
}}
/>
);
expect(container.querySelector('video')).toBe(progressiveVideo);
});
it('keeps browser playback mounted after detailed inspection fails', () => {
const asset: ImportedMediaAsset = {
id: 'asset-probe-error',
file: new File(['video'], 'lecture.mp4', { type: 'video/mp4' }),
objectUrl: 'blob:probe-error',
phase: 'error',
error: 'Detailed inspection timed out.',
};
const { container } = render(<MediaPreview asset={asset} />);
expect(container.querySelector('video')).toHaveAttribute(
'src',
asset.objectUrl
);
expect(
screen.getByText(
'Local source preview · Detailed inspection unavailable.'
)
).toBeVisible();
});
it('keeps the video-container player when browser metadata has no track hints', () => {
const file = new File(['audio-only'], 'audio-only.mp4', {
type: 'video/mp4',
});
const initial: ImportedMediaAsset = {
id: 'asset-audio-only-container',
file,
objectUrl: 'blob:audio-only-container',
phase: 'idle',
};
const { container, rerender } = render(<MediaPreview asset={initial} />);
const player = requiredElement(container.querySelector('video'));
rerender(
<MediaPreview
asset={{
...initial,
browserMetadata: {
source: 'browser',
file: {
name: file.name,
sizeBytes: file.size,
declaredMimeType: file.type,
inferredMimeType: file.type,
lastModified: file.lastModified,
},
kind: 'video',
durationSeconds: 8,
},
}}
/>
);
expect(container.querySelector('video')).toBe(player);
});
it('offers bounded configurable proxy settings after playback fails', async () => {
const user = userEvent.setup();
const onCreateProxy = vi.fn();

View File

@@ -16,6 +16,66 @@ import type { UserExportPreset } from '../../../src/presets';
afterEach(cleanup);
describe('QuickConvert', () => {
it('keeps an uninspected source usable and requests stream detection with export', async () => {
const user = userEvent.setup();
const onInspect = vi.fn();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
const file = new File(['media'], 'lecture.mp4', { type: 'video/mp4' });
const asset: ImportedMediaAsset = {
id: 'asset-basic',
file,
objectUrl: 'blob:asset-basic',
phase: 'idle',
browserMetadata: {
source: 'browser',
file: {
name: file.name,
sizeBytes: file.size,
declaredMimeType: file.type,
inferredMimeType: file.type,
lastModified: file.lastModified,
},
kind: 'video',
durationSeconds: 2_622.48,
width: 640,
height: 480,
hasVideo: true,
},
};
render(
<QuickConvert
asset={asset}
engineState={readyEngine()}
busy={false}
onInspect={onInspect}
onExport={onExport}
/>
);
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
expect(screen.getByText('Ready to play')).toBeVisible();
await user.click(
screen.getByRole('button', { name: 'Inspect details now' })
);
expect(onInspect).toHaveBeenCalledOnce();
await user.click(screen.getByRole('button', { name: 'Inspect & convert' }));
expect(onExport).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'convert',
selectDetectedStreams: true,
streamSelection: {
video: [],
audio: [],
subtitles: [],
attachments: [],
data: [],
},
})
);
});
it('labels a submission honestly when another operation is active', () => {
render(
<QuickConvert
@@ -138,6 +198,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,17 @@
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,
canReuseProbeCapabilities,
engineJobTimeoutMilliseconds,
prepareEngineArguments,
probeTimeoutMilliseconds,
shouldRefreshCoreBeforeProbe,
shouldRecycleExecutionCore,
} from '../../src/ffmpeg/EngineManager';
@@ -90,6 +96,73 @@ 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('Firefox probe core refresh policy', () => {
it('refreshes only affected Firefox-family runtimes', () => {
expect(
shouldRefreshCoreBeforeProbe(
'Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0'
)
).toBe(true);
expect(
shouldRefreshCoreBeforeProbe(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) FxiOS/142.0'
)
).toBe(true);
expect(
shouldRefreshCoreBeforeProbe(
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36'
)
).toBe(false);
expect(shouldRefreshCoreBeforeProbe('')).toBe(false);
});
it('reuses cached capabilities only for the identical engine mode', () => {
expect(canReuseProbeCapabilities('multithread', 'multithread')).toBe(true);
expect(canReuseProbeCapabilities('single-thread', 'single-thread')).toBe(
true
);
expect(canReuseProbeCapabilities('multithread', 'single-thread')).toBe(
false
);
expect(canReuseProbeCapabilities('single-thread', 'multithread')).toBe(
false
);
});
});
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

@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
BrowserMetadataError,
browserMediaFileInfo,
inferBrowserMediaKind,
inspectBrowserMediaMetadata,
} from '../../../src/media/browser-metadata';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('browser media metadata', () => {
it('returns file information synchronously and infers useful media kinds', () => {
const file = new File(['media'], 'lecture.MP4', {
type: 'application/octet-stream',
lastModified: 123,
});
expect(browserMediaFileInfo(file)).toEqual({
name: 'lecture.MP4',
sizeBytes: 5,
declaredMimeType: 'application/octet-stream',
inferredMimeType: 'video/mp4',
lastModified: 123,
});
expect(inferBrowserMediaKind(file)).toBe('video');
expect(
inferBrowserMediaKind(
new File([], 'recording.bin', { type: 'audio/ogg; codecs=opus' })
)
).toBe('audio');
expect(inferBrowserMediaKind(new File([], 'notes.txt'))).toBe('unknown');
});
it('loads duration and dimensions through a native video element', async () => {
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
const video = document.createElement('video');
const load = vi.spyOn(video, 'load').mockImplementation(() => undefined);
defineNumber(video, 'duration', 2_622.48);
defineNumber(video, 'videoWidth', 640);
defineNumber(video, 'videoHeight', 480);
Object.defineProperty(video, 'audioTracks', {
configurable: true,
value: { length: 1 },
});
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:lecture', {
createMediaElement: () => video,
});
video.dispatchEvent(new Event('loadedmetadata'));
await expect(resultPromise).resolves.toEqual({
source: 'browser',
file: {
name: 'lecture.mp4',
sizeBytes: 5,
declaredMimeType: 'video/mp4',
inferredMimeType: 'video/mp4',
lastModified: file.lastModified,
},
kind: 'video',
durationSeconds: 2_622.48,
width: 640,
height: 480,
hasAudio: true,
hasVideo: true,
});
expect(video.preload).toBe('metadata');
expect(video).not.toHaveAttribute('src');
expect(load).toHaveBeenCalledTimes(2);
});
it('uses an audio element and leaves unavailable details unknown', async () => {
const file = new File(['audio'], 'speech.flac', { type: 'audio/flac' });
const audio = document.createElement('audio');
vi.spyOn(audio, 'load').mockImplementation(() => undefined);
defineNumber(audio, 'duration', 12.5);
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:speech', {
createMediaElement: (kind) => {
expect(kind).toBe('audio');
return audio;
},
});
audio.dispatchEvent(new Event('loadedmetadata'));
await expect(resultPromise).resolves.toMatchObject({
source: 'browser',
kind: 'audio',
durationSeconds: 12.5,
hasAudio: true,
hasVideo: false,
});
});
it('keeps a video-container player viable when dimensions and audio hints are absent', async () => {
const file = new File(['audio-only'], 'audio-only.mp4', {
type: 'video/mp4',
});
const video = document.createElement('video');
vi.spyOn(video, 'load').mockImplementation(() => undefined);
defineNumber(video, 'duration', 8);
defineNumber(video, 'videoWidth', 0);
defineNumber(video, 'videoHeight', 0);
Object.defineProperty(video, 'audioTracks', {
configurable: true,
value: undefined,
});
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:audio-only', {
createMediaElement: () => video,
});
video.dispatchEvent(new Event('loadedmetadata'));
await expect(resultPromise).resolves.toMatchObject({
kind: 'video',
durationSeconds: 8,
hasVideo: undefined,
hasAudio: undefined,
});
});
it('reports load errors without revoking the caller-owned object URL', async () => {
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL');
const video = document.createElement('video');
vi.spyOn(video, 'load').mockImplementation(() => undefined);
const resultPromise = inspectBrowserMediaMetadata(
new File([], 'broken.mp4', { type: 'video/mp4' }),
'blob:broken',
{ createMediaElement: () => video }
);
video.dispatchEvent(new Event('error'));
await expect(resultPromise).rejects.toMatchObject({
name: 'BrowserMetadataError',
code: 'load-error',
});
expect(revokeObjectUrl).not.toHaveBeenCalled();
});
it('supports cancellation and a bounded metadata timeout', async () => {
const file = new File([], 'long.mp4', { type: 'video/mp4' });
const controller = new AbortController();
const firstVideo = document.createElement('video');
vi.spyOn(firstVideo, 'load').mockImplementation(() => undefined);
const cancelled = inspectBrowserMediaMetadata(file, 'blob:cancel', {
createMediaElement: () => firstVideo,
signal: controller.signal,
});
controller.abort();
await expect(cancelled).rejects.toMatchObject({
code: 'aborted',
});
vi.useFakeTimers();
const secondVideo = document.createElement('video');
vi.spyOn(secondVideo, 'load').mockImplementation(() => undefined);
const timedOut = inspectBrowserMediaMetadata(file, 'blob:timeout', {
createMediaElement: () => secondVideo,
timeoutMilliseconds: 25,
});
const timeoutExpectation = expect(timedOut).rejects.toEqual(
new BrowserMetadataError(
'timeout',
'Browser metadata loading exceeded 1 seconds.'
)
);
await vi.advanceTimersByTimeAsync(25);
await timeoutExpectation;
});
});
function defineNumber(
target: HTMLMediaElement,
property: 'duration' | 'videoHeight' | 'videoWidth',
value: number
): void {
Object.defineProperty(target, property, {
configurable: true,
value,
});
}

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.3.0.zip');
expect(line).toBe(`${digest} av-tools-0.3.0.zip\n`);
expect(parseChecksumLine(line)).toEqual({
archiveName: 'av-tools-0.1.0.zip',
archiveName: 'av-tools-0.3.0.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.3.0.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.3.0.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.3.0.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/**',
],
},
});