Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a4548851c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,10 +11,12 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
release
|
release
|
||||||
|
.core-build
|
||||||
test-results
|
test-results
|
||||||
playwright-report
|
playwright-report
|
||||||
coverage
|
coverage
|
||||||
public/vendor/ffmpeg
|
public/vendor/ffmpeg
|
||||||
|
vendor/*.tmp
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -3,6 +3,31 @@
|
|||||||
All notable changes to av-tools are documented here. The project follows
|
All notable changes to av-tools are documented here. The project follows
|
||||||
semantic versioning once a first release is legally publishable.
|
semantic versioning once a first release is legally publishable.
|
||||||
|
|
||||||
|
## 0.2.0 — 2026-07-27
|
||||||
|
|
||||||
|
- Allow recognized local audio/video to begin native browser playback while
|
||||||
|
FFmpeg inspection continues in the background.
|
||||||
|
- Replace the fixed 30-second media-inspection watchdog with a bounded,
|
||||||
|
size-aware budget and report timeout expiry separately from user
|
||||||
|
cancellation.
|
||||||
|
- Add an isolated, reproducible Chromium ST/MT Opus regression harness using
|
||||||
|
the reviewed cores, checked tone fixture and real built-in command plan.
|
||||||
|
- Rebuild ST/MT core 0.12.10 with the upstream 5 MiB stack fix, lock and
|
||||||
|
deterministically package the exact assets, pass non-empty Opus encode and
|
||||||
|
re-probe in both modes, and enable Opus presets behind runtime capabilities.
|
||||||
|
- Add bounded 0.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
|
## 0.1.0 — 2026-07-24
|
||||||
|
|
||||||
- Scaffold a local-first React/Vite audio and video toolbox application.
|
- Scaffold a local-first React/Vite audio and video toolbox application.
|
||||||
|
|||||||
92
LICENSES/DEJAVU_FONTS.txt
Normal file
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
|
`v12.15` source at revision
|
||||||
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util
|
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util
|
||||||
source.
|
source.
|
||||||
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2. The published
|
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2 for GPL-enabled FFmpeg
|
||||||
`@ffmpeg/core` and `@ffmpeg/core-mt` 0.12.10 package manifests declare
|
and linked GPL components in the reviewed WebAssembly cores. The legal code
|
||||||
`GPL-2.0-or-later`; “or later” comes from those manifests. The legal code
|
|
||||||
was checked against the standard GPL-2.0 text in the installed Freedesktop
|
was checked against the standard GPL-2.0 text in the installed Freedesktop
|
||||||
SDK licence corpus and the SPDX licence-list text.
|
SDK licence corpus and the SPDX licence-list text.
|
||||||
- `CC0-1.0.txt` applies only to generated bytes under
|
- `CC0-1.0.txt` applies only to generated bytes under
|
||||||
|
|||||||
119
README.md
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
|
It is also a Toolbox SDK application. The same static build runs directly or
|
||||||
under Toolbox Portal with shared shell, theme, help and app switching.
|
under Toolbox Portal with shared shell, theme, help and app switching.
|
||||||
|
|
||||||
> **Release status:** 0.1.0 is licensed GPL-3.0-or-later. Its canonical
|
> **Release status:** 0.2.0 is licensed GPL-3.0-or-later. Its canonical
|
||||||
> release consists of the exact reviewed `v0.1.0` source, production
|
> release consists of the exact reviewed `v0.2.0` source, production
|
||||||
> ZIP/checksum and Corresponding Source bundle published together. See
|
> ZIP/checksum and Corresponding Source bundle published together. See
|
||||||
> [Licensing](#licensing).
|
> [Licensing](#licensing).
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Two workflows keep the product intentionally smaller than a nonlinear editor:
|
|||||||
- **Edit:** a media bin and sequential non-overlapping clip timeline with
|
- **Edit:** a media bin and sequential non-overlapping clip timeline with
|
||||||
non-destructive settings, one export at a time.
|
non-destructive settings, one export at a time.
|
||||||
|
|
||||||
The planned 0.1 line covers typed/capability-gated conversion and remux,
|
The 0.2 release covers typed/capability-gated conversion and remux,
|
||||||
fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
|
fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
|
||||||
resize, frame-rate change, audio extraction/stream removal, waveform,
|
resize, frame-rate change, audio extraction/stream removal, waveform,
|
||||||
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
|
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
|
||||||
@@ -34,10 +34,18 @@ diagnostics and explicit browser export.
|
|||||||
The Edit workspace now exposes structural exports directly: fast stream-copy
|
The Edit workspace now exposes structural exports directly: fast stream-copy
|
||||||
or preset-backed accurate trim uses the selected clip's timeline range, while
|
or preset-backed accurate trim uses the selected clip's timeline range, while
|
||||||
fast concatenate requires explicit compatibility diagnostics and normalized
|
fast concatenate requires explicit compatibility diagnostics and normalized
|
||||||
concatenate requires a preset plus an explicit missing-audio policy.
|
concatenate requires a preset plus an explicit missing-audio policy. Normalized
|
||||||
|
joins can apply a uniform, capability-gated 0.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
|
Operations enter a bounded, in-memory application queue with at most eight
|
||||||
scheduled operations and one FFmpeg execution at a time.
|
scheduled operations and one FFmpeg execution at a time.
|
||||||
|
|
||||||
|
Thumbnail series and contact sheets support even, interval, chapter, marker or
|
||||||
|
bounded scene-change selection. Scene analysis records exact source
|
||||||
|
presentation timestamps before extraction. Contact-sheet labels use only the
|
||||||
|
bundled, SHA-256-verified DejaVu Sans 2.37 file and remain disabled unless the
|
||||||
|
active core exposes `drawtext`; there is no remote/system-font fallback.
|
||||||
|
|
||||||
When a source cannot be played directly, the preview panel can create a
|
When a source cannot be played directly, the preview panel can create a
|
||||||
disposable H.264/AAC MP4 proxy. The user can bound it to 1–300 seconds and
|
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
|
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/ffmpeg` 0.12.15;
|
||||||
- `@ffmpeg/util` 0.12.2;
|
- `@ffmpeg/util` 0.12.2;
|
||||||
- `@ffmpeg/core` 0.12.10 (single-thread);
|
- reviewed FFmpeg core build `0.12.10-reviewed-stack5m.1` in single-thread and
|
||||||
- `@ffmpeg/core-mt` 0.12.10 (multithread).
|
multithread profiles.
|
||||||
|
|
||||||
The published cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
|
The reviewed cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
|
||||||
with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp,
|
with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp,
|
||||||
FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their
|
FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their
|
||||||
JS/worker files are copied from exact npm packages and served from the app's
|
JS/worker files are verified against a tracked build lock, unpacked from a
|
||||||
own origin. `npm run build` does not download them.
|
deterministic repository artifact and served from the app's own origin.
|
||||||
|
`npm run build` does not download them.
|
||||||
|
|
||||||
Every engine load queries actual demuxers, muxers, decoders, encoders and
|
Every engine load queries actual demuxers, muxers, decoders, encoders and
|
||||||
filters. Presets remain disabled when requirements are absent; an extension or
|
filters. Presets remain disabled when requirements are absent; an extension or
|
||||||
@@ -92,10 +101,12 @@ WebM/VP8/Vorbis, MP3 and WAV are common preview candidates, but actual codec,
|
|||||||
browser and platform support is checked at runtime. Matroska and many other
|
browser and platform support is checked at runtime. Matroska and many other
|
||||||
valid outputs may be download-only. A preview proxy may require re-encoding.
|
valid outputs may be download-only. A preview proxy may require re-encoding.
|
||||||
|
|
||||||
The pinned core has an unresolved Opus encoding regression risk (zero-byte or
|
The 2026-07-26 Chromium regression harness ran the real built-in `audio-opus`
|
||||||
out-of-bounds behavior reported around upstream stack-size fixes). Opus
|
plan against both reviewed cores. Each selected the requested ST/MT mode,
|
||||||
presets must stay unavailable unless a real non-empty, re-probeable ST/MT smoke
|
produced non-empty Opus and passed codec/duration re-probing. Opus presets are
|
||||||
passes. Details are in [FFmpeg build](docs/FFMPEG_BUILD.md).
|
therefore enabled when runtime capability discovery also succeeds. Exact
|
||||||
|
commands and evidence boundaries are in
|
||||||
|
[FFmpeg build](docs/FFMPEG_BUILD.md).
|
||||||
|
|
||||||
## Execution modes
|
## Execution modes
|
||||||
|
|
||||||
@@ -169,9 +180,14 @@ Core assets are generated local build inputs:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run vendor:ffmpeg
|
npm run vendor:ffmpeg
|
||||||
npm run vendor:verify
|
npm run vendor:verify-runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The runtime verifier checks the tracked deterministic core artifact, source
|
||||||
|
lock, build driver and generated same-origin files. The stricter
|
||||||
|
`npm run vendor:verify` publication gate additionally requires and hashes the
|
||||||
|
ignored Corresponding Source archive generated by `npm run source:package`.
|
||||||
|
|
||||||
Regenerate or verify the tiny synthetic fixtures:
|
Regenerate or verify the tiny synthetic fixtures:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -183,7 +199,7 @@ npm run fixtures:verify
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run manifest:check
|
npm run manifest:check
|
||||||
npm run vendor:verify
|
npm run vendor:verify-runtime
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run format:check
|
npm run format:check
|
||||||
@@ -191,6 +207,8 @@ npm test
|
|||||||
npm run build
|
npm run build
|
||||||
npm run toolbox:check
|
npm run toolbox:check
|
||||||
npm run test:browser
|
npm run test:browser
|
||||||
|
npm run evidence:large-preview # set AV_TOOLS_LARGE_MEDIA first
|
||||||
|
npm run evidence:opus
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run check` combines the non-browser production checks. Browser tests cover
|
`npm run check` combines the non-browser production checks. Browser tests cover
|
||||||
@@ -198,13 +216,27 @@ the real pinned cores, MT-to-ST fallback, cancellation/recovery, generated
|
|||||||
fixtures, nested deployment, Toolbox context and the absence of runtime CDN
|
fixtures, nested deployment, Toolbox context and the absence of runtime CDN
|
||||||
traffic.
|
traffic.
|
||||||
|
|
||||||
The latest recorded local Vitest run on 2026-07-24 completed **303 passing
|
`npm run evidence:opus` independently contains each ST/MT browser process,
|
||||||
tests and one conditional skip**. It includes broad structured-command
|
prints one JSON report and exits normally even when the result is unsafe.
|
||||||
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in case
|
`npm run test:opus-regression` runs the same evidence as the positive Opus
|
||||||
requires a `subtitles` filter unavailable in that host build.
|
enablement gate and exits nonzero unless both modes produce non-empty,
|
||||||
|
re-probeable Opus. It is a mandatory release gate for the reviewed core
|
||||||
|
profile.
|
||||||
|
|
||||||
|
`AV_TOOLS_LARGE_MEDIA=/absolute/video.mp4 npm run evidence:large-preview`
|
||||||
|
performs the opt-in large-file regression: the native video element must mount
|
||||||
|
and advance before bounded inspection finishes, remain the same element after
|
||||||
|
the authoritative probe completes and produce no browser error.
|
||||||
|
|
||||||
|
The latest recorded local Vitest run on 2026-07-26 completed **313 passing
|
||||||
|
tests and two conditional skips**. It includes broad structured-command
|
||||||
|
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in and
|
||||||
|
labeled contact-sheet cases require `subtitles` and `drawtext` filters that are
|
||||||
|
unavailable in that host build.
|
||||||
|
|
||||||
The Chromium suite exercises the pinned ST core across conversion,
|
The Chromium suite exercises the pinned ST core across conversion,
|
||||||
trim/concat/split, analysis, waveform, image, metadata, chapter, subtitle,
|
trim/concat/split, bounded crossfades, analysis, waveform, scene-selected image
|
||||||
|
generation, bundled-font contact-sheet labels, metadata, chapter, subtitle,
|
||||||
queue and recovery workflows. The MT core is directly initialized and queried,
|
queue and recovery workflows. The MT core is directly initialized and queried,
|
||||||
and completes a real non-empty, re-probeable H.264/AAC conversion; the same
|
and completes a real non-empty, re-probeable H.264/AAC conversion; the same
|
||||||
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
|
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
|
||||||
@@ -243,12 +275,12 @@ repository and the `GPL-3.0-or-later` SPDX licence.
|
|||||||
|
|
||||||
Portal must consume an independently built ZIP; it must not build or import
|
Portal must consume an independently built ZIP; it must not build or import
|
||||||
av-tools source. Immutable Portal revision
|
av-tools source. Immutable Portal revision
|
||||||
`bda9da044d61c109afd9969d357cc27f76648938` has isolation and WASM CSP
|
`5c052bc6d1073a66600cfddc44c0e16da34bede8` (`v0.6.0`) provides the required
|
||||||
permissions but lacks `media-src blob:`, which blocks Blob previews. The
|
isolation, narrow WebAssembly CSP, Blob media policy and explicit WASM MIME
|
||||||
developer's active Toolbox Portal worktree currently contains the narrow local
|
mapping. The active Portal worktree additionally extends immutable caching to
|
||||||
`media-src 'self' blob:` patch; that uncommitted worktree state does not change
|
the exact reviewed FFmpeg build profile without accepting mutable-looking
|
||||||
the immutable reference or release evidence. The exact distinction, smallest
|
suffixes. Its exact reviewed and staged
|
||||||
change and fallback are documented in
|
boundaries are documented in
|
||||||
[Portal requirements](docs/PORTAL_REQUIREMENTS.md).
|
[Portal requirements](docs/PORTAL_REQUIREMENTS.md).
|
||||||
|
|
||||||
## Release
|
## Release
|
||||||
@@ -272,11 +304,11 @@ metadata:
|
|||||||
```sh
|
```sh
|
||||||
npm run package:release
|
npm run package:release
|
||||||
npm run portal:smoke -- \
|
npm run portal:smoke -- \
|
||||||
--allow-known-header-gap
|
--portal-root ../toolbox-portal
|
||||||
```
|
```
|
||||||
|
|
||||||
The header-gap override remains development-only until the Portal CSP change is
|
The smoke harness clones the selected Portal `HEAD`; it never consumes
|
||||||
committed. Full commands and safety behavior are in
|
uncommitted Portal files silently. Full commands and safety behavior are in
|
||||||
[release documentation](docs/RELEASE.md).
|
[release documentation](docs/RELEASE.md).
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
@@ -286,11 +318,11 @@ and licensed **GPL-3.0-or-later**. See [LICENSE](LICENSE). The application
|
|||||||
provides a visible licence, source and no-warranty notice in its Help dialog.
|
provides a visible licence, source and no-warranty notice in its Help dialog.
|
||||||
Third-party components retain their copyright and licence terms.
|
Third-party components retain their copyright and licence terms.
|
||||||
|
|
||||||
The wrapper/util are MIT. Core/core-mt 0.12.10 declare
|
The wrapper/util are MIT. The reviewed 0.12.10 cores statically include
|
||||||
GPL-2.0-or-later and statically include FFmpeg/external codec code with
|
GPL-enabled FFmpeg/external codec code with additional terms. The branch-based
|
||||||
additional terms. The branch-based x264/LAME inputs have been resolved to
|
x264/LAME inputs have been resolved to immutable commits in the source record.
|
||||||
immutable commits in the source record. A binary publication must include
|
A binary publication must include equivalent access to the reviewed
|
||||||
equivalent access to the reviewed Corresponding Source and notices. See:
|
Corresponding Source and notices. See:
|
||||||
|
|
||||||
- [third-party notices](THIRD_PARTY_NOTICES.md)
|
- [third-party notices](THIRD_PARTY_NOTICES.md)
|
||||||
- [source record](SOURCE.md)
|
- [source record](SOURCE.md)
|
||||||
@@ -317,19 +349,18 @@ The auditable list is in
|
|||||||
- Metadata removal is best effort, not an anonymity guarantee.
|
- Metadata removal is best effort, not an anonymity guarantee.
|
||||||
- Browser preview supports fewer combinations than FFmpeg.
|
- Browser preview supports fewer combinations than FFmpeg.
|
||||||
- MT depends on isolation; ST can still hit memory/time limits.
|
- MT depends on isolation; ST can still hit memory/time limits.
|
||||||
- Opus output is release-gated for the pinned core.
|
- Opus enablement is supported by Chromium ST/MT fixture evidence, not a
|
||||||
|
cross-browser or arbitrary-input guarantee.
|
||||||
- Projects do not contain source bytes by default and require reattachment.
|
- Projects do not contain source bytes by default and require reattachment.
|
||||||
- Cross-browser/mobile and exhaustive MT performance matrices remain
|
- Cross-browser/mobile and exhaustive MT performance matrices remain
|
||||||
unrecorded; published support claims must stay within the tested evidence.
|
unrecorded; published support claims must stay within the tested evidence.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
1. Complete Quick Convert and structural-export browser verification with the
|
1. Extend the evidence matrix beyond Chromium to Firefox, Safari and mobile;
|
||||||
real pinned ST/MT cores, including fallback and queued execution.
|
broaden MT operation parity and replace the one-run performance observation
|
||||||
2. Verify waveform, loudness normalization and combined filters in browsers.
|
with repeated cold/warm measurements.
|
||||||
3. Verify metadata/chapter/subtitle round trips in browsers.
|
2. Evaluate optional editing breadth without weakening local-first guarantees:
|
||||||
4. Verify bounded thumbnails/contact sheets, projects, derivative-cache reuse
|
additional reviewed transition shapes, richer scene-ranking controls and
|
||||||
and export in browsers.
|
explicit transition audio-curve choices beyond the current bounded uniform
|
||||||
5. Complete accessibility, mobile, performance and nested Portal matrices.
|
crossfade.
|
||||||
6. Commit/tag exact source, publish its reviewed Corresponding Source, assemble
|
|
||||||
the exact production ZIP through Portal and verify deployed headers.
|
|
||||||
|
|||||||
93
SOURCE.md
93
SOURCE.md
@@ -1,6 +1,6 @@
|
|||||||
# Source and reproducibility record
|
# Source and reproducibility record
|
||||||
|
|
||||||
This file identifies the source corresponding to av-tools 0.1.0. It is not a
|
This file identifies the source corresponding to av-tools 0.2.0. It is not a
|
||||||
substitute for satisfying a component's licence or for delivering complete
|
substitute for satisfying a component's licence or for delivering complete
|
||||||
Corresponding Source with an object-code release.
|
Corresponding Source with an object-code release.
|
||||||
|
|
||||||
@@ -9,9 +9,9 @@ Corresponding Source with an object-code release.
|
|||||||
- Canonical repository:
|
- Canonical repository:
|
||||||
`git@git.add-ideas.de:zemion/av-tools.git`
|
`git@git.add-ideas.de:zemion/av-tools.git`
|
||||||
- Browser source: `https://git.add-ideas.de/zemion/av-tools`
|
- Browser source: `https://git.add-ideas.de/zemion/av-tools`
|
||||||
- Version: `0.1.0`
|
- Version: `0.2.0`
|
||||||
- Release source identity: immutable annotated tag `v0.1.0`
|
- Release source identity: immutable annotated tag `v0.2.0`
|
||||||
(`refs/tags/v0.1.0`) in the canonical repository.
|
(`refs/tags/v0.2.0`) in the canonical repository.
|
||||||
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
|
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
|
||||||
`LICENSE`.
|
`LICENSE`.
|
||||||
- Copyright: Copyright © 2026 Albrecht Degering.
|
- Copyright: Copyright © 2026 Albrecht Degering.
|
||||||
@@ -22,10 +22,10 @@ the tag exists, is annotated, points to the reviewed release commit and is
|
|||||||
available without authentication:
|
available without authentication:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
test "$(git cat-file -t v0.1.0)" = tag
|
test "$(git cat-file -t v0.2.0)" = tag
|
||||||
git rev-parse v0.1.0^{commit}
|
git rev-parse v0.2.0^{commit}
|
||||||
git ls-remote --tags \
|
git ls-remote --tags \
|
||||||
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.1.0
|
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
||||||
@@ -40,34 +40,66 @@ the tag/source archive beside the binary release and checksum.
|
|||||||
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
|
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
|
||||||
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
|
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
|
||||||
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
|
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
|
||||||
| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` |
|
|
||||||
| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` |
|
|
||||||
|
|
||||||
Install exact packages and recreate the copied same-origin assets without a
|
Install exact packages and recreate the verified same-origin assets without a
|
||||||
network fetch during the production build:
|
network fetch during the production build:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
npm run vendor:ffmpeg
|
npm run vendor:ffmpeg
|
||||||
npm run vendor:verify
|
npm run vendor:verify-runtime
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the
|
`scripts/copy-ffmpeg-assets.mjs` fails closed against
|
||||||
installed core packages; it does not download them. The copied bytes are:
|
`scripts/reviewed-ffmpeg-core-lock.json`, verifies the deterministic tracked
|
||||||
|
ZIP and unpacks only its five declared entries. It does not download them or
|
||||||
|
fall back to published core packages. The reviewed bytes are:
|
||||||
|
|
||||||
| Release path | Bytes | SHA-256 |
|
For a publication check, generate the build-qualified Corresponding Source
|
||||||
| ------------------------------------------------ | -------: | ------------------------------------------------------------------ |
|
archive and run `npm run vendor:verify`; that stricter gate hashes the archive
|
||||||
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
|
as well as every runtime input.
|
||||||
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
|
|
||||||
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
|
| Release path | Bytes | SHA-256 |
|
||||||
| `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
|
## Corresponding ffmpeg.wasm and FFmpeg source
|
||||||
|
|
||||||
- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15,
|
- Base ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15 and the
|
||||||
`@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10:
|
source configuration for core 0.12.10:
|
||||||
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
|
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
|
||||||
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
|
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
|
||||||
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
|
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
|
||||||
@@ -88,6 +120,14 @@ The upstream build control files are `Dockerfile`, `Makefile`,
|
|||||||
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
|
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
|
||||||
`make prd-mt` builds multithread.
|
`make prd-mt` builds multithread.
|
||||||
|
|
||||||
|
The shipped profile is rebuilt by
|
||||||
|
`scripts/build-reviewed-ffmpeg-core.sh`. It applies upstream ffmpeg.wasm commit
|
||||||
|
`b409e36475bc21f0451b5b1e1d126fa82871439a` (`-sSTACK_SIZE=5MB`), uses exact
|
||||||
|
Emscripten 3.1.40 and CMake 3.27.9 identities, makes upstream container-stage
|
||||||
|
isolation explicit and refuses maintainer-local paths in output. The exact
|
||||||
|
source archive, toolchain, patch, asset and Opus-gate identities are locked in
|
||||||
|
`scripts/reviewed-ffmpeg-core-lock.json`.
|
||||||
|
|
||||||
The Dockerfile uses branch names for two inputs. Repository history resolves
|
The Dockerfile uses branch names for two inputs. Repository history resolves
|
||||||
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
|
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
|
||||||
changed since:
|
changed since:
|
||||||
@@ -121,10 +161,13 @@ npm run source:package
|
|||||||
```
|
```
|
||||||
|
|
||||||
The command writes
|
The command writes
|
||||||
`release/ffmpeg-core-0.12.10-corresponding-source.tar.xz` and its
|
`release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz`
|
||||||
|
and its
|
||||||
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
|
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
|
||||||
The npm packages contain the resulting binaries but not every linked
|
The reviewed archive for this build profile is 43,050,052 bytes with SHA-256
|
||||||
component's source and legal file, so publish this reviewed bundle and the
|
`2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a`.
|
||||||
|
The reviewed object-code archive does not contain every linked component's
|
||||||
|
source and legal file, so publish this Corresponding Source bundle and the
|
||||||
notices beside the object code. A homepage link alone is not asserted to
|
notices beside the object code. A homepage link alone is not asserted to
|
||||||
satisfy GPL source obligations.
|
satisfy GPL source obligations.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
av-tools 0.1.0 is Copyright © 2026 Albrecht Degering and licensed
|
av-tools 0.2.0 is Copyright © 2026 Albrecht Degering and licensed
|
||||||
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
||||||
inputs. It is informational, not legal advice, and does not replace their
|
inputs. It is informational, not legal advice, and does not replace their
|
||||||
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
||||||
@@ -9,30 +9,33 @@ receives licence files shipped by installed runtime npm packages under
|
|||||||
|
|
||||||
## Browser runtime
|
## Browser runtime
|
||||||
|
|
||||||
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
|
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
|
||||||
| -------------------------------- | ------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -------------------------------------- |
|
| -------------------------------- | -------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
|
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
|
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
|
||||||
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
|
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
|
||||||
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
|
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
|
||||||
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
|
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
|
||||||
| `@ffmpeg/core` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | single-thread FFmpeg JavaScript and WebAssembly | shipped verbatim as self-hosted assets |
|
| reviewed FFmpeg WASM cores | 0.12.10-reviewed-stack5m.1 | GPL-enabled combined work | `https://github.com/ffmpegwasm/ffmpeg.wasm` | ST/MT FFmpeg JavaScript, WebAssembly and pthread worker | rebuilt from locked source and shipped as verified assets |
|
||||||
| `@ffmpeg/core-mt` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | multithread FFmpeg JavaScript, WebAssembly and pthread worker | shipped verbatim as self-hosted assets |
|
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
|
||||||
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
|
| DejaVu Sans | 2.37 | Bitstream Vera/Arev terms | `https://dejavu-fonts.github.io/` | deterministic local contact-sheet labels | shipped unmodified as reviewed TTF |
|
||||||
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
|
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
|
||||||
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
|
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
|
||||||
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
|
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
|
||||||
|
|
||||||
The MIT text inspected in the ffmpeg.wasm source is
|
The MIT text inspected in the ffmpeg.wasm source is
|
||||||
`LICENSES/ffmpeg.wasm-MIT.txt`. The core npm tarballs do not contain a licence
|
`LICENSES/ffmpeg.wasm-MIT.txt`. The reviewed cores enable GPL FFmpeg features
|
||||||
file, but their package manifests declare `GPL-2.0-or-later`;
|
and statically link GPL components; `LICENSES/GPL-2.0-or-later.txt` reproduces
|
||||||
`LICENSES/GPL-2.0-or-later.txt` reproduces GPL version 2. These notices do not
|
GPL version 2. These notices do not make the complete application “MIT-only.”
|
||||||
make the complete application “MIT-only.”
|
|
||||||
|
The reviewed DejaVu file is identified byte-for-byte in `SOURCE.md`; its full
|
||||||
|
embedded terms are in `LICENSES/DEJAVU_FONTS.txt`. No remote or system font is
|
||||||
|
used as a runtime fallback.
|
||||||
|
|
||||||
## Native components linked into the WebAssembly cores
|
## Native components linked into the WebAssembly cores
|
||||||
|
|
||||||
The following list is derived from the `v12.15` ffmpeg.wasm Dockerfile, the
|
The following list is derived from the `v12.15` ffmpeg.wasm Dockerfile, the
|
||||||
configuration embedded in the copied 0.12.10 WASM and the resolved histories
|
configuration embedded in the reviewed 0.12.10 WASM and the resolved histories
|
||||||
of its source repositories. Branch-based inputs are recorded below as exact
|
of its source repositories. Branch-based inputs are recorded below as exact
|
||||||
commits. The combined core is GPL-enabled. Individual projects retain their
|
commits. The combined core is GPL-enabled. Individual projects retain their
|
||||||
own terms, notices, patent considerations and corresponding-source
|
own terms, notices, patent considerations and corresponding-source
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ tools.example.test {
|
|||||||
@wasm path_regexp wasm \.wasm$
|
@wasm path_regexp wasm \.wasm$
|
||||||
header @wasm Content-Type "application/wasm"
|
header @wasm Content-Type "application/wasm"
|
||||||
|
|
||||||
@immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$
|
@immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$
|
||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
redir /av /av/ 308
|
redir /av /av/ 308
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ cross-origin resource policy and is outside the supported deployment profile.
|
|||||||
|
|
||||||
Use `no-cache` for `index.html`, `toolbox-app.json`, notices and other stable
|
Use `no-cache` for `index.html`, `toolbox-app.json`, notices and other stable
|
||||||
names. The files under the exact version directory
|
names. The files under the exact version directory
|
||||||
`vendor/ffmpeg/0.12.10/{st,mt}/` are content-verified and may use:
|
`vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` are content-verified and may use:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
@@ -55,7 +55,7 @@ For a host with a `_headers`-style configuration, adapt:
|
|||||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
|
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
|
||||||
Cache-Control: no-cache
|
Cache-Control: no-cache
|
||||||
|
|
||||||
/av/vendor/ffmpeg/0.12.10/*
|
/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/*
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
/av/assets/*
|
/av/assets/*
|
||||||
@@ -68,7 +68,7 @@ real response rather than relying only on configuration:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sSI https://tools.example.test/av/ | sed -n '1,30p'
|
curl -sSI https://tools.example.test/av/ | sed -n '1,30p'
|
||||||
curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm | sed -n '1,30p'
|
curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm | sed -n '1,30p'
|
||||||
```
|
```
|
||||||
|
|
||||||
In browser developer tools, also verify `window.crossOriginIsolated === true`,
|
In browser developer tools, also verify `window.crossOriginIsolated === true`,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
map $uri $av_tools_cache_control {
|
map $uri $av_tools_cache_control {
|
||||||
default "no-cache";
|
default "no-cache";
|
||||||
"~^/(?:.*/)?vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable";
|
"~^/(?:.*/)?vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable";
|
||||||
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
|
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# FFmpeg WebAssembly build record
|
# FFmpeg WebAssembly build record
|
||||||
|
|
||||||
av-tools ships the published ESM assets from `@ffmpeg/core` and
|
av-tools ships the locally rebuilt, reviewed core profile
|
||||||
`@ffmpeg/core-mt` **0.12.10**. The browser wrapper is `@ffmpeg/ffmpeg`
|
`0.12.10-reviewed-stack5m.1` in single-thread and multithread forms. The
|
||||||
**0.12.15** and helpers are `@ffmpeg/util` **0.12.2**. All versions are exact,
|
browser wrapper is `@ffmpeg/ffmpeg` **0.12.15** and helpers are
|
||||||
not ranges.
|
`@ffmpeg/util` **0.12.2**. All versions and build inputs are exact, not ranges.
|
||||||
|
|
||||||
## Underlying build
|
## Underlying build
|
||||||
|
|
||||||
The matching ffmpeg.wasm upstream release is `v12.15` at
|
The base ffmpeg.wasm upstream release is `v12.15` at
|
||||||
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. Its Dockerfile selects:
|
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. Its Dockerfile selects:
|
||||||
|
|
||||||
- FFmpeg **n5.1.4** (libavcodec 59.37.100);
|
- FFmpeg **n5.1.4** (libavcodec 59.37.100);
|
||||||
@@ -15,6 +15,14 @@ The matching ffmpeg.wasm upstream release is `v12.15` at
|
|||||||
- production `-O3 -msimd128`;
|
- production `-O3 -msimd128`;
|
||||||
- pthread flags for the MT build and explicit thread disabling for ST.
|
- pthread flags for the MT build and explicit thread disabling for ST.
|
||||||
|
|
||||||
|
The reviewed profile then applies upstream ffmpeg.wasm commit
|
||||||
|
`b409e36475bc21f0451b5b1e1d126fa82871439a` to select a 5 MiB Emscripten stack.
|
||||||
|
That upstream fix addresses the libopus zero-byte/out-of-bounds failures
|
||||||
|
reported in issues `#591` and `#775`. The local driver additionally makes the
|
||||||
|
upstream container-stage dependency isolation explicit, disables native x265
|
||||||
|
assembly for the wasm32 build, bounds parallel jobs and refuses output that
|
||||||
|
contains the maintainer's repository or SDK paths.
|
||||||
|
|
||||||
The build configuration embedded in both copied WASM files is:
|
The build configuration embedded in both copied WASM files is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -35,8 +43,8 @@ The build configuration embedded in both copied WASM files is:
|
|||||||
--cxx=em++
|
--cxx=em++
|
||||||
--objcc=emcc
|
--objcc=emcc
|
||||||
--dep-cc=emcc
|
--dep-cc=emcc
|
||||||
--extra-cflags='-I/opt/include -O3 -msimd128'
|
--extra-cflags='-I../../../../install/include -O3 -msimd128'
|
||||||
--extra-cxxflags='-I/opt/include -O3 -msimd128'
|
--extra-cxxflags='-I../../../../install/include -O3 -msimd128'
|
||||||
--enable-gpl
|
--enable-gpl
|
||||||
--enable-libx264
|
--enable-libx264
|
||||||
--enable-libx265
|
--enable-libx265
|
||||||
@@ -76,16 +84,26 @@ respective branch tips. `SOURCE.md` records retrieval commands.
|
|||||||
|
|
||||||
| Mode | File | Bytes | SHA-256 |
|
| Mode | File | Bytes | SHA-256 |
|
||||||
| ---- | -------------------------- | -------: | ------------------------------------------------------------------ |
|
| ---- | -------------------------- | -------: | ------------------------------------------------------------------ |
|
||||||
| ST | `st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
|
| ST | `st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
|
||||||
| ST | `st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
|
| ST | `st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
|
||||||
| MT | `mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
|
| MT | `mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
|
||||||
| MT | `mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
|
| MT | `mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
|
||||||
| MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
|
| MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
|
||||||
|
|
||||||
`npm run vendor:ffmpeg` copies these files from `node_modules` to
|
The deterministic five-file repository artifact is 21,338,974 bytes with
|
||||||
`public/vendor/ffmpeg/0.12.10/`; `npm run vendor:verify` checks versions,
|
SHA-256
|
||||||
sizes, paths and digests. Vite then copies them unchanged into `dist`. A normal
|
`ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f`.
|
||||||
production build performs no network download.
|
|
||||||
|
`npm run vendor:ffmpeg` verifies and unpacks the deterministic tracked core
|
||||||
|
artifact to
|
||||||
|
`public/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/`;
|
||||||
|
`npm run vendor:verify-runtime` checks the build profile,
|
||||||
|
source/toolchain/patch identity, tracked artifact, generated paths, sizes and
|
||||||
|
digests without requiring a release-directory download. The stricter
|
||||||
|
`npm run vendor:verify` release gate also hashes the build-qualified
|
||||||
|
Corresponding Source archive. Vite then copies the verified bytes into `dist`.
|
||||||
|
A normal production build performs no network download and cannot fall back
|
||||||
|
to the older published npm core bytes.
|
||||||
|
|
||||||
## Multithread Chromium guardrail
|
## Multithread Chromium guardrail
|
||||||
|
|
||||||
@@ -118,26 +136,42 @@ This is a conservative browser-memory guardrail, not a guarantee that sixteen
|
|||||||
large jobs or three large contact sheets fit in memory. Cancellation and
|
large jobs or three large contact sheets fit in memory. Cancellation and
|
||||||
failed filesystem-cleanup recovery continue to recreate the core immediately.
|
failed filesystem-cleanup recovery continue to recreate the core immediately.
|
||||||
|
|
||||||
## Opus regression risk
|
## Opus regression evidence
|
||||||
|
|
||||||
Pinned core 0.12.10 predates upstream ffmpeg.wasm fixes associated with stack
|
Published core 0.12.10 predates the upstream stack-size fix and stalled in the
|
||||||
size and Opus zero-byte/out-of-bounds failures (including upstream issues
|
real Opus preset path even though it advertised the `opus` muxer and
|
||||||
`#591` and `#775`). Therefore:
|
`libopus` encoder. Capability discovery alone was therefore not accepted as
|
||||||
|
enablement evidence.
|
||||||
|
|
||||||
- decoder/encoder listing is not enough to certify Opus;
|
On 2026-07-26 the Chromium harness used the checked
|
||||||
- real ST and MT fixture encodes must produce a non-empty, re-probeable output;
|
`tests/fixtures/generated/tone.wav` fixture (32,102 bytes, SHA-256
|
||||||
- the Opus audio and VP9/Opus presets must remain disabled with a diagnostic
|
`0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c`)
|
||||||
if that smoke fails;
|
and the real built-in `audio-opus` preset/command planner. After rebuilding
|
||||||
- a core upgrade requires new checksums, capability snapshots, legal review
|
with the reviewed stack profile, the isolated ST and MT runs each:
|
||||||
and regression tests.
|
|
||||||
|
|
||||||
This is a known pinned-core risk, not a claim that every Opus job fails.
|
- loaded the requested mode rather than a fallback;
|
||||||
|
- mapped PCM audio to `opus (libopus)`;
|
||||||
|
- opened the declared Opus output and reported its libopus encoder;
|
||||||
|
- returned non-empty bytes within its independent containment limit;
|
||||||
|
- re-probed the result as positive-duration Opus audio.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Record JSON evidence.
|
||||||
|
npm run evidence:opus
|
||||||
|
|
||||||
|
# Positive Opus enablement/release gate.
|
||||||
|
npm run test:opus-regression
|
||||||
|
```
|
||||||
|
|
||||||
|
The Opus audio and VP9/Opus presets are enabled only when the reviewed-core
|
||||||
|
lock records both passing modes and runtime discovery finds every preset
|
||||||
|
requirement. This is exact Chromium fixture evidence, not a claim about every
|
||||||
|
browser, input or possible Opus build.
|
||||||
|
|
||||||
## Licence consequence
|
## Licence consequence
|
||||||
|
|
||||||
The core packages declare GPL-2.0-or-later and the actual build enables GPL and
|
The actual core build enables GPL and links external codecs. The wrapper/util
|
||||||
links external codecs. The wrapper/util are MIT. av-tools is
|
are MIT. av-tools is GPL-3.0-or-later, and the distributed application must
|
||||||
GPL-3.0-or-later, and the distributed application must not be described as
|
not be described as MIT-only. Publish the reviewed Corresponding Source and
|
||||||
MIT-only. Publish the reviewed Corresponding Source and external notices with
|
external notices with the binary release. See `LICENSE`, `LICENSES/`,
|
||||||
the binary release. See `LICENSE`, `LICENSES/`, `THIRD_PARTY_NOTICES.md` and
|
`THIRD_PARTY_NOTICES.md` and `SOURCE.md`.
|
||||||
`SOURCE.md`.
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ extensions or this document. The app queries muxers, demuxers, decoders,
|
|||||||
encoders and filters; a preset is disabled with missing components listed.
|
encoders and filters; a preset is disabled with missing components listed.
|
||||||
Browser playback is a separate capability checked with MIME/media APIs.
|
Browser playback is a separate capability checked with MIME/media APIs.
|
||||||
|
|
||||||
## 0.1.0 candidate fixture matrix
|
## 0.2.0 release fixture matrix
|
||||||
|
|
||||||
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
|
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
|
||||||
preparation and pass the checksum/probe verifier. That proves fixture
|
preparation and pass the checksum/probe verifier. That proves fixture
|
||||||
@@ -41,20 +41,20 @@ browser may still be unable to preview the result.
|
|||||||
|
|
||||||
Preset availability always depends on detected runtime requirements:
|
Preset availability always depends on detected runtime requirements:
|
||||||
|
|
||||||
| Preset family | Required examples | Browser result preview |
|
| Preset family | Required examples | Browser result preview |
|
||||||
| ------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
| ------------------ | ---------------------------------------- | -------------------------------------------------------- |
|
||||||
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
|
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
|
||||||
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | browser-dependent; Opus core regression gate applies |
|
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | enabled when the loaded core advertises all requirements |
|
||||||
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
|
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
|
||||||
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
|
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
|
||||||
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
|
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
|
||||||
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
|
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
|
||||||
| MP3 | `mp3`, `libmp3lame` | commonly playable |
|
| MP3 | `mp3`, `libmp3lame` | commonly playable |
|
||||||
| M4A/AAC | `ipod`, `aac` | commonly playable |
|
| M4A/AAC | `ipod`, `aac` | commonly playable |
|
||||||
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
|
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
|
||||||
| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies |
|
| Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded |
|
||||||
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
|
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
|
||||||
| FLAC | `flac`, `flac` | browser-dependent |
|
| FLAC | `flac`, `flac` | browser-dependent |
|
||||||
|
|
||||||
“Lossless” applies only to a lossless audio codec such as PCM/FLAC relative to
|
“Lossless” applies only to a lossless audio codec such as PCM/FLAC relative to
|
||||||
its decoded samples. It does not make a lossy source lossless. Stream-copy
|
its decoded samples. It does not make a lossy source lossless. Stream-copy
|
||||||
@@ -77,11 +77,30 @@ remux avoids re-encoding compatible streams but may change container metadata.
|
|||||||
- Normalized concat uses an explicit export preset and requires an explicit
|
- Normalized concat uses an explicit export preset and requires an explicit
|
||||||
insert-silence, drop-all-audio or reject policy when clips have mismatched
|
insert-silence, drop-all-audio or reject policy when clips have mismatched
|
||||||
audio presence.
|
audio presence.
|
||||||
|
- Normalized concat can overlap every edit with one uniform 0.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
|
- Crop, resize, frame-rate conversion, fades, normalization, burn-in and
|
||||||
contact sheets require specific filters discovered at runtime.
|
contact sheets require specific filters discovered at runtime.
|
||||||
- Even-dimension correction is applied when the selected pixel format/encoder
|
- Even-dimension correction is applied when the selected pixel format/encoder
|
||||||
requires it and is reported in the plan/result.
|
requires it and is reported in the plan/result.
|
||||||
|
|
||||||
|
## Images and scene selection
|
||||||
|
|
||||||
|
Thumbnail/contact-sheet scene mode first performs a bounded full-source
|
||||||
|
analysis with `select`/`showinfo`, stopping after at most 100 qualifying
|
||||||
|
frames. Extraction then selects those exact source PTS values; no guessed time
|
||||||
|
rounding or remote analysis is used. If no frame meets the 0.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
|
## Subtitles
|
||||||
|
|
||||||
The text fixtures cover SRT, WebVTT and ASS import with explicit time shift.
|
The text fixtures cover SRT, WebVTT and ASS import with explicit time shift.
|
||||||
@@ -115,8 +134,9 @@ elements do not reliably expose them, so ffprobe round-trip is authoritative.
|
|||||||
- frame-accurate stream-copy trimming;
|
- frame-accurate stream-copy trimming;
|
||||||
- complete metadata erasure/anonymity;
|
- complete metadata erasure/anonymity;
|
||||||
- browser preview of every FFmpeg-readable result;
|
- browser preview of every FFmpeg-readable result;
|
||||||
- Opus presets until real non-empty ST/MT encoding smoke passes for core
|
- Opus on browsers, inputs or deployments outside the exact reviewed
|
||||||
0.12.10;
|
Chromium regression: both ST and MT pass the non-empty encode/re-probe gate,
|
||||||
|
but runtime capability discovery and normal per-job limits still apply;
|
||||||
- any capability omitted by the active core's runtime query.
|
- any capability omitted by the active core's runtime query.
|
||||||
|
|
||||||
Update this file only from re-probed generated/public-domain fixtures and
|
Update this file only from re-probed generated/public-domain fixtures and
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Known limitations
|
# Known limitations
|
||||||
|
|
||||||
This is the conservative 0.1.0 development baseline. A capability appearing in
|
This is the conservative 0.2.0 release baseline. A capability appearing in
|
||||||
FFmpeg output does not remove these product/browser/resource limits.
|
FFmpeg output does not remove these product/browser/resource limits.
|
||||||
|
|
||||||
## Functional
|
## Functional
|
||||||
@@ -13,6 +13,9 @@ FFmpeg output does not remove these product/browser/resource limits.
|
|||||||
frame-accurate. Accurate mode requires decoding/re-encoding.
|
frame-accurate. Accurate mode requires decoding/re-encoding.
|
||||||
- Stream-copy concatenate works only when selected streams and codec/container
|
- Stream-copy concatenate works only when selected streams and codec/container
|
||||||
parameters are compatible. Other input needs normalized re-encoding.
|
parameters are compatible. Other input needs normalized re-encoding.
|
||||||
|
- Crossfades are uniform fade/triangular-curve transitions on normalized
|
||||||
|
sequential joins, not arbitrary transition effects or overlapping timeline
|
||||||
|
tracks. They require enough material on both sides of every internal edit.
|
||||||
- Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
|
- Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
|
||||||
cannot restore information already lost in a source.
|
cannot restore information already lost in a source.
|
||||||
- Metadata removal is best effort. Muxers may add structural/encoder fields,
|
- Metadata removal is best effort. Muxers may add structural/encoder fields,
|
||||||
@@ -26,6 +29,12 @@ FFmpeg output does not remove these product/browser/resource limits.
|
|||||||
page discards queued work.
|
page discards queued work.
|
||||||
- A file-generating batch and its managed result collection are bounded to 255
|
- A file-generating batch and its managed result collection are bounded to 255
|
||||||
outputs, leaving the 256th ZIP entry available for an optional export report.
|
outputs, leaving the 256th ZIP entry available for an optional export report.
|
||||||
|
- Scene analysis scans the source until it finds the requested changes or
|
||||||
|
reaches the end, retains at most 100 exact PTS results and can therefore take
|
||||||
|
about as long as a full decode. It does not begin an export implicitly when
|
||||||
|
no frame meets the chosen threshold.
|
||||||
|
- Labelled contact sheets require a core with `drawtext`; the reviewed local
|
||||||
|
font does not make that filter available on a core that omits it.
|
||||||
- Preview proxies are bounded, lossy H.264/AAC derivatives for local playback;
|
- Preview proxies are bounded, lossy H.264/AAC derivatives for local playback;
|
||||||
they do not replace the source or guarantee that every browser can play the
|
they do not replace the source or guarantee that every browser can play the
|
||||||
result.
|
result.
|
||||||
@@ -38,8 +47,9 @@ FFmpeg output does not remove these product/browser/resource limits.
|
|||||||
support fewer container/codec combinations.
|
support fewer container/codec combinations.
|
||||||
- Browser support, codec policy and memory differ across Chromium, Firefox and
|
- Browser support, codec policy and memory differ across Chromium, Firefox and
|
||||||
Safari and across desktop/mobile versions.
|
Safari and across desktop/mobile versions.
|
||||||
- Core 0.12.10 has an Opus regression risk. Opus remains release-gated until
|
- The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe
|
||||||
non-empty, re-probeable ST and MT fixture outputs pass.
|
gate in isolated Chromium ST and MT runs. That evidence is not a
|
||||||
|
cross-browser or every-input Opus guarantee.
|
||||||
- MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works
|
- MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works
|
||||||
without isolation but is not a guarantee that a large job fits.
|
without isolation but is not a guarantee that a large job fits.
|
||||||
- Top-level navigation is supported; iframe embedding is not.
|
- Top-level navigation is supported; iframe embedding is not.
|
||||||
@@ -69,7 +79,7 @@ FFmpeg output does not remove these product/browser/resource limits.
|
|||||||
verification against the live container.
|
verification against the live container.
|
||||||
- The application is GPL-3.0-or-later and its native legal files and pinned
|
- The application is GPL-3.0-or-later and its native legal files and pinned
|
||||||
source identities are included in the release record. Publish the production
|
source identities are included in the release record. Publish the production
|
||||||
ZIP/checksum, exact `v0.1.0` source and FFmpeg Corresponding Source bundle
|
ZIP/checksum, exact `v0.2.0` source and FFmpeg Corresponding Source bundle
|
||||||
together; development-only smoke artifacts must not be distributed.
|
together; development-only smoke artifacts must not be distributed.
|
||||||
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
|
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
|
||||||
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
|
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ output at once. No hardware encoder is used.
|
|||||||
|
|
||||||
## Fixed download footprint
|
## Fixed download footprint
|
||||||
|
|
||||||
Uncompressed self-hosted core assets for 0.12.10:
|
Uncompressed self-hosted core assets for
|
||||||
|
`0.12.10-reviewed-stack5m.1`:
|
||||||
|
|
||||||
| Mode | JavaScript/worker | WASM | Total |
|
| Mode | JavaScript/worker | WASM | Total |
|
||||||
| ------------- | ------------------: | -----------: | -----------: |
|
| ------------- | ------------------: | -----------: | -----------: |
|
||||||
| single-thread | 111,804 B | 32,232,419 B | 32,344,223 B |
|
| single-thread | 111,804 B | 32,232,580 B | 32,344,384 B |
|
||||||
| multithread | 128,947 B + 2,115 B | 32,718,323 B | 32,849,385 B |
|
| multithread | 128,953 B + 2,115 B | 32,718,455 B | 32,849,523 B |
|
||||||
|
|
||||||
Only the selected mode is loaded. HTTP compression may reduce JavaScript
|
Only the selected mode is loaded. HTTP compression may reduce JavaScript
|
||||||
transfer but not necessarily WASM to the same degree; report transfer and
|
transfer but not necessarily WASM to the same degree; report transfer and
|
||||||
@@ -72,9 +73,18 @@ Estimated output warns at 512 MiB and is blocked at 1 GiB. FFmpeg jobs are
|
|||||||
serial; the application retains at most eight scheduled operations in its
|
serial; the application retains at most eight scheduled operations in its
|
||||||
in-memory queue. Timeline clips are capped at 100, streams per input at 64,
|
in-memory queue. Timeline clips are capped at 100, streams per input at 64,
|
||||||
images at 8192×8192/67,108,864 pixels, generated output files at 255,
|
images at 8192×8192/67,108,864 pixels, generated output files at 255,
|
||||||
contact-sheet cells at 500 and runtime at four hours. The documented
|
contact-sheet cells at 500, scene-change results at 100, crossfades at 10
|
||||||
ffmpeg.wasm input ceiling is 2 GiB, but real limits can be substantially lower
|
seconds and runtime at four hours. Scene selection is a bounded two-step
|
||||||
on mobile or memory-constrained browsers.
|
workflow but its analysis step can still decode the complete source when few
|
||||||
|
frames meet the threshold. The documented ffmpeg.wasm input ceiling is 2 GiB,
|
||||||
|
but real limits can be substantially lower on mobile or memory-constrained
|
||||||
|
browsers.
|
||||||
|
|
||||||
|
Source inspection has a bounded, size-aware watchdog: 30 seconds for small
|
||||||
|
files, a two-MiB-per-second scan budget for larger files, and a five-minute
|
||||||
|
ceiling. Recognized local audio/video can begin browser playback from its
|
||||||
|
temporary object URL while that worker inspection continues; completed probe
|
||||||
|
metadata remains authoritative for editing and export.
|
||||||
|
|
||||||
Lowering resolution, duration, frame rate, passes and output count reduces
|
Lowering resolution, duration, frame rate, passes and output count reduces
|
||||||
work. MT can be faster but consumes more memory/CPU and requires isolation; ST
|
work. MT can be faster but consumes more memory/CPU and requires isolation; ST
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ X-Content-Type-Options: nosniff
|
|||||||
|
|
||||||
Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME
|
Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME
|
||||||
type and JSON as `application/json`. Test real responses under
|
type and JSON as `application/json`. Test real responses under
|
||||||
`/apps/av/vendor/ffmpeg/0.12.10/...`; a file-extension allowlist alone does not
|
`/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/...`; a file-extension allowlist alone does not
|
||||||
prove the MIME type.
|
prove the MIME type.
|
||||||
|
|
||||||
Suggested CSP:
|
Suggested CSP:
|
||||||
@@ -43,45 +43,36 @@ form-action 'none';
|
|||||||
`'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created
|
`'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created
|
||||||
worker path. `media-src blob:` permits local source/result previews.
|
worker path. `media-src blob:` permits local source/result previews.
|
||||||
|
|
||||||
## Immutable reference and active-worktree state
|
## Reviewed Portal state
|
||||||
|
|
||||||
Portal revision `bda9da044d61c109afd9969d357cc27f76648938` already sends COOP
|
Portal revision `5c052bc6d1073a66600cfddc44c0e16da34bede8` (tag `v0.6.0`)
|
||||||
`same-origin`, COEP `require-corp`, CORP `same-origin`, `script-src 'self'
|
already sends COOP `same-origin`, COEP `require-corp`, CORP `same-origin`,
|
||||||
'wasm-unsafe-eval'`, and `worker-src 'self' blob:`. Its packaged nginx CSP does
|
`script-src 'self' 'wasm-unsafe-eval'`, `worker-src 'self' blob:` and
|
||||||
**not** declare `media-src`; `default-src 'self'` therefore blocks local
|
`media-src 'self' blob:`. Its nginx profile also maps `.wasm` explicitly to
|
||||||
`blob:` audio/video URLs. The expected browser diagnostic is equivalent to:
|
`application/wasm`. Local source and result Blob previews therefore require no
|
||||||
|
CSP exception beyond that reviewed narrow media directive.
|
||||||
|
|
||||||
```text
|
As inspected on 2026-07-26, the active
|
||||||
Refused to load media from 'blob:…' because it violates
|
`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` has an additional worktree
|
||||||
"default-src 'self'".
|
patch that extends the immutable FFmpeg cache rule from plain semantic versions
|
||||||
```
|
to the one exact reviewed build profile
|
||||||
|
`0.12.10-reviewed-stack5m.1`. The corresponding deployment tests accept the
|
||||||
|
five reviewed core files and `version.json` while rejecting mutable-looking
|
||||||
|
suffixes, malformed versions and unexpected paths. A clean temporary clone
|
||||||
|
containing that exact patch passed the AV release assembly smoke without a
|
||||||
|
known-header-gap override.
|
||||||
|
|
||||||
Smallest required Portal change: append exactly
|
Until the cache extension is committed, cite it only as staged integration
|
||||||
`media-src 'self' blob:;` to that CSP. This permits only same-origin and
|
evidence, not as a published Portal revision. The clean `v0.6.0` rule leaves
|
||||||
in-memory Blob media; it does not permit remote media hosts, inline script or
|
the reviewed profile at `no-cache`; that affects performance, not loading,
|
||||||
eval. If the Portal is not changed, conversion/download can remain available
|
MIME type, isolation or preview correctness.
|
||||||
but Blob source/result preview must show a clear disabled reason.
|
|
||||||
|
|
||||||
As inspected on 2026-07-24, the active
|
|
||||||
`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` contains that exact directive
|
|
||||||
as a local worktree patch. The worktree still resolves to the immutable
|
|
||||||
revision above, so the patch is useful for local deployment testing but is not
|
|
||||||
a committed Portal revision and must not be cited as published release
|
|
||||||
evidence. The clean-reference assembly smoke therefore continues to require
|
|
||||||
`--allow-known-header-gap` until a reviewed Portal commit contains the fix.
|
|
||||||
|
|
||||||
The nginx profile includes `.wasm` in its static allowlist/gzip types but
|
|
||||||
relies on the base image `mime.types`. Verify `Content-Type:
|
|
||||||
application/wasm` against the pinned image; add an explicit mapping if the
|
|
||||||
response differs.
|
|
||||||
|
|
||||||
## Caching
|
## Caching
|
||||||
|
|
||||||
The immutable Portal reference's cache map makes Vite content-hashed assets
|
The staged Portal cache map makes Vite content-hashed assets, plain semantic
|
||||||
immutable but leaves versioned FFmpeg cores at `no-cache`. Correctness is
|
versions and the explicitly reviewed profile immutable. For performance, the
|
||||||
unaffected.
|
exact directory
|
||||||
For performance, the exact directory
|
`apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` may receive:
|
||||||
`apps/av/vendor/ffmpeg/0.12.10/{st,mt}/` may receive:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Cache-Control: public, max-age=31536000, immutable
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
# Reference implementations
|
# Reference implementations
|
||||||
|
|
||||||
Inspection date: **2026-07-24**.
|
Inspection dates: **2026-07-24** (initial implementation) and
|
||||||
|
**2026-07-26** (remaining-slice integration).
|
||||||
|
|
||||||
The references were cloned beside av-tools under
|
The references were cloned beside av-tools under
|
||||||
`/mnt/DATA/git/references/`; no reference repository was nested in this
|
`/mnt/DATA/git/references/`; no reference repository was nested in this
|
||||||
working tree. Revisions are full immutable commit ids.
|
working tree. Revisions are full immutable commit ids.
|
||||||
|
|
||||||
| Repository | Revision inspected | Used for | Copied or adapted |
|
| Repository | Revision inspected | Used for | Copied or adapted |
|
||||||
| -------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| av-tools, `git@git.add-ideas.de:zemion/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed |
|
| av-tools, `git@git.add-ideas.de:zemion/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed |
|
||||||
| Toolbox SDK, `https://git.add-ideas.de/zemion/toolbox-sdk.git` | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` (SDK 0.2.2) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.2 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
|
| Toolbox SDK, `https://git.add-ideas.de/zemion/toolbox-sdk.git` | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` (SDK 0.2.2) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.2 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
|
||||||
| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the documented artifact contract informed an independent release packager and smoke harness |
|
| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` initially; `5c052bc6d1073a66600cfddc44c0e16da34bede8` for final integration | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the AV smoke harness consumes only an exact cloned Portal revision; the separate Portal worktree contains its own reviewed integration patch |
|
||||||
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree code was copied; exact published npm packages are dependencies and their five core assets are copied verbatim by the vendor script |
|
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc`; pinned release baseline `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree source was copied; wrapper/util remain exact npm dependencies, while the five core assets are rebuilt from the recorded source/toolchain/patch profile and verified against the build lock |
|
||||||
|
|
||||||
For the **pinned** package set, the more specific upstream release tag
|
For the **pinned** package set, the more specific upstream release tag
|
||||||
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It
|
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It
|
||||||
contains `@ffmpeg/ffmpeg` 0.12.15 and core/core-mt 0.12.10. That revision's
|
contains `@ffmpeg/ffmpeg` 0.12.15 and the source configuration for core
|
||||||
build selects FFmpeg n5.1.4.
|
0.12.10. That revision's build selects FFmpeg n5.1.4. The shipped
|
||||||
|
`0.12.10-reviewed-stack5m.1` assets additionally apply upstream ffmpeg.wasm
|
||||||
|
commit `b409e36475bc21f0451b5b1e1d126fa82871439a`; exact build and source
|
||||||
|
identities are in `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
|
||||||
|
|
||||||
The user's active `/mnt/DATA/git/toolbox-portal` worktree still has Portal
|
The active `/mnt/DATA/git/toolbox-portal` worktree has immutable revision
|
||||||
revision `bda9da044d61c109afd9969d357cc27f76648938` as `HEAD`, but currently
|
`5c052bc6d1073a66600cfddc44c0e16da34bede8` as `HEAD`. Its staged cache-rule
|
||||||
contains a local `media-src 'self' blob:` nginx CSP patch. That mutable
|
extension is documented separately and is not represented as a published
|
||||||
worktree state is documented for deployment testing only; it does not replace
|
revision.
|
||||||
the immutable reference in the table.
|
|
||||||
|
|
||||||
Official behavior references:
|
Official behavior references:
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ The repository already declares the application licence and immutable
|
|||||||
third-party source identities. Before any distribution:
|
third-party source identities. Before any distribution:
|
||||||
|
|
||||||
1. commit the reviewed GPL-3.0-or-later source;
|
1. commit the reviewed GPL-3.0-or-later source;
|
||||||
2. create and verify annotated tag `v0.1.0` at that exact commit;
|
2. create and verify annotated tag `v0.2.0` at that exact commit;
|
||||||
3. push the commit and tag to the canonical public repository;
|
3. push the commit and tag to the canonical public repository;
|
||||||
4. assemble, review and publish corresponding source and exact external
|
4. assemble, review and publish corresponding source and exact external
|
||||||
notices for both core packages and linked libraries;
|
notices for both reviewed core profiles and linked libraries;
|
||||||
5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned.
|
5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned.
|
||||||
|
|
||||||
`SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not
|
`SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not
|
||||||
@@ -44,6 +44,7 @@ Normal packaging validates the root GPL version 3 text and matching
|
|||||||
```sh
|
```sh
|
||||||
npm run package:release
|
npm run package:release
|
||||||
npm run source:package
|
npm run source:package
|
||||||
|
npm run vendor:verify
|
||||||
```
|
```
|
||||||
|
|
||||||
The legacy development-only escape hatch is retained only for checking an
|
The legacy development-only escape hatch is retained only for checking an
|
||||||
@@ -56,19 +57,19 @@ npm run package:release -- --allow-unlicensed-development-smoke
|
|||||||
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
|
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
release/av-tools-0.1.0.zip
|
release/av-tools-0.2.0.zip
|
||||||
release/av-tools-0.1.0.zip.sha256
|
release/av-tools-0.2.0.zip.sha256
|
||||||
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
|
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
|
||||||
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256
|
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify independently:
|
Verify independently:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node scripts/checksum-release.mjs
|
node scripts/checksum-release.mjs
|
||||||
(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256)
|
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
|
||||||
unzip -l release/av-tools-0.1.0.zip
|
unzip -l release/av-tools-0.2.0.zip
|
||||||
tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
|
tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
|
||||||
```
|
```
|
||||||
|
|
||||||
The packager:
|
The packager:
|
||||||
@@ -90,13 +91,13 @@ When used against a fixture without an application licence, the escape hatch add
|
|||||||
|
|
||||||
## 4. Portal assembly smoke
|
## 4. Portal assembly smoke
|
||||||
|
|
||||||
Use the clean reference clone beside av-tools, never the user's active Portal
|
Use a reviewed Portal Git repository beside av-tools. The harness reads its
|
||||||
worktree:
|
exact `HEAD` and clones that revision into a temporary directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run portal:smoke -- \
|
npm run portal:smoke -- \
|
||||||
--artifact release/av-tools-0.1.0.zip \
|
--artifact release/av-tools-0.2.0.zip \
|
||||||
--allow-known-header-gap
|
--portal-root ../toolbox-portal
|
||||||
```
|
```
|
||||||
|
|
||||||
The smoke harness clones the exact Portal HEAD to a temporary directory,
|
The smoke harness clones the exact Portal HEAD to a temporary directory,
|
||||||
@@ -106,19 +107,23 @@ then verifies the generated catalogue/release records and copied FFmpeg assets.
|
|||||||
The temporary directory is removed unless `--keep-temp` is explicitly passed.
|
The temporary directory is removed unless `--keep-temp` is explicitly passed.
|
||||||
No machine-specific path is committed and no Portal worktree is changed.
|
No machine-specific path is committed and no Portal worktree is changed.
|
||||||
|
|
||||||
Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP
|
Portal `v0.6.0`
|
||||||
omission of `media-src blob:` is a failing deployment check. The override still
|
(`5c052bc6d1073a66600cfddc44c0e16da34bede8`) already passes the isolation,
|
||||||
reports the gap and is only for testing archive assembly. See
|
Blob-media CSP and explicit WASM MIME checks. Its plain-semver cache rule does
|
||||||
`docs/PORTAL_REQUIREMENTS.md`.
|
not match the reviewed core's build-profile suffix, so the harness reports a
|
||||||
|
cache warning. The staged Portal extension accepts only the exact reviewed
|
||||||
|
profile in addition to plain semantic versions and rejects malformed or
|
||||||
|
mutable-looking suffixes; a temporary clean commit containing that patch passed
|
||||||
|
this smoke without warnings.
|
||||||
|
|
||||||
The user's active `/mnt/DATA/git/toolbox-portal` worktree contains the narrow
|
`--allow-known-header-gap` remains available only for deliberately testing an
|
||||||
directive as a local patch, but the smoke intentionally does not consume that
|
older Portal revision with a known required-header error. It is unnecessary
|
||||||
mutable worktree. Until the patch exists in a reviewed immutable Portal
|
for `v0.6.0` and must not suppress review of new gaps. See
|
||||||
revision, retain the clean-reference failure/override behavior above.
|
`docs/PORTAL_REQUIREMENTS.md`.
|
||||||
|
|
||||||
## 5. Publish only reviewed bytes
|
## 5. Publish only reviewed bytes
|
||||||
|
|
||||||
From a clean `v0.1.0` checkout, rerun all checks, create the ZIP once, verify
|
From a clean `v0.2.0` checkout, rerun all checks, create the ZIP once, verify
|
||||||
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
|
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
|
||||||
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
|
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
|
||||||
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never
|
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ exportable diagnostics.
|
|||||||
Metadata is rendered as React text, never `dangerouslySetInnerHTML`. Subtitle
|
Metadata is rendered as React text, never `dangerouslySetInnerHTML`. Subtitle
|
||||||
text is parsed and rendered or sent through a generated local file; it is not
|
text is parsed and rendered or sent through a generated local file; it is not
|
||||||
inserted as HTML. Extracted URLs are inert text. The application does not open
|
inserted as HTML. Extracted URLs are inert text. The application does not open
|
||||||
links from media automatically. No remote font is fetched; ASS burn-in uses
|
links from media automatically. No remote font is fetched. Contact-sheet labels
|
||||||
packaged/system-generic font configuration only when the required local
|
mount only the same-origin, size/SHA-256-verified DejaVu asset and fail closed
|
||||||
capability is available.
|
without `drawtext`; ASS burn-in uses packaged/system-generic font configuration
|
||||||
|
only when the required local capability is available.
|
||||||
|
|
||||||
Preview/download URLs are `blob:` values made from local bytes. They are
|
Preview/download URLs are `blob:` values made from local bytes. They are
|
||||||
revoked on replacement, project close and unmount. Browser support for a
|
revoked on replacement, project close and unmount. Browser support for a
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default tseslint.config(
|
|||||||
'test-results',
|
'test-results',
|
||||||
'playwright-report',
|
'playwright-report',
|
||||||
'public/vendor/ffmpeg',
|
'public/vendor/ffmpeg',
|
||||||
|
'.core-build',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,18 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.2",
|
"@add-ideas/toolbox-contract": "0.2.2",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
"@add-ideas/toolbox-shell-react": "0.2.2",
|
||||||
"@ffmpeg/core": "0.12.10",
|
|
||||||
"@ffmpeg/core-mt": "0.12.10",
|
|
||||||
"@ffmpeg/ffmpeg": "0.12.15",
|
"@ffmpeg/ffmpeg": "0.12.15",
|
||||||
"@ffmpeg/util": "0.12.2",
|
"@ffmpeg/util": "0.12.2",
|
||||||
"fflate": "0.8.3",
|
"fflate": "0.8.3",
|
||||||
@@ -718,24 +716,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ffmpeg/core": {
|
|
||||||
"version": "0.12.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
|
|
||||||
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
|
|
||||||
"license": "GPL-2.0-or-later",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ffmpeg/core-mt": {
|
|
||||||
"version": "0.12.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz",
|
|
||||||
"integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==",
|
|
||||||
"license": "GPL-2.0-or-later",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ffmpeg/ffmpeg": {
|
"node_modules/@ffmpeg/ffmpeg": {
|
||||||
"version": "0.12.15",
|
"version": "0.12.15",
|
||||||
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
|
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
|
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs",
|
"vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs",
|
||||||
"vendor:verify": "node scripts/verify-ffmpeg-assets.mjs",
|
"vendor:verify": "node scripts/verify-ffmpeg-assets.mjs",
|
||||||
|
"vendor:verify-runtime": "node scripts/verify-ffmpeg-assets.mjs --runtime-only",
|
||||||
|
"vendor:package": "node scripts/package-reviewed-ffmpeg-core.mjs",
|
||||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||||
"prebuild": "npm run vendor:ffmpeg && npm run manifest:generate",
|
"prebuild": "npm run vendor:ffmpeg && npm run manifest:generate",
|
||||||
@@ -34,6 +36,9 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:browser": "playwright test",
|
"test:browser": "playwright test",
|
||||||
|
"evidence:large-preview": "playwright test tests/browser/large-media-preview.spec.ts --project=chromium --workers=1",
|
||||||
|
"evidence:opus": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs",
|
||||||
|
"test:opus-regression": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs --require-pass",
|
||||||
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
|
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
|
||||||
"fixtures:generate": "sh scripts/generate-fixtures.sh",
|
"fixtures:generate": "sh scripts/generate-fixtures.sh",
|
||||||
"fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS",
|
"fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS",
|
||||||
@@ -42,14 +47,12 @@
|
|||||||
"source:package": "node scripts/package-ffmpeg-source.mjs",
|
"source:package": "node scripts/package-ffmpeg-source.mjs",
|
||||||
"checksum:release": "node scripts/checksum-release.mjs",
|
"checksum:release": "node scripts/checksum-release.mjs",
|
||||||
"portal:smoke": "node scripts/portal-assembly-smoke.mjs",
|
"portal:smoke": "node scripts/portal-assembly-smoke.mjs",
|
||||||
"check": "npm run manifest:check && npm run vendor:verify && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
"check": "npm run manifest:check && npm run vendor:verify-runtime && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release"
|
"release:artifact": "npm run check && npm run vendor:verify && npm run test:opus-regression && npm run test:browser && npm run package:release"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.2",
|
"@add-ideas/toolbox-contract": "0.2.2",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
"@add-ideas/toolbox-shell-react": "0.2.2",
|
||||||
"@ffmpeg/core": "0.12.10",
|
|
||||||
"@ffmpeg/core-mt": "0.12.10",
|
|
||||||
"@ffmpeg/ffmpeg": "0.12.15",
|
"@ffmpeg/ffmpeg": "0.12.15",
|
||||||
"@ffmpeg/util": "0.12.2",
|
"@ffmpeg/util": "0.12.2",
|
||||||
"fflate": "0.8.3",
|
"fflate": "0.8.3",
|
||||||
|
|||||||
BIN
public/fonts/DejaVuSans.ttf
Normal file
BIN
public/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.av-tools",
|
"id": "de.add-ideas.av-tools",
|
||||||
"name": "Audio & Video Tools",
|
"name": "Audio & Video Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -55,10 +55,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
|
||||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
|
||||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
|
||||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
|
||||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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 { createHash } from 'node:crypto';
|
||||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, join, relative } from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
|
||||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const repositoryRoot = path.resolve(
|
||||||
const expectedVersion = '0.12.10';
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
const outputRoot = join(
|
'..'
|
||||||
repositoryRoot,
|
|
||||||
'public',
|
|
||||||
'vendor',
|
|
||||||
'ffmpeg',
|
|
||||||
expectedVersion
|
|
||||||
);
|
);
|
||||||
|
const lockPath = path.join(
|
||||||
const packages = [
|
repositoryRoot,
|
||||||
{
|
'scripts',
|
||||||
packageName: '@ffmpeg/core',
|
'reviewed-ffmpeg-core-lock.json'
|
||||||
mode: 'st',
|
);
|
||||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'],
|
const maximumArchiveBytes = 64 * 1024 * 1024;
|
||||||
},
|
const maximumAssetBytes = 64 * 1024 * 1024;
|
||||||
{
|
const requiredAssetPaths = new Set([
|
||||||
packageName: '@ffmpeg/core-mt',
|
'st/ffmpeg-core.js',
|
||||||
mode: 'mt',
|
'st/ffmpeg-core.wasm',
|
||||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'],
|
'mt/ffmpeg-core.js',
|
||||||
},
|
'mt/ffmpeg-core.wasm',
|
||||||
];
|
'mt/ffmpeg-core.worker.js',
|
||||||
|
|
||||||
const expectedHashes = new Map([
|
|
||||||
[
|
|
||||||
'@ffmpeg/core:ffmpeg-core.js',
|
|
||||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'@ffmpeg/core:ffmpeg-core.wasm',
|
|
||||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'@ffmpeg/core-mt:ffmpeg-core.js',
|
|
||||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'@ffmpeg/core-mt:ffmpeg-core.wasm',
|
|
||||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'@ffmpeg/core-mt:ffmpeg-core.worker.js',
|
|
||||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
async function loadPackageVersion(packageName) {
|
function sha256(bytes) {
|
||||||
const packagePath = join(
|
return createHash('sha256').update(bytes).digest('hex');
|
||||||
repositoryRoot,
|
|
||||||
'node_modules',
|
|
||||||
...packageName.split('/'),
|
|
||||||
'package.json'
|
|
||||||
);
|
|
||||||
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
|
|
||||||
if (parsed.version !== expectedVersion) {
|
|
||||||
throw new Error(
|
|
||||||
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyAsset(packageName, mode, fileName) {
|
function assertSafeRelativePath(value) {
|
||||||
const source = join(
|
if (
|
||||||
repositoryRoot,
|
typeof value !== 'string' ||
|
||||||
'node_modules',
|
value.length === 0 ||
|
||||||
...packageName.split('/'),
|
value.includes('\\') ||
|
||||||
'dist',
|
value.includes('\0') ||
|
||||||
'esm',
|
value.startsWith('/') ||
|
||||||
fileName
|
path.posix.normalize(value) !== value ||
|
||||||
);
|
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||||
const destination = join(outputRoot, mode, fileName);
|
) {
|
||||||
const sourceBytes = await readFile(source).catch((error) => {
|
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
|
||||||
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
|
}
|
||||||
cause: error,
|
return value;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
async function requireRealDirectory(directory, create = false) {
|
||||||
|
let details = await lstat(directory).catch((error) => {
|
||||||
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
|
if (!details && create) {
|
||||||
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
|
await mkdir(directory);
|
||||||
if (!expectedDigest || sourceDigest !== expectedDigest) {
|
details = await lstat(directory);
|
||||||
throw new Error(
|
|
||||||
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
|
||||||
await mkdir(dirname(destination), { recursive: true });
|
throw new Error(`Required directory is unsafe: ${directory}`);
|
||||||
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 lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||||
const versionManifest = {
|
const lockedAssetPaths = new Set(
|
||||||
schemaVersion: 1,
|
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||||
coreVersion: expectedVersion,
|
);
|
||||||
packages: {
|
if (
|
||||||
'@ffmpeg/core': expectedVersion,
|
lock.schemaVersion !== 1 ||
|
||||||
'@ffmpeg/core-mt': expectedVersion,
|
typeof lock.buildId !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||||
|
lock.coreVersion !== '0.12.10' ||
|
||||||
|
lock.profile !== 'reviewed-stack5m' ||
|
||||||
|
lock.sourceArchive?.path !==
|
||||||
|
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||||
|
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||||
|
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||||
|
lock.patch?.upstreamCommit !== 'b409e36475bc21f0451b5b1e1d126fa82871439a' ||
|
||||||
|
lock.patch?.stackSizeBytes !== 5 * 1024 * 1024 ||
|
||||||
|
typeof lock.opusRegression?.singleThread !== 'boolean' ||
|
||||||
|
typeof lock.opusRegression?.multiThread !== 'boolean' ||
|
||||||
|
!Array.isArray(lock.assets) ||
|
||||||
|
lock.assets.length !== 5 ||
|
||||||
|
lock.assets.some(
|
||||||
|
(asset) =>
|
||||||
|
!requiredAssetPaths.has(asset.path) ||
|
||||||
|
!Number.isSafeInteger(asset.bytes) ||
|
||||||
|
asset.bytes < 1 ||
|
||||||
|
asset.bytes > maximumAssetBytes ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
|
||||||
|
) ||
|
||||||
|
lockedAssetPaths.size !== requiredAssetPaths.size ||
|
||||||
|
[...requiredAssetPaths].some((assetPath) => !lockedAssetPaths.has(assetPath))
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifactRelativePath = assertSafeRelativePath(lock.artifact?.path);
|
||||||
|
if (artifactRelativePath !== `vendor/ffmpeg-core-${lock.buildId}.zip`) {
|
||||||
|
throw new Error('Reviewed-core artifact path and build identity disagree.');
|
||||||
|
}
|
||||||
|
const artifact = await readFile(
|
||||||
|
path.join(repositoryRoot, ...artifactRelativePath.split('/'))
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
artifact.byteLength < 1 ||
|
||||||
|
artifact.byteLength > maximumArchiveBytes ||
|
||||||
|
artifact.byteLength !== lock.artifact.bytes ||
|
||||||
|
sha256(artifact) !== lock.artifact.sha256
|
||||||
|
) {
|
||||||
|
throw new Error('Reviewed FFmpeg core archive failed locked verification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedArchiveEntries = new Map(
|
||||||
|
lock.assets.map((asset) => [asset.path, asset])
|
||||||
|
);
|
||||||
|
const archiveEntries = new Set();
|
||||||
|
const uncompressed = unzipSync(artifact, {
|
||||||
|
filter: (entry) => {
|
||||||
|
const relative = assertSafeRelativePath(entry.name);
|
||||||
|
const locked = lockedArchiveEntries.get(relative);
|
||||||
|
if (
|
||||||
|
!locked ||
|
||||||
|
archiveEntries.has(relative) ||
|
||||||
|
entry.originalSize !== locked.bytes ||
|
||||||
|
entry.originalSize < 1 ||
|
||||||
|
entry.originalSize > maximumAssetBytes ||
|
||||||
|
entry.size < 1 ||
|
||||||
|
entry.size > maximumArchiveBytes
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
archiveEntries.add(relative);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
assets,
|
});
|
||||||
};
|
if (archiveEntries.size !== lockedArchiveEntries.size) {
|
||||||
|
throw new Error('Reviewed FFmpeg core archive is missing an expected entry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorRoot = path.join(repositoryRoot, 'public', 'vendor', 'ffmpeg');
|
||||||
|
const outputRoot = path.join(vendorRoot, lock.buildId);
|
||||||
|
await requireRealDirectory(repositoryRoot);
|
||||||
|
await requireRealDirectory(path.join(repositoryRoot, 'public'));
|
||||||
|
await requireRealDirectory(path.join(repositoryRoot, 'public', 'vendor'), true);
|
||||||
|
const existingVendorRoot = await lstat(vendorRoot).catch((error) => {
|
||||||
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
existingVendorRoot &&
|
||||||
|
(existingVendorRoot.isSymbolicLink() || !existingVendorRoot.isDirectory())
|
||||||
|
) {
|
||||||
|
throw new Error(`Generated FFmpeg vendor root is unsafe: ${vendorRoot}`);
|
||||||
|
}
|
||||||
|
await rm(vendorRoot, { force: true, recursive: true });
|
||||||
|
|
||||||
|
for (const asset of lock.assets) {
|
||||||
|
const relativeAssetPath = assertSafeRelativePath(asset.path);
|
||||||
|
const bytes = uncompressed[relativeAssetPath];
|
||||||
|
if (
|
||||||
|
!bytes ||
|
||||||
|
bytes.byteLength < 1 ||
|
||||||
|
bytes.byteLength > maximumAssetBytes ||
|
||||||
|
bytes.byteLength !== asset.bytes ||
|
||||||
|
sha256(bytes) !== asset.sha256
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Reviewed FFmpeg core entry failed locked verification: ${asset.path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const destination = path.join(outputRoot, ...relativeAssetPath.split('/'));
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
await writeFile(destination, bytes, { flag: 'wx', mode: 0o644 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionManifest = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
buildId: lock.buildId,
|
||||||
|
profile: lock.profile,
|
||||||
|
coreVersion: lock.coreVersion,
|
||||||
|
ffmpegVersion: lock.ffmpegVersion,
|
||||||
|
sourceArchive: lock.sourceArchive,
|
||||||
|
sourceLock: lock.sourceLock,
|
||||||
|
buildDriver: lock.buildDriver,
|
||||||
|
toolchain: lock.toolchain,
|
||||||
|
patch: lock.patch,
|
||||||
|
opusRegression: lock.opusRegression,
|
||||||
|
assets: lock.assets,
|
||||||
|
};
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(outputRoot, 'version.json'),
|
path.join(outputRoot, 'version.json'),
|
||||||
`${JSON.stringify(versionManifest, null, 2)}\n`
|
`${JSON.stringify(versionManifest, null, 2)}\n`,
|
||||||
|
{ flag: 'wx', mode: 0o644 }
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
|
`Verified and unpacked ${lock.assets.length} reviewed FFmpeg assets to ${path.relative(repositoryRoot, outputRoot)}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# FFmpeg WebAssembly Corresponding Source
|
# FFmpeg WebAssembly Corresponding Source
|
||||||
|
|
||||||
This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt`
|
This archive accompanies the reviewed single-thread and multithread FFmpeg
|
||||||
0.12.10 object code distributed by av-tools 0.1.0.
|
WebAssembly core profile distributed by av-tools. The profile retains the
|
||||||
|
upstream core version 0.12.10 and FFmpeg n5.1.4 identities, but it is rebuilt
|
||||||
|
from source rather than copied from the published `@ffmpeg/core` packages.
|
||||||
|
|
||||||
`SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and
|
`SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and
|
||||||
every external library selected by the upstream v12.15 build. The source trees
|
every external library selected by the upstream v12.15 build. The source trees
|
||||||
@@ -18,10 +20,15 @@ The original build-control files are in `sources/ffmpeg.wasm/`, including:
|
|||||||
- `build/ffmpeg-wasm.sh`;
|
- `build/ffmpeg-wasm.sh`;
|
||||||
- the other dependency scripts under `build/`.
|
- the other dependency scripts under `build/`.
|
||||||
|
|
||||||
The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools
|
The build uses Emscripten SDK 3.1.40 and FFmpeg n5.1.4. Its one source patch
|
||||||
does not modify the five published JavaScript/WebAssembly core assets. Their
|
applies upstream ffmpeg.wasm commit
|
||||||
exact sizes, hashes and embedded configure arguments are recorded in the
|
`b409e36475bc21f0451b5b1e1d126fa82871439a`, which raises the Emscripten stack
|
||||||
application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
|
to 5 MiB and fixes the libopus encode failure reported upstream. The tracked
|
||||||
|
`scripts/build-reviewed-ffmpeg-core.sh` driver in the matching av-tools source
|
||||||
|
release also isolates optional host dependencies and records the exact
|
||||||
|
toolchain identities. The generated assets' exact sizes, hashes and embedded
|
||||||
|
configure arguments are recorded in the application's `SOURCE.md`,
|
||||||
|
`scripts/reviewed-ffmpeg-core-lock.json` and `docs/FFMPEG_BUILD.md`.
|
||||||
|
|
||||||
The source archive preserves each component's own licence and copyright
|
The source archive preserves each component's own licence and copyright
|
||||||
notices. av-tools itself is distributed separately under
|
notices. av-tools itself is distributed separately under
|
||||||
@@ -37,14 +44,11 @@ test "$(git -C source-check rev-parse FETCH_HEAD)" = "<revision>"
|
|||||||
```
|
```
|
||||||
|
|
||||||
The included ffmpeg.wasm Dockerfile documents the historical upstream build,
|
The included ffmpeg.wasm Dockerfile documents the historical upstream build,
|
||||||
but still names remote tags and branches. It is not a turnkey offline or
|
but still names remote tags and branches. For the distributed reviewed
|
||||||
byte-for-byte reproducible build recipe. To reconstruct a pinned build,
|
profile, use this archive together with the matching av-tools source release
|
||||||
adapt each remote `ADD` to use the corresponding bundled source tree. Replace
|
and invoke its `scripts/build-reviewed-ffmpeg-core.sh` driver. The bundled
|
||||||
the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/`
|
`sources/zimg/` tree already contains its pinned GoogleTest submodule at
|
||||||
tree, which already contains its pinned GoogleTest submodule at
|
`test/extra/googletest/`.
|
||||||
`test/extra/googletest/`. Keep the included build scripts and Emscripten
|
|
||||||
version unchanged. The upstream targets are `make prd` for single-thread and
|
|
||||||
`make prd-mt` for multithread.
|
|
||||||
|
|
||||||
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
|
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
|
||||||
3.1.40 container image is not copied into this archive.
|
3.1.40 container image is not copied into this archive.
|
||||||
|
|||||||
522
scripts/opus-regression.mjs
Normal file
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 repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
|
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
|
||||||
|
const reviewedLockPath = join(
|
||||||
|
repositoryRoot,
|
||||||
|
'scripts',
|
||||||
|
'reviewed-ffmpeg-core-lock.json'
|
||||||
|
);
|
||||||
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
|
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
|
||||||
const releaseDirectory = join(repositoryRoot, 'release');
|
const releaseDirectory = join(repositoryRoot, 'release');
|
||||||
const force = process.argv.includes('--force');
|
const force = process.argv.includes('--force');
|
||||||
@@ -204,7 +209,19 @@ async function publishSourcePair({
|
|||||||
|
|
||||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||||
assertLock(lock);
|
assertLock(lock);
|
||||||
const archiveName = `ffmpeg-core-${lock.coreVersion}-corresponding-source.tar.xz`;
|
const reviewedLock = JSON.parse(await readFile(reviewedLockPath, 'utf8'));
|
||||||
|
if (
|
||||||
|
reviewedLock?.schemaVersion !== 1 ||
|
||||||
|
reviewedLock.coreVersion !== lock.coreVersion ||
|
||||||
|
typeof reviewedLock.buildId !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9._-]+$/u.test(reviewedLock.buildId) ||
|
||||||
|
!reviewedLock.buildId.startsWith(`${lock.coreVersion}-`)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The reviewed-core lock does not identify a build-qualified source bundle.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const archiveName = `ffmpeg-core-${reviewedLock.buildId}-corresponding-source.tar.xz`;
|
||||||
const archivePath = join(releaseDirectory, archiveName);
|
const archivePath = join(releaseDirectory, archiveName);
|
||||||
const checksumPath = `${archivePath}.sha256`;
|
const checksumPath = `${archivePath}.sha256`;
|
||||||
await ensureReleaseDirectory();
|
await ensureReleaseDirectory();
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ const ffmpegLicenseCoverage = new Map([
|
|||||||
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||||
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||||
['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||||
['@ffmpeg/core', 'LICENSES/GPL-2.0-or-later.txt'],
|
|
||||||
['@ffmpeg/core-mt', 'LICENSES/GPL-2.0-or-later.txt'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function compareText(left, right) {
|
function compareText(left, right) {
|
||||||
@@ -479,7 +477,8 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json';
|
const vendorBuildId = '0.12.10-reviewed-stack5m.1';
|
||||||
|
const vendorManifestPath = `vendor/ffmpeg/${vendorBuildId}/version.json`;
|
||||||
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
|
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
|
||||||
if (!vendorManifestEntry) {
|
if (!vendorManifestEntry) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -488,10 +487,12 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
|||||||
}
|
}
|
||||||
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
|
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
|
||||||
if (
|
if (
|
||||||
vendorManifest.schemaVersion !== 1 ||
|
vendorManifest.schemaVersion !== 2 ||
|
||||||
|
vendorManifest.buildId !== vendorBuildId ||
|
||||||
|
vendorManifest.profile !== 'reviewed-stack5m' ||
|
||||||
vendorManifest.coreVersion !== '0.12.10' ||
|
vendorManifest.coreVersion !== '0.12.10' ||
|
||||||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' ||
|
vendorManifest.opusRegression?.singleThread !== true ||
|
||||||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' ||
|
vendorManifest.opusRegression?.multiThread !== true ||
|
||||||
!Array.isArray(vendorManifest.assets) ||
|
!Array.isArray(vendorManifest.assets) ||
|
||||||
vendorManifest.assets.length !== 5
|
vendorManifest.assets.length !== 5
|
||||||
) {
|
) {
|
||||||
@@ -511,7 +512,7 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const assetPath = safeArchivePath(
|
const assetPath = safeArchivePath(
|
||||||
path.posix.join('vendor/ffmpeg/0.12.10', asset.path)
|
path.posix.join(`vendor/ffmpeg/${vendorBuildId}`, asset.path)
|
||||||
);
|
);
|
||||||
if (verifiedVendorPaths.has(assetPath)) {
|
if (verifiedVendorPaths.has(assetPath)) {
|
||||||
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);
|
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);
|
||||||
|
|||||||
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)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
'..'
|
'..'
|
||||||
);
|
);
|
||||||
|
const reviewedCoreBuildId = '0.12.10-reviewed-stack5m.1';
|
||||||
|
|
||||||
function consumeValue(argumentsList, index, option) {
|
function consumeValue(argumentsList, index, option) {
|
||||||
const value = argumentsList[index + 1];
|
const value = argumentsList[index + 1];
|
||||||
@@ -179,12 +180,21 @@ export function inspectPortalHeaders(configuration) {
|
|||||||
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
|
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
const immutableCoreRule = configuration
|
||||||
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') &&
|
.split(/\r?\n/u)
|
||||||
!configuration.includes('vendor/ffmpeg/0.12.10')
|
.find(
|
||||||
) {
|
(line) =>
|
||||||
|
/vendor\/ffmpeg\//iu.test(line) &&
|
||||||
|
/ffmpeg-core/iu.test(line) &&
|
||||||
|
/max-age=31536000/iu.test(line) &&
|
||||||
|
/immutable/iu.test(line)
|
||||||
|
);
|
||||||
|
const supportsReviewedCore =
|
||||||
|
immutableCoreRule !== undefined &&
|
||||||
|
immutableCoreRule.replaceAll('\\', '').includes(reviewedCoreBuildId);
|
||||||
|
if (!supportsReviewedCore) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
|
`The config does not give reviewed FFmpeg build ${reviewedCoreBuildId} an explicit immutable cache rule.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { errors, warnings };
|
return { errors, warnings };
|
||||||
@@ -229,11 +239,11 @@ async function verifyAssembly(output, appVersion, digest) {
|
|||||||
}
|
}
|
||||||
for (const reference of [
|
for (const reference of [
|
||||||
'./index.html',
|
'./index.html',
|
||||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
|
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
|
||||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
|
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
|
||||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||||
]) {
|
]) {
|
||||||
const relative = reference.replace(/^\.\//, '');
|
const relative = reference.replace(/^\.\//, '');
|
||||||
await requireRegularFile(
|
await requireRegularFile(
|
||||||
|
|||||||
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 { createHash } from 'node:crypto';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { lstat, readFile } from 'node:fs/promises';
|
||||||
import { dirname, join, relative } from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const repositoryRoot = path.resolve(
|
||||||
const expectedVersion = '0.12.10';
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
const outputRoot = join(
|
'..'
|
||||||
|
);
|
||||||
|
const argumentsList = process.argv.slice(2);
|
||||||
|
const runtimeOnly = argumentsList.includes('--runtime-only');
|
||||||
|
if (
|
||||||
|
argumentsList.some((argument) => argument !== '--runtime-only') ||
|
||||||
|
(runtimeOnly && argumentsList.length !== 1)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Usage: node scripts/verify-ffmpeg-assets.mjs [--runtime-only]'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const lock = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
path.join(repositoryRoot, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
lock.schemaVersion !== 1 ||
|
||||||
|
typeof lock.buildId !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||||
|
lock.coreVersion !== '0.12.10' ||
|
||||||
|
lock.profile !== 'reviewed-stack5m' ||
|
||||||
|
lock.sourceArchive?.path !==
|
||||||
|
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||||
|
!Number.isSafeInteger(lock.sourceArchive?.bytes) ||
|
||||||
|
lock.sourceArchive.bytes < 1 ||
|
||||||
|
lock.sourceArchive.bytes > 512 * 1024 * 1024 ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||||
|
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||||
|
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||||
|
lock.artifact?.path !== `vendor/ffmpeg-core-${lock.buildId}.zip` ||
|
||||||
|
!Number.isSafeInteger(lock.artifact?.bytes) ||
|
||||||
|
lock.artifact.bytes < 1 ||
|
||||||
|
lock.artifact.bytes > 64 * 1024 * 1024 ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '') ||
|
||||||
|
!Array.isArray(lock.assets) ||
|
||||||
|
lock.assets.length !== 5
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid reviewed FFmpeg core lock.');
|
||||||
|
}
|
||||||
|
const outputRoot = path.join(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
'public',
|
'public',
|
||||||
'vendor',
|
'vendor',
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
expectedVersion
|
lock.buildId
|
||||||
);
|
);
|
||||||
const manifestPath = join(outputRoot, 'version.json');
|
const manifestPath = path.join(outputRoot, 'version.json');
|
||||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||||
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||||
|
const safeAssetPath = (value) => {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value.length === 0 ||
|
||||||
|
value.includes('\\') ||
|
||||||
|
value.includes('\0') ||
|
||||||
|
value.startsWith('/') ||
|
||||||
|
path.posix.normalize(value) !== value ||
|
||||||
|
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsafe FFmpeg asset path: ${String(value)}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyLockedFile = async (record, label) => {
|
||||||
|
const relative = safeAssetPath(record.path);
|
||||||
|
const absolute = path.join(repositoryRoot, ...relative.split('/'));
|
||||||
|
const details = await lstat(absolute).catch((error) => {
|
||||||
|
throw new Error(`${label} is missing: ${relative}`, { cause: error });
|
||||||
|
});
|
||||||
|
if (details.isSymbolicLink() || !details.isFile()) {
|
||||||
|
throw new Error(`${label} must be a regular file: ${relative}`);
|
||||||
|
}
|
||||||
|
const bytes = await readFile(absolute);
|
||||||
|
if (
|
||||||
|
(record.bytes !== undefined && bytes.byteLength !== record.bytes) ||
|
||||||
|
sha256(bytes) !== record.sha256
|
||||||
|
) {
|
||||||
|
throw new Error(`${label} does not match its locked identity: ${relative}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
manifest.schemaVersion !== 1 ||
|
manifest.schemaVersion !== 2 ||
|
||||||
manifest.coreVersion !== expectedVersion ||
|
manifest.buildId !== lock.buildId ||
|
||||||
|
manifest.profile !== lock.profile ||
|
||||||
|
manifest.coreVersion !== lock.coreVersion ||
|
||||||
|
manifest.ffmpegVersion !== lock.ffmpegVersion ||
|
||||||
|
!isDeepStrictEqual(manifest.sourceArchive, lock.sourceArchive) ||
|
||||||
|
!isDeepStrictEqual(manifest.sourceLock, lock.sourceLock) ||
|
||||||
|
!isDeepStrictEqual(manifest.buildDriver, lock.buildDriver) ||
|
||||||
|
!isDeepStrictEqual(manifest.toolchain, lock.toolchain) ||
|
||||||
|
!isDeepStrictEqual(manifest.patch, lock.patch) ||
|
||||||
|
!isDeepStrictEqual(manifest.opusRegression, lock.opusRegression) ||
|
||||||
!Array.isArray(manifest.assets) ||
|
!Array.isArray(manifest.assets) ||
|
||||||
manifest.assets.length !== 5
|
manifest.assets.length !== lock.assets.length
|
||||||
) {
|
) {
|
||||||
throw new Error(`Invalid FFmpeg asset manifest: ${manifestPath}`);
|
throw new Error(`Invalid reviewed FFmpeg asset manifest: ${manifestPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedPaths = new Set([
|
if (!runtimeOnly) {
|
||||||
'mt/ffmpeg-core.js',
|
await verifyLockedFile(lock.sourceArchive, 'FFmpeg Corresponding Source');
|
||||||
'mt/ffmpeg-core.wasm',
|
}
|
||||||
'mt/ffmpeg-core.worker.js',
|
await verifyLockedFile(lock.sourceLock, 'FFmpeg source lock');
|
||||||
'st/ffmpeg-core.js',
|
await verifyLockedFile(lock.buildDriver, 'Reviewed-core build driver');
|
||||||
'st/ffmpeg-core.wasm',
|
await verifyLockedFile(lock.artifact, 'Reviewed-core deterministic artifact');
|
||||||
]);
|
|
||||||
const expectedHashes = new Map([
|
|
||||||
[
|
|
||||||
'st/ffmpeg-core.js',
|
|
||||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'st/ffmpeg-core.wasm',
|
|
||||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'mt/ffmpeg-core.js',
|
|
||||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'mt/ffmpeg-core.wasm',
|
|
||||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'mt/ffmpeg-core.worker.js',
|
|
||||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
const lockedAssets = new Map(lock.assets.map((asset) => [asset.path, asset]));
|
||||||
|
const seen = new Set();
|
||||||
for (const asset of manifest.assets) {
|
for (const asset of manifest.assets) {
|
||||||
if (!expectedPaths.delete(asset.path)) {
|
safeAssetPath(asset.path);
|
||||||
throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`);
|
const locked = lockedAssets.get(asset.path);
|
||||||
}
|
|
||||||
const bytes = await readFile(join(outputRoot, asset.path));
|
|
||||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
||||||
if (
|
if (
|
||||||
asset.bytes !== bytes.byteLength ||
|
!locked ||
|
||||||
asset.sha256 !== digest ||
|
seen.has(asset.path) ||
|
||||||
expectedHashes.get(asset.path) !== digest
|
asset.bytes !== locked.bytes ||
|
||||||
|
asset.sha256 !== locked.sha256
|
||||||
) {
|
) {
|
||||||
|
throw new Error(`Unexpected reviewed FFmpeg asset: ${asset.path}`);
|
||||||
|
}
|
||||||
|
seen.add(asset.path);
|
||||||
|
const bytes = await readFile(path.join(outputRoot, ...asset.path.split('/')));
|
||||||
|
if (bytes.byteLength !== locked.bytes || sha256(bytes) !== locked.sha256) {
|
||||||
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
|
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (seen.size !== lockedAssets.size) {
|
||||||
if (expectedPaths.size > 0) {
|
throw new Error('One or more reviewed FFmpeg assets are missing.');
|
||||||
throw new Error(
|
|
||||||
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}`
|
`Verified ${seen.size} FFmpeg ${lock.buildId} assets in ${path.relative(repositoryRoot, outputRoot)}${runtimeOnly ? ' (runtime inputs only)' : ' with Corresponding Source'}`
|
||||||
);
|
);
|
||||||
|
|||||||
295
src/App.tsx
295
src/App.tsx
@@ -9,6 +9,10 @@ import {
|
|||||||
type WorkspaceMode,
|
type WorkspaceMode,
|
||||||
} from './app/application-state';
|
} from './app/application-state';
|
||||||
import { AppErrorBoundary } from './app/AppErrorBoundary';
|
import { AppErrorBoundary } from './app/AppErrorBoundary';
|
||||||
|
import {
|
||||||
|
SourceInspectionRegistry,
|
||||||
|
type SourceInspectionToken,
|
||||||
|
} from './app/source-inspection-registry';
|
||||||
import { FileDropZone } from './components/FileDropZone';
|
import { FileDropZone } from './components/FileDropZone';
|
||||||
import { MediaBin } from './components/MediaBin';
|
import { MediaBin } from './components/MediaBin';
|
||||||
import {
|
import {
|
||||||
@@ -79,7 +83,10 @@ import { buildPreviewProxyPlan } from './commands/preview-proxy';
|
|||||||
import { buildTimelineExportPlan } from './commands/timeline-export';
|
import { buildTimelineExportPlan } from './commands/timeline-export';
|
||||||
import {
|
import {
|
||||||
buildContactSheetPlan,
|
buildContactSheetPlan,
|
||||||
|
buildSceneChangeAnalysisPlan,
|
||||||
|
buildSceneThumbnailSeriesPlan,
|
||||||
buildThumbnailSeriesPlan,
|
buildThumbnailSeriesPlan,
|
||||||
|
parseSceneChangeFrames,
|
||||||
} from './commands/thumbnails';
|
} from './commands/thumbnails';
|
||||||
import { buildSplitPlan } from './commands/split';
|
import { buildSplitPlan } from './commands/split';
|
||||||
import { buildTrimPlan } from './commands/trim';
|
import { buildTrimPlan } from './commands/trim';
|
||||||
@@ -152,6 +159,11 @@ import {
|
|||||||
type WaveformPeakData,
|
type WaveformPeakData,
|
||||||
} from './waveform';
|
} from './waveform';
|
||||||
import type { MediaJob, MediaJobStep } from './jobs';
|
import type { MediaJob, MediaJobStep } from './jobs';
|
||||||
|
import { loadBundledContactSheetFont } from './assets/bundled-font';
|
||||||
|
import {
|
||||||
|
FFMPEG_CORE_VERSION,
|
||||||
|
FFMPEG_OPUS_REGRESSION_VERIFIED,
|
||||||
|
} from './version';
|
||||||
|
|
||||||
function timestamp(): string {
|
function timestamp(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -554,6 +566,7 @@ export function AvToolsApplication() {
|
|||||||
const projectRef = useRef(project);
|
const projectRef = useRef(project);
|
||||||
const sourceProbeTail = useRef<Promise<void>>(Promise.resolve());
|
const sourceProbeTail = useRef<Promise<void>>(Promise.resolve());
|
||||||
const sourceProbePendingRef = useRef(0);
|
const sourceProbePendingRef = useRef(0);
|
||||||
|
const sourceInspectionRegistry = useRef(new SourceInspectionRegistry());
|
||||||
const planExecutionTail = useRef<Promise<void>>(Promise.resolve());
|
const planExecutionTail = useRef<Promise<void>>(Promise.resolve());
|
||||||
const scheduledPlanCountRef = useRef(0);
|
const scheduledPlanCountRef = useRef(0);
|
||||||
const scheduledWorkRef = useRef(new Map<string, ScheduledWorkDefinition>());
|
const scheduledWorkRef = useRef(new Map<string, ScheduledWorkDefinition>());
|
||||||
@@ -703,7 +716,10 @@ export function AvToolsApplication() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const inspectionRegistry = sourceInspectionRegistry.current;
|
||||||
return () => {
|
return () => {
|
||||||
|
const cancelActiveInspection = inspectionRegistry.clear();
|
||||||
|
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||||
for (const asset of assetsRef.current) {
|
for (const asset of assetsRef.current) {
|
||||||
URL.revokeObjectURL(asset.objectUrl);
|
URL.revokeObjectURL(asset.objectUrl);
|
||||||
}
|
}
|
||||||
@@ -751,15 +767,37 @@ export function AvToolsApplication() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const inspectAsset = useCallback(
|
const inspectAsset = useCallback(
|
||||||
async (asset: ImportedMediaAsset, existingAsset: boolean) => {
|
async (
|
||||||
|
asset: ImportedMediaAsset,
|
||||||
|
existingAsset: boolean,
|
||||||
|
token: SourceInspectionToken
|
||||||
|
) => {
|
||||||
|
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
|
||||||
updateAssetState(asset.id, {
|
updateAssetState(asset.id, {
|
||||||
phase: 'probing',
|
phase: 'probing',
|
||||||
error: undefined,
|
error: undefined,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const probeResult = await runSourceProbe(() =>
|
const probeResult = await runSourceProbe(async () => {
|
||||||
engineManager.probe(asset.file)
|
if (!sourceInspectionRegistry.current.activate(token)) {
|
||||||
);
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await engineManager.probe(
|
||||||
|
asset.file,
|
||||||
|
undefined,
|
||||||
|
token.signal
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sourceInspectionRegistry.current.finish(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!probeResult ||
|
||||||
|
!sourceInspectionRegistry.current.isCurrent(token)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const probe = normalizeFfprobeReport(probeResult.json);
|
const probe = normalizeFfprobeReport(probeResult.json);
|
||||||
const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`;
|
const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`;
|
||||||
const importedChapters = probe.chapters.map((chapter) => ({
|
const importedChapters = probe.chapters.map((chapter) => ({
|
||||||
@@ -772,8 +810,15 @@ export function AvToolsApplication() {
|
|||||||
? { metadata: chapter.tags }
|
? { metadata: chapter.tags }
|
||||||
: {}),
|
: {}),
|
||||||
}));
|
}));
|
||||||
|
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
|
||||||
updateAssetState(asset.id, { phase: 'ready', probe, rawProbe });
|
updateAssetState(asset.id, { phase: 'ready', probe, rawProbe });
|
||||||
setProject((current) => {
|
setProject((current) => {
|
||||||
|
if (
|
||||||
|
!sourceInspectionRegistry.current.isCurrent(token) ||
|
||||||
|
!current.assets.some((entry) => entry.id === asset.id)
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
let next = projectReducer(current, {
|
let next = projectReducer(current, {
|
||||||
type: 'asset/update',
|
type: 'asset/update',
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
@@ -823,6 +868,7 @@ export function AvToolsApplication() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
|
||||||
updateAssetState(asset.id, {
|
updateAssetState(asset.id, {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
error:
|
error:
|
||||||
@@ -881,6 +927,7 @@ export function AvToolsApplication() {
|
|||||||
const pending: Array<{
|
const pending: Array<{
|
||||||
asset: ImportedMediaAsset;
|
asset: ImportedMediaAsset;
|
||||||
existingAsset: boolean;
|
existingAsset: boolean;
|
||||||
|
token: SourceInspectionToken;
|
||||||
}> = [];
|
}> = [];
|
||||||
const claimedIds = new Set(assetsRef.current.map((asset) => asset.id));
|
const claimedIds = new Set(assetsRef.current.map((asset) => asset.id));
|
||||||
|
|
||||||
@@ -916,7 +963,11 @@ export function AvToolsApplication() {
|
|||||||
if (reattach && match) {
|
if (reattach && match) {
|
||||||
asset.id = match.entry.id;
|
asset.id = match.entry.id;
|
||||||
claimedIds.add(match.entry.id);
|
claimedIds.add(match.entry.id);
|
||||||
pending.push({ asset, existingAsset: true });
|
pending.push({
|
||||||
|
asset,
|
||||||
|
existingAsset: true,
|
||||||
|
token: sourceInspectionRegistry.current.register(asset.id),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setProject((current) =>
|
setProject((current) =>
|
||||||
projectReducer(current, {
|
projectReducer(current, {
|
||||||
@@ -932,7 +983,11 @@ export function AvToolsApplication() {
|
|||||||
updatedAt: timestamp(),
|
updatedAt: timestamp(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
pending.push({ asset, existingAsset: false });
|
pending.push({
|
||||||
|
asset,
|
||||||
|
existingAsset: false,
|
||||||
|
token: sourceInspectionRegistry.current.register(asset.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,7 +998,7 @@ export function AvToolsApplication() {
|
|||||||
});
|
});
|
||||||
setSelectedAssetId((current) => current ?? pending[0]?.asset.id);
|
setSelectedAssetId((current) => current ?? pending[0]?.asset.id);
|
||||||
for (const item of pending) {
|
for (const item of pending) {
|
||||||
await inspectAsset(item.asset, item.existingAsset);
|
await inspectAsset(item.asset, item.existingAsset, item.token);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[inspectAsset, project.assets, resourceContext.policy]
|
[inspectAsset, project.assets, resourceContext.policy]
|
||||||
@@ -953,7 +1008,13 @@ export function AvToolsApplication() {
|
|||||||
(id: string) => {
|
(id: string) => {
|
||||||
const asset = assets.find((entry) => entry.id === id);
|
const asset = assets.find((entry) => entry.id === id);
|
||||||
if (asset) URL.revokeObjectURL(asset.objectUrl);
|
if (asset) URL.revokeObjectURL(asset.objectUrl);
|
||||||
setAssets((current) => current.filter((entry) => entry.id !== id));
|
const cancelActiveInspection =
|
||||||
|
sourceInspectionRegistry.current.remove(id);
|
||||||
|
setAssets((current) => {
|
||||||
|
const next = current.filter((entry) => entry.id !== id);
|
||||||
|
assetsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setWaveformByAsset((current) =>
|
setWaveformByAsset((current) =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(current).filter(([assetId]) => assetId !== id)
|
Object.entries(current).filter(([assetId]) => assetId !== id)
|
||||||
@@ -982,6 +1043,7 @@ export function AvToolsApplication() {
|
|||||||
assetId: id,
|
assetId: id,
|
||||||
updatedAt: timestamp(),
|
updatedAt: timestamp(),
|
||||||
});
|
});
|
||||||
|
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||||
},
|
},
|
||||||
[applyProjectAction, assets, selectedAssetId]
|
[applyProjectAction, assets, selectedAssetId]
|
||||||
);
|
);
|
||||||
@@ -1203,7 +1265,7 @@ export function AvToolsApplication() {
|
|||||||
? ('multi-thread' as const)
|
? ('multi-thread' as const)
|
||||||
: ('single-thread' as const),
|
: ('single-thread' as const),
|
||||||
ffmpegVersion: readyStateAfterRun.capabilities.versionText,
|
ffmpegVersion: readyStateAfterRun.capabilities.versionText,
|
||||||
coreVersion: '0.12.10',
|
coreVersion: FFMPEG_CORE_VERSION,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
@@ -2245,6 +2307,9 @@ export function AvToolsApplication() {
|
|||||||
|
|
||||||
const resetProject = useCallback(() => {
|
const resetProject = useCallback(() => {
|
||||||
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
|
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
|
||||||
|
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
|
||||||
|
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||||
|
assetsRef.current = [];
|
||||||
clearResult();
|
clearResult();
|
||||||
setAssets([]);
|
setAssets([]);
|
||||||
setSelectedAssetId(undefined);
|
setSelectedAssetId(undefined);
|
||||||
@@ -2280,6 +2345,9 @@ export function AvToolsApplication() {
|
|||||||
try {
|
try {
|
||||||
const imported = importProjectDocument(await file.text());
|
const imported = importProjectDocument(await file.text());
|
||||||
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
|
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
|
||||||
|
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
|
||||||
|
if (cancelActiveInspection) void engineManager.cancelActive();
|
||||||
|
assetsRef.current = [];
|
||||||
clearResult();
|
clearResult();
|
||||||
setAssets([]);
|
setAssets([]);
|
||||||
setSelectedAssetId(undefined);
|
setSelectedAssetId(undefined);
|
||||||
@@ -2556,6 +2624,7 @@ export function AvToolsApplication() {
|
|||||||
channelLayout: 'stereo',
|
channelLayout: 'stereo',
|
||||||
missingAudioPolicy: request.missingAudioPolicy,
|
missingAudioPolicy: request.missingAudioPolicy,
|
||||||
subtitlePolicy: request.subtitlePolicy,
|
subtitlePolicy: request.subtitlePolicy,
|
||||||
|
crossfadeSeconds: request.crossfadeSeconds,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
await runPlan(
|
await runPlan(
|
||||||
@@ -2950,33 +3019,80 @@ export function AvToolsApplication() {
|
|||||||
if (!asset || duration === undefined) {
|
if (!asset || duration === undefined) {
|
||||||
throw new Error('Select an attached, inspected video source first.');
|
throw new Error('Select an attached, inspected video source first.');
|
||||||
}
|
}
|
||||||
const distribution =
|
const source = {
|
||||||
request.distribution.type === 'evenly-spaced'
|
id: asset.id,
|
||||||
? { count: request.distribution.count }
|
sourceIndex: 0,
|
||||||
: request.distribution.type === 'fixed-interval'
|
fileName: asset.file.name,
|
||||||
? { intervalSeconds: request.distribution.intervalSeconds }
|
};
|
||||||
: { timesSeconds: request.distribution.timesSeconds };
|
const size = {
|
||||||
const plan = buildThumbnailSeriesPlan({
|
...(request.width ? { width: request.width } : {}),
|
||||||
jobId: crypto.randomUUID(),
|
...(request.height ? { height: request.height } : {}),
|
||||||
source: {
|
};
|
||||||
id: asset.id,
|
const sceneDistribution =
|
||||||
sourceIndex: 0,
|
request.distribution.type === 'scene-changes'
|
||||||
fileName: asset.file.name,
|
? request.distribution
|
||||||
},
|
: undefined;
|
||||||
durationSeconds: duration,
|
const execution = sceneDistribution
|
||||||
...distribution,
|
? await runWorkflow(
|
||||||
size: {
|
'Create scene-change thumbnails',
|
||||||
...(request.width ? { width: request.width } : {}),
|
2,
|
||||||
...(request.height ? { height: request.height } : {}),
|
async (executeStep) => {
|
||||||
},
|
const analysis = await executeStep(
|
||||||
format: request.format,
|
buildSceneChangeAnalysisPlan({
|
||||||
quality: request.quality,
|
jobId: crypto.randomUUID(),
|
||||||
});
|
source,
|
||||||
const execution = await runPlan(
|
durationSeconds: duration,
|
||||||
plan,
|
threshold: sceneDistribution.threshold,
|
||||||
[asset.file],
|
maxFrames: sceneDistribution.count,
|
||||||
'Create thumbnail series'
|
}),
|
||||||
);
|
[asset.file],
|
||||||
|
'Analyze scene changes'
|
||||||
|
);
|
||||||
|
const frames = parseSceneChangeFrames(analysis.logText).slice(
|
||||||
|
0,
|
||||||
|
sceneDistribution.count
|
||||||
|
);
|
||||||
|
if (frames.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return executeStep(
|
||||||
|
buildSceneThumbnailSeriesPlan({
|
||||||
|
jobId: crypto.randomUUID(),
|
||||||
|
source,
|
||||||
|
durationSeconds: duration,
|
||||||
|
frames,
|
||||||
|
size,
|
||||||
|
format: request.format,
|
||||||
|
quality: request.quality,
|
||||||
|
}),
|
||||||
|
[asset.file],
|
||||||
|
'Extract analyzed scene frames'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: await runPlan(
|
||||||
|
buildThumbnailSeriesPlan({
|
||||||
|
jobId: crypto.randomUUID(),
|
||||||
|
source,
|
||||||
|
durationSeconds: duration,
|
||||||
|
...(request.distribution.type === 'evenly-spaced'
|
||||||
|
? { count: request.distribution.count }
|
||||||
|
: request.distribution.type === 'fixed-interval'
|
||||||
|
? {
|
||||||
|
intervalSeconds: request.distribution.intervalSeconds,
|
||||||
|
}
|
||||||
|
: request.distribution.type === 'scene-changes'
|
||||||
|
? { count: request.distribution.count }
|
||||||
|
: { timesSeconds: request.distribution.timesSeconds }),
|
||||||
|
size,
|
||||||
|
format: request.format,
|
||||||
|
quality: request.quality,
|
||||||
|
}),
|
||||||
|
[asset.file],
|
||||||
|
'Create thumbnail series'
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
execution.results.map((output, index) =>
|
execution.results.map((output, index) =>
|
||||||
@@ -3002,7 +3118,7 @@ export function AvToolsApplication() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[inspectedClipAsset, project.id, runPlan]
|
[inspectedClipAsset, project.id, runPlan, runWorkflow]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContactSheet = useCallback(
|
const handleContactSheet = useCallback(
|
||||||
@@ -3012,22 +3128,75 @@ export function AvToolsApplication() {
|
|||||||
if (!asset || duration === undefined) {
|
if (!asset || duration === undefined) {
|
||||||
throw new Error('Select an attached, inspected video source first.');
|
throw new Error('Select an attached, inspected video source first.');
|
||||||
}
|
}
|
||||||
const plan = buildContactSheetPlan({
|
const source = {
|
||||||
jobId: crypto.randomUUID(),
|
id: asset.id,
|
||||||
source: {
|
sourceIndex: 0,
|
||||||
id: asset.id,
|
fileName: asset.file.name,
|
||||||
sourceIndex: 0,
|
};
|
||||||
fileName: asset.file.name,
|
const labelsRequested = request.timestampLabels || request.sourceLabel;
|
||||||
},
|
const font = labelsRequested
|
||||||
durationSeconds: duration,
|
? await loadBundledContactSheetFont()
|
||||||
...request,
|
: undefined;
|
||||||
drawtextAvailable: false,
|
const buildSheet = (
|
||||||
});
|
sceneFrames?: ReturnType<typeof parseSceneChangeFrames>
|
||||||
const execution = await runPlan(
|
) =>
|
||||||
plan,
|
buildContactSheetPlan({
|
||||||
[asset.file],
|
jobId: crypto.randomUUID(),
|
||||||
'Create contact sheet'
|
source,
|
||||||
);
|
durationSeconds: duration,
|
||||||
|
...request,
|
||||||
|
...(sceneFrames
|
||||||
|
? { frameCount: sceneFrames.length, sceneFrames }
|
||||||
|
: {}),
|
||||||
|
drawtextAvailable: Boolean(font),
|
||||||
|
...(font
|
||||||
|
? {
|
||||||
|
font: {
|
||||||
|
id: 'bundled-contact-sheet-font',
|
||||||
|
sourceIndex: 1,
|
||||||
|
fileName: font.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const inputs = font ? [asset.file, font] : [asset.file];
|
||||||
|
const sceneSelection =
|
||||||
|
request.selection.type === 'scene-changes'
|
||||||
|
? request.selection
|
||||||
|
: undefined;
|
||||||
|
const execution = sceneSelection
|
||||||
|
? await runWorkflow(
|
||||||
|
'Create scene-change contact sheet',
|
||||||
|
2,
|
||||||
|
async (executeStep) => {
|
||||||
|
const analysis = await executeStep(
|
||||||
|
buildSceneChangeAnalysisPlan({
|
||||||
|
jobId: crypto.randomUUID(),
|
||||||
|
source,
|
||||||
|
durationSeconds: duration,
|
||||||
|
threshold: sceneSelection.threshold,
|
||||||
|
maxFrames: request.frameCount,
|
||||||
|
}),
|
||||||
|
[asset.file],
|
||||||
|
'Analyze scene changes'
|
||||||
|
);
|
||||||
|
const frames = parseSceneChangeFrames(analysis.logText).slice(
|
||||||
|
0,
|
||||||
|
request.frameCount
|
||||||
|
);
|
||||||
|
if (frames.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return executeStep(
|
||||||
|
buildSheet(frames),
|
||||||
|
inputs,
|
||||||
|
'Render scene-change contact sheet'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: await runPlan(buildSheet(), inputs, 'Create contact sheet');
|
||||||
const output = execution.results[0];
|
const output = execution.results[0];
|
||||||
if (!output) throw new Error('The contact sheet was not returned.');
|
if (!output) throw new Error('The contact sheet was not returned.');
|
||||||
try {
|
try {
|
||||||
@@ -3045,7 +3214,7 @@ export function AvToolsApplication() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[inspectedClipAsset, project.id, runPlan]
|
[inspectedClipAsset, project.id, runPlan, runWorkflow]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMetadataAction = useCallback(
|
const handleMetadataAction = useCallback(
|
||||||
@@ -3413,7 +3582,8 @@ export function AvToolsApplication() {
|
|||||||
status: 'unavailable' as const,
|
status: 'unavailable' as const,
|
||||||
reasons: ['Initialize the engine to verify this preset.'],
|
reasons: ['Initialize the engine to verify this preset.'],
|
||||||
};
|
};
|
||||||
const opusBlocked = preset.audio?.codec === 'libopus';
|
const opusBlocked =
|
||||||
|
preset.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
||||||
return {
|
return {
|
||||||
id: preset.id,
|
id: preset.id,
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
@@ -3423,7 +3593,7 @@ export function AvToolsApplication() {
|
|||||||
...(availability.status === 'unavailable' || opusBlocked
|
...(availability.status === 'unavailable' || opusBlocked
|
||||||
? {
|
? {
|
||||||
disabledReason: opusBlocked
|
disabledReason: opusBlocked
|
||||||
? 'Opus output remains disabled until the pinned core passes its non-empty ST/MT regression smoke.'
|
? 'Opus output is disabled until the reviewed core passes the non-empty ST/MT regression encode and re-probe gate.'
|
||||||
: availability.reasons.join(' '),
|
: availability.reasons.join(' '),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -3481,6 +3651,7 @@ export function AvToolsApplication() {
|
|||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
hasVideo: false,
|
hasVideo: false,
|
||||||
hasSubtitles: false,
|
hasSubtitles: false,
|
||||||
|
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const unsupportedStreams = asset.probe.streams.filter(
|
const unsupportedStreams = asset.probe.streams.filter(
|
||||||
@@ -3517,6 +3688,7 @@ export function AvToolsApplication() {
|
|||||||
return {
|
return {
|
||||||
id: clip.id,
|
id: clip.id,
|
||||||
label,
|
label,
|
||||||
|
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
|
||||||
hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'),
|
hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'),
|
||||||
hasVideo: asset.probe.streams.some(
|
hasVideo: asset.probe.streams.some(
|
||||||
(stream) =>
|
(stream) =>
|
||||||
@@ -3622,10 +3794,12 @@ export function AvToolsApplication() {
|
|||||||
filters: ['fps', 'scale', 'tile'],
|
filters: ['fps', 'scale', 'tile'],
|
||||||
}),
|
}),
|
||||||
'contact-sheet-labels': {
|
'contact-sheet-labels': {
|
||||||
available: false,
|
...capabilityStatus({ filters: ['drawtext'] }),
|
||||||
reason:
|
|
||||||
'No reviewed local font is bundled, so labels are omitted instead of fetching a remote font.',
|
|
||||||
},
|
},
|
||||||
|
'scene-change-analysis': capabilityStatus({
|
||||||
|
muxers: ['null'],
|
||||||
|
filters: ['select', 'showinfo'],
|
||||||
|
}),
|
||||||
'metadata-write': sourceMuxer
|
'metadata-write': sourceMuxer
|
||||||
? capabilityStatus({ muxers: [sourceMuxer] })
|
? capabilityStatus({ muxers: [sourceMuxer] })
|
||||||
: { available: false, reason: 'Select a source first.' },
|
: { available: false, reason: 'Select a source first.' },
|
||||||
@@ -3678,6 +3852,9 @@ export function AvToolsApplication() {
|
|||||||
},
|
},
|
||||||
anyVideoPreset ? undefined : 'No verified video preset is available.'
|
anyVideoPreset ? undefined : 'No verified video preset is available.'
|
||||||
),
|
),
|
||||||
|
'concat-crossfade': capabilityStatus({
|
||||||
|
filters: ['xfade', ...(structuralHasAnyAudio ? ['acrossfade'] : [])],
|
||||||
|
}),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const advancedSource = inspectedClipAsset?.probe
|
const advancedSource = inspectedClipAsset?.probe
|
||||||
|
|||||||
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';
|
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
|
||||||
const MAX_CONCAT_SOURCES = 100;
|
const MAX_CONCAT_SOURCES = 100;
|
||||||
|
export const MIN_CROSSFADE_SECONDS = 0.04;
|
||||||
|
export const MAX_CROSSFADE_SECONDS = 10;
|
||||||
|
|
||||||
export interface FastConcatOptions {
|
export interface FastConcatOptions {
|
||||||
readonly jobId: string;
|
readonly jobId: string;
|
||||||
@@ -59,6 +61,11 @@ export interface NormalizedConcatOptions extends FastConcatOptions {
|
|||||||
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
|
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
|
||||||
readonly missingAudioPolicy: MissingAudioPolicy;
|
readonly missingAudioPolicy: MissingAudioPolicy;
|
||||||
readonly subtitlePolicy?: 'drop-all' | 'reject';
|
readonly subtitlePolicy?: 'drop-all' | 'reject';
|
||||||
|
/**
|
||||||
|
* Uniform overlap at every edit. Zero/undefined keeps hard cuts. The command
|
||||||
|
* layer bounds both the transition and the available material around edits.
|
||||||
|
*/
|
||||||
|
readonly crossfadeSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConcatCompatibility(
|
export function validateConcatCompatibility(
|
||||||
@@ -334,6 +341,8 @@ export function buildNormalizedConcatPlan(
|
|||||||
}
|
}
|
||||||
const includeAudio =
|
const includeAudio =
|
||||||
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
|
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
|
||||||
|
const crossfadeSeconds = options.crossfadeSeconds ?? 0;
|
||||||
|
validateCrossfade(crossfadeSeconds, ranges);
|
||||||
const inputs = options.sources.map((source) =>
|
const inputs = options.sources.map((source) =>
|
||||||
createPlannedInput(options.jobId, source)
|
createPlannedInput(options.jobId, source)
|
||||||
);
|
);
|
||||||
@@ -360,12 +369,35 @@ export function buildNormalizedConcatPlan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const labels = options.sources
|
if (crossfadeSeconds > 0) {
|
||||||
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
|
let videoLabel = 'v0';
|
||||||
.join('');
|
let audioLabel = 'a0';
|
||||||
graphParts.push(
|
let effectiveDuration = ranges[0]?.durationSeconds ?? 0;
|
||||||
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
|
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]');
|
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
|
||||||
if (includeAudio) {
|
if (includeAudio) {
|
||||||
args.push('-map', '[aout]');
|
args.push('-map', '[aout]');
|
||||||
@@ -397,10 +429,9 @@ export function buildNormalizedConcatPlan(
|
|||||||
temporaryFiles: [],
|
temporaryFiles: [],
|
||||||
args,
|
args,
|
||||||
outputs: [output],
|
outputs: [output],
|
||||||
expectedDurationSeconds: ranges.reduce(
|
expectedDurationSeconds:
|
||||||
(sum, range) => sum + range.durationSeconds,
|
ranges.reduce((sum, range) => sum + range.durationSeconds, 0) -
|
||||||
0
|
crossfadeSeconds * (ranges.length - 1),
|
||||||
),
|
|
||||||
requiredCapabilities: mergeRequirements(presetRequirements, {
|
requiredCapabilities: mergeRequirements(presetRequirements, {
|
||||||
muxers: [options.targetMuxer],
|
muxers: [options.targetMuxer],
|
||||||
filters: [
|
filters: [
|
||||||
@@ -411,7 +442,8 @@ export function buildNormalizedConcatPlan(
|
|||||||
'setsar',
|
'setsar',
|
||||||
'fps',
|
'fps',
|
||||||
'format',
|
'format',
|
||||||
'concat',
|
...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']),
|
||||||
|
...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []),
|
||||||
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
|
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
|
||||||
...(mixedAudioPresence &&
|
...(mixedAudioPresence &&
|
||||||
options.missingAudioPolicy === 'insert-silence'
|
options.missingAudioPolicy === 'insert-silence'
|
||||||
@@ -423,7 +455,9 @@ export function buildNormalizedConcatPlan(
|
|||||||
diagnostic(
|
diagnostic(
|
||||||
'normalized-concat',
|
'normalized-concat',
|
||||||
'info',
|
'info',
|
||||||
'Clips are normalized to common video and audio properties, then re-encoded.'
|
crossfadeSeconds > 0
|
||||||
|
? `Clips are normalized and joined with a ${normalizeSeconds(crossfadeSeconds)} second audio/video crossfade at every edit.`
|
||||||
|
: 'Clips are normalized to common video and audio properties, then re-encoded.'
|
||||||
),
|
),
|
||||||
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
|
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
|
||||||
? [
|
? [
|
||||||
@@ -447,6 +481,34 @@ export function buildNormalizedConcatPlan(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateCrossfade(
|
||||||
|
crossfadeSeconds: number,
|
||||||
|
ranges: readonly { readonly durationSeconds: number }[]
|
||||||
|
): void {
|
||||||
|
if (!Number.isFinite(crossfadeSeconds) || crossfadeSeconds < 0) {
|
||||||
|
throw new RangeError('Crossfade duration must be finite and nonnegative');
|
||||||
|
}
|
||||||
|
if (crossfadeSeconds === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
crossfadeSeconds < MIN_CROSSFADE_SECONDS ||
|
||||||
|
crossfadeSeconds > MAX_CROSSFADE_SECONDS
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Crossfade duration must be ${MIN_CROSSFADE_SECONDS}–${MAX_CROSSFADE_SECONDS} seconds`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [index, range] of ranges.entries()) {
|
||||||
|
const required = index === 0 || index === ranges.length - 1 ? 1 : 2;
|
||||||
|
if (range.durationSeconds < crossfadeSeconds * required) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Clip ${index + 1} needs at least ${normalizeSeconds(crossfadeSeconds * required)} seconds for the requested crossfade material`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function comparableKeys(
|
function comparableKeys(
|
||||||
kind: ConcatStreamDescription['kind']
|
kind: ConcatStreamDescription['kind']
|
||||||
): readonly (keyof ConcatStreamDescription)[] {
|
): readonly (keyof ConcatStreamDescription)[] {
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
|
|||||||
|
|
||||||
export type ImageFormat = 'png' | 'jpeg' | 'webp';
|
export type ImageFormat = 'png' | 'jpeg' | 'webp';
|
||||||
|
|
||||||
|
export const MAX_SCENE_CHANGE_FRAMES = 100;
|
||||||
|
export const MIN_SCENE_CHANGE_THRESHOLD = 0.01;
|
||||||
|
export const MAX_SCENE_CHANGE_THRESHOLD = 0.95;
|
||||||
|
|
||||||
|
export interface SceneChangeFrame {
|
||||||
|
/** Exact source presentation timestamp in the source video time base. */
|
||||||
|
readonly pts: number;
|
||||||
|
readonly timeSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThumbnailSize {
|
export interface ThumbnailSize {
|
||||||
readonly width?: number;
|
readonly width?: number;
|
||||||
readonly height?: number;
|
readonly height?: number;
|
||||||
@@ -41,6 +51,21 @@ export interface ThumbnailSeriesOptions {
|
|||||||
readonly quality?: number;
|
readonly quality?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SceneChangeAnalysisOptions {
|
||||||
|
readonly jobId: string;
|
||||||
|
readonly source: SourceReference;
|
||||||
|
readonly durationSeconds: number;
|
||||||
|
readonly threshold: number;
|
||||||
|
readonly maxFrames: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneThumbnailSeriesOptions extends Omit<
|
||||||
|
ThumbnailSeriesOptions,
|
||||||
|
'timesSeconds' | 'intervalSeconds' | 'count'
|
||||||
|
> {
|
||||||
|
readonly frames: readonly SceneChangeFrame[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContactSheetOptions {
|
export interface ContactSheetOptions {
|
||||||
readonly jobId: string;
|
readonly jobId: string;
|
||||||
readonly source: SourceReference;
|
readonly source: SourceReference;
|
||||||
@@ -57,6 +82,10 @@ export interface ContactSheetOptions {
|
|||||||
readonly format: ImageFormat;
|
readonly format: ImageFormat;
|
||||||
readonly quality?: number;
|
readonly quality?: number;
|
||||||
readonly drawtextAvailable?: boolean;
|
readonly drawtextAvailable?: boolean;
|
||||||
|
/** Reviewed local font mounted as a second, read-only job input. */
|
||||||
|
readonly font?: SourceReference;
|
||||||
|
/** Exact frames returned by a preceding bounded scene-change analysis. */
|
||||||
|
readonly sceneFrames?: readonly SceneChangeFrame[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSingleThumbnailPlan(
|
export function buildSingleThumbnailPlan(
|
||||||
@@ -164,6 +193,148 @@ export function buildThumbnailSeriesPlan(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSceneChangeAnalysisPlan(
|
||||||
|
options: SceneChangeAnalysisOptions
|
||||||
|
): FFmpegCommandPlan {
|
||||||
|
validateSceneAnalysisOptions(options);
|
||||||
|
const input = createPlannedInput(options.jobId, options.source);
|
||||||
|
return freezeCommandPlan({
|
||||||
|
id: `${options.jobId}:scene-change-analysis`,
|
||||||
|
operation: 'scene-change-analysis',
|
||||||
|
inputs: [input],
|
||||||
|
temporaryFiles: [],
|
||||||
|
args: [
|
||||||
|
'-i',
|
||||||
|
input.path,
|
||||||
|
'-an',
|
||||||
|
'-vf',
|
||||||
|
`select='gt(scene\\,${normalizedSceneThreshold(options.threshold)})',showinfo`,
|
||||||
|
'-frames:v',
|
||||||
|
String(options.maxFrames),
|
||||||
|
'-f',
|
||||||
|
'null',
|
||||||
|
'-',
|
||||||
|
],
|
||||||
|
outputs: [],
|
||||||
|
expectedDurationSeconds: options.durationSeconds,
|
||||||
|
requiredCapabilities: {
|
||||||
|
muxers: ['null'],
|
||||||
|
filters: ['select', 'showinfo'],
|
||||||
|
},
|
||||||
|
diagnostics: [
|
||||||
|
diagnostic(
|
||||||
|
'bounded-scene-analysis',
|
||||||
|
'info',
|
||||||
|
`Scene analysis stops after ${options.maxFrames} qualifying frames and retains no source media.`
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSceneChangeFrames(
|
||||||
|
logText: string
|
||||||
|
): readonly SceneChangeFrame[] {
|
||||||
|
const frames: SceneChangeFrame[] = [];
|
||||||
|
const seenPts = new Set<number>();
|
||||||
|
const linePattern =
|
||||||
|
/^[^\n]*\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/gimu;
|
||||||
|
for (const match of logText.matchAll(linePattern)) {
|
||||||
|
const pts = Number(match[1]);
|
||||||
|
const timeSeconds = Number(match[2]);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(pts) ||
|
||||||
|
pts < 0 ||
|
||||||
|
!Number.isFinite(timeSeconds) ||
|
||||||
|
timeSeconds < 0 ||
|
||||||
|
seenPts.has(pts)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenPts.add(pts);
|
||||||
|
frames.push({ pts, timeSeconds });
|
||||||
|
if (frames.length >= MAX_SCENE_CHANGE_FRAMES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze(
|
||||||
|
frames
|
||||||
|
.sort((left, right) => left.timeSeconds - right.timeSeconds)
|
||||||
|
.map((frame) => Object.freeze({ ...frame }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSceneThumbnailSeriesPlan(
|
||||||
|
options: SceneThumbnailSeriesOptions
|
||||||
|
): FFmpegCommandPlan {
|
||||||
|
const frames = validateSceneFrames(
|
||||||
|
options.frames,
|
||||||
|
options.durationSeconds,
|
||||||
|
'Thumbnail series'
|
||||||
|
);
|
||||||
|
const input = createPlannedInput(options.jobId, options.source);
|
||||||
|
const extension = extensionForImageFormat(options.format);
|
||||||
|
const outputs = frames.map((frame, index) =>
|
||||||
|
createPlannedOutput(options.jobId, {
|
||||||
|
id: `thumbnail-${index + 1}`,
|
||||||
|
baseName: options.source.fileName,
|
||||||
|
operation: 'thumbnail',
|
||||||
|
suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(frame.timeSeconds * 1000)}ms`,
|
||||||
|
extension,
|
||||||
|
role: 'thumbnail',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const size = scaleFilter(options.size);
|
||||||
|
const selected = frames.map((frame) => `eq(pts\\,${frame.pts})`).join('+');
|
||||||
|
const splitOutputs = frames
|
||||||
|
.map((_, index) => `[scene-source-${index}]`)
|
||||||
|
.join('');
|
||||||
|
const filtergraph = [
|
||||||
|
`[0:v]select='${selected}',split=${frames.length}${splitOutputs}`,
|
||||||
|
...frames.map((_, index) => {
|
||||||
|
const filters = [
|
||||||
|
`select='eq(n\\,${index})'`,
|
||||||
|
'setpts=N/FRAME_RATE/TB',
|
||||||
|
...(size ? [size] : []),
|
||||||
|
];
|
||||||
|
return `[scene-source-${index}]${filters.join(',')}[thumb-${index}]`;
|
||||||
|
}),
|
||||||
|
].join(';');
|
||||||
|
const outputArguments = outputs.flatMap((output, index) => [
|
||||||
|
'-map',
|
||||||
|
`[thumb-${index}]`,
|
||||||
|
'-frames:v',
|
||||||
|
'1',
|
||||||
|
...imageQualityArguments(options.format, options.quality),
|
||||||
|
output.path,
|
||||||
|
]);
|
||||||
|
return freezeCommandPlan({
|
||||||
|
id: `${options.jobId}:scene-thumbnail-series`,
|
||||||
|
operation: 'thumbnail-series',
|
||||||
|
inputs: [input],
|
||||||
|
temporaryFiles: [],
|
||||||
|
args: [
|
||||||
|
'-i',
|
||||||
|
input.path,
|
||||||
|
'-filter_complex',
|
||||||
|
filtergraph,
|
||||||
|
...outputArguments,
|
||||||
|
],
|
||||||
|
outputs,
|
||||||
|
expectedDurationSeconds: options.durationSeconds,
|
||||||
|
requiredCapabilities: {
|
||||||
|
...imageRequirements(options.format),
|
||||||
|
filters: ['select', 'split', 'setpts', ...(size ? ['scale'] : [])],
|
||||||
|
},
|
||||||
|
diagnostics: [
|
||||||
|
diagnostic(
|
||||||
|
'scene-change-selection',
|
||||||
|
'info',
|
||||||
|
`${frames.length} frames are selected by exact presentation timestamp from a preceding bounded scene analysis.`
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveThumbnailTimes(
|
export function resolveThumbnailTimes(
|
||||||
options: Pick<
|
options: Pick<
|
||||||
ThumbnailSeriesOptions,
|
ThumbnailSeriesOptions,
|
||||||
@@ -271,7 +442,22 @@ export function buildContactSheetPlan(
|
|||||||
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
|
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
|
||||||
throw new TypeError('Background must be a named colour or #RRGGBB');
|
throw new TypeError('Background must be a named colour or #RRGGBB');
|
||||||
}
|
}
|
||||||
|
const sceneFrames = options.sceneFrames
|
||||||
|
? validateSceneFrames(
|
||||||
|
options.sceneFrames,
|
||||||
|
options.durationSeconds,
|
||||||
|
'Contact sheet'
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (sceneFrames && sceneFrames.length !== options.frameCount) {
|
||||||
|
throw new RangeError(
|
||||||
|
'Contact sheet frame count must match the analyzed scene selection'
|
||||||
|
);
|
||||||
|
}
|
||||||
const input = createPlannedInput(options.jobId, options.source);
|
const input = createPlannedInput(options.jobId, options.source);
|
||||||
|
const fontInput = options.font
|
||||||
|
? createPlannedInput(options.jobId, options.font, 'font')
|
||||||
|
: undefined;
|
||||||
const output = createPlannedOutput(options.jobId, {
|
const output = createPlannedOutput(options.jobId, {
|
||||||
baseName: options.source.fileName,
|
baseName: options.source.fileName,
|
||||||
operation: 'contact-sheet',
|
operation: 'contact-sheet',
|
||||||
@@ -280,14 +466,20 @@ export function buildContactSheetPlan(
|
|||||||
});
|
});
|
||||||
const fps = options.frameCount / options.durationSeconds;
|
const fps = options.frameCount / options.durationSeconds;
|
||||||
const filters = [
|
const filters = [
|
||||||
`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`,
|
...(sceneFrames
|
||||||
|
? [
|
||||||
|
`select='${sceneFrames
|
||||||
|
.map((frame) => `eq(pts\\,${frame.pts})`)
|
||||||
|
.join('+')}'`,
|
||||||
|
]
|
||||||
|
: [`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`]),
|
||||||
options.maintainAspect === false
|
options.maintainAspect === false
|
||||||
? `scale=${options.cellWidth}:${options.cellWidth}`
|
? `scale=${options.cellWidth}:${options.cellWidth}`
|
||||||
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
|
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
|
||||||
];
|
];
|
||||||
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
|
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
|
||||||
const diagnostics = [];
|
const diagnostics = [];
|
||||||
if (labelsEnabled && !options.drawtextAvailable) {
|
if (labelsEnabled && (!options.drawtextAvailable || !fontInput)) {
|
||||||
labelsEnabled = false;
|
labelsEnabled = false;
|
||||||
diagnostics.push(
|
diagnostics.push(
|
||||||
diagnostic(
|
diagnostic(
|
||||||
@@ -302,16 +494,19 @@ export function buildContactSheetPlan(
|
|||||||
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
|
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
|
||||||
: '%{pts\\:hms}';
|
: '%{pts\\:hms}';
|
||||||
filters.push(
|
filters.push(
|
||||||
`drawtext=text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
|
`drawtext=fontfile='${fontInput?.path ?? ''}':text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (sceneFrames) {
|
||||||
|
filters.push('setpts=N/FRAME_RATE/TB');
|
||||||
|
}
|
||||||
filters.push(
|
filters.push(
|
||||||
`tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}`
|
`tile=${options.columns}x${options.rows}:nb_frames=${options.frameCount}:padding=${spacing}:margin=${spacing}:color=${background}`
|
||||||
);
|
);
|
||||||
return freezeCommandPlan({
|
return freezeCommandPlan({
|
||||||
id: `${options.jobId}:contact-sheet`,
|
id: `${options.jobId}:contact-sheet`,
|
||||||
operation: 'contact-sheet',
|
operation: 'contact-sheet',
|
||||||
inputs: [input],
|
inputs: [input, ...(fontInput ? [fontInput] : [])],
|
||||||
temporaryFiles: [],
|
temporaryFiles: [],
|
||||||
args: [
|
args: [
|
||||||
'-i',
|
'-i',
|
||||||
@@ -327,12 +522,101 @@ export function buildContactSheetPlan(
|
|||||||
expectedDurationSeconds: options.durationSeconds,
|
expectedDurationSeconds: options.durationSeconds,
|
||||||
requiredCapabilities: {
|
requiredCapabilities: {
|
||||||
...imageRequirements(options.format),
|
...imageRequirements(options.format),
|
||||||
filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])],
|
filters: [
|
||||||
|
sceneFrames ? 'select' : 'fps',
|
||||||
|
'scale',
|
||||||
|
...(sceneFrames ? ['setpts'] : []),
|
||||||
|
'tile',
|
||||||
|
...(labelsEnabled ? ['drawtext'] : []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
diagnostics,
|
diagnostics: [
|
||||||
|
...diagnostics,
|
||||||
|
...(sceneFrames
|
||||||
|
? [
|
||||||
|
diagnostic(
|
||||||
|
'scene-change-selection',
|
||||||
|
'info',
|
||||||
|
`${sceneFrames.length} analyzed scene changes populate this contact sheet.`
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSceneAnalysisOptions(
|
||||||
|
options: Pick<
|
||||||
|
SceneChangeAnalysisOptions,
|
||||||
|
'durationSeconds' | 'threshold' | 'maxFrames'
|
||||||
|
>
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(options.durationSeconds) ||
|
||||||
|
options.durationSeconds <= 0
|
||||||
|
) {
|
||||||
|
throw new RangeError('Scene analysis source duration must be positive');
|
||||||
|
}
|
||||||
|
normalizedSceneThreshold(options.threshold);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(options.maxFrames) ||
|
||||||
|
options.maxFrames < 1 ||
|
||||||
|
options.maxFrames > MAX_SCENE_CHANGE_FRAMES
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Scene analysis frame limit must be an integer from 1 to ${MAX_SCENE_CHANGE_FRAMES}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSceneThreshold(value: number): string {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(value) ||
|
||||||
|
value < MIN_SCENE_CHANGE_THRESHOLD ||
|
||||||
|
value > MAX_SCENE_CHANGE_THRESHOLD
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Scene threshold must be ${MIN_SCENE_CHANGE_THRESHOLD}–${MAX_SCENE_CHANGE_THRESHOLD}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return String(Number(value.toFixed(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSceneFrames(
|
||||||
|
frames: readonly SceneChangeFrame[],
|
||||||
|
durationSeconds: number,
|
||||||
|
label: string
|
||||||
|
): readonly SceneChangeFrame[] {
|
||||||
|
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||||
|
throw new RangeError(`${label} source duration must be positive`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
frames.length < 1 ||
|
||||||
|
frames.length > MAX_SCENE_CHANGE_FRAMES ||
|
||||||
|
frames.some(
|
||||||
|
(frame) =>
|
||||||
|
!Number.isSafeInteger(frame.pts) ||
|
||||||
|
frame.pts < 0 ||
|
||||||
|
!Number.isFinite(frame.timeSeconds) ||
|
||||||
|
frame.timeSeconds < 0 ||
|
||||||
|
frame.timeSeconds >= durationSeconds
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`${label} scene frames must be bounded, finite points inside the source`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const uniquePts = new Set(frames.map((frame) => frame.pts));
|
||||||
|
if (uniquePts.size !== frames.length) {
|
||||||
|
throw new RangeError(`${label} scene frames must have unique timestamps`);
|
||||||
|
}
|
||||||
|
return Object.freeze(
|
||||||
|
[...frames]
|
||||||
|
.sort((left, right) => left.timeSeconds - right.timeSeconds)
|
||||||
|
.map((frame) => Object.freeze({ ...frame }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
|
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
|
||||||
const filter = scaleFilter(size);
|
const filter = scaleFilter(size);
|
||||||
return filter ? ['-vf', filter] : [];
|
return filter ? ['-vf', filter] : [];
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import {
|
|||||||
GENERATED_OUTPUT_LIMIT_EXPLANATION,
|
GENERATED_OUTPUT_LIMIT_EXPLANATION,
|
||||||
MAX_GENERATED_OUTPUT_FILES,
|
MAX_GENERATED_OUTPUT_FILES,
|
||||||
} from '../limits';
|
} from '../limits';
|
||||||
|
import {
|
||||||
|
MAX_SCENE_CHANGE_FRAMES,
|
||||||
|
MAX_SCENE_CHANGE_THRESHOLD,
|
||||||
|
MIN_SCENE_CHANGE_THRESHOLD,
|
||||||
|
} from '../commands/thumbnails';
|
||||||
import {
|
import {
|
||||||
parseUserPresetJson,
|
parseUserPresetJson,
|
||||||
serializeUserPreset,
|
serializeUserPreset,
|
||||||
@@ -58,6 +63,7 @@ export type AdvancedCapabilityKey =
|
|||||||
| 'thumbnail-series'
|
| 'thumbnail-series'
|
||||||
| 'contact-sheet'
|
| 'contact-sheet'
|
||||||
| 'contact-sheet-labels'
|
| 'contact-sheet-labels'
|
||||||
|
| 'scene-change-analysis'
|
||||||
| 'metadata-write'
|
| 'metadata-write'
|
||||||
| 'chapter-write'
|
| 'chapter-write'
|
||||||
| 'subtitle-extract'
|
| 'subtitle-extract'
|
||||||
@@ -181,6 +187,11 @@ export type AdvancedAudioRequest =
|
|||||||
export type ThumbnailDistribution =
|
export type ThumbnailDistribution =
|
||||||
| { readonly type: 'evenly-spaced'; readonly count: number }
|
| { readonly type: 'evenly-spaced'; readonly count: number }
|
||||||
| { readonly type: 'fixed-interval'; readonly intervalSeconds: number }
|
| { readonly type: 'fixed-interval'; readonly intervalSeconds: number }
|
||||||
|
| {
|
||||||
|
readonly type: 'scene-changes';
|
||||||
|
readonly count: number;
|
||||||
|
readonly threshold: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: 'chapter-starts' | 'split-markers';
|
readonly type: 'chapter-starts' | 'split-markers';
|
||||||
readonly timesSeconds: readonly number[];
|
readonly timesSeconds: readonly number[];
|
||||||
@@ -206,6 +217,9 @@ export interface ContactSheetRequest {
|
|||||||
readonly sourceLabel: boolean;
|
readonly sourceLabel: boolean;
|
||||||
readonly format: ImageFormat;
|
readonly format: ImageFormat;
|
||||||
readonly quality: number;
|
readonly quality: number;
|
||||||
|
readonly selection:
|
||||||
|
| { readonly type: 'evenly-spaced' }
|
||||||
|
| { readonly type: 'scene-changes'; readonly threshold: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MetadataExecutionRequest =
|
export type MetadataExecutionRequest =
|
||||||
@@ -556,6 +570,7 @@ export function AdvancedOperations({
|
|||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
formatAvailability={imageFormatAvailability}
|
formatAvailability={imageFormatAvailability}
|
||||||
labelAvailability={availability['contact-sheet-labels']}
|
labelAvailability={availability['contact-sheet-labels']}
|
||||||
|
sceneAvailability={availability['scene-change-analysis']}
|
||||||
onThumbnailSeries={onThumbnailSeries}
|
onThumbnailSeries={onThumbnailSeries}
|
||||||
onContactSheet={onContactSheet}
|
onContactSheet={onContactSheet}
|
||||||
action={actionContext}
|
action={actionContext}
|
||||||
@@ -1442,6 +1457,7 @@ function ImagePanel({
|
|||||||
chapters,
|
chapters,
|
||||||
formatAvailability,
|
formatAvailability,
|
||||||
labelAvailability,
|
labelAvailability,
|
||||||
|
sceneAvailability,
|
||||||
onThumbnailSeries,
|
onThumbnailSeries,
|
||||||
onContactSheet,
|
onContactSheet,
|
||||||
action,
|
action,
|
||||||
@@ -1453,6 +1469,7 @@ function ImagePanel({
|
|||||||
Readonly<Record<ImageFormat, AdvancedCapabilityStatus>>
|
Readonly<Record<ImageFormat, AdvancedCapabilityStatus>>
|
||||||
>;
|
>;
|
||||||
readonly labelAvailability?: AdvancedCapabilityStatus;
|
readonly labelAvailability?: AdvancedCapabilityStatus;
|
||||||
|
readonly sceneAvailability?: AdvancedCapabilityStatus;
|
||||||
readonly onThumbnailSeries?: RequestHandler<ThumbnailSeriesRequest>;
|
readonly onThumbnailSeries?: RequestHandler<ThumbnailSeriesRequest>;
|
||||||
readonly onContactSheet?: RequestHandler<ContactSheetRequest>;
|
readonly onContactSheet?: RequestHandler<ContactSheetRequest>;
|
||||||
readonly action: ActionContext;
|
readonly action: ActionContext;
|
||||||
@@ -1474,6 +1491,10 @@ function ImagePanel({
|
|||||||
const [maintainAspect, setMaintainAspect] = useState(true);
|
const [maintainAspect, setMaintainAspect] = useState(true);
|
||||||
const [timestampLabels, setTimestampLabels] = useState(false);
|
const [timestampLabels, setTimestampLabels] = useState(false);
|
||||||
const [sourceLabel, setSourceLabel] = useState(false);
|
const [sourceLabel, setSourceLabel] = useState(false);
|
||||||
|
const [sceneThreshold, setSceneThreshold] = useState('0.35');
|
||||||
|
const [sheetSelection, setSheetSelection] = useState<
|
||||||
|
'evenly-spaced' | 'scene-changes'
|
||||||
|
>('evenly-spaced');
|
||||||
|
|
||||||
const videoReason = !source
|
const videoReason = !source
|
||||||
? 'Select a source first.'
|
? 'Select a source first.'
|
||||||
@@ -1521,6 +1542,29 @@ function ImagePanel({
|
|||||||
type: distribution,
|
type: distribution,
|
||||||
intervalSeconds: Number(interval),
|
intervalSeconds: Number(interval),
|
||||||
};
|
};
|
||||||
|
} else if (distribution === 'scene-changes') {
|
||||||
|
distributionReason =
|
||||||
|
integerRangeReason(
|
||||||
|
seriesCount,
|
||||||
|
1,
|
||||||
|
MAX_SCENE_CHANGE_FRAMES,
|
||||||
|
'Frame count'
|
||||||
|
) ??
|
||||||
|
numberRangeReason(
|
||||||
|
sceneThreshold,
|
||||||
|
MIN_SCENE_CHANGE_THRESHOLD,
|
||||||
|
MAX_SCENE_CHANGE_THRESHOLD,
|
||||||
|
'Scene threshold'
|
||||||
|
) ??
|
||||||
|
(sceneAvailability?.available
|
||||||
|
? undefined
|
||||||
|
: (sceneAvailability?.reason ??
|
||||||
|
'Scene-change analysis has not been verified.'));
|
||||||
|
resolvedDistribution = {
|
||||||
|
type: distribution,
|
||||||
|
count: Number(seriesCount),
|
||||||
|
threshold: Number(sceneThreshold),
|
||||||
|
};
|
||||||
} else if (distribution === 'chapter-starts') {
|
} else if (distribution === 'chapter-starts') {
|
||||||
const times = chapters.map((chapter) => chapter.startSeconds);
|
const times = chapters.map((chapter) => chapter.startSeconds);
|
||||||
distributionReason =
|
distributionReason =
|
||||||
@@ -1556,7 +1600,12 @@ function ImagePanel({
|
|||||||
'Timestamp and filename labels require verified drawtext and a safe local font.')
|
'Timestamp and filename labels require verified drawtext and a safe local font.')
|
||||||
: undefined;
|
: undefined;
|
||||||
const gridReason =
|
const gridReason =
|
||||||
integerRangeReason(sheetFrames, 1, 500, 'Frame count') ??
|
integerRangeReason(
|
||||||
|
sheetFrames,
|
||||||
|
1,
|
||||||
|
sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500,
|
||||||
|
'Frame count'
|
||||||
|
) ??
|
||||||
integerRangeReason(sheetRows, 1, 500, 'Rows') ??
|
integerRangeReason(sheetRows, 1, 500, 'Rows') ??
|
||||||
integerRangeReason(sheetColumns, 1, 500, 'Columns') ??
|
integerRangeReason(sheetColumns, 1, 500, 'Columns') ??
|
||||||
integerRangeReason(cellWidth, 32, 4096, 'Cell width') ??
|
integerRangeReason(cellWidth, 32, 4096, 'Cell width') ??
|
||||||
@@ -1571,7 +1620,19 @@ function ImagePanel({
|
|||||||
gridReason ??
|
gridReason ??
|
||||||
numberRangeReason(quality, 1, 100, 'Quality') ??
|
numberRangeReason(quality, 1, 100, 'Quality') ??
|
||||||
formatReason ??
|
formatReason ??
|
||||||
labelReason;
|
labelReason ??
|
||||||
|
(sheetSelection === 'scene-changes'
|
||||||
|
? (numberRangeReason(
|
||||||
|
sceneThreshold,
|
||||||
|
MIN_SCENE_CHANGE_THRESHOLD,
|
||||||
|
MAX_SCENE_CHANGE_THRESHOLD,
|
||||||
|
'Scene threshold'
|
||||||
|
) ??
|
||||||
|
(sceneAvailability?.available
|
||||||
|
? undefined
|
||||||
|
: (sceneAvailability?.reason ??
|
||||||
|
'Scene-change analysis has not been verified.')))
|
||||||
|
: undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="advanced-operations__section-grid">
|
<div className="advanced-operations__section-grid">
|
||||||
@@ -1597,6 +1658,12 @@ function ImagePanel({
|
|||||||
>
|
>
|
||||||
<option value="evenly-spaced">Evenly spaced</option>
|
<option value="evenly-spaced">Evenly spaced</option>
|
||||||
<option value="fixed-interval">Fixed interval</option>
|
<option value="fixed-interval">Fixed interval</option>
|
||||||
|
<option
|
||||||
|
value="scene-changes"
|
||||||
|
disabled={!sceneAvailability?.available}
|
||||||
|
>
|
||||||
|
Scene changes
|
||||||
|
</option>
|
||||||
<option value="chapter-starts" disabled={chapters.length === 0}>
|
<option value="chapter-starts" disabled={chapters.length === 0}>
|
||||||
Chapter starts
|
Chapter starts
|
||||||
</option>
|
</option>
|
||||||
@@ -1605,12 +1672,17 @@ function ImagePanel({
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{distribution === 'evenly-spaced' ? (
|
{distribution === 'evenly-spaced' ||
|
||||||
|
distribution === 'scene-changes' ? (
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Number of frames"
|
label="Number of frames"
|
||||||
value={seriesCount}
|
value={seriesCount}
|
||||||
min={1}
|
min={1}
|
||||||
max={MAX_GENERATED_OUTPUT_FILES}
|
max={
|
||||||
|
distribution === 'scene-changes'
|
||||||
|
? MAX_SCENE_CHANGE_FRAMES
|
||||||
|
: MAX_GENERATED_OUTPUT_FILES
|
||||||
|
}
|
||||||
onChange={setSeriesCount}
|
onChange={setSeriesCount}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1625,6 +1697,16 @@ function ImagePanel({
|
|||||||
onChange={setInterval}
|
onChange={setInterval}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{distribution === 'scene-changes' ? (
|
||||||
|
<NumberField
|
||||||
|
label="Scene sensitivity threshold"
|
||||||
|
value={sceneThreshold}
|
||||||
|
min={MIN_SCENE_CHANGE_THRESHOLD}
|
||||||
|
max={MAX_SCENE_CHANGE_THRESHOLD}
|
||||||
|
step={0.01}
|
||||||
|
onChange={setSceneThreshold}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ImageSettings
|
<ImageSettings
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
@@ -1673,11 +1755,32 @@ function ImagePanel({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="advanced-form-grid advanced-form-grid--three">
|
<div className="advanced-form-grid advanced-form-grid--three">
|
||||||
|
<label className="advanced-field">
|
||||||
|
<span>Frame selection</span>
|
||||||
|
<select
|
||||||
|
value={sheetSelection}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSheetSelection(
|
||||||
|
event.currentTarget.value as 'evenly-spaced' | 'scene-changes'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="evenly-spaced">Evenly spaced</option>
|
||||||
|
<option
|
||||||
|
value="scene-changes"
|
||||||
|
disabled={!sceneAvailability?.available}
|
||||||
|
>
|
||||||
|
Scene changes
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Frames"
|
label="Frames"
|
||||||
value={sheetFrames}
|
value={sheetFrames}
|
||||||
min={1}
|
min={1}
|
||||||
max={500}
|
max={
|
||||||
|
sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500
|
||||||
|
}
|
||||||
onChange={setSheetFrames}
|
onChange={setSheetFrames}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
@@ -1716,6 +1819,16 @@ function ImagePanel({
|
|||||||
onChange={setBackground}
|
onChange={setBackground}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{sheetSelection === 'scene-changes' ? (
|
||||||
|
<NumberField
|
||||||
|
label="Contact-sheet scene threshold"
|
||||||
|
value={sceneThreshold}
|
||||||
|
min={MIN_SCENE_CHANGE_THRESHOLD}
|
||||||
|
max={MAX_SCENE_CHANGE_THRESHOLD}
|
||||||
|
step={0.01}
|
||||||
|
onChange={setSceneThreshold}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="advanced-check-grid">
|
<div className="advanced-check-grid">
|
||||||
<CheckField
|
<CheckField
|
||||||
label="Maintain aspect ratio"
|
label="Maintain aspect ratio"
|
||||||
@@ -1769,6 +1882,13 @@ function ImagePanel({
|
|||||||
sourceLabel,
|
sourceLabel,
|
||||||
format,
|
format,
|
||||||
quality: Number(quality),
|
quality: Number(quality),
|
||||||
|
selection:
|
||||||
|
sheetSelection === 'scene-changes'
|
||||||
|
? {
|
||||||
|
type: 'scene-changes',
|
||||||
|
threshold: Number(sceneThreshold),
|
||||||
|
}
|
||||||
|
: { type: 'evenly-spaced' },
|
||||||
},
|
},
|
||||||
'Contact sheet added to the local queue.'
|
'Contact sheet added to the local queue.'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import applicationLicenseUrl from '../../LICENSE?url&no-inline';
|
import applicationLicenseUrl from '../../LICENSE?url&no-inline';
|
||||||
|
import dejavuFontLicenseUrl from '../../LICENSES/DEJAVU_FONTS.txt?url&no-inline';
|
||||||
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
|
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
|
||||||
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
|
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
|
||||||
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
|
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
|
||||||
@@ -7,7 +8,7 @@ import sourceRecordUrl from '../../SOURCE.md?url&no-inline';
|
|||||||
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
|
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
|
||||||
import {
|
import {
|
||||||
APP_VERSION,
|
APP_VERSION,
|
||||||
FFMPEG_CORE_VERSION,
|
FFMPEG_CORE_BUILD_ID,
|
||||||
FFMPEG_ENGINE_VERSION,
|
FFMPEG_ENGINE_VERSION,
|
||||||
FFMPEG_UTIL_VERSION,
|
FFMPEG_UTIL_VERSION,
|
||||||
FFMPEG_WRAPPER_VERSION,
|
FFMPEG_WRAPPER_VERSION,
|
||||||
@@ -101,7 +102,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>FFmpeg WASM cores</dt>
|
<dt>FFmpeg WASM cores</dt>
|
||||||
<dd>{FFMPEG_CORE_VERSION} · ST & MT</dd>
|
<dd>{FFMPEG_CORE_BUILD_ID} · ST & MT</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>ffmpeg.wasm utilities</dt>
|
<dt>ffmpeg.wasm utilities</dt>
|
||||||
@@ -156,6 +157,16 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
GPL text
|
GPL text
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
DejaVu Sans 2.37 — Bitstream Vera and Arev font terms
|
||||||
|
<a
|
||||||
|
href={dejavuFontLicenseUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Font licence
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
FFmpeg, linked codecs, Toolbox SDK, React and other runtime
|
FFmpeg, linked codecs, Toolbox SDK, React and other runtime
|
||||||
packages retain their respective terms.
|
packages retain their respective terms.
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { ImportedMediaAsset } from '../app/application-state';
|
|||||||
import {
|
import {
|
||||||
assessBrowserPlayback,
|
assessBrowserPlayback,
|
||||||
assessGeneratedResultPreview,
|
assessGeneratedResultPreview,
|
||||||
|
isAudioMimeType,
|
||||||
|
isVideoMimeType,
|
||||||
|
mimeTypeFromFileName,
|
||||||
type MediaProbe,
|
type MediaProbe,
|
||||||
} from '../media';
|
} from '../media';
|
||||||
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
|
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
|
||||||
@@ -47,15 +50,31 @@ export function MediaPreview({
|
|||||||
const mediaElement = useRef<HTMLMediaElement>(null);
|
const mediaElement = useRef<HTMLMediaElement>(null);
|
||||||
const sourceUrl = result?.objectUrl ?? asset?.objectUrl;
|
const sourceUrl = result?.objectUrl ?? asset?.objectUrl;
|
||||||
const playbackFailed = sourceUrl === failedSourceUrl;
|
const playbackFailed = sourceUrl === failedSourceUrl;
|
||||||
|
const fileMimeType = asset?.file.type;
|
||||||
|
const declaredSourceMimeType =
|
||||||
|
fileMimeType &&
|
||||||
|
(isVideoMimeType(fileMimeType) || isAudioMimeType(fileMimeType))
|
||||||
|
? fileMimeType
|
||||||
|
: asset
|
||||||
|
? mimeTypeFromFileName(asset.file.name)
|
||||||
|
: undefined;
|
||||||
const hasVideo = result
|
const hasVideo = result
|
||||||
? result.mimeType.startsWith('video/')
|
? result.mimeType.startsWith('video/')
|
||||||
: asset?.probe?.streams.some(
|
: asset?.probe
|
||||||
(stream) =>
|
? asset.probe.streams.some(
|
||||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
(stream) =>
|
||||||
) === true;
|
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||||
|
)
|
||||||
|
: declaredSourceMimeType
|
||||||
|
? isVideoMimeType(declaredSourceMimeType)
|
||||||
|
: false;
|
||||||
const hasAudio = result
|
const hasAudio = result
|
||||||
? result.mimeType.startsWith('audio/')
|
? result.mimeType.startsWith('audio/')
|
||||||
: asset?.probe?.streams.some((stream) => stream.type === 'audio') === true;
|
: asset?.probe
|
||||||
|
? asset.probe.streams.some((stream) => stream.type === 'audio')
|
||||||
|
: declaredSourceMimeType
|
||||||
|
? isAudioMimeType(declaredSourceMimeType)
|
||||||
|
: false;
|
||||||
const hasImage = result?.mimeType.startsWith('image/') === true;
|
const hasImage = result?.mimeType.startsWith('image/') === true;
|
||||||
const resultPlayback = useMemo(() => {
|
const resultPlayback = useMemo(() => {
|
||||||
if (!result) return undefined;
|
if (!result) return undefined;
|
||||||
@@ -186,7 +205,10 @@ export function MediaPreview({
|
|||||||
? resultPlayback?.previewable === false
|
? resultPlayback?.previewable === false
|
||||||
? `Download only · ${result.name}`
|
? `Download only · ${result.name}`
|
||||||
: `Generated result · ${result.name}`
|
: `Generated result · ${result.name}`
|
||||||
: (playback?.reason ?? 'Local source preview')}
|
: (playback?.reason ??
|
||||||
|
(asset?.phase === 'probing'
|
||||||
|
? 'Local source preview · Media inspection continues in the background.'
|
||||||
|
: 'Local source preview'))}
|
||||||
</span>
|
</span>
|
||||||
{needsProxy && !proxySettings ? (
|
{needsProxy && !proxySettings ? (
|
||||||
<button type="button" className="text-button" onClick={onCreateProxy}>
|
<button type="button" className="text-button" onClick={onCreateProxy}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
presetAvailability,
|
presetAvailability,
|
||||||
} from '../presets/preset-registry';
|
} from '../presets/preset-registry';
|
||||||
import { formatBytes, formatDuration } from '../app/application-state';
|
import { formatBytes, formatDuration } from '../app/application-state';
|
||||||
|
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
export type QuickOperation = 'convert' | 'remux';
|
export type QuickOperation = 'convert' | 'remux';
|
||||||
@@ -139,7 +140,8 @@ export function QuickConvert({
|
|||||||
engineState.capabilities
|
engineState.capabilities
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const opusNeedsRegressionTest = preset?.audio?.codec === 'libopus';
|
const opusBlockedByPinnedCore =
|
||||||
|
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
||||||
const presetNeedsMissingAudio =
|
const presetNeedsMissingAudio =
|
||||||
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
|
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
|
||||||
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
||||||
@@ -186,7 +188,7 @@ export function QuickConvert({
|
|||||||
? !remuxMuxerMissing && remuxErrors.length === 0
|
? !remuxMuxerMissing && remuxErrors.length === 0
|
||||||
: preset !== undefined &&
|
: preset !== undefined &&
|
||||||
availability?.status !== 'unavailable' &&
|
availability?.status !== 'unavailable' &&
|
||||||
!opusNeedsRegressionTest &&
|
!opusBlockedByPinnedCore &&
|
||||||
!presetNeedsMissingAudio);
|
!presetNeedsMissingAudio);
|
||||||
|
|
||||||
const presetOption = (entry: ExportPreset) => {
|
const presetOption = (entry: ExportPreset) => {
|
||||||
@@ -199,8 +201,8 @@ export function QuickConvert({
|
|||||||
? presetAvailability({ requirements }, engineState.capabilities)
|
? presetAvailability({ requirements }, engineState.capabilities)
|
||||||
: undefined;
|
: undefined;
|
||||||
const reason =
|
const reason =
|
||||||
entry.audio?.codec === 'libopus'
|
entry.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED
|
||||||
? 'Opus awaits a pinned-core regression test'
|
? 'the reviewed core has not passed the ST/MT Opus regression encode'
|
||||||
: entryAvailability?.status === 'unavailable'
|
: entryAvailability?.status === 'unavailable'
|
||||||
? entryAvailability.reasons.join(', ')
|
? entryAvailability.reasons.join(', ')
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -455,11 +457,11 @@ export function QuickConvert({
|
|||||||
Select at least one output stream.
|
Select at least one output stream.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{opusNeedsRegressionTest ? (
|
{opusBlockedByPinnedCore ? (
|
||||||
<p className="notice notice--warning">
|
<p className="notice notice--warning">
|
||||||
<Icon name="warning" />
|
<Icon name="warning" />
|
||||||
Opus export is held back until the pinned core passes its browser
|
Opus export is disabled until the reviewed core passes its non-empty
|
||||||
regression test.
|
encode and re-probe gate in both engine modes.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,8 @@
|
|||||||
font-weight: 740;
|
font-weight: 740;
|
||||||
}
|
}
|
||||||
|
|
||||||
.structural-field select {
|
.structural-field select,
|
||||||
|
.structural-field input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
padding: 0.48rem 0.62rem;
|
padding: 0.48rem 0.62rem;
|
||||||
@@ -402,6 +403,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.structural-field select:focus-visible,
|
.structural-field select:focus-visible,
|
||||||
|
.structural-field input:focus-visible,
|
||||||
.structural-operations input:focus-visible,
|
.structural-operations input:focus-visible,
|
||||||
.structural-operations button:focus-visible {
|
.structural-operations button:focus-visible {
|
||||||
outline: 3px solid var(--toolbox-focus);
|
outline: 3px solid var(--toolbox-focus);
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useId, useState } from 'react';
|
|||||||
import './StructuralOperations.css';
|
import './StructuralOperations.css';
|
||||||
|
|
||||||
export type StructuralCapabilityKey =
|
export type StructuralCapabilityKey =
|
||||||
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized';
|
| 'trim-fast'
|
||||||
|
| 'trim-accurate'
|
||||||
|
| 'concat-fast'
|
||||||
|
| 'concat-normalized'
|
||||||
|
| 'concat-crossfade';
|
||||||
|
|
||||||
export interface StructuralCapabilityStatus {
|
export interface StructuralCapabilityStatus {
|
||||||
readonly available: boolean;
|
readonly available: boolean;
|
||||||
@@ -35,6 +39,7 @@ export interface StructuralTimelineClip {
|
|||||||
readonly hasAudio: boolean;
|
readonly hasAudio: boolean;
|
||||||
readonly hasVideo: boolean;
|
readonly hasVideo: boolean;
|
||||||
readonly hasSubtitles: boolean;
|
readonly hasSubtitles: boolean;
|
||||||
|
readonly durationSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StructuralCompatibilityDiagnostic {
|
export interface StructuralCompatibilityDiagnostic {
|
||||||
@@ -92,6 +97,7 @@ export type StructuralConcatRequest =
|
|||||||
readonly presetId: string;
|
readonly presetId: string;
|
||||||
readonly missingAudioPolicy: MissingAudioPolicy;
|
readonly missingAudioPolicy: MissingAudioPolicy;
|
||||||
readonly subtitlePolicy: NormalizedSubtitlePolicy;
|
readonly subtitlePolicy: NormalizedSubtitlePolicy;
|
||||||
|
readonly crossfadeSeconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RequestHandler<T> = (request: T) => void | Promise<void>;
|
type RequestHandler<T> = (request: T) => void | Promise<void>;
|
||||||
@@ -135,6 +141,7 @@ export function StructuralOperations({
|
|||||||
const [subtitlePolicy, setSubtitlePolicy] = useState<
|
const [subtitlePolicy, setSubtitlePolicy] = useState<
|
||||||
NormalizedSubtitlePolicy | ''
|
NormalizedSubtitlePolicy | ''
|
||||||
>('');
|
>('');
|
||||||
|
const [crossfadeSeconds, setCrossfadeSeconds] = useState('0');
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [status, setStatus] = useState<string>();
|
const [status, setStatus] = useState<string>();
|
||||||
|
|
||||||
@@ -156,6 +163,7 @@ export function StructuralOperations({
|
|||||||
preset: concatPreset,
|
preset: concatPreset,
|
||||||
missingAudioPolicy,
|
missingAudioPolicy,
|
||||||
subtitlePolicy,
|
subtitlePolicy,
|
||||||
|
crossfadeSeconds,
|
||||||
availability,
|
availability,
|
||||||
connected: Boolean(onConcat),
|
connected: Boolean(onConcat),
|
||||||
busy: busy || pending,
|
busy: busy || pending,
|
||||||
@@ -445,6 +453,30 @@ export function StructuralOperations({
|
|||||||
)}
|
)}
|
||||||
onChange={setSubtitlePolicy}
|
onChange={setSubtitlePolicy}
|
||||||
/>
|
/>
|
||||||
|
<label
|
||||||
|
className="structural-field"
|
||||||
|
htmlFor={`${componentId}-crossfade`}
|
||||||
|
>
|
||||||
|
<span>Crossfade duration</span>
|
||||||
|
<input
|
||||||
|
id={`${componentId}-crossfade`}
|
||||||
|
type="number"
|
||||||
|
aria-label="Crossfade duration"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.04"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={crossfadeSeconds}
|
||||||
|
disabled={busy || pending}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCrossfadeSeconds(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
0 keeps hard cuts. A value from 0.04–10 seconds overlaps video
|
||||||
|
and, when retained, audio at every edit.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -488,6 +520,7 @@ export function StructuralOperations({
|
|||||||
presetId: concatPreset.id,
|
presetId: concatPreset.id,
|
||||||
missingAudioPolicy,
|
missingAudioPolicy,
|
||||||
subtitlePolicy,
|
subtitlePolicy,
|
||||||
|
crossfadeSeconds: Number(crossfadeSeconds),
|
||||||
},
|
},
|
||||||
'Normalized concat queued.'
|
'Normalized concat queued.'
|
||||||
);
|
);
|
||||||
@@ -856,6 +889,7 @@ function reasonForConcat({
|
|||||||
preset,
|
preset,
|
||||||
missingAudioPolicy,
|
missingAudioPolicy,
|
||||||
subtitlePolicy,
|
subtitlePolicy,
|
||||||
|
crossfadeSeconds,
|
||||||
availability,
|
availability,
|
||||||
connected,
|
connected,
|
||||||
busy,
|
busy,
|
||||||
@@ -865,6 +899,7 @@ function reasonForConcat({
|
|||||||
readonly preset: StructuralExportPresetOption | undefined;
|
readonly preset: StructuralExportPresetOption | undefined;
|
||||||
readonly missingAudioPolicy: MissingAudioPolicy | '';
|
readonly missingAudioPolicy: MissingAudioPolicy | '';
|
||||||
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
|
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
|
||||||
|
readonly crossfadeSeconds: string;
|
||||||
readonly availability: StructuralOperationsProps['availability'];
|
readonly availability: StructuralOperationsProps['availability'];
|
||||||
readonly connected: boolean;
|
readonly connected: boolean;
|
||||||
readonly busy: boolean;
|
readonly busy: boolean;
|
||||||
@@ -924,13 +959,39 @@ function reasonForConcat({
|
|||||||
) {
|
) {
|
||||||
return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.';
|
return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.';
|
||||||
}
|
}
|
||||||
|
const crossfade = Number(crossfadeSeconds);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(crossfade) ||
|
||||||
|
crossfade < 0 ||
|
||||||
|
(crossfade > 0 && (crossfade < 0.04 || crossfade > 10))
|
||||||
|
) {
|
||||||
|
return 'Crossfade duration must be 0, or a value from 0.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) {
|
if (!connected) {
|
||||||
return 'This action has not been connected to the job runner.';
|
return 'This action has not been connected to the job runner.';
|
||||||
}
|
}
|
||||||
return capabilityReason(
|
const baseReason = capabilityReason(
|
||||||
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
|
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
|
||||||
);
|
);
|
||||||
|
if (baseReason || mode === 'fast' || Number(crossfadeSeconds) === 0) {
|
||||||
|
return baseReason;
|
||||||
|
}
|
||||||
|
return capabilityReason(availability?.['concat-crossfade']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function capabilityReason(
|
function capabilityReason(
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
type FFmpegFactory = () => Promise<FFmpegAdapter>;
|
type FFmpegFactory = () => Promise<FFmpegAdapter>;
|
||||||
|
|
||||||
export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000;
|
export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000;
|
||||||
|
export const MIN_PROBE_TIMEOUT_MILLISECONDS = 30_000;
|
||||||
|
export const MAX_PROBE_TIMEOUT_MILLISECONDS = 5 * 60_000;
|
||||||
|
export const PROBE_BUDGET_BYTES_PER_SECOND = 2 * 1024 * 1024;
|
||||||
export const MULTITHREAD_CODEC_THREAD_LIMIT = 2;
|
export const MULTITHREAD_CODEC_THREAD_LIMIT = 2;
|
||||||
/**
|
/**
|
||||||
* The pinned Emscripten core can retain native heap state across heterogeneous
|
* The pinned Emscripten core can retain native heap state across heterogeneous
|
||||||
@@ -55,6 +58,19 @@ export function shouldRecycleExecutionCore(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function probeTimeoutMilliseconds(inputBytes: number): number {
|
||||||
|
if (!Number.isFinite(inputBytes) || inputBytes <= 0) {
|
||||||
|
return MIN_PROBE_TIMEOUT_MILLISECONDS;
|
||||||
|
}
|
||||||
|
const sizeAwareBudget = Math.ceil(
|
||||||
|
(inputBytes / PROBE_BUDGET_BYTES_PER_SECOND) * 1_000
|
||||||
|
);
|
||||||
|
return Math.min(
|
||||||
|
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||||
|
Math.max(MIN_PROBE_TIMEOUT_MILLISECONDS, sizeAwareBudget)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
||||||
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||||
return new FFmpeg();
|
return new FFmpeg();
|
||||||
@@ -521,8 +537,18 @@ export class EngineManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async probe(file: File, timeoutMilliseconds = 30_000): Promise<ProbeResult> {
|
async probe(
|
||||||
|
file: File,
|
||||||
|
timeoutMilliseconds = probeTimeoutMilliseconds(file.size),
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ProbeResult> {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
const engine = this.#engine;
|
const engine = this.#engine;
|
||||||
const mode = this.#mode;
|
const mode = this.#mode;
|
||||||
if (!engine || !mode || this.#state.status !== 'ready') {
|
if (!engine || !mode || this.#state.status !== 'ready') {
|
||||||
@@ -537,6 +563,10 @@ export class EngineManager {
|
|||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
|
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
await mounted.cleanup();
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
const probeOutput = mounted.outputPaths[0];
|
const probeOutput = mounted.outputPaths[0];
|
||||||
const inputPath = mounted.inputPaths[0];
|
const inputPath = mounted.inputPaths[0];
|
||||||
if (!probeOutput || !inputPath) {
|
if (!probeOutput || !inputPath) {
|
||||||
@@ -565,7 +595,15 @@ export class EngineManager {
|
|||||||
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
|
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
|
||||||
message: describeMountedInputAccess(mounted),
|
message: describeMountedInputAccess(mounted),
|
||||||
});
|
});
|
||||||
const watchdog = setTimeout(() => this.cancelActive(), timeoutMilliseconds);
|
let timedOut = false;
|
||||||
|
const watchdog = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
void this.cancelActive();
|
||||||
|
}, timeoutMilliseconds);
|
||||||
|
const cancelOnAbort = () => {
|
||||||
|
void this.cancelActive();
|
||||||
|
};
|
||||||
|
signal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||||
try {
|
try {
|
||||||
const exitCode = await engine.ffprobe([
|
const exitCode = await engine.ffprobe([
|
||||||
'-v',
|
'-v',
|
||||||
@@ -630,6 +668,13 @@ export class EngineManager {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.#cancelledJobIds.delete(activeProbeId)) {
|
if (this.#cancelledJobIds.delete(activeProbeId)) {
|
||||||
|
if (timedOut) {
|
||||||
|
throw new EngineError(
|
||||||
|
'probe-failed',
|
||||||
|
`Media inspection exceeded its ${String(Math.ceil(timeoutMilliseconds / 1_000))}-second safety limit.`,
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new EngineError('cancelled', 'The media probe was cancelled.', {
|
throw new EngineError('cancelled', 'The media probe was cancelled.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
});
|
});
|
||||||
@@ -641,6 +686,7 @@ export class EngineManager {
|
|||||||
details: this.#logs.text(),
|
details: this.#logs.text(),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
signal?.removeEventListener('abort', cancelOnAbort);
|
||||||
clearTimeout(watchdog);
|
clearTimeout(watchdog);
|
||||||
this.#activeJobId = null;
|
this.#activeJobId = null;
|
||||||
this.#machineProgressLines = [];
|
this.#machineProgressLines = [];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EngineMode } from './ffmpeg.types';
|
import type { EngineMode } from './ffmpeg.types';
|
||||||
import { FFMPEG_CORE_VERSION } from '../version';
|
import { FFMPEG_CORE_BUILD_ID } from '../version';
|
||||||
|
|
||||||
export function resolveFromAppBase(
|
export function resolveFromAppBase(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
@@ -22,8 +22,8 @@ export function getCoreAssetUrls(
|
|||||||
): CoreAssetUrls {
|
): CoreAssetUrls {
|
||||||
const directory =
|
const directory =
|
||||||
mode === 'multithread'
|
mode === 'multithread'
|
||||||
? `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/mt/`
|
? `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/mt/`
|
||||||
: `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/st/`;
|
: `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/st/`;
|
||||||
const base = resolver(directory);
|
const base = resolver(directory);
|
||||||
const urls: CoreAssetUrls = {
|
const urls: CoreAssetUrls = {
|
||||||
coreURL: new URL('ffmpeg-core.js', base).href,
|
coreURL: new URL('ffmpeg-core.js', base).href,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.av-tools",
|
"id": "de.add-ideas.av-tools",
|
||||||
"name": "Audio & Video Tools",
|
"name": "Audio & Video Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
|
||||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
|
||||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
|
||||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
|
||||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
|
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export const APP_VERSION = '0.1.0';
|
export const APP_VERSION = '0.2.0';
|
||||||
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
|
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
|
||||||
export const FFMPEG_UTIL_VERSION = '0.12.2';
|
export const FFMPEG_UTIL_VERSION = '0.12.2';
|
||||||
export const FFMPEG_CORE_VERSION = '0.12.10';
|
export const FFMPEG_CORE_VERSION = '0.12.10';
|
||||||
|
export const FFMPEG_CORE_BUILD_ID = '0.12.10-reviewed-stack5m.1';
|
||||||
export const FFMPEG_ENGINE_VERSION = 'n5.1.4';
|
export const FFMPEG_ENGINE_VERSION = 'n5.1.4';
|
||||||
|
export const FFMPEG_OPUS_REGRESSION_VERIFIED = true;
|
||||||
|
|||||||
@@ -191,12 +191,12 @@ test.describe('nested static deployment and Toolbox context', () => {
|
|||||||
|
|
||||||
const coreScript = runtime.responses.find((response) =>
|
const coreScript = runtime.responses.find((response) =>
|
||||||
response.url.endsWith(
|
response.url.endsWith(
|
||||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js'
|
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const coreWasm = runtime.responses.find((response) =>
|
const coreWasm = runtime.responses.find((response) =>
|
||||||
response.url.endsWith(
|
response.url.endsWith(
|
||||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm'
|
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
expect(coreScript).toMatchObject({
|
expect(coreScript).toMatchObject({
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const expectedAssets = [
|
const expectedAssets = [
|
||||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||||
];
|
];
|
||||||
for (const suffix of expectedAssets) {
|
for (const suffix of expectedAssets) {
|
||||||
expect(
|
expect(
|
||||||
@@ -87,11 +87,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
const wasm = runtime.responses.find((response) =>
|
const wasm = runtime.responses.find((response) =>
|
||||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm')
|
response.url.endsWith(
|
||||||
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
expect(wasm?.contentType).toContain('application/wasm');
|
expect(wasm?.contentType).toContain('application/wasm');
|
||||||
expect(
|
expect(
|
||||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/'))
|
runtime.requests.some((url) =>
|
||||||
|
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/')
|
||||||
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
@@ -119,8 +123,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const runtime = observeRuntime(page);
|
const runtime = observeRuntime(page);
|
||||||
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) =>
|
await page.route(
|
||||||
route.abort('failed')
|
'**/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/**',
|
||||||
|
(route) => route.abort('failed')
|
||||||
);
|
);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const environment = await page.evaluate(() => ({
|
const environment = await page.evaluate(() => ({
|
||||||
@@ -147,13 +152,17 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
)
|
)
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/'))
|
runtime.requests.some((url) =>
|
||||||
|
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/')
|
||||||
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
runtime.responses.some(
|
runtime.responses.some(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.status === 200 &&
|
response.status === 200 &&
|
||||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
response.url.endsWith(
|
||||||
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(runtime.pageErrors).toEqual([]);
|
expect(runtime.pageErrors).toEqual([]);
|
||||||
@@ -247,7 +256,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
runtime.requests.some((url) =>
|
runtime.requests.some((url) =>
|
||||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
url.endsWith(
|
||||||
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
assertLocalOnly(runtime);
|
assertLocalOnly(runtime);
|
||||||
@@ -303,7 +314,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
).toHaveCount(4);
|
).toHaveCount(4);
|
||||||
expect(
|
expect(
|
||||||
runtime.requests.filter((url) =>
|
runtime.requests.filter((url) =>
|
||||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
url.endsWith(
|
||||||
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
assertLocalOnly(runtime);
|
assertLocalOnly(runtime);
|
||||||
@@ -356,4 +369,43 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
assertLocalOnly(runtime);
|
assertLocalOnly(runtime);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('removes active and queued inspections without applying stale probe results', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const runtime = observeRuntime(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await selectSingleThread(page);
|
||||||
|
|
||||||
|
await mediaInput(page).setInputFiles([
|
||||||
|
cancellationFixture.filePath,
|
||||||
|
PRIMARY_FIXTURE,
|
||||||
|
]);
|
||||||
|
const activeName = basename(cancellationFixture.filePath);
|
||||||
|
const activeCard = page.locator('.media-card').filter({
|
||||||
|
hasText: activeName,
|
||||||
|
});
|
||||||
|
const queuedCard = page.locator('.media-card').filter({
|
||||||
|
hasText: 'pattern-av.mp4',
|
||||||
|
});
|
||||||
|
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(queuedCard.locator('.phase--queued')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Remove pattern-av.mp4' }).click();
|
||||||
|
await page.getByRole('button', { name: `Remove ${activeName}` }).click();
|
||||||
|
await expect(page.locator('.media-card')).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Choose files' })
|
||||||
|
).toBeEnabled({ timeout: 120_000 });
|
||||||
|
|
||||||
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
|
'Ready',
|
||||||
|
{ timeout: 120_000 }
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
|
assertLocalOnly(runtime);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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>;
|
tags?: Record<string, string>;
|
||||||
disposition?: Record<string, number>;
|
disposition?: Record<string, number>;
|
||||||
}>;
|
}>;
|
||||||
format?: { tags?: Record<string, string> };
|
format?: { duration?: string; tags?: Record<string, string> };
|
||||||
chapters?: Array<{ tags?: Record<string, string> }>;
|
chapters?: Array<{ tags?: Record<string, string> }>;
|
||||||
} {
|
} {
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
@@ -547,6 +547,31 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
|||||||
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await structural.getByLabel('Crossfade duration').fill('0.2');
|
||||||
|
const crossfadeAction = structural.getByRole('button', {
|
||||||
|
name: 'Export normalized concat',
|
||||||
|
});
|
||||||
|
if (await crossfadeAction.isEnabled()) {
|
||||||
|
const [crossfadeCard] = await runForResults(
|
||||||
|
page,
|
||||||
|
() => crossfadeAction.click(),
|
||||||
|
['compatible-a-concat.mp4'],
|
||||||
|
{ verifyMedia: true }
|
||||||
|
);
|
||||||
|
const crossfadeDuration = Number(
|
||||||
|
probe((await downloadResult(page, crossfadeCard as Locator)).filePath)
|
||||||
|
.format?.duration
|
||||||
|
);
|
||||||
|
expect(crossfadeDuration).toBeGreaterThan(1.35);
|
||||||
|
expect(crossfadeDuration).toBeLessThan(1.75);
|
||||||
|
} else {
|
||||||
|
await expect(
|
||||||
|
structural.getByText(
|
||||||
|
/required browser and FFmpeg capabilities have not been verified|filter (?:x|a)fade/iu
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const advanced = page.locator('.advanced-operations');
|
const advanced = page.locator('.advanced-operations');
|
||||||
@@ -687,6 +712,72 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
|||||||
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
|
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
|
||||||
.size
|
.size
|
||||||
).toBeGreaterThan(500);
|
).toBeGreaterThan(500);
|
||||||
|
|
||||||
|
await importMedia(page, ['pattern-av.mp4']);
|
||||||
|
const patternClip = page
|
||||||
|
.locator('.timeline-clip')
|
||||||
|
.filter({ hasText: 'pattern-av.mp4' });
|
||||||
|
await patternClip.locator('.timeline-clip__main').click();
|
||||||
|
const imageSelection = advanced.getByLabel('Frame selection').first();
|
||||||
|
const sceneOption = imageSelection.locator(
|
||||||
|
'option[value="scene-changes"]'
|
||||||
|
);
|
||||||
|
if (!(await sceneOption.isDisabled())) {
|
||||||
|
await imageSelection.selectOption('scene-changes');
|
||||||
|
await advanced.getByLabel('Number of frames').fill('2');
|
||||||
|
await advanced.getByLabel('Scene sensitivity threshold').fill('0.01');
|
||||||
|
await runForResults(
|
||||||
|
page,
|
||||||
|
() =>
|
||||||
|
advanced
|
||||||
|
.getByRole('button', { name: 'Generate thumbnail series' })
|
||||||
|
.click(),
|
||||||
|
[
|
||||||
|
'pattern-av-thumbnail-001-83ms.jpg',
|
||||||
|
'pattern-av-thumbnail-002-1000ms.jpg',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactSelection = advanced.getByLabel('Frame selection').nth(1);
|
||||||
|
await contactSelection.selectOption('scene-changes');
|
||||||
|
await advanced.getByLabel('Contact-sheet scene threshold').fill('0.01');
|
||||||
|
await advanced
|
||||||
|
.getByRole('spinbutton', { name: 'Frames', exact: true })
|
||||||
|
.fill('2');
|
||||||
|
await advanced
|
||||||
|
.getByRole('spinbutton', { name: 'Rows', exact: true })
|
||||||
|
.fill('1');
|
||||||
|
await advanced
|
||||||
|
.getByRole('spinbutton', { name: 'Columns', exact: true })
|
||||||
|
.fill('2');
|
||||||
|
const timestampLabels = advanced.getByLabel('Timestamp labels');
|
||||||
|
if (await timestampLabels.isEnabled()) {
|
||||||
|
await timestampLabels.check();
|
||||||
|
await advanced.getByLabel('Source filename label').check();
|
||||||
|
}
|
||||||
|
const [sceneSheetCard] = await runForResults(
|
||||||
|
page,
|
||||||
|
() =>
|
||||||
|
advanced
|
||||||
|
.getByRole('button', { name: 'Generate contact sheet' })
|
||||||
|
.click(),
|
||||||
|
['pattern-av-contact-sheet.jpg']
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
statSync(
|
||||||
|
(await downloadResult(page, sceneSheetCard as Locator)).filePath
|
||||||
|
).size
|
||||||
|
).toBeGreaterThan(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await patternClip.getByRole('button', { name: 'Remove clip' }).click();
|
||||||
|
await expect(patternClip).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.locator('.timeline-clip')
|
||||||
|
.filter({ hasText: 'compatible-a.mp4' })
|
||||||
|
.first()
|
||||||
|
.locator('.timeline-clip__main')
|
||||||
|
.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('single frame and a transformed timeline with audio/video fades', async () => {
|
await test.step('single frame and a transformed timeline with audio/video fades', async () => {
|
||||||
@@ -988,7 +1079,9 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
runtime.requests.some((url) =>
|
runtime.requests.some((url) =>
|
||||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
url.endsWith(
|
||||||
|
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
assertLocalOnly(runtime);
|
assertLocalOnly(runtime);
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
buildPeakNormalizationPlan,
|
buildPeakNormalizationPlan,
|
||||||
buildPreviewProxyPlan,
|
buildPreviewProxyPlan,
|
||||||
buildRemuxPlan,
|
buildRemuxPlan,
|
||||||
|
buildSceneChangeAnalysisPlan,
|
||||||
|
buildSceneThumbnailSeriesPlan,
|
||||||
buildSingleThumbnailPlan,
|
buildSingleThumbnailPlan,
|
||||||
buildSplitPlan,
|
buildSplitPlan,
|
||||||
buildStaticWaveformPlan,
|
buildStaticWaveformPlan,
|
||||||
@@ -44,6 +46,7 @@ import {
|
|||||||
createConcatListEntries,
|
createConcatListEntries,
|
||||||
parseLoudnessMeasurement,
|
parseLoudnessMeasurement,
|
||||||
parsePeakMeasurement,
|
parsePeakMeasurement,
|
||||||
|
parseSceneChangeFrames,
|
||||||
resolveThumbnailTimes,
|
resolveThumbnailTimes,
|
||||||
streamMapArguments,
|
streamMapArguments,
|
||||||
type ConcatSource,
|
type ConcatSource,
|
||||||
@@ -63,8 +66,16 @@ const NATIVE_SUBTITLES_FILTER_AVAILABLE =
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
}).stdout ?? ''
|
}).stdout ?? ''
|
||||||
);
|
);
|
||||||
|
const NATIVE_DRAWTEXT_FILTER_AVAILABLE =
|
||||||
|
NATIVE_FFMPEG_AVAILABLE &&
|
||||||
|
/\bdrawtext\b/u.test(
|
||||||
|
spawnSync('ffmpeg', ['-hide_banner', '-filters'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).stdout ?? ''
|
||||||
|
);
|
||||||
const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip;
|
const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip;
|
||||||
const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip;
|
const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip;
|
||||||
|
const itNativeDrawtext = NATIVE_DRAWTEXT_FILTER_AVAILABLE ? it : it.skip;
|
||||||
|
|
||||||
interface NativeExecution {
|
interface NativeExecution {
|
||||||
readonly outputPaths: readonly string[];
|
readonly outputPaths: readonly string[];
|
||||||
@@ -752,6 +763,33 @@ describeNative('native execution of structured command plans', () => {
|
|||||||
Number(probe(normalized.outputPaths[0]!).format?.duration)
|
Number(probe(normalized.outputPaths[0]!).format?.duration)
|
||||||
).toBeCloseTo(1.6, 1);
|
).toBeCloseTo(1.6, 1);
|
||||||
|
|
||||||
|
const crossfaded = runNative(
|
||||||
|
buildNormalizedConcatPlan({
|
||||||
|
jobId: 'crossfade-concat-native',
|
||||||
|
sources: [
|
||||||
|
concatSource('first', 0, 'compatible-a.mp4', 128, 72),
|
||||||
|
concatSource('second', 1, 'compatible-b.mp4', 128, 72),
|
||||||
|
],
|
||||||
|
targetExtension: 'mp4',
|
||||||
|
targetMuxer: 'mp4',
|
||||||
|
preset: preset('mp4-h264-balanced'),
|
||||||
|
width: 128,
|
||||||
|
height: 72,
|
||||||
|
frameRate: 10,
|
||||||
|
sampleRate: 16_000,
|
||||||
|
channelLayout: 'mono',
|
||||||
|
missingAudioPolicy: 'reject',
|
||||||
|
crossfadeSeconds: 0.2,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
first: fixture('compatible-a.mp4'),
|
||||||
|
second: fixture('compatible-b.mp4'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
Number(probe(crossfaded.outputPaths[0]!).format?.duration)
|
||||||
|
).toBeCloseTo(1.8, 1);
|
||||||
|
|
||||||
const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4');
|
const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4');
|
||||||
nativeCommand(
|
nativeCommand(
|
||||||
[
|
[
|
||||||
@@ -788,6 +826,7 @@ describeNative('native execution of structured command plans', () => {
|
|||||||
sampleRate: 16_000,
|
sampleRate: 16_000,
|
||||||
channelLayout: 'mono',
|
channelLayout: 'mono',
|
||||||
missingAudioPolicy: 'insert-silence',
|
missingAudioPolicy: 'insert-silence',
|
||||||
|
crossfadeSeconds: 0.2,
|
||||||
}),
|
}),
|
||||||
{ first: fixture('compatible-a.mp4'), 'video-only': videoOnly }
|
{ first: fixture('compatible-a.mp4'), 'video-only': videoOnly }
|
||||||
);
|
);
|
||||||
@@ -1185,8 +1224,80 @@ describeNative('native execution of structured command plans', () => {
|
|||||||
codec_name: 'png',
|
codec_name: 'png',
|
||||||
width: 166,
|
width: 166,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sceneAnalysis = runNative(
|
||||||
|
buildSceneChangeAnalysisPlan({
|
||||||
|
jobId: 'scene-analysis-native',
|
||||||
|
source: media,
|
||||||
|
durationSeconds: 2,
|
||||||
|
threshold: 0.01,
|
||||||
|
maxFrames: 4,
|
||||||
|
}),
|
||||||
|
{ media: mediaFile },
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
const sceneFrames = parseSceneChangeFrames(sceneAnalysis.stderr);
|
||||||
|
expect(sceneFrames.length).toBeGreaterThan(0);
|
||||||
|
const sceneSeries = runNative(
|
||||||
|
buildSceneThumbnailSeriesPlan({
|
||||||
|
jobId: 'scene-series-native',
|
||||||
|
source: media,
|
||||||
|
durationSeconds: 2,
|
||||||
|
frames: sceneFrames,
|
||||||
|
size: { width: 80 },
|
||||||
|
format: 'png',
|
||||||
|
}),
|
||||||
|
{ media: mediaFile }
|
||||||
|
);
|
||||||
|
expect(sceneSeries.outputPaths).toHaveLength(sceneFrames.length);
|
||||||
|
|
||||||
|
const sceneSheet = runNative(
|
||||||
|
buildContactSheetPlan({
|
||||||
|
jobId: 'scene-contact-sheet-native',
|
||||||
|
source: media,
|
||||||
|
durationSeconds: 2,
|
||||||
|
frameCount: sceneFrames.length,
|
||||||
|
rows: 1,
|
||||||
|
columns: sceneFrames.length,
|
||||||
|
cellWidth: 80,
|
||||||
|
spacing: 2,
|
||||||
|
sceneFrames,
|
||||||
|
format: 'png',
|
||||||
|
}),
|
||||||
|
{ media: mediaFile }
|
||||||
|
);
|
||||||
|
expect(statSync(sceneSheet.outputPaths[0]!).size).toBeGreaterThan(500);
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
|
itNativeDrawtext(
|
||||||
|
'executes contact-sheet labels with the reviewed bundled font',
|
||||||
|
() => {
|
||||||
|
const media = source('media', 0, 'pattern-av.mp4');
|
||||||
|
const labeledSheet = runNative(
|
||||||
|
buildContactSheetPlan({
|
||||||
|
jobId: 'labeled-contact-sheet-native',
|
||||||
|
source: media,
|
||||||
|
durationSeconds: 2,
|
||||||
|
frameCount: 2,
|
||||||
|
rows: 1,
|
||||||
|
columns: 2,
|
||||||
|
cellWidth: 120,
|
||||||
|
timestampLabels: true,
|
||||||
|
sourceLabel: true,
|
||||||
|
drawtextAvailable: true,
|
||||||
|
font: source('font', 1, 'DejaVuSans.ttf'),
|
||||||
|
format: 'png',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
media: fixture('pattern-av.mp4'),
|
||||||
|
font: resolve(process.cwd(), 'public/fonts/DejaVuSans.ttf'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(statSync(labeledSheet.outputPaths[0]!).size).toBeGreaterThan(500);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
|
||||||
it('executes a transformed, faded, normalized sequential timeline', () => {
|
it('executes a transformed, faded, normalized sequential timeline', () => {
|
||||||
const execution = runNative(
|
const execution = runNative(
|
||||||
buildTimelineExportPlan({
|
buildTimelineExportPlan({
|
||||||
|
|||||||
@@ -283,6 +283,72 @@ describe('AdvancedOperations', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits bounded scene analysis settings for series and contact sheets', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onThumbnailSeries = vi.fn();
|
||||||
|
const onContactSheet = vi.fn();
|
||||||
|
render(
|
||||||
|
<AdvancedOperations
|
||||||
|
source={SOURCE}
|
||||||
|
availability={capabilities(
|
||||||
|
'thumbnail-series',
|
||||||
|
'contact-sheet',
|
||||||
|
'scene-change-analysis',
|
||||||
|
'contact-sheet-labels'
|
||||||
|
)}
|
||||||
|
imageFormatAvailability={{ jpeg: AVAILABLE }}
|
||||||
|
onThumbnailSeries={onThumbnailSeries}
|
||||||
|
onContactSheet={onContactSheet}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('tab', {
|
||||||
|
name: 'Thumbnails and contact sheets',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const selectionFields = screen.getAllByLabelText('Frame selection');
|
||||||
|
await user.selectOptions(selectionFields[0]!, 'scene-changes');
|
||||||
|
await user.clear(screen.getByLabelText('Scene sensitivity threshold'));
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('Scene sensitivity threshold'),
|
||||||
|
'0.42'
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Generate thumbnail series' })
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onThumbnailSeries).toHaveBeenCalledWith({
|
||||||
|
distribution: {
|
||||||
|
type: 'scene-changes',
|
||||||
|
count: 12,
|
||||||
|
threshold: 0.42,
|
||||||
|
},
|
||||||
|
width: 480,
|
||||||
|
format: 'jpeg',
|
||||||
|
quality: 85,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.selectOptions(selectionFields[1]!, 'scene-changes');
|
||||||
|
await user.clear(screen.getByLabelText('Contact-sheet scene threshold'));
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText('Contact-sheet scene threshold'),
|
||||||
|
'0.3'
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Generate contact sheet' })
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onContactSheet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
frameCount: 12,
|
||||||
|
selection: { type: 'scene-changes', threshold: 0.3 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
|
it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSubtitleAction = vi.fn();
|
const onSubtitleAction = vi.fn();
|
||||||
|
|||||||
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,
|
buildPeakAnalysisPlan,
|
||||||
buildPeakNormalizationFilter,
|
buildPeakNormalizationFilter,
|
||||||
buildPeakNormalizationPlan,
|
buildPeakNormalizationPlan,
|
||||||
|
buildSceneChangeAnalysisPlan,
|
||||||
|
buildSceneThumbnailSeriesPlan,
|
||||||
buildSubtitleBurnPlan,
|
buildSubtitleBurnPlan,
|
||||||
buildSubtitleExtractionPlan,
|
buildSubtitleExtractionPlan,
|
||||||
buildSubtitleMuxPlan,
|
buildSubtitleMuxPlan,
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
parseChapterJson,
|
parseChapterJson,
|
||||||
parseLoudnessMeasurement,
|
parseLoudnessMeasurement,
|
||||||
parsePeakMeasurement,
|
parsePeakMeasurement,
|
||||||
|
parseSceneChangeFrames,
|
||||||
parseMetadataJson,
|
parseMetadataJson,
|
||||||
resolveThumbnailTimes,
|
resolveThumbnailTimes,
|
||||||
serializeChapterJson,
|
serializeChapterJson,
|
||||||
@@ -360,6 +363,65 @@ describe('thumbnail and contact sheet plans', () => {
|
|||||||
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
|
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('analyzes a bounded number of scene changes and parses exact timestamps', () => {
|
||||||
|
const plan = buildSceneChangeAnalysisPlan({
|
||||||
|
jobId: 'scenes',
|
||||||
|
source: SOURCE,
|
||||||
|
durationSeconds: 10,
|
||||||
|
threshold: 0.35,
|
||||||
|
maxFrames: 12,
|
||||||
|
});
|
||||||
|
expect(plan.outputs).toEqual([]);
|
||||||
|
expect(plan.args).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"select='gt(scene\\,0.35)',showinfo",
|
||||||
|
'-frames:v',
|
||||||
|
'12',
|
||||||
|
'-f',
|
||||||
|
'null',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(plan.requiredCapabilities.filters).toEqual(['select', 'showinfo']);
|
||||||
|
expect(
|
||||||
|
parseSceneChangeFrames(`
|
||||||
|
[Parsed_showinfo_1 @ 0x1] n: 0 pts: 4096 pts_time:0.25 duration:512
|
||||||
|
n: 1 pts: 16384 pts_time:1 duration:512
|
||||||
|
[Parsed_showinfo_1 @ 0x1] n: 2 pts: 16384 pts_time:1 duration:512
|
||||||
|
invalid showinfo n: 3 pts: nope pts_time:2
|
||||||
|
`)
|
||||||
|
).toEqual([
|
||||||
|
{ pts: 4096, timeSeconds: 0.25 },
|
||||||
|
{ pts: 16384, timeSeconds: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds exact scene-selected thumbnails from analyzed PTS values', () => {
|
||||||
|
const plan = buildSceneThumbnailSeriesPlan({
|
||||||
|
jobId: 'scene-thumbs',
|
||||||
|
source: SOURCE,
|
||||||
|
durationSeconds: 10,
|
||||||
|
frames: [
|
||||||
|
{ pts: 4096, timeSeconds: 0.25 },
|
||||||
|
{ pts: 16384, timeSeconds: 1 },
|
||||||
|
],
|
||||||
|
format: 'png',
|
||||||
|
size: { width: 320 },
|
||||||
|
});
|
||||||
|
expect(plan.outputs.map((output) => output.fileName)).toEqual([
|
||||||
|
'source-thumbnail-001-250ms.png',
|
||||||
|
'source-thumbnail-002-1000ms.png',
|
||||||
|
]);
|
||||||
|
expect(plan.args.join(' ')).toContain(
|
||||||
|
"select='eq(pts\\,4096)+eq(pts\\,16384)',split=2"
|
||||||
|
);
|
||||||
|
expect(plan.requiredCapabilities.filters).toEqual([
|
||||||
|
'select',
|
||||||
|
'split',
|
||||||
|
'setpts',
|
||||||
|
'scale',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('degrades contact-sheet labels when drawtext is unavailable', () => {
|
it('degrades contact-sheet labels when drawtext is unavailable', () => {
|
||||||
const plan = buildContactSheetPlan({
|
const plan = buildContactSheetPlan({
|
||||||
jobId: 'sheet',
|
jobId: 'sheet',
|
||||||
@@ -381,6 +443,49 @@ describe('thumbnail and contact sheet plans', () => {
|
|||||||
);
|
);
|
||||||
expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
|
expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a mounted reviewed font and exact scene frames for labels', () => {
|
||||||
|
const plan = buildContactSheetPlan({
|
||||||
|
jobId: 'scene-sheet',
|
||||||
|
source: SOURCE,
|
||||||
|
durationSeconds: 10,
|
||||||
|
frameCount: 2,
|
||||||
|
rows: 1,
|
||||||
|
columns: 2,
|
||||||
|
cellWidth: 240,
|
||||||
|
timestampLabels: true,
|
||||||
|
sourceLabel: true,
|
||||||
|
format: 'jpeg',
|
||||||
|
drawtextAvailable: true,
|
||||||
|
font: {
|
||||||
|
id: 'bundled-font',
|
||||||
|
sourceIndex: 1,
|
||||||
|
fileName: 'DejaVuSans.ttf',
|
||||||
|
},
|
||||||
|
sceneFrames: [
|
||||||
|
{ pts: 4096, timeSeconds: 0.25 },
|
||||||
|
{ pts: 16384, timeSeconds: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(plan.inputs[1]).toMatchObject({
|
||||||
|
kind: 'font',
|
||||||
|
sourceIndex: 1,
|
||||||
|
});
|
||||||
|
expect(plan.args.join(' ')).toContain(
|
||||||
|
"select='eq(pts\\,4096)+eq(pts\\,16384)'"
|
||||||
|
);
|
||||||
|
expect(plan.args.join(' ')).toContain(
|
||||||
|
"drawtext=fontfile='/input/job-scene-sheet/source-1.ttf'"
|
||||||
|
);
|
||||||
|
expect(plan.args.join(' ')).toContain('nb_frames=2');
|
||||||
|
expect(plan.requiredCapabilities.filters).toEqual([
|
||||||
|
'select',
|
||||||
|
'scale',
|
||||||
|
'setpts',
|
||||||
|
'tile',
|
||||||
|
'drawtext',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('composed visual operations', () => {
|
describe('composed visual operations', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
buildConvertPlan,
|
buildConvertPlan,
|
||||||
buildFastConcatPlan,
|
buildFastConcatPlan,
|
||||||
|
buildNormalizedConcatPlan,
|
||||||
buildPreviewProxyPlan,
|
buildPreviewProxyPlan,
|
||||||
buildRemuxPlan,
|
buildRemuxPlan,
|
||||||
buildSplitPlan,
|
buildSplitPlan,
|
||||||
@@ -276,6 +277,64 @@ describe('concat and transform validation', () => {
|
|||||||
expect(plan.expectedDurationSeconds).toBe(5);
|
expect(plan.expectedDurationSeconds).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds bounded chained audio/video crossfades for normalized concat', () => {
|
||||||
|
const preset = findBuiltInPreset('mp4-h264-balanced');
|
||||||
|
const sources = [
|
||||||
|
{ ...SOURCES[0]!, durationSeconds: 4 },
|
||||||
|
{ ...SOURCES[1]!, durationSeconds: 5 },
|
||||||
|
{
|
||||||
|
...SOURCES[1]!,
|
||||||
|
id: 'three',
|
||||||
|
sourceIndex: 2,
|
||||||
|
fileName: 'three.mp4',
|
||||||
|
durationSeconds: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const plan = buildNormalizedConcatPlan({
|
||||||
|
jobId: 'crossfade',
|
||||||
|
sources,
|
||||||
|
targetExtension: 'mp4',
|
||||||
|
targetMuxer: 'mp4',
|
||||||
|
preset: preset!,
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
frameRate: 25,
|
||||||
|
sampleRate: 48_000,
|
||||||
|
channelLayout: 'stereo',
|
||||||
|
missingAudioPolicy: 'reject',
|
||||||
|
subtitlePolicy: 'reject',
|
||||||
|
crossfadeSeconds: 0.5,
|
||||||
|
});
|
||||||
|
const graph = plan.args[plan.args.indexOf('-filter_complex') + 1] ?? '';
|
||||||
|
expect(graph).toContain(
|
||||||
|
'[v0][v1]xfade=transition=fade:duration=0.5:offset=3.5[vxf1]'
|
||||||
|
);
|
||||||
|
expect(graph).toContain(
|
||||||
|
'[vxf1][v2]xfade=transition=fade:duration=0.5:offset=8[vout]'
|
||||||
|
);
|
||||||
|
expect(graph).toContain('[a0][a1]acrossfade=d=0.5:c1=tri:c2=tri[axf1]');
|
||||||
|
expect(graph).not.toContain('concat=n=3');
|
||||||
|
expect(plan.expectedDurationSeconds).toBe(11);
|
||||||
|
expect(plan.requiredCapabilities.filters).toEqual(
|
||||||
|
expect.arrayContaining(['xfade', 'acrossfade'])
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
buildNormalizedConcatPlan({
|
||||||
|
jobId: 'too-long-crossfade',
|
||||||
|
sources: SOURCES,
|
||||||
|
targetExtension: 'mp4',
|
||||||
|
targetMuxer: 'mp4',
|
||||||
|
preset: preset!,
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
frameRate: 25,
|
||||||
|
missingAudioPolicy: 'reject',
|
||||||
|
subtitlePolicy: 'reject',
|
||||||
|
crossfadeSeconds: 2.5,
|
||||||
|
})
|
||||||
|
).toThrow(/Clip 1 needs at least/iu);
|
||||||
|
});
|
||||||
|
|
||||||
it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
|
it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
|
||||||
expect(
|
expect(
|
||||||
validateCrop({
|
validateCrop({
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ describe('HelpDialog', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('n5.1.4')).toBeInTheDocument();
|
expect(screen.getByText('n5.1.4')).toBeInTheDocument();
|
||||||
expect(screen.getByText('0.12.15')).toBeInTheDocument();
|
expect(screen.getByText('0.12.15')).toBeInTheDocument();
|
||||||
expect(screen.getByText('0.12.10 · ST & MT')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText('0.12.10-reviewed-stack5m.1 · ST & MT')
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/copyright © 2026 Albrecht Degering/i)
|
screen.getByText(/copyright © 2026 Albrecht Degering/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -29,6 +31,9 @@ describe('HelpDialog', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('link', { name: 'GPL text', hidden: true })
|
screen.getByRole('link', { name: 'GPL text', hidden: true })
|
||||||
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
|
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: 'Font licence', hidden: true })
|
||||||
|
).toHaveAttribute('href', expect.stringContaining('DEJAVU_FONTS'));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('link', {
|
screen.getByRole('link', {
|
||||||
name: 'Complete inventory',
|
name: 'Complete inventory',
|
||||||
|
|||||||
@@ -26,6 +26,41 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('MediaPreview', () => {
|
describe('MediaPreview', () => {
|
||||||
|
it('starts declared local video playback while inspection continues and preserves the element', () => {
|
||||||
|
const probingAsset: ImportedMediaAsset = {
|
||||||
|
id: 'asset-probing-video',
|
||||||
|
file: new File(['video'], 'lecture.mp4', {
|
||||||
|
type: 'application/octet-stream',
|
||||||
|
}),
|
||||||
|
objectUrl: 'blob:probing-video',
|
||||||
|
phase: 'probing',
|
||||||
|
};
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<MediaPreview asset={probingAsset} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressiveVideo = requiredElement(container.querySelector('video'));
|
||||||
|
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Local source preview · Media inspection continues in the background.'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MediaPreview
|
||||||
|
asset={{
|
||||||
|
...videoAsset(),
|
||||||
|
id: probingAsset.id,
|
||||||
|
file: probingAsset.file,
|
||||||
|
objectUrl: probingAsset.objectUrl,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).toBe(progressiveVideo);
|
||||||
|
});
|
||||||
|
|
||||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
it('offers bounded configurable proxy settings after playback fails', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onCreateProxy = vi.fn();
|
const onCreateProxy = vi.fn();
|
||||||
|
|||||||
@@ -138,6 +138,28 @@ describe('QuickConvert', () => {
|
|||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enables Opus after the reviewed core passed both regression modes', () => {
|
||||||
|
const engineState: EngineState = {
|
||||||
|
status: 'ready',
|
||||||
|
mode: 'single-thread',
|
||||||
|
capabilities: {
|
||||||
|
...capabilities(['mp4', 'opus']),
|
||||||
|
encoders: new Set(['libx264', 'aac', 'libopus']),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QuickConvert
|
||||||
|
asset={createAsset('asset-a', 'source-a.mp4')}
|
||||||
|
engineState={engineState}
|
||||||
|
busy={false}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('option', { name: 'Opus' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes a validated user preset and returns it unchanged on export', async () => {
|
it('exposes a validated user preset and returns it unchanged on export', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ describe('StructuralOperations', () => {
|
|||||||
presetId: 'video-ready',
|
presetId: 'video-ready',
|
||||||
missingAudioPolicy: 'insert-silence',
|
missingAudioPolicy: 'insert-silence',
|
||||||
subtitlePolicy: 'reject',
|
subtitlePolicy: 'reject',
|
||||||
|
crossfadeSeconds: 0,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -328,6 +329,55 @@ describe('StructuralOperations', () => {
|
|||||||
presetId: 'video-ready',
|
presetId: 'video-ready',
|
||||||
missingAudioPolicy: 'insert-silence',
|
missingAudioPolicy: 'insert-silence',
|
||||||
subtitlePolicy: 'drop-all',
|
subtitlePolicy: 'drop-all',
|
||||||
|
crossfadeSeconds: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a bounded crossfade only after its filters are verified', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onConcat = vi.fn();
|
||||||
|
render(
|
||||||
|
<StructuralOperations
|
||||||
|
timeline={{
|
||||||
|
...TIMELINE,
|
||||||
|
clips: TIMELINE.clips.map((clip) => ({
|
||||||
|
...clip,
|
||||||
|
durationSeconds: 3,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
exportPresets={PRESETS}
|
||||||
|
availability={capabilities('concat-normalized', 'concat-crossfade')}
|
||||||
|
onConcat={onConcat}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('radio', { name: /Normalized re-encode/iu })
|
||||||
|
);
|
||||||
|
await user.selectOptions(
|
||||||
|
screen.getByLabelText('Normalized concat preset'),
|
||||||
|
'video-ready'
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('radio', { name: /Reject subtitle input/iu })
|
||||||
|
);
|
||||||
|
await user.clear(screen.getByLabelText(/^Crossfade duration/iu));
|
||||||
|
await user.type(screen.getByLabelText(/^Crossfade duration/iu), '0.5');
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Export normalized concat' })
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onConcat).toHaveBeenCalledWith({
|
||||||
|
operation: 'concat',
|
||||||
|
mode: 'normalized',
|
||||||
|
clipIds: ['clip-a', 'clip-b'],
|
||||||
|
presetId: 'video-ready',
|
||||||
|
missingAudioPolicy: 'insert-silence',
|
||||||
|
subtitlePolicy: 'reject',
|
||||||
|
crossfadeSeconds: 0.5,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
|
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
|
||||||
|
EngineManager,
|
||||||
MAX_CONTACT_SHEETS_PER_ENGINE,
|
MAX_CONTACT_SHEETS_PER_ENGINE,
|
||||||
MAX_EXECUTIONS_PER_ENGINE,
|
MAX_EXECUTIONS_PER_ENGINE,
|
||||||
|
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||||
|
MIN_PROBE_TIMEOUT_MILLISECONDS,
|
||||||
MULTITHREAD_CODEC_THREAD_LIMIT,
|
MULTITHREAD_CODEC_THREAD_LIMIT,
|
||||||
engineJobTimeoutMilliseconds,
|
engineJobTimeoutMilliseconds,
|
||||||
prepareEngineArguments,
|
prepareEngineArguments,
|
||||||
|
probeTimeoutMilliseconds,
|
||||||
shouldRecycleExecutionCore,
|
shouldRecycleExecutionCore,
|
||||||
} from '../../src/ffmpeg/EngineManager';
|
} from '../../src/ffmpeg/EngineManager';
|
||||||
|
|
||||||
@@ -90,6 +94,39 @@ describe('engineJobTimeoutMilliseconds', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('probeTimeoutMilliseconds', () => {
|
||||||
|
it('keeps small probes responsive, scales real media, and caps the watchdog', () => {
|
||||||
|
expect(probeTimeoutMilliseconds(34_057)).toBe(
|
||||||
|
MIN_PROBE_TIMEOUT_MILLISECONDS
|
||||||
|
);
|
||||||
|
expect(probeTimeoutMilliseconds(153_019_068)).toBe(72_966);
|
||||||
|
expect(probeTimeoutMilliseconds(2 * 1024 * 1024 * 1024)).toBe(
|
||||||
|
MAX_PROBE_TIMEOUT_MILLISECONDS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-cancelled probe before loading an engine', async () => {
|
||||||
|
const factory = vi.fn(async () => {
|
||||||
|
throw new Error('The engine factory must not be called.');
|
||||||
|
});
|
||||||
|
const manager = new EngineManager(factory);
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.probe(
|
||||||
|
new File(['media'], 'cancelled.mp4', { type: 'video/mp4' }),
|
||||||
|
undefined,
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'cancelled',
|
||||||
|
message: 'The media probe was cancelled.',
|
||||||
|
});
|
||||||
|
expect(factory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('execution core recycling policy', () => {
|
describe('execution core recycling policy', () => {
|
||||||
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
|
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
|
||||||
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
|
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ describe('FFmpeg runtime utilities', () => {
|
|||||||
'multithread',
|
'multithread',
|
||||||
(path) => `https://tools.example/deep/${path}`
|
(path) => `https://tools.example/deep/${path}`
|
||||||
);
|
);
|
||||||
expect(mt.coreURL).toContain('/0.12.10/mt/ffmpeg-core.js');
|
expect(mt.coreURL).toContain(
|
||||||
expect(mt.workerURL).toContain('/0.12.10/mt/ffmpeg-core.worker.js');
|
'/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js'
|
||||||
|
);
|
||||||
|
expect(mt.workerURL).toContain(
|
||||||
|
'/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps experimental progress and removes invalid numbers', () => {
|
it('clamps experimental progress and removes invalid numbers', () => {
|
||||||
|
|||||||
@@ -165,30 +165,30 @@ describe('licence release gate', () => {
|
|||||||
describe('release checksum', () => {
|
describe('release checksum', () => {
|
||||||
it('formats and parses the portable sidecar form', () => {
|
it('formats and parses the portable sidecar form', () => {
|
||||||
const digest = 'a'.repeat(64);
|
const digest = 'a'.repeat(64);
|
||||||
const line = checksumLine(digest, 'av-tools-0.1.0.zip');
|
const line = checksumLine(digest, 'av-tools-0.2.0.zip');
|
||||||
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
|
expect(line).toBe(`${digest} av-tools-0.2.0.zip\n`);
|
||||||
expect(parseChecksumLine(line)).toEqual({
|
expect(parseChecksumLine(line)).toEqual({
|
||||||
archiveName: 'av-tools-0.1.0.zip',
|
archiveName: 'av-tools-0.2.0.zip',
|
||||||
digest,
|
digest,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes and verifies a checksum sidecar', async () => {
|
it('writes and verifies a checksum sidecar', async () => {
|
||||||
const directory = await temporaryDirectory();
|
const directory = await temporaryDirectory();
|
||||||
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
const archive = path.join(directory, 'av-tools-0.2.0.zip');
|
||||||
await writeFile(archive, 'deterministic bytes');
|
await writeFile(archive, 'deterministic bytes');
|
||||||
const result = await writeChecksum(archive);
|
const result = await writeChecksum(archive);
|
||||||
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
|
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
|
||||||
result.digest
|
result.digest
|
||||||
);
|
);
|
||||||
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
|
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
|
||||||
' av-tools-0.1.0.zip'
|
' av-tools-0.2.0.zip'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects checksum mismatches', async () => {
|
it('rejects checksum mismatches', async () => {
|
||||||
const directory = await temporaryDirectory();
|
const directory = await temporaryDirectory();
|
||||||
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
const archive = path.join(directory, 'av-tools-0.2.0.zip');
|
||||||
const sidecar = `${archive}.sha256`;
|
const sidecar = `${archive}.sha256`;
|
||||||
await writeFile(archive, 'changed');
|
await writeFile(archive, 'changed');
|
||||||
await mkdir(path.dirname(sidecar), { recursive: true });
|
await mkdir(path.dirname(sidecar), { recursive: true });
|
||||||
@@ -207,7 +207,7 @@ describe('portal assembly smoke validation', () => {
|
|||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
|
||||||
types { application/wasm wasm; }
|
types { application/wasm wasm; }
|
||||||
# vendor/ffmpeg/0.12.10
|
"~^/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/(?:st|mt)/ffmpeg-core\\.(?:js|wasm)$" "public, max-age=31536000, immutable";
|
||||||
`;
|
`;
|
||||||
|
|
||||||
it('accepts the required isolation, CSP and MIME profile', () => {
|
it('accepts the required isolation, CSP and MIME profile', () => {
|
||||||
@@ -226,6 +226,15 @@ describe('portal assembly smoke validation', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not mistake a plain-semver cache rule for reviewed-build coverage', () => {
|
||||||
|
const inspection = inspectPortalHeaders(
|
||||||
|
isolatedHeaders.replace('0.12.10-reviewed-stack5m.1', '0.12.10')
|
||||||
|
);
|
||||||
|
expect(inspection.warnings).toContainEqual(
|
||||||
|
expect.stringMatching(/reviewed FFmpeg build/u)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('parses only explicit smoke overrides and paths', () => {
|
it('parses only explicit smoke overrides and paths', () => {
|
||||||
const parsed = parsePortalArguments(
|
const parsed = parsePortalArguments(
|
||||||
[
|
[
|
||||||
|
|||||||
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',
|
setupFiles: './src/test/setup.ts',
|
||||||
css: true,
|
css: true,
|
||||||
restoreMocks: true,
|
restoreMocks: true,
|
||||||
exclude: ['tests/browser/**', 'node_modules/**', 'dist/**'],
|
exclude: [
|
||||||
|
'tests/browser/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'.core-build/**',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user