Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a4548851c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -3,6 +3,31 @@
|
||||
All notable changes to av-tools are documented here. The project follows
|
||||
semantic versioning once a first release is legally publishable.
|
||||
|
||||
## 0.2.0 — 2026-07-27
|
||||
|
||||
- Allow recognized local audio/video to begin native browser playback while
|
||||
FFmpeg inspection continues in the background.
|
||||
- Replace the fixed 30-second media-inspection watchdog with a bounded,
|
||||
size-aware budget and report timeout expiry separately from user
|
||||
cancellation.
|
||||
- Add an isolated, reproducible Chromium ST/MT Opus regression harness using
|
||||
the reviewed cores, checked tone fixture and real built-in command plan.
|
||||
- Rebuild ST/MT core 0.12.10 with the upstream 5 MiB stack fix, lock and
|
||||
deterministically package the exact assets, pass non-empty Opus encode and
|
||||
re-probe in both modes, and enable Opus presets behind runtime capabilities.
|
||||
- Add bounded 0.04–10 second audio/video crossfades to normalized sequential
|
||||
joins, including runtime `xfade`/`acrossfade` gates and clip-material checks.
|
||||
- Add bounded scene-change analysis and exact-PTS thumbnail/contact-sheet
|
||||
extraction, with an honest no-scenes result instead of silent substitution.
|
||||
- Bundle unmodified DejaVu Sans 2.37 with byte identity, upstream archive
|
||||
provenance and full terms; verify it in-browser before labelled contact
|
||||
sheets and fail closed when `drawtext` is unavailable.
|
||||
- Add native execution coverage, pinned-core browser matrix steps and an opt-in
|
||||
large-file browser playback-during-inspection evidence run.
|
||||
- Make removal of queued or active source inspections generation-safe, cancel
|
||||
owned active probes and prevent stale results from updating deleted or
|
||||
same-ID replacement assets.
|
||||
|
||||
## 0.1.0 — 2026-07-24
|
||||
|
||||
- Scaffold a local-first React/Vite audio and video toolbox application.
|
||||
|
||||
92
LICENSES/DEJAVU_FONTS.txt
Normal file
92
LICENSES/DEJAVU_FONTS.txt
Normal 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.
|
||||
@@ -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
|
||||
|
||||
119
README.md
119
README.md
@@ -8,8 +8,8 @@ telemetry, runtime CDN, remote-media fetch or third-party script.
|
||||
It is also a Toolbox SDK application. The same static build runs directly or
|
||||
under Toolbox Portal with shared shell, theme, help and app switching.
|
||||
|
||||
> **Release status:** 0.1.0 is licensed GPL-3.0-or-later. Its canonical
|
||||
> release consists of the exact reviewed `v0.1.0` source, production
|
||||
> **Release status:** 0.2.0 is licensed GPL-3.0-or-later. Its canonical
|
||||
> release consists of the exact reviewed `v0.2.0` source, production
|
||||
> ZIP/checksum and Corresponding Source bundle published together. See
|
||||
> [Licensing](#licensing).
|
||||
|
||||
@@ -23,7 +23,7 @@ Two workflows keep the product intentionally smaller than a nonlinear editor:
|
||||
- **Edit:** a media bin and sequential non-overlapping clip timeline with
|
||||
non-destructive settings, one export at a time.
|
||||
|
||||
The planned 0.1 line covers typed/capability-gated conversion and remux,
|
||||
The 0.2 release covers typed/capability-gated conversion and remux,
|
||||
fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
|
||||
resize, frame-rate change, audio extraction/stream removal, waveform,
|
||||
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
|
||||
@@ -34,10 +34,18 @@ diagnostics and explicit browser export.
|
||||
The Edit workspace now exposes structural exports directly: fast stream-copy
|
||||
or preset-backed accurate trim uses the selected clip's timeline range, while
|
||||
fast concatenate requires explicit compatibility diagnostics and normalized
|
||||
concatenate requires a preset plus an explicit missing-audio policy.
|
||||
concatenate requires a preset plus an explicit missing-audio policy. Normalized
|
||||
joins can apply a uniform, capability-gated 0.04–10 second audio/video
|
||||
crossfade at every edit, with clip-material checks before queueing.
|
||||
Operations enter a bounded, in-memory application queue with at most eight
|
||||
scheduled operations and one FFmpeg execution at a time.
|
||||
|
||||
Thumbnail series and contact sheets support even, interval, chapter, marker or
|
||||
bounded scene-change selection. Scene analysis records exact source
|
||||
presentation timestamps before extraction. Contact-sheet labels use only the
|
||||
bundled, SHA-256-verified DejaVu Sans 2.37 file and remain disabled unless the
|
||||
active core exposes `drawtext`; there is no remote/system-font fallback.
|
||||
|
||||
When a source cannot be played directly, the preview panel can create a
|
||||
disposable H.264/AAC MP4 proxy. The user can bound it to 1–300 seconds and
|
||||
choose a maximum width from 480–1920 pixels. A proxy is reused from the local
|
||||
@@ -53,14 +61,15 @@ Exact runtime packages:
|
||||
|
||||
- `@ffmpeg/ffmpeg` 0.12.15;
|
||||
- `@ffmpeg/util` 0.12.2;
|
||||
- `@ffmpeg/core` 0.12.10 (single-thread);
|
||||
- `@ffmpeg/core-mt` 0.12.10 (multithread).
|
||||
- reviewed FFmpeg core build `0.12.10-reviewed-stack5m.1` in single-thread and
|
||||
multithread profiles.
|
||||
|
||||
The published cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
|
||||
The reviewed cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
|
||||
with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp,
|
||||
FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their
|
||||
JS/worker files are copied from exact npm packages and served from the app's
|
||||
own origin. `npm run build` does not download them.
|
||||
JS/worker files are verified against a tracked build lock, unpacked from a
|
||||
deterministic repository artifact and served from the app's own origin.
|
||||
`npm run build` does not download them.
|
||||
|
||||
Every engine load queries actual demuxers, muxers, decoders, encoders and
|
||||
filters. Presets remain disabled when requirements are absent; an extension or
|
||||
@@ -92,10 +101,12 @@ WebM/VP8/Vorbis, MP3 and WAV are common preview candidates, but actual codec,
|
||||
browser and platform support is checked at runtime. Matroska and many other
|
||||
valid outputs may be download-only. A preview proxy may require re-encoding.
|
||||
|
||||
The pinned core has an unresolved Opus encoding regression risk (zero-byte or
|
||||
out-of-bounds behavior reported around upstream stack-size fixes). Opus
|
||||
presets must stay unavailable unless a real non-empty, re-probeable ST/MT smoke
|
||||
passes. Details are in [FFmpeg build](docs/FFMPEG_BUILD.md).
|
||||
The 2026-07-26 Chromium regression harness ran the real built-in `audio-opus`
|
||||
plan against both reviewed cores. Each selected the requested ST/MT mode,
|
||||
produced non-empty Opus and passed codec/duration re-probing. Opus presets are
|
||||
therefore enabled when runtime capability discovery also succeeds. Exact
|
||||
commands and evidence boundaries are in
|
||||
[FFmpeg build](docs/FFMPEG_BUILD.md).
|
||||
|
||||
## Execution modes
|
||||
|
||||
@@ -169,9 +180,14 @@ Core assets are generated local build inputs:
|
||||
|
||||
```sh
|
||||
npm run vendor:ffmpeg
|
||||
npm run vendor:verify
|
||||
npm run vendor:verify-runtime
|
||||
```
|
||||
|
||||
The runtime verifier checks the tracked deterministic core artifact, source
|
||||
lock, build driver and generated same-origin files. The stricter
|
||||
`npm run vendor:verify` publication gate additionally requires and hashes the
|
||||
ignored Corresponding Source archive generated by `npm run source:package`.
|
||||
|
||||
Regenerate or verify the tiny synthetic fixtures:
|
||||
|
||||
```sh
|
||||
@@ -183,7 +199,7 @@ npm run fixtures:verify
|
||||
|
||||
```sh
|
||||
npm run manifest:check
|
||||
npm run vendor:verify
|
||||
npm run vendor:verify-runtime
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
@@ -191,6 +207,8 @@ npm test
|
||||
npm run build
|
||||
npm run toolbox:check
|
||||
npm run test:browser
|
||||
npm run evidence:large-preview # set AV_TOOLS_LARGE_MEDIA first
|
||||
npm run evidence:opus
|
||||
```
|
||||
|
||||
`npm run check` combines the non-browser production checks. Browser tests cover
|
||||
@@ -198,13 +216,27 @@ the real pinned cores, MT-to-ST fallback, cancellation/recovery, generated
|
||||
fixtures, nested deployment, Toolbox context and the absence of runtime CDN
|
||||
traffic.
|
||||
|
||||
The latest recorded local Vitest run on 2026-07-24 completed **303 passing
|
||||
tests and one conditional skip**. It includes broad structured-command
|
||||
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in case
|
||||
requires a `subtitles` filter unavailable in that host build.
|
||||
`npm run evidence:opus` independently contains each ST/MT browser process,
|
||||
prints one JSON report and exits normally even when the result is unsafe.
|
||||
`npm run test:opus-regression` runs the same evidence as the positive Opus
|
||||
enablement gate and exits nonzero unless both modes produce non-empty,
|
||||
re-probeable Opus. It is a mandatory release gate for the reviewed core
|
||||
profile.
|
||||
|
||||
`AV_TOOLS_LARGE_MEDIA=/absolute/video.mp4 npm run evidence:large-preview`
|
||||
performs the opt-in large-file regression: the native video element must mount
|
||||
and advance before bounded inspection finishes, remain the same element after
|
||||
the authoritative probe completes and produce no browser error.
|
||||
|
||||
The latest recorded local Vitest run on 2026-07-26 completed **313 passing
|
||||
tests and two conditional skips**. It includes broad structured-command
|
||||
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in and
|
||||
labeled contact-sheet cases require `subtitles` and `drawtext` filters that are
|
||||
unavailable in that host build.
|
||||
|
||||
The Chromium suite exercises the pinned ST core across conversion,
|
||||
trim/concat/split, analysis, waveform, image, metadata, chapter, subtitle,
|
||||
trim/concat/split, bounded crossfades, analysis, waveform, scene-selected image
|
||||
generation, bundled-font contact-sheet labels, metadata, chapter, subtitle,
|
||||
queue and recovery workflows. The MT core is directly initialized and queried,
|
||||
and completes a real non-empty, re-probeable H.264/AAC conversion; the same
|
||||
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
|
||||
@@ -243,12 +275,12 @@ repository and the `GPL-3.0-or-later` SPDX licence.
|
||||
|
||||
Portal must consume an independently built ZIP; it must not build or import
|
||||
av-tools source. Immutable Portal revision
|
||||
`bda9da044d61c109afd9969d357cc27f76648938` has isolation and WASM CSP
|
||||
permissions but lacks `media-src blob:`, which blocks Blob previews. The
|
||||
developer's active Toolbox Portal worktree currently contains the narrow local
|
||||
`media-src 'self' blob:` patch; that uncommitted worktree state does not change
|
||||
the immutable reference or release evidence. The exact distinction, smallest
|
||||
change and fallback are documented in
|
||||
`5c052bc6d1073a66600cfddc44c0e16da34bede8` (`v0.6.0`) provides the required
|
||||
isolation, narrow WebAssembly CSP, Blob media policy and explicit WASM MIME
|
||||
mapping. The active Portal worktree additionally extends immutable caching to
|
||||
the exact reviewed FFmpeg build profile without accepting mutable-looking
|
||||
suffixes. Its exact reviewed and staged
|
||||
boundaries are documented in
|
||||
[Portal requirements](docs/PORTAL_REQUIREMENTS.md).
|
||||
|
||||
## Release
|
||||
@@ -272,11 +304,11 @@ metadata:
|
||||
```sh
|
||||
npm run package:release
|
||||
npm run portal:smoke -- \
|
||||
--allow-known-header-gap
|
||||
--portal-root ../toolbox-portal
|
||||
```
|
||||
|
||||
The header-gap override remains development-only until the Portal CSP change is
|
||||
committed. Full commands and safety behavior are in
|
||||
The smoke harness clones the selected Portal `HEAD`; it never consumes
|
||||
uncommitted Portal files silently. Full commands and safety behavior are in
|
||||
[release documentation](docs/RELEASE.md).
|
||||
|
||||
## Licensing
|
||||
@@ -286,11 +318,11 @@ and licensed **GPL-3.0-or-later**. See [LICENSE](LICENSE). The application
|
||||
provides a visible licence, source and no-warranty notice in its Help dialog.
|
||||
Third-party components retain their copyright and licence terms.
|
||||
|
||||
The wrapper/util are MIT. Core/core-mt 0.12.10 declare
|
||||
GPL-2.0-or-later and statically include FFmpeg/external codec code with
|
||||
additional terms. The branch-based x264/LAME inputs have been resolved to
|
||||
immutable commits in the source record. A binary publication must include
|
||||
equivalent access to the reviewed Corresponding Source and notices. See:
|
||||
The wrapper/util are MIT. The reviewed 0.12.10 cores statically include
|
||||
GPL-enabled FFmpeg/external codec code with additional terms. The branch-based
|
||||
x264/LAME inputs have been resolved to immutable commits in the source record.
|
||||
A binary publication must include equivalent access to the reviewed
|
||||
Corresponding Source and notices. See:
|
||||
|
||||
- [third-party notices](THIRD_PARTY_NOTICES.md)
|
||||
- [source record](SOURCE.md)
|
||||
@@ -317,19 +349,18 @@ The auditable list is in
|
||||
- Metadata removal is best effort, not an anonymity guarantee.
|
||||
- Browser preview supports fewer combinations than FFmpeg.
|
||||
- MT depends on isolation; ST can still hit memory/time limits.
|
||||
- Opus output is release-gated for the pinned core.
|
||||
- Opus enablement is supported by Chromium ST/MT fixture evidence, not a
|
||||
cross-browser or arbitrary-input guarantee.
|
||||
- Projects do not contain source bytes by default and require reattachment.
|
||||
- Cross-browser/mobile and exhaustive MT performance matrices remain
|
||||
unrecorded; published support claims must stay within the tested evidence.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. Complete Quick Convert and structural-export browser verification with the
|
||||
real pinned ST/MT cores, including fallback and queued execution.
|
||||
2. Verify waveform, loudness normalization and combined filters in browsers.
|
||||
3. Verify metadata/chapter/subtitle round trips in browsers.
|
||||
4. Verify bounded thumbnails/contact sheets, projects, derivative-cache reuse
|
||||
and export in browsers.
|
||||
5. Complete accessibility, mobile, performance and nested Portal matrices.
|
||||
6. Commit/tag exact source, publish its reviewed Corresponding Source, assemble
|
||||
the exact production ZIP through Portal and verify deployed headers.
|
||||
1. Extend the evidence matrix beyond Chromium to Firefox, Safari and mobile;
|
||||
broaden MT operation parity and replace the one-run performance observation
|
||||
with repeated cold/warm measurements.
|
||||
2. Evaluate optional editing breadth without weakening local-first guarantees:
|
||||
additional reviewed transition shapes, richer scene-ranking controls and
|
||||
explicit transition audio-curve choices beyond the current bounded uniform
|
||||
crossfade.
|
||||
|
||||
91
SOURCE.md
91
SOURCE.md
@@ -1,6 +1,6 @@
|
||||
# Source and reproducibility record
|
||||
|
||||
This file identifies the source corresponding to av-tools 0.1.0. It is not a
|
||||
This file identifies the source corresponding to av-tools 0.2.0. It is not a
|
||||
substitute for satisfying a component's licence or for delivering complete
|
||||
Corresponding Source with an object-code release.
|
||||
|
||||
@@ -9,9 +9,9 @@ Corresponding Source with an object-code release.
|
||||
- Canonical repository:
|
||||
`git@git.add-ideas.de:zemion/av-tools.git`
|
||||
- Browser source: `https://git.add-ideas.de/zemion/av-tools`
|
||||
- Version: `0.1.0`
|
||||
- Release source identity: immutable annotated tag `v0.1.0`
|
||||
(`refs/tags/v0.1.0`) in the canonical repository.
|
||||
- Version: `0.2.0`
|
||||
- Release source identity: immutable annotated tag `v0.2.0`
|
||||
(`refs/tags/v0.2.0`) in the canonical repository.
|
||||
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
|
||||
`LICENSE`.
|
||||
- Copyright: Copyright © 2026 Albrecht Degering.
|
||||
@@ -22,10 +22,10 @@ the tag exists, is annotated, points to the reviewed release commit and is
|
||||
available without authentication:
|
||||
|
||||
```sh
|
||||
test "$(git cat-file -t v0.1.0)" = tag
|
||||
git rev-parse v0.1.0^{commit}
|
||||
test "$(git cat-file -t v0.2.0)" = tag
|
||||
git rev-parse v0.2.0^{commit}
|
||||
git ls-remote --tags \
|
||||
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.1.0
|
||||
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.2.0
|
||||
```
|
||||
|
||||
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
||||
@@ -40,34 +40,66 @@ the tag/source archive beside the binary release and checksum.
|
||||
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
|
||||
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
|
||||
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
|
||||
| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` |
|
||||
| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` |
|
||||
|
||||
Install exact packages and recreate the copied same-origin assets without a
|
||||
Install exact packages and recreate the verified same-origin assets without a
|
||||
network fetch during the production build:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run vendor:ffmpeg
|
||||
npm run vendor:verify
|
||||
npm run vendor:verify-runtime
|
||||
npm run build
|
||||
```
|
||||
|
||||
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the
|
||||
installed core packages; it does not download them. The copied bytes are:
|
||||
`scripts/copy-ffmpeg-assets.mjs` fails closed against
|
||||
`scripts/reviewed-ffmpeg-core-lock.json`, verifies the deterministic tracked
|
||||
ZIP and unpacks only its five declared entries. It does not download them or
|
||||
fall back to published core packages. The reviewed bytes are:
|
||||
|
||||
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/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` |
|
||||
| ---------------------------------------------------------------------------- | -------: | ------------------------------------------------------------------ |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Third-party notices
|
||||
|
||||
av-tools 0.1.0 is Copyright © 2026 Albrecht Degering and licensed
|
||||
av-tools 0.2.0 is Copyright © 2026 Albrecht Degering and licensed
|
||||
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
||||
inputs. It is informational, not legal advice, and does not replace their
|
||||
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
||||
@@ -10,29 +10,32 @@ 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 |
|
||||
| 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -5,7 +5,7 @@ extensions or this document. The app queries muxers, demuxers, decoders,
|
||||
encoders and filters; a preset is disabled with missing components listed.
|
||||
Browser playback is a separate capability checked with MIME/media APIs.
|
||||
|
||||
## 0.1.0 candidate fixture matrix
|
||||
## 0.2.0 release fixture matrix
|
||||
|
||||
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
|
||||
preparation and pass the checksum/probe verifier. That proves fixture
|
||||
@@ -42,9 +42,9 @@ 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 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 |
|
||||
@@ -52,7 +52,7 @@ Preset availability always depends on detected runtime requirements:
|
||||
| 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 |
|
||||
| Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded |
|
||||
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
|
||||
| FLAC | `flac`, `flac` | browser-dependent |
|
||||
|
||||
@@ -77,11 +77,30 @@ remux avoids re-encoding compatible streams but may change container metadata.
|
||||
- Normalized concat uses an explicit export preset and requires an explicit
|
||||
insert-silence, drop-all-audio or reject policy when clips have mismatched
|
||||
audio presence.
|
||||
- Normalized concat can overlap every edit with one uniform 0.04–10 second
|
||||
`xfade` and, when audio is retained, `acrossfade`. End clips must contain at
|
||||
least one transition duration and internal clips two; the active core must
|
||||
expose each required filter.
|
||||
- Crop, resize, frame-rate conversion, fades, normalization, burn-in and
|
||||
contact sheets require specific filters discovered at runtime.
|
||||
- Even-dimension correction is applied when the selected pixel format/encoder
|
||||
requires it and is reported in the plan/result.
|
||||
|
||||
## Images and scene selection
|
||||
|
||||
Thumbnail/contact-sheet scene mode first performs a bounded full-source
|
||||
analysis with `select`/`showinfo`, stopping after at most 100 qualifying
|
||||
frames. Extraction then selects those exact source PTS values; no guessed time
|
||||
rounding or remote analysis is used. If no frame meets the 0.01–0.95 threshold,
|
||||
the operation reports that result and asks for a lower threshold or a
|
||||
deterministic selection mode.
|
||||
|
||||
Contact-sheet labels require runtime `drawtext`. The app mounts only its
|
||||
same-origin DejaVu Sans 2.37 asset after verifying 757,076 bytes and SHA-256
|
||||
`7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954`.
|
||||
No remote or host-system font fallback is used. `SOURCE.md` and
|
||||
`LICENSES/DEJAVU_FONTS.txt` provide provenance and terms.
|
||||
|
||||
## Subtitles
|
||||
|
||||
The text fixtures cover SRT, WebVTT and ASS import with explicit time shift.
|
||||
@@ -115,8 +134,9 @@ elements do not reliably expose them, so ffprobe round-trip is authoritative.
|
||||
- frame-accurate stream-copy trimming;
|
||||
- complete metadata erasure/anonymity;
|
||||
- browser preview of every FFmpeg-readable result;
|
||||
- Opus presets until real non-empty ST/MT encoding smoke passes for core
|
||||
0.12.10;
|
||||
- Opus on browsers, inputs or deployments outside the exact reviewed
|
||||
Chromium regression: both ST and MT pass the non-empty encode/re-probe gate,
|
||||
but runtime capability discovery and normal per-job limits still apply;
|
||||
- any capability omitted by the active core's runtime query.
|
||||
|
||||
Update this file only from re-probed generated/public-domain fixtures and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Known limitations
|
||||
|
||||
This is the conservative 0.1.0 development baseline. A capability appearing in
|
||||
This is the conservative 0.2.0 release baseline. A capability appearing in
|
||||
FFmpeg output does not remove these product/browser/resource limits.
|
||||
|
||||
## Functional
|
||||
@@ -13,6 +13,9 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
frame-accurate. Accurate mode requires decoding/re-encoding.
|
||||
- Stream-copy concatenate works only when selected streams and codec/container
|
||||
parameters are compatible. Other input needs normalized re-encoding.
|
||||
- Crossfades are uniform fade/triangular-curve transitions on normalized
|
||||
sequential joins, not arbitrary transition effects or overlapping timeline
|
||||
tracks. They require enough material on both sides of every internal edit.
|
||||
- Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
|
||||
cannot restore information already lost in a source.
|
||||
- Metadata removal is best effort. Muxers may add structural/encoder fields,
|
||||
@@ -26,6 +29,12 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
page discards queued work.
|
||||
- A file-generating batch and its managed result collection are bounded to 255
|
||||
outputs, leaving the 256th ZIP entry available for an optional export report.
|
||||
- Scene analysis scans the source until it finds the requested changes or
|
||||
reaches the end, retains at most 100 exact PTS results and can therefore take
|
||||
about as long as a full decode. It does not begin an export implicitly when
|
||||
no frame meets the chosen threshold.
|
||||
- Labelled contact sheets require a core with `drawtext`; the reviewed local
|
||||
font does not make that filter available on a core that omits it.
|
||||
- Preview proxies are bounded, lossy H.264/AAC derivatives for local playback;
|
||||
they do not replace the source or guarantee that every browser can play the
|
||||
result.
|
||||
@@ -38,8 +47,9 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
support fewer container/codec combinations.
|
||||
- Browser support, codec policy and memory differ across Chromium, Firefox and
|
||||
Safari and across desktop/mobile versions.
|
||||
- Core 0.12.10 has an Opus regression risk. Opus remains release-gated until
|
||||
non-empty, re-probeable ST and MT fixture outputs pass.
|
||||
- The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe
|
||||
gate in isolated Chromium ST and MT runs. That evidence is not a
|
||||
cross-browser or every-input Opus guarantee.
|
||||
- MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works
|
||||
without isolation but is not a guarantee that a large job fits.
|
||||
- Top-level navigation is supported; iframe embedding is not.
|
||||
@@ -69,7 +79,7 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
verification against the live container.
|
||||
- The application is GPL-3.0-or-later and its native legal files and pinned
|
||||
source identities are included in the release record. Publish the production
|
||||
ZIP/checksum, exact `v0.1.0` source and FFmpeg Corresponding Source bundle
|
||||
ZIP/checksum, exact `v0.2.0` source and FFmpeg Corresponding Source bundle
|
||||
together; development-only smoke artifacts must not be distributed.
|
||||
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
|
||||
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
|
||||
|
||||
@@ -6,12 +6,13 @@ output at once. No hardware encoder is used.
|
||||
|
||||
## Fixed download footprint
|
||||
|
||||
Uncompressed self-hosted core assets for 0.12.10:
|
||||
Uncompressed self-hosted core assets for
|
||||
`0.12.10-reviewed-stack5m.1`:
|
||||
|
||||
| Mode | JavaScript/worker | WASM | Total |
|
||||
| ------------- | ------------------: | -----------: | -----------: |
|
||||
| single-thread | 111,804 B | 32,232,419 B | 32,344,223 B |
|
||||
| multithread | 128,947 B + 2,115 B | 32,718,323 B | 32,849,385 B |
|
||||
| single-thread | 111,804 B | 32,232,580 B | 32,344,384 B |
|
||||
| multithread | 128,953 B + 2,115 B | 32,718,455 B | 32,849,523 B |
|
||||
|
||||
Only the selected mode is loaded. HTTP compression may reduce JavaScript
|
||||
transfer but not necessarily WASM to the same degree; report transfer and
|
||||
@@ -72,9 +73,18 @@ Estimated output warns at 512 MiB and is blocked at 1 GiB. FFmpeg jobs are
|
||||
serial; the application retains at most eight scheduled operations in its
|
||||
in-memory queue. Timeline clips are capped at 100, streams per input at 64,
|
||||
images at 8192×8192/67,108,864 pixels, generated output files at 255,
|
||||
contact-sheet cells at 500 and runtime at four hours. The documented
|
||||
ffmpeg.wasm input ceiling is 2 GiB, but real limits can be substantially lower
|
||||
on mobile or memory-constrained browsers.
|
||||
contact-sheet cells at 500, scene-change results at 100, crossfades at 10
|
||||
seconds and runtime at four hours. Scene selection is a bounded two-step
|
||||
workflow but its analysis step can still decode the complete source when few
|
||||
frames meet the threshold. The documented ffmpeg.wasm input ceiling is 2 GiB,
|
||||
but real limits can be substantially lower on mobile or memory-constrained
|
||||
browsers.
|
||||
|
||||
Source inspection has a bounded, size-aware watchdog: 30 seconds for small
|
||||
files, a two-MiB-per-second scan budget for larger files, and a five-minute
|
||||
ceiling. Recognized local audio/video can begin browser playback from its
|
||||
temporary object URL while that worker inspection continues; completed probe
|
||||
metadata remains authoritative for editing and export.
|
||||
|
||||
Lowering resolution, duration, frame rate, passes and output count reduces
|
||||
work. MT can be faster but consumes more memory/CPU and requires isolation; ST
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` initially; `5c052bc6d1073a66600cfddc44c0e16da34bede8` for final integration | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the AV smoke harness consumes only an exact cloned Portal revision; the separate Portal worktree contains its own reviewed integration patch |
|
||||
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc`; pinned release baseline `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree source was copied; wrapper/util remain exact npm dependencies, while the five core assets are rebuilt from the recorded source/toolchain/patch profile and verified against the build lock |
|
||||
|
||||
For the **pinned** package set, the more specific upstream release tag
|
||||
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It
|
||||
contains `@ffmpeg/ffmpeg` 0.12.15 and core/core-mt 0.12.10. That revision's
|
||||
build selects FFmpeg n5.1.4.
|
||||
contains `@ffmpeg/ffmpeg` 0.12.15 and the source configuration for core
|
||||
0.12.10. That revision's build selects FFmpeg n5.1.4. The shipped
|
||||
`0.12.10-reviewed-stack5m.1` assets additionally apply upstream ffmpeg.wasm
|
||||
commit `b409e36475bc21f0451b5b1e1d126fa82871439a`; exact build and source
|
||||
identities are in `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
|
||||
|
||||
The user's active `/mnt/DATA/git/toolbox-portal` worktree still has Portal
|
||||
revision `bda9da044d61c109afd9969d357cc27f76648938` as `HEAD`, but currently
|
||||
contains a local `media-src 'self' blob:` nginx CSP patch. That mutable
|
||||
worktree state is documented for deployment testing only; it does not replace
|
||||
the immutable reference in the table.
|
||||
The active `/mnt/DATA/git/toolbox-portal` worktree has immutable revision
|
||||
`5c052bc6d1073a66600cfddc44c0e16da34bede8` as `HEAD`. Its staged cache-rule
|
||||
extension is documented separately and is not represented as a published
|
||||
revision.
|
||||
|
||||
Official behavior references:
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ The repository already declares the application licence and immutable
|
||||
third-party source identities. Before any distribution:
|
||||
|
||||
1. commit the reviewed GPL-3.0-or-later source;
|
||||
2. create and verify annotated tag `v0.1.0` at that exact commit;
|
||||
2. create and verify annotated tag `v0.2.0` at that exact commit;
|
||||
3. push the commit and tag to the canonical public repository;
|
||||
4. assemble, review and publish corresponding source and exact external
|
||||
notices for both core packages and linked libraries;
|
||||
notices for both reviewed core profiles and linked libraries;
|
||||
5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned.
|
||||
|
||||
`SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not
|
||||
@@ -44,6 +44,7 @@ Normal packaging validates the root GPL version 3 text and matching
|
||||
```sh
|
||||
npm run package:release
|
||||
npm run source:package
|
||||
npm run vendor:verify
|
||||
```
|
||||
|
||||
The legacy development-only escape hatch is retained only for checking an
|
||||
@@ -56,19 +57,19 @@ npm run package:release -- --allow-unlicensed-development-smoke
|
||||
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
|
||||
|
||||
```text
|
||||
release/av-tools-0.1.0.zip
|
||||
release/av-tools-0.1.0.zip.sha256
|
||||
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
|
||||
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256
|
||||
release/av-tools-0.2.0.zip
|
||||
release/av-tools-0.2.0.zip.sha256
|
||||
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
|
||||
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
|
||||
```
|
||||
|
||||
Verify independently:
|
||||
|
||||
```sh
|
||||
node scripts/checksum-release.mjs
|
||||
(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256)
|
||||
unzip -l release/av-tools-0.1.0.zip
|
||||
tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
|
||||
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
|
||||
unzip -l release/av-tools-0.2.0.zip
|
||||
tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
|
||||
```
|
||||
|
||||
The packager:
|
||||
@@ -90,13 +91,13 @@ When used against a fixture without an application licence, the escape hatch add
|
||||
|
||||
## 4. Portal assembly smoke
|
||||
|
||||
Use the clean reference clone beside av-tools, never the user's active Portal
|
||||
worktree:
|
||||
Use a reviewed Portal Git repository beside av-tools. The harness reads its
|
||||
exact `HEAD` and clones that revision into a temporary directory:
|
||||
|
||||
```sh
|
||||
npm run portal:smoke -- \
|
||||
--artifact release/av-tools-0.1.0.zip \
|
||||
--allow-known-header-gap
|
||||
--artifact release/av-tools-0.2.0.zip \
|
||||
--portal-root ../toolbox-portal
|
||||
```
|
||||
|
||||
The smoke harness clones the exact Portal HEAD to a temporary directory,
|
||||
@@ -106,19 +107,23 @@ then verifies the generated catalogue/release records and copied FFmpeg assets.
|
||||
The temporary directory is removed unless `--keep-temp` is explicitly passed.
|
||||
No machine-specific path is committed and no Portal worktree is changed.
|
||||
|
||||
Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP
|
||||
omission of `media-src blob:` is a failing deployment check. The override still
|
||||
reports the gap and is only for testing archive assembly. See
|
||||
`docs/PORTAL_REQUIREMENTS.md`.
|
||||
Portal `v0.6.0`
|
||||
(`5c052bc6d1073a66600cfddc44c0e16da34bede8`) already passes the isolation,
|
||||
Blob-media CSP and explicit WASM MIME checks. Its plain-semver cache rule does
|
||||
not match the reviewed core's build-profile suffix, so the harness reports a
|
||||
cache warning. The staged Portal extension accepts only the exact reviewed
|
||||
profile in addition to plain semantic versions and rejects malformed or
|
||||
mutable-looking suffixes; a temporary clean commit containing that patch passed
|
||||
this smoke without warnings.
|
||||
|
||||
The user's active `/mnt/DATA/git/toolbox-portal` worktree contains the narrow
|
||||
directive as a local patch, but the smoke intentionally does not consume that
|
||||
mutable worktree. Until the patch exists in a reviewed immutable Portal
|
||||
revision, retain the clean-reference failure/override behavior above.
|
||||
`--allow-known-header-gap` remains available only for deliberately testing an
|
||||
older Portal revision with a known required-header error. It is unnecessary
|
||||
for `v0.6.0` and must not suppress review of new gaps. See
|
||||
`docs/PORTAL_REQUIREMENTS.md`.
|
||||
|
||||
## 5. Publish only reviewed bytes
|
||||
|
||||
From a clean `v0.1.0` checkout, rerun all checks, create the ZIP once, verify
|
||||
From a clean `v0.2.0` checkout, rerun all checks, create the ZIP once, verify
|
||||
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
|
||||
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
|
||||
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ export default tseslint.config(
|
||||
'test-results',
|
||||
'playwright-report',
|
||||
'public/vendor/ffmpeg',
|
||||
'.core-build',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,18 +1,16 @@
|
||||
{
|
||||
"name": "av-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "av-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
||||
"@ffmpeg/core": "0.12.10",
|
||||
"@ffmpeg/core-mt": "0.12.10",
|
||||
"@ffmpeg/ffmpeg": "0.12.15",
|
||||
"@ffmpeg/util": "0.12.2",
|
||||
"fflate": "0.8.3",
|
||||
@@ -718,24 +716,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/core": {
|
||||
"version": "0.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
|
||||
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/core-mt": {
|
||||
"version": "0.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz",
|
||||
"integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/ffmpeg": {
|
||||
"version": "0.12.15",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
|
||||
|
||||
13
package.json
13
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "av-tools",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -22,6 +22,8 @@
|
||||
"dev": "vite",
|
||||
"vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs",
|
||||
"vendor:verify": "node scripts/verify-ffmpeg-assets.mjs",
|
||||
"vendor:verify-runtime": "node scripts/verify-ffmpeg-assets.mjs --runtime-only",
|
||||
"vendor:package": "node scripts/package-reviewed-ffmpeg-core.mjs",
|
||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||
"prebuild": "npm run vendor:ffmpeg && npm run manifest:generate",
|
||||
@@ -34,6 +36,9 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:browser": "playwright test",
|
||||
"evidence:large-preview": "playwright test tests/browser/large-media-preview.spec.ts --project=chromium --workers=1",
|
||||
"evidence:opus": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs",
|
||||
"test:opus-regression": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs --require-pass",
|
||||
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
|
||||
"fixtures:generate": "sh scripts/generate-fixtures.sh",
|
||||
"fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS",
|
||||
@@ -42,14 +47,12 @@
|
||||
"source:package": "node scripts/package-ffmpeg-source.mjs",
|
||||
"checksum:release": "node scripts/checksum-release.mjs",
|
||||
"portal:smoke": "node scripts/portal-assembly-smoke.mjs",
|
||||
"check": "npm run manifest:check && npm run vendor:verify && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release"
|
||||
"check": "npm run manifest:check && npm run vendor:verify-runtime && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
||||
"release:artifact": "npm run check && npm run vendor:verify && npm run test:opus-regression && npm run test:browser && npm run package:release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
||||
"@ffmpeg/core": "0.12.10",
|
||||
"@ffmpeg/core-mt": "0.12.10",
|
||||
"@ffmpeg/ffmpeg": "0.12.15",
|
||||
"@ffmpeg/util": "0.12.2",
|
||||
"fflate": "0.8.3",
|
||||
|
||||
BIN
public/fonts/DejaVuSans.ttf
Normal file
BIN
public/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.av-tools",
|
||||
"name": "Audio & Video Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -55,10 +55,10 @@
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
|
||||
]
|
||||
}
|
||||
|
||||
420
scripts/build-reviewed-ffmpeg-core.sh
Normal file
420
scripts/build-reviewed-ffmpeg-core.sh
Normal 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"
|
||||
99
scripts/build-tools/install
Normal file
99
scripts/build-tools/install
Normal 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
|
||||
@@ -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'
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
const lockedAssetPaths = new Set(
|
||||
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
if (parsed.version !== expectedVersion) {
|
||||
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(
|
||||
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
|
||||
`Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAsset(packageName, mode, fileName) {
|
||||
const source = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'dist',
|
||||
'esm',
|
||||
fileName
|
||||
);
|
||||
const destination = join(outputRoot, mode, fileName);
|
||||
const sourceBytes = await readFile(source).catch((error) => {
|
||||
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
|
||||
cause: error,
|
||||
});
|
||||
});
|
||||
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
|
||||
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
|
||||
if (!expectedDigest || sourceDigest !== expectedDigest) {
|
||||
throw new Error(
|
||||
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
dereference: true,
|
||||
errorOnExist: false,
|
||||
force: true,
|
||||
preserveTimestamps: false,
|
||||
});
|
||||
|
||||
const destinationBytes = await readFile(destination);
|
||||
if (!sourceBytes.equals(destinationBytes)) {
|
||||
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: relative(outputRoot, destination).replaceAll('\\', '/'),
|
||||
bytes: destinationBytes.byteLength,
|
||||
sha256: sourceDigest,
|
||||
};
|
||||
}
|
||||
|
||||
await rm(outputRoot, { force: true, recursive: true });
|
||||
|
||||
const assets = [];
|
||||
for (const entry of packages) {
|
||||
await loadPackageVersion(entry.packageName);
|
||||
for (const fileName of entry.files) {
|
||||
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
|
||||
}
|
||||
}
|
||||
|
||||
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
|
||||
const versionManifest = {
|
||||
schemaVersion: 1,
|
||||
coreVersion: expectedVersion,
|
||||
packages: {
|
||||
'@ffmpeg/core': expectedVersion,
|
||||
'@ffmpeg/core-mt': expectedVersion,
|
||||
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)}`
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
522
scripts/opus-regression.mjs
Normal file
522
scripts/opus-regression.mjs
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
279
scripts/package-reviewed-ffmpeg-core.mjs
Normal file
279
scripts/package-reviewed-ffmpeg-core.mjs
Normal 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})`
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
73
scripts/reviewed-ffmpeg-core-lock.json
Normal file
73
scripts/reviewed-ffmpeg-core-lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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'}`
|
||||
);
|
||||
|
||||
257
src/App.tsx
257
src/App.tsx
@@ -9,6 +9,10 @@ import {
|
||||
type WorkspaceMode,
|
||||
} from './app/application-state';
|
||||
import { AppErrorBoundary } from './app/AppErrorBoundary';
|
||||
import {
|
||||
SourceInspectionRegistry,
|
||||
type SourceInspectionToken,
|
||||
} from './app/source-inspection-registry';
|
||||
import { FileDropZone } from './components/FileDropZone';
|
||||
import { MediaBin } from './components/MediaBin';
|
||||
import {
|
||||
@@ -79,7 +83,10 @@ import { buildPreviewProxyPlan } from './commands/preview-proxy';
|
||||
import { buildTimelineExportPlan } from './commands/timeline-export';
|
||||
import {
|
||||
buildContactSheetPlan,
|
||||
buildSceneChangeAnalysisPlan,
|
||||
buildSceneThumbnailSeriesPlan,
|
||||
buildThumbnailSeriesPlan,
|
||||
parseSceneChangeFrames,
|
||||
} from './commands/thumbnails';
|
||||
import { buildSplitPlan } from './commands/split';
|
||||
import { buildTrimPlan } from './commands/trim';
|
||||
@@ -152,6 +159,11 @@ import {
|
||||
type WaveformPeakData,
|
||||
} from './waveform';
|
||||
import type { MediaJob, MediaJobStep } from './jobs';
|
||||
import { loadBundledContactSheetFont } from './assets/bundled-font';
|
||||
import {
|
||||
FFMPEG_CORE_VERSION,
|
||||
FFMPEG_OPUS_REGRESSION_VERIFIED,
|
||||
} from './version';
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString();
|
||||
@@ -554,6 +566,7 @@ export function AvToolsApplication() {
|
||||
const projectRef = useRef(project);
|
||||
const sourceProbeTail = useRef<Promise<void>>(Promise.resolve());
|
||||
const sourceProbePendingRef = useRef(0);
|
||||
const sourceInspectionRegistry = useRef(new SourceInspectionRegistry());
|
||||
const planExecutionTail = useRef<Promise<void>>(Promise.resolve());
|
||||
const scheduledPlanCountRef = useRef(0);
|
||||
const scheduledWorkRef = useRef(new Map<string, ScheduledWorkDefinition>());
|
||||
@@ -703,7 +716,10 @@ export function AvToolsApplication() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const inspectionRegistry = sourceInspectionRegistry.current;
|
||||
return () => {
|
||||
const cancelActiveInspection = inspectionRegistry.clear();
|
||||
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||
for (const asset of assetsRef.current) {
|
||||
URL.revokeObjectURL(asset.objectUrl);
|
||||
}
|
||||
@@ -751,15 +767,37 @@ export function AvToolsApplication() {
|
||||
);
|
||||
|
||||
const inspectAsset = useCallback(
|
||||
async (asset: ImportedMediaAsset, existingAsset: boolean) => {
|
||||
async (
|
||||
asset: ImportedMediaAsset,
|
||||
existingAsset: boolean,
|
||||
token: SourceInspectionToken
|
||||
) => {
|
||||
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
|
||||
updateAssetState(asset.id, {
|
||||
phase: 'probing',
|
||||
error: undefined,
|
||||
});
|
||||
try {
|
||||
const probeResult = await runSourceProbe(() =>
|
||||
engineManager.probe(asset.file)
|
||||
const probeResult = await runSourceProbe(async () => {
|
||||
if (!sourceInspectionRegistry.current.activate(token)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await engineManager.probe(
|
||||
asset.file,
|
||||
undefined,
|
||||
token.signal
|
||||
);
|
||||
} finally {
|
||||
sourceInspectionRegistry.current.finish(token);
|
||||
}
|
||||
});
|
||||
if (
|
||||
!probeResult ||
|
||||
!sourceInspectionRegistry.current.isCurrent(token)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const probe = normalizeFfprobeReport(probeResult.json);
|
||||
const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`;
|
||||
const importedChapters = probe.chapters.map((chapter) => ({
|
||||
@@ -772,8 +810,15 @@ export function AvToolsApplication() {
|
||||
? { metadata: chapter.tags }
|
||||
: {}),
|
||||
}));
|
||||
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
|
||||
updateAssetState(asset.id, { phase: 'ready', probe, rawProbe });
|
||||
setProject((current) => {
|
||||
if (
|
||||
!sourceInspectionRegistry.current.isCurrent(token) ||
|
||||
!current.assets.some((entry) => entry.id === asset.id)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
let next = projectReducer(current, {
|
||||
type: 'asset/update',
|
||||
assetId: asset.id,
|
||||
@@ -823,6 +868,7 @@ export function AvToolsApplication() {
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
|
||||
updateAssetState(asset.id, {
|
||||
phase: 'error',
|
||||
error:
|
||||
@@ -881,6 +927,7 @@ export function AvToolsApplication() {
|
||||
const pending: Array<{
|
||||
asset: ImportedMediaAsset;
|
||||
existingAsset: boolean;
|
||||
token: SourceInspectionToken;
|
||||
}> = [];
|
||||
const claimedIds = new Set(assetsRef.current.map((asset) => asset.id));
|
||||
|
||||
@@ -916,7 +963,11 @@ export function AvToolsApplication() {
|
||||
if (reattach && match) {
|
||||
asset.id = match.entry.id;
|
||||
claimedIds.add(match.entry.id);
|
||||
pending.push({ asset, existingAsset: true });
|
||||
pending.push({
|
||||
asset,
|
||||
existingAsset: true,
|
||||
token: sourceInspectionRegistry.current.register(asset.id),
|
||||
});
|
||||
} else {
|
||||
setProject((current) =>
|
||||
projectReducer(current, {
|
||||
@@ -932,7 +983,11 @@ export function AvToolsApplication() {
|
||||
updatedAt: timestamp(),
|
||||
})
|
||||
);
|
||||
pending.push({ asset, existingAsset: false });
|
||||
pending.push({
|
||||
asset,
|
||||
existingAsset: false,
|
||||
token: sourceInspectionRegistry.current.register(asset.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,7 +998,7 @@ export function AvToolsApplication() {
|
||||
});
|
||||
setSelectedAssetId((current) => current ?? pending[0]?.asset.id);
|
||||
for (const item of pending) {
|
||||
await inspectAsset(item.asset, item.existingAsset);
|
||||
await inspectAsset(item.asset, item.existingAsset, item.token);
|
||||
}
|
||||
},
|
||||
[inspectAsset, project.assets, resourceContext.policy]
|
||||
@@ -953,7 +1008,13 @@ export function AvToolsApplication() {
|
||||
(id: string) => {
|
||||
const asset = assets.find((entry) => entry.id === id);
|
||||
if (asset) URL.revokeObjectURL(asset.objectUrl);
|
||||
setAssets((current) => current.filter((entry) => entry.id !== id));
|
||||
const cancelActiveInspection =
|
||||
sourceInspectionRegistry.current.remove(id);
|
||||
setAssets((current) => {
|
||||
const next = current.filter((entry) => entry.id !== id);
|
||||
assetsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setWaveformByAsset((current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(current).filter(([assetId]) => assetId !== id)
|
||||
@@ -982,6 +1043,7 @@ export function AvToolsApplication() {
|
||||
assetId: id,
|
||||
updatedAt: timestamp(),
|
||||
});
|
||||
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||
},
|
||||
[applyProjectAction, assets, selectedAssetId]
|
||||
);
|
||||
@@ -1203,7 +1265,7 @@ export function AvToolsApplication() {
|
||||
? ('multi-thread' as const)
|
||||
: ('single-thread' as const),
|
||||
ffmpegVersion: readyStateAfterRun.capabilities.versionText,
|
||||
coreVersion: '0.12.10',
|
||||
coreVersion: FFMPEG_CORE_VERSION,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -2245,6 +2307,9 @@ export function AvToolsApplication() {
|
||||
|
||||
const resetProject = useCallback(() => {
|
||||
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
|
||||
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
|
||||
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||
assetsRef.current = [];
|
||||
clearResult();
|
||||
setAssets([]);
|
||||
setSelectedAssetId(undefined);
|
||||
@@ -2280,6 +2345,9 @@ export function AvToolsApplication() {
|
||||
try {
|
||||
const imported = importProjectDocument(await file.text());
|
||||
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
|
||||
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
|
||||
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||
assetsRef.current = [];
|
||||
clearResult();
|
||||
setAssets([]);
|
||||
setSelectedAssetId(undefined);
|
||||
@@ -2556,6 +2624,7 @@ export function AvToolsApplication() {
|
||||
channelLayout: 'stereo',
|
||||
missingAudioPolicy: request.missingAudioPolicy,
|
||||
subtitlePolicy: request.subtitlePolicy,
|
||||
crossfadeSeconds: request.crossfadeSeconds,
|
||||
});
|
||||
})();
|
||||
await runPlan(
|
||||
@@ -2950,30 +3019,77 @@ export function AvToolsApplication() {
|
||||
if (!asset || duration === undefined) {
|
||||
throw new Error('Select an attached, inspected video source first.');
|
||||
}
|
||||
const distribution =
|
||||
request.distribution.type === 'evenly-spaced'
|
||||
? { count: request.distribution.count }
|
||||
: request.distribution.type === 'fixed-interval'
|
||||
? { intervalSeconds: request.distribution.intervalSeconds }
|
||||
: { timesSeconds: request.distribution.timesSeconds };
|
||||
const plan = buildThumbnailSeriesPlan({
|
||||
jobId: crypto.randomUUID(),
|
||||
source: {
|
||||
const source = {
|
||||
id: asset.id,
|
||||
sourceIndex: 0,
|
||||
fileName: asset.file.name,
|
||||
},
|
||||
durationSeconds: duration,
|
||||
...distribution,
|
||||
size: {
|
||||
};
|
||||
const size = {
|
||||
...(request.width ? { width: request.width } : {}),
|
||||
...(request.height ? { height: request.height } : {}),
|
||||
},
|
||||
};
|
||||
const sceneDistribution =
|
||||
request.distribution.type === 'scene-changes'
|
||||
? request.distribution
|
||||
: undefined;
|
||||
const execution = sceneDistribution
|
||||
? await runWorkflow(
|
||||
'Create scene-change thumbnails',
|
||||
2,
|
||||
async (executeStep) => {
|
||||
const analysis = await executeStep(
|
||||
buildSceneChangeAnalysisPlan({
|
||||
jobId: crypto.randomUUID(),
|
||||
source,
|
||||
durationSeconds: duration,
|
||||
threshold: sceneDistribution.threshold,
|
||||
maxFrames: sceneDistribution.count,
|
||||
}),
|
||||
[asset.file],
|
||||
'Analyze scene changes'
|
||||
);
|
||||
const frames = parseSceneChangeFrames(analysis.logText).slice(
|
||||
0,
|
||||
sceneDistribution.count
|
||||
);
|
||||
if (frames.length === 0) {
|
||||
throw new Error(
|
||||
'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.'
|
||||
);
|
||||
}
|
||||
return executeStep(
|
||||
buildSceneThumbnailSeriesPlan({
|
||||
jobId: crypto.randomUUID(),
|
||||
source,
|
||||
durationSeconds: duration,
|
||||
frames,
|
||||
size,
|
||||
format: request.format,
|
||||
quality: request.quality,
|
||||
});
|
||||
const execution = await runPlan(
|
||||
plan,
|
||||
}),
|
||||
[asset.file],
|
||||
'Extract analyzed scene frames'
|
||||
);
|
||||
}
|
||||
)
|
||||
: await runPlan(
|
||||
buildThumbnailSeriesPlan({
|
||||
jobId: crypto.randomUUID(),
|
||||
source,
|
||||
durationSeconds: duration,
|
||||
...(request.distribution.type === 'evenly-spaced'
|
||||
? { count: request.distribution.count }
|
||||
: request.distribution.type === 'fixed-interval'
|
||||
? {
|
||||
intervalSeconds: request.distribution.intervalSeconds,
|
||||
}
|
||||
: request.distribution.type === 'scene-changes'
|
||||
? { count: request.distribution.count }
|
||||
: { timesSeconds: request.distribution.timesSeconds }),
|
||||
size,
|
||||
format: request.format,
|
||||
quality: request.quality,
|
||||
}),
|
||||
[asset.file],
|
||||
'Create thumbnail series'
|
||||
);
|
||||
@@ -3002,7 +3118,7 @@ export function AvToolsApplication() {
|
||||
);
|
||||
}
|
||||
},
|
||||
[inspectedClipAsset, project.id, runPlan]
|
||||
[inspectedClipAsset, project.id, runPlan, runWorkflow]
|
||||
);
|
||||
|
||||
const handleContactSheet = useCallback(
|
||||
@@ -3012,22 +3128,75 @@ export function AvToolsApplication() {
|
||||
if (!asset || duration === undefined) {
|
||||
throw new Error('Select an attached, inspected video source first.');
|
||||
}
|
||||
const plan = buildContactSheetPlan({
|
||||
jobId: crypto.randomUUID(),
|
||||
source: {
|
||||
const source = {
|
||||
id: asset.id,
|
||||
sourceIndex: 0,
|
||||
fileName: asset.file.name,
|
||||
},
|
||||
};
|
||||
const labelsRequested = request.timestampLabels || request.sourceLabel;
|
||||
const font = labelsRequested
|
||||
? await loadBundledContactSheetFont()
|
||||
: undefined;
|
||||
const buildSheet = (
|
||||
sceneFrames?: ReturnType<typeof parseSceneChangeFrames>
|
||||
) =>
|
||||
buildContactSheetPlan({
|
||||
jobId: crypto.randomUUID(),
|
||||
source,
|
||||
durationSeconds: duration,
|
||||
...request,
|
||||
drawtextAvailable: false,
|
||||
...(sceneFrames
|
||||
? { frameCount: sceneFrames.length, sceneFrames }
|
||||
: {}),
|
||||
drawtextAvailable: Boolean(font),
|
||||
...(font
|
||||
? {
|
||||
font: {
|
||||
id: 'bundled-contact-sheet-font',
|
||||
sourceIndex: 1,
|
||||
fileName: font.name,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const execution = await runPlan(
|
||||
plan,
|
||||
const inputs = font ? [asset.file, font] : [asset.file];
|
||||
const sceneSelection =
|
||||
request.selection.type === 'scene-changes'
|
||||
? request.selection
|
||||
: undefined;
|
||||
const execution = sceneSelection
|
||||
? await runWorkflow(
|
||||
'Create scene-change contact sheet',
|
||||
2,
|
||||
async (executeStep) => {
|
||||
const analysis = await executeStep(
|
||||
buildSceneChangeAnalysisPlan({
|
||||
jobId: crypto.randomUUID(),
|
||||
source,
|
||||
durationSeconds: duration,
|
||||
threshold: sceneSelection.threshold,
|
||||
maxFrames: request.frameCount,
|
||||
}),
|
||||
[asset.file],
|
||||
'Create contact sheet'
|
||||
'Analyze scene changes'
|
||||
);
|
||||
const frames = parseSceneChangeFrames(analysis.logText).slice(
|
||||
0,
|
||||
request.frameCount
|
||||
);
|
||||
if (frames.length === 0) {
|
||||
throw new Error(
|
||||
'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.'
|
||||
);
|
||||
}
|
||||
return executeStep(
|
||||
buildSheet(frames),
|
||||
inputs,
|
||||
'Render scene-change contact sheet'
|
||||
);
|
||||
}
|
||||
)
|
||||
: await runPlan(buildSheet(), inputs, 'Create contact sheet');
|
||||
const output = execution.results[0];
|
||||
if (!output) throw new Error('The contact sheet was not returned.');
|
||||
try {
|
||||
@@ -3045,7 +3214,7 @@ export function AvToolsApplication() {
|
||||
);
|
||||
}
|
||||
},
|
||||
[inspectedClipAsset, project.id, runPlan]
|
||||
[inspectedClipAsset, project.id, runPlan, runWorkflow]
|
||||
);
|
||||
|
||||
const handleMetadataAction = useCallback(
|
||||
@@ -3413,7 +3582,8 @@ export function AvToolsApplication() {
|
||||
status: 'unavailable' as const,
|
||||
reasons: ['Initialize the engine to verify this preset.'],
|
||||
};
|
||||
const opusBlocked = preset.audio?.codec === 'libopus';
|
||||
const opusBlocked =
|
||||
preset.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
||||
return {
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
@@ -3423,7 +3593,7 @@ export function AvToolsApplication() {
|
||||
...(availability.status === 'unavailable' || opusBlocked
|
||||
? {
|
||||
disabledReason: opusBlocked
|
||||
? 'Opus output remains disabled until the pinned core passes its non-empty ST/MT regression smoke.'
|
||||
? 'Opus output is disabled until the reviewed core passes the non-empty ST/MT regression encode and re-probe gate.'
|
||||
: availability.reasons.join(' '),
|
||||
}
|
||||
: {}),
|
||||
@@ -3481,6 +3651,7 @@ export function AvToolsApplication() {
|
||||
hasAudio: false,
|
||||
hasVideo: false,
|
||||
hasSubtitles: false,
|
||||
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
|
||||
};
|
||||
}
|
||||
const unsupportedStreams = asset.probe.streams.filter(
|
||||
@@ -3517,6 +3688,7 @@ export function AvToolsApplication() {
|
||||
return {
|
||||
id: clip.id,
|
||||
label,
|
||||
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
|
||||
hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'),
|
||||
hasVideo: asset.probe.streams.some(
|
||||
(stream) =>
|
||||
@@ -3622,10 +3794,12 @@ export function AvToolsApplication() {
|
||||
filters: ['fps', 'scale', 'tile'],
|
||||
}),
|
||||
'contact-sheet-labels': {
|
||||
available: false,
|
||||
reason:
|
||||
'No reviewed local font is bundled, so labels are omitted instead of fetching a remote font.',
|
||||
...capabilityStatus({ filters: ['drawtext'] }),
|
||||
},
|
||||
'scene-change-analysis': capabilityStatus({
|
||||
muxers: ['null'],
|
||||
filters: ['select', 'showinfo'],
|
||||
}),
|
||||
'metadata-write': sourceMuxer
|
||||
? capabilityStatus({ muxers: [sourceMuxer] })
|
||||
: { available: false, reason: 'Select a source first.' },
|
||||
@@ -3678,6 +3852,9 @@ export function AvToolsApplication() {
|
||||
},
|
||||
anyVideoPreset ? undefined : 'No verified video preset is available.'
|
||||
),
|
||||
'concat-crossfade': capabilityStatus({
|
||||
filters: ['xfade', ...(structuralHasAnyAudio ? ['acrossfade'] : [])],
|
||||
}),
|
||||
} as const;
|
||||
|
||||
const advancedSource = inspectedClipAsset?.probe
|
||||
|
||||
76
src/app/source-inspection-registry.ts
Normal file
76
src/app/source-inspection-registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
src/assets/bundled-font.ts
Normal file
54
src/assets/bundled-font.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
});
|
||||
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)[] {
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import applicationLicenseUrl from '../../LICENSE?url&no-inline';
|
||||
import dejavuFontLicenseUrl from '../../LICENSES/DEJAVU_FONTS.txt?url&no-inline';
|
||||
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
|
||||
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
|
||||
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
|
||||
@@ -7,7 +8,7 @@ import sourceRecordUrl from '../../SOURCE.md?url&no-inline';
|
||||
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
|
||||
import {
|
||||
APP_VERSION,
|
||||
FFMPEG_CORE_VERSION,
|
||||
FFMPEG_CORE_BUILD_ID,
|
||||
FFMPEG_ENGINE_VERSION,
|
||||
FFMPEG_UTIL_VERSION,
|
||||
FFMPEG_WRAPPER_VERSION,
|
||||
@@ -101,7 +102,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>FFmpeg WASM cores</dt>
|
||||
<dd>{FFMPEG_CORE_VERSION} · ST & MT</dd>
|
||||
<dd>{FFMPEG_CORE_BUILD_ID} · ST & 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.
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { ImportedMediaAsset } from '../app/application-state';
|
||||
import {
|
||||
assessBrowserPlayback,
|
||||
assessGeneratedResultPreview,
|
||||
isAudioMimeType,
|
||||
isVideoMimeType,
|
||||
mimeTypeFromFileName,
|
||||
type MediaProbe,
|
||||
} from '../media';
|
||||
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
|
||||
@@ -47,15 +50,31 @@ export function MediaPreview({
|
||||
const mediaElement = useRef<HTMLMediaElement>(null);
|
||||
const sourceUrl = result?.objectUrl ?? asset?.objectUrl;
|
||||
const playbackFailed = sourceUrl === failedSourceUrl;
|
||||
const fileMimeType = asset?.file.type;
|
||||
const declaredSourceMimeType =
|
||||
fileMimeType &&
|
||||
(isVideoMimeType(fileMimeType) || isAudioMimeType(fileMimeType))
|
||||
? fileMimeType
|
||||
: asset
|
||||
? mimeTypeFromFileName(asset.file.name)
|
||||
: undefined;
|
||||
const hasVideo = result
|
||||
? result.mimeType.startsWith('video/')
|
||||
: asset?.probe?.streams.some(
|
||||
: asset?.probe
|
||||
? asset.probe.streams.some(
|
||||
(stream) =>
|
||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||
) === true;
|
||||
)
|
||||
: declaredSourceMimeType
|
||||
? isVideoMimeType(declaredSourceMimeType)
|
||||
: false;
|
||||
const hasAudio = result
|
||||
? result.mimeType.startsWith('audio/')
|
||||
: asset?.probe?.streams.some((stream) => stream.type === 'audio') === true;
|
||||
: asset?.probe
|
||||
? asset.probe.streams.some((stream) => stream.type === 'audio')
|
||||
: declaredSourceMimeType
|
||||
? isAudioMimeType(declaredSourceMimeType)
|
||||
: false;
|
||||
const hasImage = result?.mimeType.startsWith('image/') === true;
|
||||
const resultPlayback = useMemo(() => {
|
||||
if (!result) return undefined;
|
||||
@@ -186,7 +205,10 @@ export function MediaPreview({
|
||||
? resultPlayback?.previewable === false
|
||||
? `Download only · ${result.name}`
|
||||
: `Generated result · ${result.name}`
|
||||
: (playback?.reason ?? 'Local source preview')}
|
||||
: (playback?.reason ??
|
||||
(asset?.phase === 'probing'
|
||||
? 'Local source preview · Media inspection continues in the background.'
|
||||
: 'Local source preview'))}
|
||||
</span>
|
||||
{needsProxy && !proxySettings ? (
|
||||
<button type="button" className="text-button" onClick={onCreateProxy}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
presetAvailability,
|
||||
} from '../presets/preset-registry';
|
||||
import { formatBytes, formatDuration } from '../app/application-state';
|
||||
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export type QuickOperation = 'convert' | 'remux';
|
||||
@@ -139,7 +140,8 @@ export function QuickConvert({
|
||||
engineState.capabilities
|
||||
)
|
||||
: undefined;
|
||||
const opusNeedsRegressionTest = preset?.audio?.codec === 'libopus';
|
||||
const opusBlockedByPinnedCore =
|
||||
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
||||
const presetNeedsMissingAudio =
|
||||
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
|
||||
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
||||
@@ -186,7 +188,7 @@ export function QuickConvert({
|
||||
? !remuxMuxerMissing && remuxErrors.length === 0
|
||||
: preset !== undefined &&
|
||||
availability?.status !== 'unavailable' &&
|
||||
!opusNeedsRegressionTest &&
|
||||
!opusBlockedByPinnedCore &&
|
||||
!presetNeedsMissingAudio);
|
||||
|
||||
const presetOption = (entry: ExportPreset) => {
|
||||
@@ -199,8 +201,8 @@ export function QuickConvert({
|
||||
? presetAvailability({ requirements }, engineState.capabilities)
|
||||
: undefined;
|
||||
const reason =
|
||||
entry.audio?.codec === 'libopus'
|
||||
? 'Opus awaits a pinned-core regression test'
|
||||
entry.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED
|
||||
? 'the reviewed core has not passed the ST/MT Opus regression encode'
|
||||
: entryAvailability?.status === 'unavailable'
|
||||
? entryAvailability.reasons.join(', ')
|
||||
: undefined;
|
||||
@@ -455,11 +457,11 @@ export function QuickConvert({
|
||||
Select at least one output stream.
|
||||
</p>
|
||||
) : null}
|
||||
{opusNeedsRegressionTest ? (
|
||||
{opusBlockedByPinnedCore ? (
|
||||
<p className="notice notice--warning">
|
||||
<Icon name="warning" />
|
||||
Opus export is held back until the pinned core passes its browser
|
||||
regression test.
|
||||
Opus export is disabled until the reviewed core passes its non-empty
|
||||
encode and re-probe gate in both engine modes.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.04–10 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.04–10 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(
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
type FFmpegFactory = () => Promise<FFmpegAdapter>;
|
||||
|
||||
export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000;
|
||||
export const MIN_PROBE_TIMEOUT_MILLISECONDS = 30_000;
|
||||
export const MAX_PROBE_TIMEOUT_MILLISECONDS = 5 * 60_000;
|
||||
export const PROBE_BUDGET_BYTES_PER_SECOND = 2 * 1024 * 1024;
|
||||
export const MULTITHREAD_CODEC_THREAD_LIMIT = 2;
|
||||
/**
|
||||
* The pinned Emscripten core can retain native heap state across heterogeneous
|
||||
@@ -55,6 +58,19 @@ export function shouldRecycleExecutionCore(
|
||||
);
|
||||
}
|
||||
|
||||
export function probeTimeoutMilliseconds(inputBytes: number): number {
|
||||
if (!Number.isFinite(inputBytes) || inputBytes <= 0) {
|
||||
return MIN_PROBE_TIMEOUT_MILLISECONDS;
|
||||
}
|
||||
const sizeAwareBudget = Math.ceil(
|
||||
(inputBytes / PROBE_BUDGET_BYTES_PER_SECOND) * 1_000
|
||||
);
|
||||
return Math.min(
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||
Math.max(MIN_PROBE_TIMEOUT_MILLISECONDS, sizeAwareBudget)
|
||||
);
|
||||
}
|
||||
|
||||
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
||||
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||
return new FFmpeg();
|
||||
@@ -521,8 +537,18 @@ export class EngineManager {
|
||||
};
|
||||
}
|
||||
|
||||
async probe(file: File, timeoutMilliseconds = 30_000): Promise<ProbeResult> {
|
||||
async probe(
|
||||
file: File,
|
||||
timeoutMilliseconds = probeTimeoutMilliseconds(file.size),
|
||||
signal?: AbortSignal
|
||||
): Promise<ProbeResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
await this.initialize();
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
const engine = this.#engine;
|
||||
const mode = this.#mode;
|
||||
if (!engine || !mode || this.#state.status !== 'ready') {
|
||||
@@ -537,6 +563,10 @@ export class EngineManager {
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
|
||||
if (signal?.aborted) {
|
||||
await mounted.cleanup();
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
const probeOutput = mounted.outputPaths[0];
|
||||
const inputPath = mounted.inputPaths[0];
|
||||
if (!probeOutput || !inputPath) {
|
||||
@@ -565,7 +595,15 @@ export class EngineManager {
|
||||
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
|
||||
message: describeMountedInputAccess(mounted),
|
||||
});
|
||||
const watchdog = setTimeout(() => this.cancelActive(), timeoutMilliseconds);
|
||||
let timedOut = false;
|
||||
const watchdog = setTimeout(() => {
|
||||
timedOut = true;
|
||||
void this.cancelActive();
|
||||
}, timeoutMilliseconds);
|
||||
const cancelOnAbort = () => {
|
||||
void this.cancelActive();
|
||||
};
|
||||
signal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||
try {
|
||||
const exitCode = await engine.ffprobe([
|
||||
'-v',
|
||||
@@ -630,6 +668,13 @@ export class EngineManager {
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.#cancelledJobIds.delete(activeProbeId)) {
|
||||
if (timedOut) {
|
||||
throw new EngineError(
|
||||
'probe-failed',
|
||||
`Media inspection exceeded its ${String(Math.ceil(timeoutMilliseconds / 1_000))}-second safety limit.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.', {
|
||||
cause: error,
|
||||
});
|
||||
@@ -641,6 +686,7 @@ export class EngineManager {
|
||||
details: this.#logs.text(),
|
||||
});
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancelOnAbort);
|
||||
clearTimeout(watchdog);
|
||||
this.#activeJobId = null;
|
||||
this.#machineProgressLines = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.av-tools",
|
||||
"name": "Audio & Video Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -48,10 +48,10 @@
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const APP_VERSION = '0.1.0';
|
||||
export const APP_VERSION = '0.2.0';
|
||||
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
|
||||
export const FFMPEG_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;
|
||||
|
||||
@@ -191,12 +191,12 @@ test.describe('nested static deployment and Toolbox context', () => {
|
||||
|
||||
const coreScript = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js'
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js'
|
||||
)
|
||||
);
|
||||
const coreWasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm'
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(coreScript).toMatchObject({
|
||||
|
||||
@@ -75,9 +75,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
);
|
||||
|
||||
const expectedAssets = [
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||
];
|
||||
for (const suffix of expectedAssets) {
|
||||
expect(
|
||||
@@ -87,11 +87,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toBe(true);
|
||||
}
|
||||
const wasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm')
|
||||
response.url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(wasm?.contentType).toContain('application/wasm');
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/'))
|
||||
runtime.requests.some((url) =>
|
||||
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/')
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
@@ -119,8 +123,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) =>
|
||||
route.abort('failed')
|
||||
await page.route(
|
||||
'**/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/**',
|
||||
(route) => route.abort('failed')
|
||||
);
|
||||
await page.goto('/');
|
||||
const environment = await page.evaluate(() => ({
|
||||
@@ -147,13 +152,17 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/'))
|
||||
runtime.requests.some((url) =>
|
||||
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.responses.some(
|
||||
(response) =>
|
||||
response.status === 200 &&
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
response.url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
@@ -247,7 +256,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
@@ -303,7 +314,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toHaveCount(4);
|
||||
expect(
|
||||
runtime.requests.filter((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toHaveLength(2);
|
||||
assertLocalOnly(runtime);
|
||||
@@ -356,4 +369,43 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('removes active and queued inspections without applying stale probe results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
|
||||
await mediaInput(page).setInputFiles([
|
||||
cancellationFixture.filePath,
|
||||
PRIMARY_FIXTURE,
|
||||
]);
|
||||
const activeName = basename(cancellationFixture.filePath);
|
||||
const activeCard = page.locator('.media-card').filter({
|
||||
hasText: activeName,
|
||||
});
|
||||
const queuedCard = page.locator('.media-card').filter({
|
||||
hasText: 'pattern-av.mp4',
|
||||
});
|
||||
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(queuedCard.locator('.phase--queued')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove pattern-av.mp4' }).click();
|
||||
await page.getByRole('button', { name: `Remove ${activeName}` }).click();
|
||||
await expect(page.locator('.media-card')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Choose files' })
|
||||
).toBeEnabled({ timeout: 120_000 });
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
|
||||
61
tests/browser/large-media-preview.spec.ts
Normal file
61
tests/browser/large-media-preview.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const mediaPath = process.env.AV_TOOLS_LARGE_MEDIA;
|
||||
|
||||
test('plays a recognized large local video while inspection continues', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!mediaPath || !existsSync(mediaPath),
|
||||
'Set AV_TOOLS_LARGE_MEDIA to a local video for this opt-in evidence run.'
|
||||
);
|
||||
test.setTimeout(360_000);
|
||||
await page.goto('/');
|
||||
|
||||
const startedAt = Date.now();
|
||||
await page
|
||||
.locator('input[type="file"][accept*="audio"]')
|
||||
.first()
|
||||
.setInputFiles(mediaPath as string);
|
||||
const name = basename(mediaPath as string);
|
||||
const card = page.locator('.media-card').filter({ hasText: name });
|
||||
const preview = page.locator('.preview video');
|
||||
await expect(preview).toBeVisible({ timeout: 15_000 });
|
||||
const previewVisibleMilliseconds = Date.now() - startedAt;
|
||||
expect(previewVisibleMilliseconds).toBeLessThan(15_000);
|
||||
await expect(card.locator('.phase--probing')).toBeVisible();
|
||||
|
||||
const elementIdentity = await preview.evaluate((element) => {
|
||||
element.dataset.evidenceIdentity = crypto.randomUUID();
|
||||
return element.dataset.evidenceIdentity;
|
||||
});
|
||||
await preview.evaluate(async (element) => {
|
||||
const video = element as HTMLVideoElement;
|
||||
video.muted = true;
|
||||
await video.play();
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
preview.evaluate(
|
||||
(element) => (element as HTMLVideoElement).currentTime
|
||||
),
|
||||
{
|
||||
timeout: 15_000,
|
||||
}
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 330_000,
|
||||
});
|
||||
await expect(preview).toHaveAttribute(
|
||||
'data-evidence-identity',
|
||||
elementIdentity
|
||||
);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
@@ -149,7 +149,7 @@ function probe(filePath: string): {
|
||||
tags?: Record<string, string>;
|
||||
disposition?: Record<string, number>;
|
||||
}>;
|
||||
format?: { tags?: Record<string, string> };
|
||||
format?: { duration?: string; tags?: Record<string, string> };
|
||||
chapters?: Array<{ tags?: Record<string, string> }>;
|
||||
} {
|
||||
return JSON.parse(
|
||||
@@ -547,6 +547,31 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
|
||||
await structural.getByLabel('Crossfade duration').fill('0.2');
|
||||
const crossfadeAction = structural.getByRole('button', {
|
||||
name: 'Export normalized concat',
|
||||
});
|
||||
if (await crossfadeAction.isEnabled()) {
|
||||
const [crossfadeCard] = await runForResults(
|
||||
page,
|
||||
() => crossfadeAction.click(),
|
||||
['compatible-a-concat.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const crossfadeDuration = Number(
|
||||
probe((await downloadResult(page, crossfadeCard as Locator)).filePath)
|
||||
.format?.duration
|
||||
);
|
||||
expect(crossfadeDuration).toBeGreaterThan(1.35);
|
||||
expect(crossfadeDuration).toBeLessThan(1.75);
|
||||
} else {
|
||||
await expect(
|
||||
structural.getByText(
|
||||
/required browser and FFmpeg capabilities have not been verified|filter (?:x|a)fade/iu
|
||||
)
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
@@ -687,6 +712,72 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
|
||||
.size
|
||||
).toBeGreaterThan(500);
|
||||
|
||||
await importMedia(page, ['pattern-av.mp4']);
|
||||
const patternClip = page
|
||||
.locator('.timeline-clip')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
await patternClip.locator('.timeline-clip__main').click();
|
||||
const imageSelection = advanced.getByLabel('Frame selection').first();
|
||||
const sceneOption = imageSelection.locator(
|
||||
'option[value="scene-changes"]'
|
||||
);
|
||||
if (!(await sceneOption.isDisabled())) {
|
||||
await imageSelection.selectOption('scene-changes');
|
||||
await advanced.getByLabel('Number of frames').fill('2');
|
||||
await advanced.getByLabel('Scene sensitivity threshold').fill('0.01');
|
||||
await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
.click(),
|
||||
[
|
||||
'pattern-av-thumbnail-001-83ms.jpg',
|
||||
'pattern-av-thumbnail-002-1000ms.jpg',
|
||||
]
|
||||
);
|
||||
|
||||
const contactSelection = advanced.getByLabel('Frame selection').nth(1);
|
||||
await contactSelection.selectOption('scene-changes');
|
||||
await advanced.getByLabel('Contact-sheet scene threshold').fill('0.01');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Frames', exact: true })
|
||||
.fill('2');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Rows', exact: true })
|
||||
.fill('1');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Columns', exact: true })
|
||||
.fill('2');
|
||||
const timestampLabels = advanced.getByLabel('Timestamp labels');
|
||||
if (await timestampLabels.isEnabled()) {
|
||||
await timestampLabels.check();
|
||||
await advanced.getByLabel('Source filename label').check();
|
||||
}
|
||||
const [sceneSheetCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate contact sheet' })
|
||||
.click(),
|
||||
['pattern-av-contact-sheet.jpg']
|
||||
);
|
||||
expect(
|
||||
statSync(
|
||||
(await downloadResult(page, sceneSheetCard as Locator)).filePath
|
||||
).size
|
||||
).toBeGreaterThan(500);
|
||||
}
|
||||
|
||||
await patternClip.getByRole('button', { name: 'Remove clip' }).click();
|
||||
await expect(patternClip).toHaveCount(0);
|
||||
await page
|
||||
.locator('.timeline-clip')
|
||||
.filter({ hasText: 'compatible-a.mp4' })
|
||||
.first()
|
||||
.locator('.timeline-clip__main')
|
||||
.click();
|
||||
});
|
||||
|
||||
await test.step('single frame and a transformed timeline with audio/video fades', async () => {
|
||||
@@ -988,7 +1079,9 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
31
tests/unit/bundled-font-assets.test.ts
Normal file
31
tests/unit/bundled-font-assets.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -26,6 +26,41 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('MediaPreview', () => {
|
||||
it('starts declared local video playback while inspection continues and preserves the element', () => {
|
||||
const probingAsset: ImportedMediaAsset = {
|
||||
id: 'asset-probing-video',
|
||||
file: new File(['video'], 'lecture.mp4', {
|
||||
type: 'application/octet-stream',
|
||||
}),
|
||||
objectUrl: 'blob:probing-video',
|
||||
phase: 'probing',
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<MediaPreview asset={probingAsset} />
|
||||
);
|
||||
|
||||
const progressiveVideo = requiredElement(container.querySelector('video'));
|
||||
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local source preview · Media inspection continues in the background.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<MediaPreview
|
||||
asset={{
|
||||
...videoAsset(),
|
||||
id: probingAsset.id,
|
||||
file: probingAsset.file,
|
||||
objectUrl: probingAsset.objectUrl,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('video')).toBe(progressiveVideo);
|
||||
});
|
||||
|
||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateProxy = vi.fn();
|
||||
|
||||
@@ -138,6 +138,28 @@ describe('QuickConvert', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Opus after the reviewed core passed both regression modes', () => {
|
||||
const engineState: EngineState = {
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
...capabilities(['mp4', 'opus']),
|
||||
encoders: new Set(['libx264', 'aac', 'libopus']),
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<QuickConvert
|
||||
asset={createAsset('asset-a', 'source-a.mp4')}
|
||||
engineState={engineState}
|
||||
busy={false}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Opus' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('exposes a validated user preset and returns it unchanged on export', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
|
||||
EngineManager,
|
||||
MAX_CONTACT_SHEETS_PER_ENGINE,
|
||||
MAX_EXECUTIONS_PER_ENGINE,
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MIN_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MULTITHREAD_CODEC_THREAD_LIMIT,
|
||||
engineJobTimeoutMilliseconds,
|
||||
prepareEngineArguments,
|
||||
probeTimeoutMilliseconds,
|
||||
shouldRecycleExecutionCore,
|
||||
} from '../../src/ffmpeg/EngineManager';
|
||||
|
||||
@@ -90,6 +94,39 @@ describe('engineJobTimeoutMilliseconds', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeTimeoutMilliseconds', () => {
|
||||
it('keeps small probes responsive, scales real media, and caps the watchdog', () => {
|
||||
expect(probeTimeoutMilliseconds(34_057)).toBe(
|
||||
MIN_PROBE_TIMEOUT_MILLISECONDS
|
||||
);
|
||||
expect(probeTimeoutMilliseconds(153_019_068)).toBe(72_966);
|
||||
expect(probeTimeoutMilliseconds(2 * 1024 * 1024 * 1024)).toBe(
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an already-cancelled probe before loading an engine', async () => {
|
||||
const factory = vi.fn(async () => {
|
||||
throw new Error('The engine factory must not be called.');
|
||||
});
|
||||
const manager = new EngineManager(factory);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
manager.probe(
|
||||
new File(['media'], 'cancelled.mp4', { type: 'video/mp4' }),
|
||||
undefined,
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'cancelled',
|
||||
message: 'The media probe was cancelled.',
|
||||
});
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution core recycling policy', () => {
|
||||
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
|
||||
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -165,30 +165,30 @@ describe('licence release gate', () => {
|
||||
describe('release checksum', () => {
|
||||
it('formats and parses the portable sidecar form', () => {
|
||||
const digest = 'a'.repeat(64);
|
||||
const line = checksumLine(digest, 'av-tools-0.1.0.zip');
|
||||
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
|
||||
const line = checksumLine(digest, 'av-tools-0.2.0.zip');
|
||||
expect(line).toBe(`${digest} av-tools-0.2.0.zip\n`);
|
||||
expect(parseChecksumLine(line)).toEqual({
|
||||
archiveName: 'av-tools-0.1.0.zip',
|
||||
archiveName: 'av-tools-0.2.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.2.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.2.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.2.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(
|
||||
[
|
||||
|
||||
52
tests/unit/source-inspection-registry.test.ts
Normal file
52
tests/unit/source-inspection-registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
BIN
vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip
vendored
Normal file
BIN
vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip
vendored
Normal file
Binary file not shown.
@@ -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/**',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user