Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597ea044b6 | |||
| f2c0fc12d2 | |||
| 6c68f9bfbd | |||
| 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
|
||||||
|
|||||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
|||||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||||
|
|||||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -3,6 +3,59 @@
|
|||||||
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.3.0 — 2026-07-27
|
||||||
|
|
||||||
|
- Make imported media and browser-native duration/dimensions available without
|
||||||
|
loading FFmpeg, so recognized files can start playback immediately.
|
||||||
|
- Move authoritative codec, stream, chapter and tag inspection to explicit or
|
||||||
|
operation-driven demand; keep playback usable after inspection failure and
|
||||||
|
expose bounded retry controls.
|
||||||
|
- Inspect automatically when Quick Convert, preview proxies, timeline renders
|
||||||
|
or advanced tools require authoritative details, while preserving
|
||||||
|
deterministic multi-file timeline order.
|
||||||
|
- Recover Firefox probes with a fresh same-mode core, safe MT-to-ST fallback
|
||||||
|
and operation reservations that prevent concurrent mounts or preference
|
||||||
|
changes.
|
||||||
|
- Add Chromium and Firefox evidence proving the supplied 153 MB, 43-minute
|
||||||
|
lecture plays before detailed inspection and retains the same media element
|
||||||
|
afterward.
|
||||||
|
|
||||||
|
## 0.2.1 — 2026-07-27
|
||||||
|
|
||||||
|
- Move the canonical source, manifest schema links, and package registry from
|
||||||
|
the personal `zemion` namespace to the `lotobo` (LocalToolBox) Gitea
|
||||||
|
organization.
|
||||||
|
- Upgrade the Toolbox contract, React shell, and validation testkit to 0.2.3,
|
||||||
|
republished from the organization-owned registry without functional shell
|
||||||
|
changes.
|
||||||
|
- Replace the generic starter favicon with original add·ideas
|
||||||
|
audio/video file artwork.
|
||||||
|
|
||||||
|
## 0.2.0 — 2026-07-27
|
||||||
|
|
||||||
|
- Allow recognized local audio/video to begin native browser playback while
|
||||||
|
FFmpeg inspection continues in the background.
|
||||||
|
- Replace the fixed 30-second media-inspection watchdog with a bounded,
|
||||||
|
size-aware budget and report timeout expiry separately from user
|
||||||
|
cancellation.
|
||||||
|
- Add an isolated, reproducible Chromium ST/MT Opus regression harness using
|
||||||
|
the reviewed cores, checked tone fixture and real built-in command plan.
|
||||||
|
- Rebuild ST/MT core 0.12.10 with the upstream 5 MiB stack fix, lock and
|
||||||
|
deterministically package the exact assets, pass non-empty Opus encode and
|
||||||
|
re-probe in both modes, and enable Opus presets behind runtime capabilities.
|
||||||
|
- Add bounded 0.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
|
||||||
|
|||||||
137
README.md
137
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.3.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.3.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).
|
||||||
|
|
||||||
@@ -17,13 +17,13 @@ under Toolbox Portal with shared shell, theme, help and app switching.
|
|||||||
|
|
||||||
Two workflows keep the product intentionally smaller than a nonlinear editor:
|
Two workflows keep the product intentionally smaller than a nonlinear editor:
|
||||||
|
|
||||||
- **Quick Convert:** drop/import media, inspect streams, choose a
|
- **Quick Convert:** drop/import media, play it and read browser-native file
|
||||||
capability-checked preset/streams, convert, preview when the browser can, and
|
information immediately, then inspect streams on demand when an export needs
|
||||||
download.
|
authoritative details.
|
||||||
- **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.3 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
|
||||||
|
|
||||||
@@ -156,7 +167,7 @@ the conditional native command-plan integration suite; it is not a production
|
|||||||
runtime dependency.
|
runtime dependency.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone git@git.add-ideas.de:zemion/av-tools.git
|
git clone git@git.add-ideas.de:lotobo/av-tools.git
|
||||||
cd av-tools
|
cd av-tools
|
||||||
npm ci
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -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,20 +216,38 @@ the real pinned cores, MT-to-ST fallback, cancellation/recovery, generated
|
|||||||
fixtures, nested deployment, Toolbox context and the absence of runtime CDN
|
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 while detailed inspection remains idle, browser-native
|
||||||
|
duration/dimensions must appear without loading FFmpeg, and the same element
|
||||||
|
must survive a subsequently requested authoritative probe. The command runs
|
||||||
|
that evidence in both Chromium and Firefox.
|
||||||
|
|
||||||
|
The latest recorded local Vitest run on 2026-07-27 completed **332 passing
|
||||||
|
tests and two conditional skips**. It includes broad structured-command
|
||||||
|
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in and
|
||||||
|
labeled contact-sheet cases require `subtitles` and `drawtext` filters that are
|
||||||
|
unavailable in that host build.
|
||||||
|
|
||||||
The Chromium suite exercises the pinned ST core across conversion,
|
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
|
||||||
static/Toolbox-context loading is also covered. This evidence is
|
static/Toolbox-context loading is also covered. This evidence is
|
||||||
Chromium-specific: Firefox, Safari, mobile and exhaustive MT operation parity
|
Chromium-specific. Firefox now covers the opt-in large-file
|
||||||
remain unrecorded. The exact one-run performance record and its limitations are
|
play-before-inspection regression and its explicit authoritative-probe
|
||||||
in [performance observations](docs/PERFORMANCE.md).
|
transition; broad Firefox operation parity, Safari, mobile and exhaustive MT
|
||||||
|
operation parity remain unrecorded. The exact one-run performance record and
|
||||||
|
its limitations are in [performance observations](docs/PERFORMANCE.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -243,12 +279,12 @@ repository and the `GPL-3.0-or-later` SPDX licence.
|
|||||||
|
|
||||||
Portal must consume an independently built ZIP; it must not build or import
|
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 +308,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 +322,11 @@ and licensed **GPL-3.0-or-later**. See [LICENSE](LICENSE). The application
|
|||||||
provides a visible licence, source and no-warranty notice in its Help dialog.
|
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 +353,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.
|
|
||||||
|
|||||||
95
SOURCE.md
95
SOURCE.md
@@ -1,17 +1,17 @@
|
|||||||
# 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.3.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.
|
||||||
|
|
||||||
## av-tools
|
## av-tools
|
||||||
|
|
||||||
- Canonical repository:
|
- Canonical repository:
|
||||||
`git@git.add-ideas.de:zemion/av-tools.git`
|
`git@git.add-ideas.de:lotobo/av-tools.git`
|
||||||
- Browser source: `https://git.add-ideas.de/zemion/av-tools`
|
- Browser source: `https://git.add-ideas.de/lotobo/av-tools`
|
||||||
- Version: `0.1.0`
|
- Version: `0.3.0`
|
||||||
- Release source identity: immutable annotated tag `v0.1.0`
|
- Release source identity: immutable annotated tag `v0.3.0`
|
||||||
(`refs/tags/v0.1.0`) in the canonical repository.
|
(`refs/tags/v0.3.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.3.0)" = tag
|
||||||
git rev-parse v0.1.0^{commit}
|
git rev-parse v0.3.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/lotobo/av-tools.git refs/tags/v0.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
||||||
@@ -40,34 +40,66 @@ the tag/source archive beside the binary release and checksum.
|
|||||||
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
|
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
|
||||||
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
|
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
|
||||||
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
|
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
|
||||||
| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` |
|
|
||||||
| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` |
|
|
||||||
|
|
||||||
Install exact packages and recreate the copied same-origin assets without a
|
Install exact packages and recreate the verified same-origin assets without a
|
||||||
network fetch during the production build:
|
network fetch during the production build:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
npm run vendor:ffmpeg
|
npm run vendor:ffmpeg
|
||||||
npm run vendor:verify
|
npm run vendor:verify-runtime
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the
|
`scripts/copy-ffmpeg-assets.mjs` fails closed against
|
||||||
installed core packages; it does not download them. The copied bytes are:
|
`scripts/reviewed-ffmpeg-core-lock.json`, verifies the deterministic tracked
|
||||||
|
ZIP and unpacks only its five declared entries. It does not download them or
|
||||||
|
fall back to published core packages. The reviewed bytes are:
|
||||||
|
|
||||||
|
For a publication check, generate the build-qualified Corresponding Source
|
||||||
|
archive and run `npm run vendor:verify`; that stricter gate hashes the archive
|
||||||
|
as well as every runtime input.
|
||||||
|
|
||||||
| Release path | Bytes | SHA-256 |
|
| Release path | Bytes | SHA-256 |
|
||||||
| ------------------------------------------------ | -------: | ------------------------------------------------------------------ |
|
| ---------------------------------------------------------------------------- | -------: | ------------------------------------------------------------------ |
|
||||||
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
|
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
|
||||||
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
|
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
|
||||||
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
|
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
|
||||||
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
|
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
|
||||||
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
|
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
|
||||||
|
| `vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip` (deterministic artifact) | 21338974 | `ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f` |
|
||||||
|
|
||||||
|
## Bundled contact-sheet font
|
||||||
|
|
||||||
|
Contact-sheet labels use one unmodified DejaVu Sans 2.37 TrueType file. It is
|
||||||
|
served same-origin and mounted read-only for the duration of a labelled job;
|
||||||
|
the browser verifies its size and SHA-256 before use.
|
||||||
|
|
||||||
|
| Release path | Bytes | SHA-256 |
|
||||||
|
| --------------------------- | ------: | ------------------------------------------------------------------ |
|
||||||
|
| `fonts/DejaVuSans.ttf` | 757,076 | `7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954` |
|
||||||
|
| `LICENSES/DEJAVU_FONTS.txt` | n/a | licence text embedded in the reviewed font and official archive |
|
||||||
|
|
||||||
|
The byte-for-byte source is `ttf/DejaVuSans.ttf` in the official
|
||||||
|
`dejavu-fonts-ttf-2.37.tar.bz2` release:
|
||||||
|
|
||||||
|
- project download record: `https://dejavu-fonts.github.io/Download.html`;
|
||||||
|
- archive URL:
|
||||||
|
`https://downloads.sourceforge.net/project/dejavu/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2`;
|
||||||
|
- archive SHA-256:
|
||||||
|
`fa9ca4d13871dd122f61258a80d01751d603b4d3ee14095d65453b4e846e17d7`;
|
||||||
|
- corresponding upstream tag `version_2_37`, commit
|
||||||
|
`0eda8a319c08835009849583cd090bb5b141ce25`.
|
||||||
|
|
||||||
|
The release archive and the bundled file were independently hashed and the
|
||||||
|
font's internal version string is `Version 2.37`. The file is not modified or
|
||||||
|
subsetted. Its Bitstream Vera/Arev permission terms are reproduced verbatim in
|
||||||
|
`LICENSES/DEJAVU_FONTS.txt`.
|
||||||
|
|
||||||
## Corresponding ffmpeg.wasm and FFmpeg source
|
## Corresponding ffmpeg.wasm and FFmpeg source
|
||||||
|
|
||||||
- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15,
|
- Base ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15 and the
|
||||||
`@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10:
|
source configuration for core 0.12.10:
|
||||||
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
|
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
|
||||||
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
|
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
|
||||||
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
|
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
|
||||||
@@ -88,6 +120,14 @@ The upstream build control files are `Dockerfile`, `Makefile`,
|
|||||||
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
|
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
|
||||||
`make prd-mt` builds multithread.
|
`make prd-mt` builds multithread.
|
||||||
|
|
||||||
|
The shipped profile is rebuilt by
|
||||||
|
`scripts/build-reviewed-ffmpeg-core.sh`. It applies upstream ffmpeg.wasm commit
|
||||||
|
`b409e36475bc21f0451b5b1e1d126fa82871439a` (`-sSTACK_SIZE=5MB`), uses exact
|
||||||
|
Emscripten 3.1.40 and CMake 3.27.9 identities, makes upstream container-stage
|
||||||
|
isolation explicit and refuses maintainer-local paths in output. The exact
|
||||||
|
source archive, toolchain, patch, asset and Opus-gate identities are locked in
|
||||||
|
`scripts/reviewed-ffmpeg-core-lock.json`.
|
||||||
|
|
||||||
The Dockerfile uses branch names for two inputs. Repository history resolves
|
The Dockerfile uses branch names for two inputs. Repository history resolves
|
||||||
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
|
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
|
||||||
changed since:
|
changed since:
|
||||||
@@ -121,10 +161,13 @@ npm run source:package
|
|||||||
```
|
```
|
||||||
|
|
||||||
The command writes
|
The command writes
|
||||||
`release/ffmpeg-core-0.12.10-corresponding-source.tar.xz` and its
|
`release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz`
|
||||||
|
and its
|
||||||
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
|
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
|
||||||
The npm packages contain the resulting binaries but not every linked
|
The reviewed archive for this build profile is 43,050,052 bytes with SHA-256
|
||||||
component's source and legal file, so publish this reviewed bundle and the
|
`2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a`.
|
||||||
|
The reviewed object-code archive does not contain every linked component's
|
||||||
|
source and legal file, so publish this Corresponding Source bundle and the
|
||||||
notices beside the object code. A homepage link alone is not asserted to
|
notices beside the object code. A homepage link alone is not asserted to
|
||||||
satisfy GPL source obligations.
|
satisfy GPL source obligations.
|
||||||
|
|
||||||
|
|||||||
@@ -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.3.0 is Copyright © 2026 Albrecht Degering and licensed
|
||||||
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
||||||
inputs. It is informational, not legal advice, and does not replace their
|
inputs. It is informational, not legal advice, and does not replace their
|
||||||
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
||||||
@@ -10,29 +10,32 @@ receives licence files shipped by installed runtime npm packages under
|
|||||||
## Browser runtime
|
## Browser runtime
|
||||||
|
|
||||||
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
|
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
|
||||||
| -------------------------------- | ------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -------------------------------------- |
|
| -------------------------------- | -------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
|
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | `https://git.add-ideas.de/lotobo/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.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.3 | Apache-2.0 | `https://git.add-ideas.de/lotobo/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
|
||||||
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
|
| `@ffmpeg/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
|
||||||
@@ -70,7 +73,7 @@ shipped in the static ZIP:
|
|||||||
|
|
||||||
| Package | Exact version | Licence | Role |
|
| Package | Exact version | Licence | Role |
|
||||||
| ----------------------------- | ------------- | ---------- | ------------------------------------- |
|
| ----------------------------- | ------------- | ---------- | ------------------------------------- |
|
||||||
| `@add-ideas/toolbox-testkit` | 0.2.2 | Apache-2.0 | manifest and nested-path test helpers |
|
| `@add-ideas/toolbox-testkit` | 0.2.3 | Apache-2.0 | manifest and nested-path test helpers |
|
||||||
| `@eslint/js` | 10.0.1 | MIT | lint rules |
|
| `@eslint/js` | 10.0.1 | MIT | lint rules |
|
||||||
| `@playwright/test` | 1.61.1 | Apache-2.0 | browser tests |
|
| `@playwright/test` | 1.61.1 | Apache-2.0 | browser tests |
|
||||||
| `@testing-library/jest-dom` | 6.9.1 | MIT | DOM assertions |
|
| `@testing-library/jest-dom` | 6.9.1 | MIT | DOM assertions |
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ Browser storage boundary
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `src/toolbox/` owns the one typed manifest and suite integration.
|
- `src/toolbox/` owns the one typed manifest and suite integration.
|
||||||
- `src/media/` normalizes ffprobe JSON, duration/timecode, stream selection,
|
- `src/media/` reads lightweight browser-native metadata, normalizes
|
||||||
playback MIME decisions and safe names.
|
authoritative ffprobe JSON, and owns duration/timecode, stream-selection,
|
||||||
|
playback-MIME and safe-name decisions. Browser metadata and probe data are
|
||||||
|
deliberately separate models.
|
||||||
- `src/project/` owns the versioned, non-destructive project document and
|
- `src/project/` owns the versioned, non-destructive project document and
|
||||||
migrations. A source is represented by identity/metadata and must be
|
migrations. A source is represented by identity/metadata and must be
|
||||||
reattached after load; the current application does not persist source bytes.
|
reattached after load; the current application does not persist source bytes.
|
||||||
@@ -51,20 +53,35 @@ raw command field.
|
|||||||
|
|
||||||
## FFmpeg lifecycle
|
## FFmpeg lifecycle
|
||||||
|
|
||||||
1. Inspect secure-context, worker, `crossOriginIsolated` and
|
1. Import creates an object URL immediately. A detached native media element
|
||||||
|
reads duration/dimensions where supported; playback and file identity do not
|
||||||
|
initialize FFmpeg.
|
||||||
|
2. Detailed inspection remains idle until the user requests it or an operation
|
||||||
|
requires codecs, complete streams, chapters, tags or dispositions.
|
||||||
|
3. Inspect secure-context, worker, `crossOriginIsolated` and
|
||||||
`SharedArrayBuffer` availability.
|
`SharedArrayBuffer` availability.
|
||||||
2. Select MT only when all MT prerequisites are true (or ST when explicitly
|
4. Select MT only when all MT prerequisites are true (or ST when explicitly
|
||||||
selected). A failed MT load is terminated and retried once with ST.
|
selected). A failed MT load is terminated and retried once with ST.
|
||||||
3. Load wrapper/core assets via base-relative, same-origin URLs.
|
5. Load wrapper/core assets via base-relative, same-origin URLs.
|
||||||
4. Query version/build configuration/formats/codecs/encoders/filters
|
6. Query version/build configuration/formats/codecs/encoders/filters
|
||||||
sequentially and publish immutable capability state.
|
sequentially and publish immutable capability state.
|
||||||
5. For one job, validate resource estimates and structured settings, allocate
|
7. For one job, validate resource estimates and structured settings, allocate
|
||||||
a generated directory, mount/write inputs, run a generated argument array,
|
a generated directory, mount/write inputs, run a generated argument array,
|
||||||
read only declared outputs, and re-probe applicable results.
|
read only declared outputs, and re-probe applicable results.
|
||||||
6. Return result bytes and an export report, then unmount/delete virtual files.
|
8. Return result bytes and an export report, then unmount/delete virtual files.
|
||||||
7. Retain the idle engine for the next sequential job; terminate it on mode
|
9. Retain the idle engine for the next sequential job; terminate it on mode
|
||||||
changes, unrecoverable errors or cancellation.
|
changes, unrecoverable errors or cancellation.
|
||||||
|
|
||||||
|
A detailed-probe failure changes only the inspection state. The File, object
|
||||||
|
URL, browser metadata and provisional timeline clip remain available, and the
|
||||||
|
user can retry. An authoritative probe supersedes browser duration/dimensions
|
||||||
|
and reconciles an untouched provisional full-source clip.
|
||||||
|
|
||||||
|
The reviewed Emscripten runtime can stall when Firefox probes on a core that
|
||||||
|
has retained prior application state. Before an explicit Firefox probe, the
|
||||||
|
manager recreates the identical ST/MT core and reuses only that mode's already
|
||||||
|
validated capability inventory. Chromium retains the normal warm-core path.
|
||||||
|
|
||||||
The FFmpeg instance is a single process. `maxConcurrentFFmpegJobs` is one and
|
The FFmpeg instance is a single process. `maxConcurrentFFmpegJobs` is one and
|
||||||
no probe/export operations overlap on it. The application-facing scheduler is
|
no probe/export operations overlap on it. The application-facing scheduler is
|
||||||
also bounded: it retains at most eight scheduled operations in memory,
|
also bounded: it retains at most eight scheduled operations in memory,
|
||||||
|
|||||||
@@ -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.3.0 release fixture matrix
|
||||||
|
|
||||||
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
|
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
|
||||||
preparation and pass the checksum/probe verifier. That proves fixture
|
preparation and pass the checksum/probe verifier. That proves fixture
|
||||||
@@ -42,9 +42,9 @@ browser may still be unable to preview the result.
|
|||||||
Preset availability always depends on detected runtime requirements:
|
Preset availability always depends on detected runtime requirements:
|
||||||
|
|
||||||
| Preset family | Required examples | Browser result preview |
|
| Preset family | Required examples | Browser result preview |
|
||||||
| ------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
| ------------------ | ---------------------------------------- | -------------------------------------------------------- |
|
||||||
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
|
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
|
||||||
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | browser-dependent; Opus core regression gate applies |
|
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | enabled when the loaded core advertises all requirements |
|
||||||
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
|
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
|
||||||
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
|
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
|
||||||
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
|
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
|
||||||
@@ -52,7 +52,7 @@ Preset availability always depends on detected runtime requirements:
|
|||||||
| MP3 | `mp3`, `libmp3lame` | commonly playable |
|
| MP3 | `mp3`, `libmp3lame` | commonly playable |
|
||||||
| M4A/AAC | `ipod`, `aac` | commonly playable |
|
| M4A/AAC | `ipod`, `aac` | commonly playable |
|
||||||
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
|
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
|
||||||
| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies |
|
| Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded |
|
||||||
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
|
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
|
||||||
| FLAC | `flac`, `flac` | browser-dependent |
|
| FLAC | `flac`, `flac` | browser-dependent |
|
||||||
|
|
||||||
@@ -77,11 +77,30 @@ remux avoids re-encoding compatible streams but may change container metadata.
|
|||||||
- Normalized concat uses an explicit export preset and requires an explicit
|
- Normalized concat uses an explicit export preset and requires an explicit
|
||||||
insert-silence, drop-all-audio or reject policy when clips have mismatched
|
insert-silence, drop-all-audio or reject policy when clips have mismatched
|
||||||
audio presence.
|
audio presence.
|
||||||
|
- Normalized concat can overlap every edit with one uniform 0.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.3.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.
|
||||||
@@ -36,10 +45,14 @@ FFmpeg output does not remove these product/browser/resource limits.
|
|||||||
presets from runtime formats/codecs/encoders/filters.
|
presets from runtime formats/codecs/encoders/filters.
|
||||||
- FFmpeg-readable output may be download-only because browser media elements
|
- FFmpeg-readable output may be download-only because browser media elements
|
||||||
support fewer container/codec combinations.
|
support fewer container/codec combinations.
|
||||||
|
- Browser-native basic information intentionally omits codecs, complete stream
|
||||||
|
lists, chapters, tags, subtitles, attachments and dispositions. Features
|
||||||
|
that need those details request an authoritative local FFprobe pass.
|
||||||
- Browser support, codec policy and memory differ across Chromium, Firefox and
|
- 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,13 +82,15 @@ 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.3.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
|
||||||
loading and one two-size ST/MT performance baseline. Firefox, Safari, mobile,
|
loading and one two-size ST/MT performance baseline. Firefox covers the
|
||||||
exhaustive MT feature parity and repeated performance measurements remain
|
opt-in large-file browser-metadata/playback-to-authoritative-probe
|
||||||
unrecorded. Live Portal container/header verification is a deployment gate.
|
regression. Broad Firefox operation parity, Safari, mobile, exhaustive MT
|
||||||
|
feature parity and repeated performance measurements remain unrecorded. Live
|
||||||
|
Portal container/header verification is a deployment gate.
|
||||||
|
|
||||||
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
|
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
|
||||||
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.
|
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.
|
||||||
|
|||||||
@@ -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,22 @@ Estimated output warns at 512 MiB and is blocked at 1 GiB. FFmpeg jobs are
|
|||||||
serial; the application retains at most eight scheduled operations in its
|
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.
|
||||||
|
|
||||||
|
Import does not start FFmpeg. Recognized local audio/video can begin browser
|
||||||
|
playback from its temporary object URL while a native media element reads
|
||||||
|
lightweight duration/dimensions. Detailed FFprobe inspection starts only when
|
||||||
|
requested or required for an export and has a bounded, size-aware watchdog:
|
||||||
|
30 seconds for small files, a two-MiB-per-second budget for larger files, and a
|
||||||
|
five-minute ceiling. A detailed-inspection timeout is nonfatal; browser
|
||||||
|
playback and basic file information remain available. Completed probe metadata
|
||||||
|
remains authoritative for stream mapping, remux safety, chapters, tags,
|
||||||
|
subtitles/attachments and export validation.
|
||||||
|
|
||||||
Lowering resolution, duration, frame rate, passes and output count reduces
|
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:lotobo/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed |
|
||||||
| Toolbox SDK, `https://git.add-ideas.de/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/lotobo/toolbox-sdk.git` | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a` (SDK 0.2.3) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.3 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
|
||||||
| Toolbox Portal, `https://git.add-ideas.de/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/lotobo/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` initially; `5c052bc6d1073a66600cfddc44c0e16da34bede8` for final integration | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the AV smoke harness consumes only an exact cloned Portal revision; the separate Portal worktree contains its own reviewed integration patch |
|
||||||
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc` | 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.3.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.3.0.zip
|
||||||
release/av-tools-0.1.0.zip.sha256
|
release/av-tools-0.3.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.3.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.3.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.3.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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
52
package-lock.json
generated
52
package-lock.json
generated
@@ -1,18 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.3.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.3",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||||
"@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",
|
||||||
@@ -20,7 +18,7 @@
|
|||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.2",
|
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.61.1",
|
"@playwright/test": "1.61.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -46,18 +44,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.2/toolbox-contract-0.2.2.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||||
"integrity": "sha512-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==",
|
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.2/toolbox-shell-react-0.2.2.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||||
"integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
|
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.2"
|
"@add-ideas/toolbox-contract": "0.2.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -65,13 +63,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"node_modules/@add-ideas/toolbox-testkit": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.2/toolbox-testkit-0.2.2.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||||
"integrity": "sha512-ZxrRYiJI/j5aXwcd3mQItgDl1iOJ6N26+MpIG18qVZNwKKFCTCfSTzgzRooPt8rRc1MQYTPo4c+l/QpOWuTXxA==",
|
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.2"
|
"@add-ideas/toolbox-contract": "0.2.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
@@ -718,24 +716,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ffmpeg/core": {
|
|
||||||
"version": "0.12.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
|
|
||||||
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
|
|
||||||
"license": "GPL-2.0-or-later",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ffmpeg/core-mt": {
|
|
||||||
"version": "0.12.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz",
|
|
||||||
"integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==",
|
|
||||||
"license": "GPL-2.0-or-later",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ffmpeg/ffmpeg": {
|
"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",
|
||||||
|
|||||||
25
package.json
25
package.json
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.3.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",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://git.add-ideas.de/zemion/av-tools.git"
|
"url": "git+https://git.add-ideas.de/lotobo/av-tools.git"
|
||||||
},
|
},
|
||||||
"homepage": "https://git.add-ideas.de/zemion/av-tools",
|
"homepage": "https://git.add-ideas.de/lotobo/av-tools",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://git.add-ideas.de/zemion/av-tools/issues"
|
"url": "https://git.add-ideas.de/lotobo/av-tools/issues"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -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 --project=firefox-large-media --workers=1",
|
||||||
|
"evidence:opus": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs",
|
||||||
|
"test:opus-regression": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs --require-pass",
|
||||||
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
|
"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.3",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||||
"@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",
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.2",
|
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.61.1",
|
"@playwright/test": "1.61.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
@@ -21,5 +21,10 @@ export default defineConfig({
|
|||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox-large-media',
|
||||||
|
testMatch: 'large-media-preview.spec.ts',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
public/fonts/DejaVuSans.ttf
Normal file
BIN
public/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||||
"schemaVersion": 1,
|
"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.3.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",
|
||||||
@@ -44,21 +44,21 @@
|
|||||||
"telemetry": false
|
"telemetry": false
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/zemion/av-tools",
|
"repository": "https://git.add-ideas.de/lotobo/av-tools",
|
||||||
"license": "GPL-3.0-or-later"
|
"license": "GPL-3.0-or-later"
|
||||||
},
|
},
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"id": "source",
|
"id": "source",
|
||||||
"label": "Source",
|
"label": "Source",
|
||||||
"url": "https://git.add-ideas.de/zemion/av-tools"
|
"url": "https://git.add-ideas.de/lotobo/av-tools"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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('/'),
|
function assertSafeRelativePath(value) {
|
||||||
'package.json'
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value.length === 0 ||
|
||||||
|
value.includes('\\') ||
|
||||||
|
value.includes('\0') ||
|
||||||
|
value.startsWith('/') ||
|
||||||
|
path.posix.normalize(value) !== value ||
|
||||||
|
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireRealDirectory(directory, create = false) {
|
||||||
|
let details = await lstat(directory).catch((error) => {
|
||||||
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (!details && create) {
|
||||||
|
await mkdir(directory);
|
||||||
|
details = await lstat(directory);
|
||||||
|
}
|
||||||
|
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
|
||||||
|
throw new Error(`Required directory is unsafe: ${directory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||||
|
const lockedAssetPaths = new Set(
|
||||||
|
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||||
);
|
);
|
||||||
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
|
if (
|
||||||
if (parsed.version !== expectedVersion) {
|
lock.schemaVersion !== 1 ||
|
||||||
|
typeof lock.buildId !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||||
|
lock.coreVersion !== '0.12.10' ||
|
||||||
|
lock.profile !== 'reviewed-stack5m' ||
|
||||||
|
lock.sourceArchive?.path !==
|
||||||
|
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||||
|
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||||
|
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||||
|
lock.patch?.upstreamCommit !== 'b409e36475bc21f0451b5b1e1d126fa82871439a' ||
|
||||||
|
lock.patch?.stackSizeBytes !== 5 * 1024 * 1024 ||
|
||||||
|
typeof lock.opusRegression?.singleThread !== 'boolean' ||
|
||||||
|
typeof lock.opusRegression?.multiThread !== 'boolean' ||
|
||||||
|
!Array.isArray(lock.assets) ||
|
||||||
|
lock.assets.length !== 5 ||
|
||||||
|
lock.assets.some(
|
||||||
|
(asset) =>
|
||||||
|
!requiredAssetPaths.has(asset.path) ||
|
||||||
|
!Number.isSafeInteger(asset.bytes) ||
|
||||||
|
asset.bytes < 1 ||
|
||||||
|
asset.bytes > maximumAssetBytes ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
|
||||||
|
) ||
|
||||||
|
lockedAssetPaths.size !== requiredAssetPaths.size ||
|
||||||
|
[...requiredAssetPaths].some((assetPath) => !lockedAssetPaths.has(assetPath))
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifactRelativePath = assertSafeRelativePath(lock.artifact?.path);
|
||||||
|
if (artifactRelativePath !== `vendor/ffmpeg-core-${lock.buildId}.zip`) {
|
||||||
|
throw new Error('Reviewed-core artifact path and build identity disagree.');
|
||||||
|
}
|
||||||
|
const artifact = await readFile(
|
||||||
|
path.join(repositoryRoot, ...artifactRelativePath.split('/'))
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
artifact.byteLength < 1 ||
|
||||||
|
artifact.byteLength > maximumArchiveBytes ||
|
||||||
|
artifact.byteLength !== lock.artifact.bytes ||
|
||||||
|
sha256(artifact) !== lock.artifact.sha256
|
||||||
|
) {
|
||||||
|
throw new Error('Reviewed FFmpeg core archive failed locked verification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedArchiveEntries = new Map(
|
||||||
|
lock.assets.map((asset) => [asset.path, asset])
|
||||||
|
);
|
||||||
|
const archiveEntries = new Set();
|
||||||
|
const uncompressed = unzipSync(artifact, {
|
||||||
|
filter: (entry) => {
|
||||||
|
const relative = assertSafeRelativePath(entry.name);
|
||||||
|
const locked = lockedArchiveEntries.get(relative);
|
||||||
|
if (
|
||||||
|
!locked ||
|
||||||
|
archiveEntries.has(relative) ||
|
||||||
|
entry.originalSize !== locked.bytes ||
|
||||||
|
entry.originalSize < 1 ||
|
||||||
|
entry.originalSize > maximumAssetBytes ||
|
||||||
|
entry.size < 1 ||
|
||||||
|
entry.size > maximumArchiveBytes
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
|
`Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
archiveEntries.add(relative);
|
||||||
|
return true;
|
||||||
async function copyAsset(packageName, mode, fileName) {
|
|
||||||
const source = join(
|
|
||||||
repositoryRoot,
|
|
||||||
'node_modules',
|
|
||||||
...packageName.split('/'),
|
|
||||||
'dist',
|
|
||||||
'esm',
|
|
||||||
fileName
|
|
||||||
);
|
|
||||||
const destination = join(outputRoot, mode, fileName);
|
|
||||||
const sourceBytes = await readFile(source).catch((error) => {
|
|
||||||
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
|
|
||||||
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
|
|
||||||
if (!expectedDigest || sourceDigest !== expectedDigest) {
|
|
||||||
throw new Error(
|
|
||||||
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(dirname(destination), { recursive: true });
|
|
||||||
await cp(source, destination, {
|
|
||||||
dereference: true,
|
|
||||||
errorOnExist: false,
|
|
||||||
force: true,
|
|
||||||
preserveTimestamps: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const destinationBytes = await readFile(destination);
|
|
||||||
if (!sourceBytes.equals(destinationBytes)) {
|
|
||||||
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: relative(outputRoot, destination).replaceAll('\\', '/'),
|
|
||||||
bytes: destinationBytes.byteLength,
|
|
||||||
sha256: sourceDigest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await rm(outputRoot, { force: true, recursive: true });
|
|
||||||
|
|
||||||
const assets = [];
|
|
||||||
for (const entry of packages) {
|
|
||||||
await loadPackageVersion(entry.packageName);
|
|
||||||
for (const fileName of entry.files) {
|
|
||||||
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
|
|
||||||
const versionManifest = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
coreVersion: expectedVersion,
|
|
||||||
packages: {
|
|
||||||
'@ffmpeg/core': expectedVersion,
|
|
||||||
'@ffmpeg/core-mt': expectedVersion,
|
|
||||||
},
|
},
|
||||||
assets,
|
});
|
||||||
};
|
if (archiveEntries.size !== lockedArchiveEntries.size) {
|
||||||
|
throw new Error('Reviewed FFmpeg core archive is missing an expected entry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorRoot = path.join(repositoryRoot, 'public', 'vendor', 'ffmpeg');
|
||||||
|
const outputRoot = path.join(vendorRoot, lock.buildId);
|
||||||
|
await requireRealDirectory(repositoryRoot);
|
||||||
|
await requireRealDirectory(path.join(repositoryRoot, 'public'));
|
||||||
|
await requireRealDirectory(path.join(repositoryRoot, 'public', 'vendor'), true);
|
||||||
|
const existingVendorRoot = await lstat(vendorRoot).catch((error) => {
|
||||||
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
existingVendorRoot &&
|
||||||
|
(existingVendorRoot.isSymbolicLink() || !existingVendorRoot.isDirectory())
|
||||||
|
) {
|
||||||
|
throw new Error(`Generated FFmpeg vendor root is unsafe: ${vendorRoot}`);
|
||||||
|
}
|
||||||
|
await rm(vendorRoot, { force: true, recursive: true });
|
||||||
|
|
||||||
|
for (const asset of lock.assets) {
|
||||||
|
const relativeAssetPath = assertSafeRelativePath(asset.path);
|
||||||
|
const bytes = uncompressed[relativeAssetPath];
|
||||||
|
if (
|
||||||
|
!bytes ||
|
||||||
|
bytes.byteLength < 1 ||
|
||||||
|
bytes.byteLength > maximumAssetBytes ||
|
||||||
|
bytes.byteLength !== asset.bytes ||
|
||||||
|
sha256(bytes) !== asset.sha256
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Reviewed FFmpeg core entry failed locked verification: ${asset.path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const destination = path.join(outputRoot, ...relativeAssetPath.split('/'));
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
await writeFile(destination, bytes, { flag: 'wx', mode: 0o644 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionManifest = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
buildId: lock.buildId,
|
||||||
|
profile: lock.profile,
|
||||||
|
coreVersion: lock.coreVersion,
|
||||||
|
ffmpegVersion: lock.ffmpegVersion,
|
||||||
|
sourceArchive: lock.sourceArchive,
|
||||||
|
sourceLock: lock.sourceLock,
|
||||||
|
buildDriver: lock.buildDriver,
|
||||||
|
toolchain: lock.toolchain,
|
||||||
|
patch: lock.patch,
|
||||||
|
opusRegression: lock.opusRegression,
|
||||||
|
assets: lock.assets,
|
||||||
|
};
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(outputRoot, 'version.json'),
|
path.join(outputRoot, 'version.json'),
|
||||||
`${JSON.stringify(versionManifest, null, 2)}\n`
|
`${JSON.stringify(versionManifest, null, 2)}\n`,
|
||||||
|
{ flag: 'wx', mode: 0o644 }
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
|
`Verified and unpacked ${lock.assets.length} reviewed FFmpeg assets to ${path.relative(repositoryRoot, outputRoot)}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if (source.version !== packageJson.version) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
source.source?.repository !== 'https://git.add-ideas.de/zemion/av-tools' ||
|
source.source?.repository !== 'https://git.add-ideas.de/lotobo/av-tools' ||
|
||||||
source.source?.license !== 'GPL-3.0-or-later'
|
source.source?.license !== 'GPL-3.0-or-later'
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
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();
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
|||||||
const maximumEntryBytes = 256 * 1024 * 1024;
|
const maximumEntryBytes = 256 * 1024 * 1024;
|
||||||
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
|
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
|
||||||
const applicationLicenseSpdx = 'GPL-3.0-or-later';
|
const applicationLicenseSpdx = 'GPL-3.0-or-later';
|
||||||
const canonicalRepository = 'https://git.add-ideas.de/zemion/av-tools';
|
const canonicalRepository = 'https://git.add-ideas.de/lotobo/av-tools';
|
||||||
const canonicalPackageRepository =
|
const canonicalPackageRepository =
|
||||||
'git+https://git.add-ideas.de/zemion/av-tools.git';
|
'git+https://git.add-ideas.de/lotobo/av-tools.git';
|
||||||
const requiredNativeLegalFiles = [
|
const requiredNativeLegalFiles = [
|
||||||
'LICENSES/native/FFmpeg-LICENSE.md',
|
'LICENSES/native/FFmpeg-LICENSE.md',
|
||||||
'LICENSES/native/FreeType-LICENSE.txt',
|
'LICENSES/native/FreeType-LICENSE.txt',
|
||||||
@@ -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'}`
|
||||||
);
|
);
|
||||||
|
|||||||
819
src/App.tsx
819
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,21 @@
|
|||||||
import type { MediaProbe } from '../media';
|
import type { BrowserMediaMetadata, MediaProbe } from '../media';
|
||||||
|
|
||||||
export type AssetPhase = 'queued' | 'probing' | 'ready' | 'error';
|
/**
|
||||||
|
* State of the optional authoritative FFprobe inspection.
|
||||||
|
*
|
||||||
|
* The File and its object URL are usable in every phase. In particular,
|
||||||
|
* `error` means only that detailed metadata is unavailable; it never marks the
|
||||||
|
* local source or browser playback as failed.
|
||||||
|
*/
|
||||||
|
export type AssetPhase = 'idle' | 'probing' | 'ready' | 'error';
|
||||||
|
|
||||||
export interface ImportedMediaAsset {
|
export interface ImportedMediaAsset {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
objectUrl: string;
|
objectUrl: string;
|
||||||
phase: AssetPhase;
|
phase: AssetPhase;
|
||||||
|
browserMetadata?: BrowserMediaMetadata;
|
||||||
|
browserMetadataError?: string;
|
||||||
probe?: MediaProbe;
|
probe?: MediaProbe;
|
||||||
rawProbe?: string;
|
rawProbe?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -29,10 +38,47 @@ export function createAsset(file: File): ImportedMediaAsset {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
file,
|
file,
|
||||||
objectUrl: URL.createObjectURL(file),
|
objectUrl: URL.createObjectURL(file),
|
||||||
phase: 'queued',
|
phase: 'idle',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assetDurationSeconds(
|
||||||
|
asset: ImportedMediaAsset | undefined
|
||||||
|
): number | undefined {
|
||||||
|
return (
|
||||||
|
asset?.probe?.durationSeconds ?? asset?.browserMetadata?.durationSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetHasVideo(
|
||||||
|
asset: ImportedMediaAsset | undefined
|
||||||
|
): boolean | undefined {
|
||||||
|
if (asset?.probe) {
|
||||||
|
return asset.probe.streams.some(
|
||||||
|
(stream) =>
|
||||||
|
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (asset?.browserMetadata?.hasVideo !== undefined) {
|
||||||
|
return asset.browserMetadata.hasVideo;
|
||||||
|
}
|
||||||
|
if (asset?.browserMetadata?.kind === 'video') return true;
|
||||||
|
if (asset?.browserMetadata?.kind === 'audio') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetHasAudio(
|
||||||
|
asset: ImportedMediaAsset | undefined
|
||||||
|
): boolean | undefined {
|
||||||
|
if (asset?.probe) {
|
||||||
|
return asset.probe.streams.some((stream) => stream.type === 'audio');
|
||||||
|
}
|
||||||
|
if (asset?.browserMetadata?.hasAudio !== undefined) {
|
||||||
|
return asset.browserMetadata.hasAudio;
|
||||||
|
}
|
||||||
|
return asset?.browserMetadata?.kind === 'audio' ? true : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
|
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
|
||||||
if (bytes < 1_000) return `${bytes} B`;
|
if (bytes < 1_000) return `${bytes} B`;
|
||||||
|
|||||||
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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (crossfadeSeconds > 0) {
|
||||||
|
let videoLabel = 'v0';
|
||||||
|
let audioLabel = 'a0';
|
||||||
|
let effectiveDuration = ranges[0]?.durationSeconds ?? 0;
|
||||||
|
for (let index = 1; index < options.sources.length; index += 1) {
|
||||||
|
const finalTransition = index === options.sources.length - 1;
|
||||||
|
const nextVideoLabel = finalTransition ? 'vout' : `vxf${index}`;
|
||||||
|
graphParts.push(
|
||||||
|
`[${videoLabel}][v${index}]xfade=transition=fade:duration=${normalizeSeconds(crossfadeSeconds)}:offset=${normalizeSeconds(effectiveDuration - crossfadeSeconds)}[${nextVideoLabel}]`
|
||||||
|
);
|
||||||
|
videoLabel = nextVideoLabel;
|
||||||
|
if (includeAudio) {
|
||||||
|
const nextAudioLabel = finalTransition ? 'aout' : `axf${index}`;
|
||||||
|
graphParts.push(
|
||||||
|
`[${audioLabel}][a${index}]acrossfade=d=${normalizeSeconds(crossfadeSeconds)}:c1=tri:c2=tri[${nextAudioLabel}]`
|
||||||
|
);
|
||||||
|
audioLabel = nextAudioLabel;
|
||||||
|
}
|
||||||
|
effectiveDuration +=
|
||||||
|
(ranges[index]?.durationSeconds ?? 0) - crossfadeSeconds;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const labels = options.sources
|
const labels = options.sources
|
||||||
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
|
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
|
||||||
.join('');
|
.join('');
|
||||||
graphParts.push(
|
graphParts.push(
|
||||||
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
|
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
|
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
|
||||||
if (includeAudio) {
|
if (includeAudio) {
|
||||||
args.push('-map', '[aout]');
|
args.push('-map', '[aout]');
|
||||||
@@ -397,10 +429,9 @@ export function buildNormalizedConcatPlan(
|
|||||||
temporaryFiles: [],
|
temporaryFiles: [],
|
||||||
args,
|
args,
|
||||||
outputs: [output],
|
outputs: [output],
|
||||||
expectedDurationSeconds: ranges.reduce(
|
expectedDurationSeconds:
|
||||||
(sum, range) => sum + range.durationSeconds,
|
ranges.reduce((sum, range) => sum + range.durationSeconds, 0) -
|
||||||
0
|
crossfadeSeconds * (ranges.length - 1),
|
||||||
),
|
|
||||||
requiredCapabilities: mergeRequirements(presetRequirements, {
|
requiredCapabilities: mergeRequirements(presetRequirements, {
|
||||||
muxers: [options.targetMuxer],
|
muxers: [options.targetMuxer],
|
||||||
filters: [
|
filters: [
|
||||||
@@ -411,7 +442,8 @@ export function buildNormalizedConcatPlan(
|
|||||||
'setsar',
|
'setsar',
|
||||||
'fps',
|
'fps',
|
||||||
'format',
|
'format',
|
||||||
'concat',
|
...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']),
|
||||||
|
...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []),
|
||||||
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
|
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
|
||||||
...(mixedAudioPresence &&
|
...(mixedAudioPresence &&
|
||||||
options.missingAudioPolicy === 'insert-silence'
|
options.missingAudioPolicy === 'insert-silence'
|
||||||
@@ -423,7 +455,9 @@ export function buildNormalizedConcatPlan(
|
|||||||
diagnostic(
|
diagnostic(
|
||||||
'normalized-concat',
|
'normalized-concat',
|
||||||
'info',
|
'info',
|
||||||
'Clips are normalized to common video and audio properties, then re-encoded.'
|
crossfadeSeconds > 0
|
||||||
|
? `Clips are normalized and joined with a ${normalizeSeconds(crossfadeSeconds)} second audio/video crossfade at every edit.`
|
||||||
|
: 'Clips are normalized to common video and audio properties, then re-encoded.'
|
||||||
),
|
),
|
||||||
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
|
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
|
||||||
? [
|
? [
|
||||||
@@ -447,6 +481,34 @@ export function buildNormalizedConcatPlan(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateCrossfade(
|
||||||
|
crossfadeSeconds: number,
|
||||||
|
ranges: readonly { readonly durationSeconds: number }[]
|
||||||
|
): void {
|
||||||
|
if (!Number.isFinite(crossfadeSeconds) || crossfadeSeconds < 0) {
|
||||||
|
throw new RangeError('Crossfade duration must be finite and nonnegative');
|
||||||
|
}
|
||||||
|
if (crossfadeSeconds === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
crossfadeSeconds < MIN_CROSSFADE_SECONDS ||
|
||||||
|
crossfadeSeconds > MAX_CROSSFADE_SECONDS
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Crossfade duration must be ${MIN_CROSSFADE_SECONDS}–${MAX_CROSSFADE_SECONDS} seconds`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [index, range] of ranges.entries()) {
|
||||||
|
const required = index === 0 || index === ranges.length - 1 ? 1 : 2;
|
||||||
|
if (range.durationSeconds < crossfadeSeconds * required) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Clip ${index + 1} needs at least ${normalizeSeconds(crossfadeSeconds * required)} seconds for the requested crossfade material`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function comparableKeys(
|
function comparableKeys(
|
||||||
kind: ConcatStreamDescription['kind']
|
kind: ConcatStreamDescription['kind']
|
||||||
): readonly (keyof ConcatStreamDescription)[] {
|
): readonly (keyof ConcatStreamDescription)[] {
|
||||||
|
|||||||
@@ -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.'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface CapabilityPanelProps {
|
|||||||
preference: EnginePreference;
|
preference: EnginePreference;
|
||||||
resourcePolicy: ResourcePolicy;
|
resourcePolicy: ResourcePolicy;
|
||||||
deviceMemoryGiB?: number;
|
deviceMemoryGiB?: number;
|
||||||
|
disabled?: boolean;
|
||||||
onPreferenceChange: (preference: EnginePreference) => void;
|
onPreferenceChange: (preference: EnginePreference) => void;
|
||||||
onInitialize: () => void;
|
onInitialize: () => void;
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export function CapabilityPanel({
|
|||||||
preference,
|
preference,
|
||||||
resourcePolicy,
|
resourcePolicy,
|
||||||
deviceMemoryGiB,
|
deviceMemoryGiB,
|
||||||
|
disabled = false,
|
||||||
onPreferenceChange,
|
onPreferenceChange,
|
||||||
onInitialize,
|
onInitialize,
|
||||||
}: CapabilityPanelProps) {
|
}: CapabilityPanelProps) {
|
||||||
@@ -58,7 +60,12 @@ export function CapabilityPanel({
|
|||||||
Engine mode
|
Engine mode
|
||||||
<select
|
<select
|
||||||
value={preference}
|
value={preference}
|
||||||
disabled={state.status === 'running'}
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
state.status === 'loading' ||
|
||||||
|
state.status === 'recovering' ||
|
||||||
|
state.status === 'running'
|
||||||
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onPreferenceChange(event.currentTarget.value as EnginePreference)
|
onPreferenceChange(event.currentTarget.value as EnginePreference)
|
||||||
}
|
}
|
||||||
@@ -188,7 +195,11 @@ export function CapabilityPanel({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
{state.status === 'idle' ? (
|
{state.status === 'idle' ? (
|
||||||
<button className="button button--secondary" onClick={onInitialize}>
|
<button
|
||||||
|
className="button button--secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onInitialize}
|
||||||
|
>
|
||||||
Initialize engine
|
Initialize engine
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -59,10 +60,9 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
<span className="step-number">1</span>
|
<span className="step-number">1</span>
|
||||||
<h3>Add media</h3>
|
<h3>Add media</h3>
|
||||||
<p>
|
<p>
|
||||||
Pick or drop local files. The browser gives FFmpeg direct,
|
Pick or drop local files. Playback, file identity, duration and
|
||||||
read-only access when WORKERFS is available. If it must use the
|
dimensions use the browser first; FFmpeg stays unloaded until
|
||||||
bounded memory-copy fallback, the job log reports the exact copied
|
detailed streams or an export are requested.
|
||||||
byte count.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -70,7 +70,8 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
<h3>Choose an operation</h3>
|
<h3>Choose an operation</h3>
|
||||||
<p>
|
<p>
|
||||||
Quick Convert produces one output. Edit arranges non-destructive
|
Quick Convert produces one output. Edit arranges non-destructive
|
||||||
clips on a sequential timeline.
|
clips on a sequential timeline. Detailed inspection adds codecs,
|
||||||
|
complete streams, chapters and tags only when needed.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -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.
|
||||||
@@ -185,7 +196,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
</aside>
|
</aside>
|
||||||
<footer>
|
<footer>
|
||||||
<a
|
<a
|
||||||
href="https://git.add-ideas.de/zemion/av-tools"
|
href="https://git.add-ideas.de/lotobo/av-tools"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
import { formatBytes, formatDuration } from '../app/application-state';
|
import {
|
||||||
|
assetDurationSeconds,
|
||||||
|
assetHasVideo,
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
} from '../app/application-state';
|
||||||
import type {
|
import type {
|
||||||
AvProjectV1,
|
AvProjectV1,
|
||||||
FadeCurve,
|
FadeCurve,
|
||||||
@@ -39,6 +44,7 @@ export interface InspectorProps {
|
|||||||
onChaptersChange: (chapters: ProjectChapter[]) => void;
|
onChaptersChange: (chapters: ProjectChapter[]) => void;
|
||||||
onSubtitlesChange: (tracks: ProjectSubtitleTrack[]) => void;
|
onSubtitlesChange: (tracks: ProjectSubtitleTrack[]) => void;
|
||||||
onSubtitleImport?: (file: File) => void;
|
onSubtitleImport?: (file: File) => void;
|
||||||
|
onInspectDetails?: (assetId: string) => void;
|
||||||
onThumbnail: (request: ThumbnailRequest) => void;
|
onThumbnail: (request: ThumbnailRequest) => void;
|
||||||
currentPreviewTimeSeconds?: number;
|
currentPreviewTimeSeconds?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -74,6 +80,7 @@ export function Inspector({
|
|||||||
onChaptersChange,
|
onChaptersChange,
|
||||||
onSubtitlesChange,
|
onSubtitlesChange,
|
||||||
onSubtitleImport,
|
onSubtitleImport,
|
||||||
|
onInspectDetails,
|
||||||
onThumbnail,
|
onThumbnail,
|
||||||
currentPreviewTimeSeconds,
|
currentPreviewTimeSeconds,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -119,7 +126,13 @@ export function Inspector({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="inspector__body">
|
<div className="inspector__body">
|
||||||
{tab === 'media' ? <MediaInspector asset={asset} /> : null}
|
{tab === 'media' ? (
|
||||||
|
<MediaInspector
|
||||||
|
asset={asset}
|
||||||
|
disabled={disabled}
|
||||||
|
onInspectDetails={onInspectDetails}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{tab === 'transform' ? (
|
{tab === 'transform' ? (
|
||||||
<TransformInspector
|
<TransformInspector
|
||||||
clip={clip}
|
clip={clip}
|
||||||
@@ -159,7 +172,7 @@ export function Inspector({
|
|||||||
) : null}
|
) : null}
|
||||||
{tab === 'thumbnails' ? (
|
{tab === 'thumbnails' ? (
|
||||||
<ThumbnailInspector
|
<ThumbnailInspector
|
||||||
duration={asset?.probe?.durationSeconds}
|
duration={assetDurationSeconds(asset)}
|
||||||
currentPreviewTimeSeconds={currentPreviewTimeSeconds}
|
currentPreviewTimeSeconds={currentPreviewTimeSeconds}
|
||||||
disabled={disabled || !asset}
|
disabled={disabled || !asset}
|
||||||
onCreate={onThumbnail}
|
onCreate={onThumbnail}
|
||||||
@@ -170,20 +183,30 @@ export function Inspector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
function MediaInspector({
|
||||||
|
asset,
|
||||||
|
disabled,
|
||||||
|
onInspectDetails,
|
||||||
|
}: {
|
||||||
|
asset?: ImportedMediaAsset;
|
||||||
|
disabled: boolean;
|
||||||
|
onInspectDetails?: (assetId: string) => void;
|
||||||
|
}) {
|
||||||
if (!asset) return <p className="panel-empty">Select a media source.</p>;
|
if (!asset) return <p className="panel-empty">Select a media source.</p>;
|
||||||
const probe = asset.probe;
|
const probe = asset.probe;
|
||||||
|
const durationSeconds = assetDurationSeconds(asset);
|
||||||
|
const hasVideo = assetHasVideo(asset) === true;
|
||||||
|
const width =
|
||||||
|
probe?.streams.find((stream) => stream.type === 'video')?.width ??
|
||||||
|
asset.browserMetadata?.width;
|
||||||
|
const height =
|
||||||
|
probe?.streams.find((stream) => stream.type === 'video')?.height ??
|
||||||
|
asset.browserMetadata?.height;
|
||||||
return (
|
return (
|
||||||
<div className="inspector-section">
|
<div className="inspector-section">
|
||||||
<div className="inspector-file">
|
<div className="inspector-file">
|
||||||
<span>
|
<span>
|
||||||
<Icon
|
<Icon name={hasVideo ? 'video' : 'audio'} />
|
||||||
name={
|
|
||||||
probe?.streams.some((stream) => stream.type === 'video')
|
|
||||||
? 'video'
|
|
||||||
: 'audio'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{asset.file.name}</strong>
|
<strong>{asset.file.name}</strong>
|
||||||
@@ -191,6 +214,18 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>File type</dt>
|
||||||
|
<dd>
|
||||||
|
{asset.browserMetadata?.file.declaredMimeType &&
|
||||||
|
asset.browserMetadata.file.declaredMimeType !==
|
||||||
|
'application/octet-stream'
|
||||||
|
? asset.browserMetadata.file.declaredMimeType
|
||||||
|
: (asset.browserMetadata?.file.inferredMimeType ??
|
||||||
|
asset.browserMetadata?.file.declaredMimeType ??
|
||||||
|
(asset.file.type || '—'))}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Container</dt>
|
<dt>Container</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -199,8 +234,21 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Duration</dt>
|
<dt>Duration</dt>
|
||||||
<dd>{formatDuration(probe?.durationSeconds)}</dd>
|
<dd>
|
||||||
|
{formatDuration(durationSeconds)}
|
||||||
|
{!probe && durationSeconds !== undefined ? ' · browser' : ''}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
{hasVideo ? (
|
||||||
|
<div>
|
||||||
|
<dt>Dimensions</dt>
|
||||||
|
<dd>
|
||||||
|
{width !== undefined && height !== undefined
|
||||||
|
? `${width}×${height}${probe ? '' : ' · browser'}`
|
||||||
|
: '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<dt>Bit rate</dt>
|
<dt>Bit rate</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -210,6 +258,41 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{!probe ? (
|
||||||
|
<div
|
||||||
|
className={`notice ${
|
||||||
|
asset.phase === 'error' ? 'notice--warning' : ''
|
||||||
|
}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<Icon name={asset.phase === 'error' ? 'warning' : 'info'} />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{asset.phase === 'probing'
|
||||||
|
? 'Inspecting detailed media information…'
|
||||||
|
: asset.phase === 'error'
|
||||||
|
? 'Detailed inspection is unavailable.'
|
||||||
|
: 'Detailed information has not been inspected.'}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{asset.phase === 'error'
|
||||||
|
? asset.error
|
||||||
|
: 'Codecs, complete streams, chapters and tags are loaded only when needed.'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{asset.phase !== 'probing' && onInspectDetails ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onInspectDetails(asset.id)}
|
||||||
|
>
|
||||||
|
<Icon name="info" />
|
||||||
|
{asset.phase === 'error' ? 'Retry inspection' : 'Inspect details'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{probe && Object.keys(probe.tags).length > 0 ? (
|
{probe && Object.keys(probe.tags).length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<h3>Container tags</h3>
|
<h3>Container tags</h3>
|
||||||
@@ -223,7 +306,8 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</dl>
|
</dl>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<h3>Streams</h3>
|
{probe ? <h3>Streams</h3> : null}
|
||||||
|
{probe ? (
|
||||||
<ol className="inspector-streams">
|
<ol className="inspector-streams">
|
||||||
{probe?.streams.map((stream) => (
|
{probe?.streams.map((stream) => (
|
||||||
<li key={stream.index}>
|
<li key={stream.index}>
|
||||||
@@ -235,7 +319,8 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
{stream.type === 'video' ? (
|
{stream.type === 'video' ? (
|
||||||
<>
|
<>
|
||||||
<small>
|
<small>
|
||||||
Encoded pixels {stream.width ?? '?'}×{stream.height ?? '?'}
|
Encoded pixels {stream.width ?? '?'}×
|
||||||
|
{stream.height ?? '?'}
|
||||||
{stream.codedWidth !== undefined &&
|
{stream.codedWidth !== undefined &&
|
||||||
stream.codedHeight !== undefined
|
stream.codedHeight !== undefined
|
||||||
? ` · coded ${stream.codedWidth}×${stream.codedHeight}`
|
? ` · coded ${stream.codedWidth}×${stream.codedHeight}`
|
||||||
@@ -281,8 +366,9 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)) ?? <li>Waiting for inspection.</li>}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
) : null}
|
||||||
{probe && probe.chapters.length > 0 ? (
|
{probe && probe.chapters.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<h3>Source chapters</h3>
|
<h3>Source chapters</h3>
|
||||||
@@ -333,8 +419,8 @@ function TransformInspector({
|
|||||||
}) {
|
}) {
|
||||||
if (!clip) return <p className="panel-empty">Select a timeline clip.</p>;
|
if (!clip) return <p className="panel-empty">Select a timeline clip.</p>;
|
||||||
const video = asset?.probe?.streams.find((stream) => stream.type === 'video');
|
const video = asset?.probe?.streams.find((stream) => stream.type === 'video');
|
||||||
const sourceWidth = video?.width;
|
const sourceWidth = video?.width ?? asset?.browserMetadata?.width;
|
||||||
const sourceHeight = video?.height;
|
const sourceHeight = video?.height ?? asset?.browserMetadata?.height;
|
||||||
const canShowVisualCrop =
|
const canShowVisualCrop =
|
||||||
asset !== undefined &&
|
asset !== undefined &&
|
||||||
isPositiveSafeInteger(sourceWidth) &&
|
isPositiveSafeInteger(sourceWidth) &&
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
import { formatBytes, formatDuration } from '../app/application-state';
|
import {
|
||||||
|
assetDurationSeconds,
|
||||||
|
assetHasVideo,
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
} from '../app/application-state';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
export interface MediaBinProps {
|
export interface MediaBinProps {
|
||||||
assets: readonly ImportedMediaAsset[];
|
assets: readonly ImportedMediaAsset[];
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
onInspect: (id: string) => void;
|
||||||
|
inspectDisabled?: boolean;
|
||||||
|
removeDisabled?: boolean;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,6 +21,9 @@ export function MediaBin({
|
|||||||
assets,
|
assets,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onInspect,
|
||||||
|
inspectDisabled = false,
|
||||||
|
removeDisabled = false,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: MediaBinProps) {
|
}: MediaBinProps) {
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
@@ -27,9 +38,13 @@ export function MediaBin({
|
|||||||
return (
|
return (
|
||||||
<ul className="media-list" aria-label="Imported media">
|
<ul className="media-list" aria-label="Imported media">
|
||||||
{assets.map((asset) => {
|
{assets.map((asset) => {
|
||||||
|
const hasVideo = assetHasVideo(asset) === true;
|
||||||
const video = asset.probe?.streams.find(
|
const video = asset.probe?.streams.find(
|
||||||
(stream) => stream.type === 'video'
|
(stream) => stream.type === 'video'
|
||||||
);
|
);
|
||||||
|
const durationSeconds = assetDurationSeconds(asset);
|
||||||
|
const width = video?.width ?? asset.browserMetadata?.width;
|
||||||
|
const height = video?.height ?? asset.browserMetadata?.height;
|
||||||
const audioCount =
|
const audioCount =
|
||||||
asset.probe?.streams.filter((stream) => stream.type === 'audio')
|
asset.probe?.streams.filter((stream) => stream.type === 'audio')
|
||||||
.length ?? 0;
|
.length ?? 0;
|
||||||
@@ -43,34 +58,67 @@ export function MediaBin({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
|
aria-pressed={selectedId === asset.id}
|
||||||
onClick={() => onSelect(asset.id)}
|
onClick={() => onSelect(asset.id)}
|
||||||
>
|
>
|
||||||
<span className="media-card__type">
|
<span className="media-card__type">
|
||||||
<Icon name={video ? 'video' : 'audio'} />
|
<Icon name={hasVideo ? 'video' : 'audio'} />
|
||||||
</span>
|
</span>
|
||||||
<span className="media-card__content">
|
<span className="media-card__content">
|
||||||
<strong title={asset.file.name}>{asset.file.name}</strong>
|
<strong title={asset.file.name}>{asset.file.name}</strong>
|
||||||
<small>
|
<small>
|
||||||
{formatBytes(asset.file.size)}
|
{formatBytes(asset.file.size)}
|
||||||
{asset.probe?.durationSeconds !== undefined
|
{durationSeconds !== undefined
|
||||||
? ` · ${formatDuration(asset.probe.durationSeconds)}`
|
? ` · ${formatDuration(durationSeconds)}`
|
||||||
: ''}
|
: ''}
|
||||||
</small>
|
</small>
|
||||||
<small className={`phase phase--${asset.phase}`}>
|
<small className={`phase phase--${asset.phase}`}>
|
||||||
{asset.phase === 'probing'
|
{asset.phase === 'probing'
|
||||||
? 'Inspecting locally…'
|
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Inspecting details…`
|
||||||
: asset.phase === 'ready'
|
: asset.phase === 'ready'
|
||||||
? `${video ? `${video.width ?? '?'}×${video.height ?? '?'}` : 'Audio'} · ${audioCount} audio`
|
? `${hasVideo ? `${width ?? '?'}×${height ?? '?'}` : 'Audio'} · ${audioCount} audio`
|
||||||
: asset.phase === 'error'
|
: asset.phase === 'error'
|
||||||
? asset.error
|
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Details unavailable`
|
||||||
: 'Queued'}
|
: asset.browserMetadataError
|
||||||
|
? 'File ready · Basic metadata unavailable'
|
||||||
|
: asset.browserMetadata
|
||||||
|
? 'Ready to play · Details on demand'
|
||||||
|
: 'File ready · Reading basic metadata…'}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{asset.phase !== 'ready' ? (
|
||||||
|
<button
|
||||||
|
className="icon-action"
|
||||||
|
type="button"
|
||||||
|
aria-label={`${asset.phase === 'error' ? 'Retry detailed inspection for' : 'Inspect details for'} ${asset.file.name}`}
|
||||||
|
title={
|
||||||
|
asset.phase === 'error'
|
||||||
|
? asset.error
|
||||||
|
: 'Read codecs, streams, chapters and tags'
|
||||||
|
}
|
||||||
|
disabled={inspectDisabled || asset.phase === 'probing'}
|
||||||
|
{...(asset.error
|
||||||
|
? { 'aria-describedby': `inspection-error-${asset.id}` }
|
||||||
|
: {})}
|
||||||
|
onClick={() => onInspect(asset.id)}
|
||||||
|
>
|
||||||
|
<Icon name={asset.phase === 'probing' ? 'queue' : 'info'} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{asset.error ? (
|
||||||
|
<span
|
||||||
|
className="visually-hidden"
|
||||||
|
id={`inspection-error-${asset.id}`}
|
||||||
|
>
|
||||||
|
{asset.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="icon-action"
|
className="icon-action"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${asset.file.name}`}
|
aria-label={`Remove ${asset.file.name}`}
|
||||||
|
disabled={removeDisabled}
|
||||||
onClick={() => onRemove(asset.id)}
|
onClick={() => onRemove(asset.id)}
|
||||||
>
|
>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
|
import { assetHasAudio, assetHasVideo } 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 +51,28 @@ 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(
|
: assetHasVideo(asset) !== undefined
|
||||||
(stream) =>
|
? assetHasVideo(asset) === true
|
||||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
: declaredSourceMimeType
|
||||||
) === true;
|
? 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;
|
: assetHasAudio(asset) !== undefined
|
||||||
|
? assetHasAudio(asset) === true
|
||||||
|
: 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 +203,12 @@ 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 · Detailed inspection runs separately.'
|
||||||
|
: asset?.phase === 'error'
|
||||||
|
? 'Local source preview · Detailed inspection unavailable.'
|
||||||
|
: 'Local source preview · Basic browser metadata'))}
|
||||||
</span>
|
</span>
|
||||||
{needsProxy && !proxySettings ? (
|
{needsProxy && !proxySettings ? (
|
||||||
<button type="button" className="text-button" onClick={onCreateProxy}>
|
<button type="button" className="text-button" onClick={onCreateProxy}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
|
import { assetDurationSeconds, assetHasVideo } from '../app/application-state';
|
||||||
import type { EngineState } from '../ffmpeg/ffmpeg.types';
|
import type { EngineState } from '../ffmpeg/ffmpeg.types';
|
||||||
import type { MediaStreamProbe } from '../media';
|
|
||||||
import type { StreamSelection } from '../commands/command-utils';
|
import type { StreamSelection } from '../commands/command-utils';
|
||||||
import {
|
import {
|
||||||
validateRemuxCompatibility,
|
validateRemuxCompatibility,
|
||||||
@@ -16,9 +16,14 @@ 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';
|
||||||
|
import {
|
||||||
|
quickStreamUnavailableReason,
|
||||||
|
type QuickOperation,
|
||||||
|
} from './quick-convert-selection';
|
||||||
|
|
||||||
export type QuickOperation = 'convert' | 'remux';
|
export type { QuickOperation } from './quick-convert-selection';
|
||||||
|
|
||||||
export interface QuickExportConfiguration {
|
export interface QuickExportConfiguration {
|
||||||
operation: QuickOperation;
|
operation: QuickOperation;
|
||||||
@@ -27,6 +32,11 @@ export interface QuickExportConfiguration {
|
|||||||
streamSelection: StreamSelection;
|
streamSelection: StreamSelection;
|
||||||
removeMetadata: boolean;
|
removeMetadata: boolean;
|
||||||
removeChapters: boolean;
|
removeChapters: boolean;
|
||||||
|
/**
|
||||||
|
* The source was not authoritatively inspected when the user submitted the
|
||||||
|
* form. Resolve a safe default selection from the on-demand probe.
|
||||||
|
*/
|
||||||
|
selectDetectedStreams?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuickConvertProps {
|
export interface QuickConvertProps {
|
||||||
@@ -35,6 +45,7 @@ export interface QuickConvertProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
queueing?: boolean;
|
queueing?: boolean;
|
||||||
presets?: readonly ExportPreset[];
|
presets?: readonly ExportPreset[];
|
||||||
|
onInspect?: () => void;
|
||||||
onExport: (configuration: QuickExportConfiguration) => void;
|
onExport: (configuration: QuickExportConfiguration) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +55,7 @@ export function QuickConvert({
|
|||||||
busy,
|
busy,
|
||||||
queueing = false,
|
queueing = false,
|
||||||
presets = BUILT_IN_PRESETS,
|
presets = BUILT_IN_PRESETS,
|
||||||
|
onInspect,
|
||||||
onExport,
|
onExport,
|
||||||
}: QuickConvertProps) {
|
}: QuickConvertProps) {
|
||||||
const [operation, setOperation] = useState<QuickOperation>('convert');
|
const [operation, setOperation] = useState<QuickOperation>('convert');
|
||||||
@@ -58,6 +70,7 @@ export function QuickConvert({
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const preset = presets.find((entry) => entry.id === presetId);
|
const preset = presets.find((entry) => entry.id === presetId);
|
||||||
|
const hasDetailedInformation = asset?.probe !== undefined;
|
||||||
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
|
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
|
||||||
const requestedStreams = useMemo(
|
const requestedStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -139,9 +152,12 @@ 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;
|
hasDetailedInformation &&
|
||||||
|
preset?.kind === 'audio' &&
|
||||||
|
effectiveSelection.audio.length === 0;
|
||||||
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
||||||
stream.type === 'video' ||
|
stream.type === 'video' ||
|
||||||
stream.type === 'audio' ||
|
stream.type === 'audio' ||
|
||||||
@@ -157,7 +173,8 @@ export function QuickConvert({
|
|||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
const remuxDiagnostics = remuxContainer
|
const remuxDiagnostics =
|
||||||
|
remuxContainer && hasDetailedInformation
|
||||||
? validateRemuxCompatibility(
|
? validateRemuxCompatibility(
|
||||||
{
|
{
|
||||||
streams: remuxStreams,
|
streams: remuxStreams,
|
||||||
@@ -177,16 +194,16 @@ export function QuickConvert({
|
|||||||
const remuxUnavailableReason = remuxMuxerMissing
|
const remuxUnavailableReason = remuxMuxerMissing
|
||||||
? `The loaded core is missing muxer ${remuxContainer}.`
|
? `The loaded core is missing muxer ${remuxContainer}.`
|
||||||
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
|
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
|
||||||
const hasSelectedStream = selectedStreams.size > 0;
|
const hasSelectedStream = !hasDetailedInformation || selectedStreams.size > 0;
|
||||||
const canExport =
|
const canExport =
|
||||||
asset?.phase === 'ready' &&
|
asset !== undefined &&
|
||||||
!busy &&
|
!busy &&
|
||||||
hasSelectedStream &&
|
hasSelectedStream &&
|
||||||
(operation === 'remux'
|
(operation === 'remux'
|
||||||
? !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 +216,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;
|
||||||
@@ -226,27 +243,23 @@ export function QuickConvert({
|
|||||||
{asset ? (
|
{asset ? (
|
||||||
<div className="source-summary">
|
<div className="source-summary">
|
||||||
<span className="source-summary__icon">
|
<span className="source-summary__icon">
|
||||||
<Icon
|
<Icon name={assetHasVideo(asset) === true ? 'video' : 'audio'} />
|
||||||
name={
|
|
||||||
asset.probe?.streams.some((stream) => stream.type === 'video')
|
|
||||||
? 'video'
|
|
||||||
: 'audio'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{asset.file.name}</strong>
|
<strong>{asset.file.name}</strong>
|
||||||
<span>
|
<span>
|
||||||
{formatBytes(asset.file.size)} ·{' '}
|
{formatBytes(asset.file.size)} ·{' '}
|
||||||
{formatDuration(asset.probe?.durationSeconds)}
|
{formatDuration(assetDurationSeconds(asset))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`phase phase--${asset.phase}`}>
|
<span className={`phase phase--${asset.phase}`}>
|
||||||
{asset.phase === 'ready'
|
{asset.phase === 'ready'
|
||||||
? 'Ready'
|
? 'Details ready'
|
||||||
|
: asset.phase === 'probing'
|
||||||
|
? 'Inspecting details'
|
||||||
: asset.phase === 'error'
|
: asset.phase === 'error'
|
||||||
? 'Could not inspect'
|
? 'Ready to play'
|
||||||
: 'Preparing'}
|
: 'Ready to play'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -338,14 +351,16 @@ export function QuickConvert({
|
|||||||
['ogg', 'Ogg (.ogg)'],
|
['ogg', 'Ogg (.ogg)'],
|
||||||
] as const
|
] as const
|
||||||
).map(([container, label]) => {
|
).map(([container, label]) => {
|
||||||
const diagnostics = validateRemuxCompatibility(
|
const diagnostics = hasDetailedInformation
|
||||||
|
? validateRemuxCompatibility(
|
||||||
{
|
{
|
||||||
streams: remuxStreams,
|
streams: remuxStreams,
|
||||||
hasChapters: Boolean(asset?.probe?.chapters.length),
|
hasChapters: Boolean(asset?.probe?.chapters.length),
|
||||||
},
|
},
|
||||||
container,
|
container,
|
||||||
effectiveSelection
|
effectiveSelection
|
||||||
);
|
)
|
||||||
|
: [];
|
||||||
const missingMuxer =
|
const missingMuxer =
|
||||||
engineState.status === 'ready' &&
|
engineState.status === 'ready' &&
|
||||||
!engineState.capabilities.muxers.has(container);
|
!engineState.capabilities.muxers.has(container);
|
||||||
@@ -408,7 +423,25 @@ export function QuickConvert({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p>Streams appear after local inspection.</p>
|
<div className="stream-picker__empty">
|
||||||
|
<p>
|
||||||
|
Streams will be identified only when you inspect details or
|
||||||
|
start this export.
|
||||||
|
</p>
|
||||||
|
{asset && asset.phase !== 'probing' && onInspect ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onInspect}
|
||||||
|
>
|
||||||
|
<Icon name="info" />
|
||||||
|
{asset.phase === 'error'
|
||||||
|
? 'Retry inspection'
|
||||||
|
: 'Inspect details now'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -449,17 +482,24 @@ export function QuickConvert({
|
|||||||
Select at least one audio stream for an audio-only preset.
|
Select at least one audio stream for an audio-only preset.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{asset?.phase === 'error' && !asset.probe ? (
|
||||||
|
<p className="notice notice--warning">
|
||||||
|
<Icon name="warning" />
|
||||||
|
Playback and basic file information remain available. Detailed
|
||||||
|
inspection can be retried when an export needs it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{!hasSelectedStream && streams.length > 0 ? (
|
{!hasSelectedStream && streams.length > 0 ? (
|
||||||
<p className="notice notice--error">
|
<p className="notice notice--error">
|
||||||
<Icon name="warning" />
|
<Icon name="warning" />
|
||||||
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}
|
||||||
|
|
||||||
@@ -472,7 +512,11 @@ export function QuickConvert({
|
|||||||
? 'Validated user preset'
|
? 'Validated user preset'
|
||||||
: 'Stream copy'}
|
: 'Stream copy'}
|
||||||
</strong>
|
</strong>
|
||||||
<span>Resource estimates are checked before allocation.</span>
|
<span>
|
||||||
|
{hasDetailedInformation
|
||||||
|
? 'Resource estimates are checked before allocation.'
|
||||||
|
: 'Detailed stream inspection will run before this export.'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="button button--primary button--large"
|
className="button button--primary button--large"
|
||||||
@@ -486,6 +530,9 @@ export function QuickConvert({
|
|||||||
streamSelection: effectiveSelection,
|
streamSelection: effectiveSelection,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
removeChapters,
|
removeChapters,
|
||||||
|
...(!hasDetailedInformation
|
||||||
|
? { selectDetectedStreams: true as const }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -495,47 +542,14 @@ export function QuickConvert({
|
|||||||
: queueing
|
: queueing
|
||||||
? 'Add to queue'
|
? 'Add to queue'
|
||||||
: operation === 'remux'
|
: operation === 'remux'
|
||||||
|
? hasDetailedInformation
|
||||||
? 'Remux'
|
? 'Remux'
|
||||||
: 'Convert'}
|
: 'Inspect & remux'
|
||||||
|
: hasDetailedInformation
|
||||||
|
? 'Convert'
|
||||||
|
: 'Inspect & convert'}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickStreamUnavailableReason(
|
|
||||||
stream: MediaStreamProbe,
|
|
||||||
operation: QuickOperation,
|
|
||||||
preset: ExportPreset | undefined
|
|
||||||
): string | undefined {
|
|
||||||
if (stream.type === 'unknown') {
|
|
||||||
return 'unknown stream kinds cannot be mapped safely.';
|
|
||||||
}
|
|
||||||
if (operation === 'remux') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (!preset) {
|
|
||||||
return 'choose an export preset first.';
|
|
||||||
}
|
|
||||||
if (stream.type === 'video' && !preset.video) {
|
|
||||||
return 'the selected preset creates audio only.';
|
|
||||||
}
|
|
||||||
if (stream.type === 'audio' && !preset.audio) {
|
|
||||||
return 'the selected preset creates video or images without audio.';
|
|
||||||
}
|
|
||||||
if (stream.type === 'subtitle') {
|
|
||||||
if (preset.subtitlePolicy === 'none') {
|
|
||||||
return 'the selected preset explicitly omits subtitles.';
|
|
||||||
}
|
|
||||||
if (preset.subtitlePolicy === 'burn-in') {
|
|
||||||
return 'burn-in requires an explicitly attached subtitle source.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(stream.type === 'attachment' || stream.type === 'data') &&
|
|
||||||
preset.container !== 'matroska'
|
|
||||||
) {
|
|
||||||
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
70
src/components/quick-convert-selection.ts
Normal file
70
src/components/quick-convert-selection.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { StreamSelection } from '../commands/command-utils';
|
||||||
|
import type { MediaProbe, MediaStreamProbe } from '../media';
|
||||||
|
import type { ExportPreset } from '../commands/convert';
|
||||||
|
|
||||||
|
export type QuickOperation = 'convert' | 'remux';
|
||||||
|
|
||||||
|
export function defaultQuickStreamSelection(
|
||||||
|
probe: MediaProbe,
|
||||||
|
operation: QuickOperation,
|
||||||
|
preset: ExportPreset | undefined
|
||||||
|
): StreamSelection {
|
||||||
|
const selected = probe.streams.filter(
|
||||||
|
(stream) =>
|
||||||
|
quickStreamUnavailableReason(stream, operation, preset) === undefined
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
video: selected
|
||||||
|
.filter((stream) => stream.type === 'video')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
audio: selected
|
||||||
|
.filter((stream) => stream.type === 'audio')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
subtitles: selected
|
||||||
|
.filter((stream) => stream.type === 'subtitle')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
attachments: selected
|
||||||
|
.filter((stream) => stream.type === 'attachment')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
data: selected
|
||||||
|
.filter((stream) => stream.type === 'data')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quickStreamUnavailableReason(
|
||||||
|
stream: MediaStreamProbe,
|
||||||
|
operation: QuickOperation,
|
||||||
|
preset: ExportPreset | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (stream.type === 'unknown') {
|
||||||
|
return 'unknown stream kinds cannot be mapped safely.';
|
||||||
|
}
|
||||||
|
if (operation === 'remux') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!preset) {
|
||||||
|
return 'choose an export preset first.';
|
||||||
|
}
|
||||||
|
if (stream.type === 'video' && !preset.video) {
|
||||||
|
return 'the selected preset creates audio only.';
|
||||||
|
}
|
||||||
|
if (stream.type === 'audio' && !preset.audio) {
|
||||||
|
return 'the selected preset creates video or images without audio.';
|
||||||
|
}
|
||||||
|
if (stream.type === 'subtitle') {
|
||||||
|
if (preset.subtitlePolicy === 'none') {
|
||||||
|
return 'the selected preset explicitly omits subtitles.';
|
||||||
|
}
|
||||||
|
if (preset.subtitlePolicy === 'burn-in') {
|
||||||
|
return 'burn-in requires an explicitly attached subtitle source.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(stream.type === 'attachment' || stream.type === 'data') &&
|
||||||
|
preset.container !== 'matroska'
|
||||||
|
) {
|
||||||
|
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -23,7 +23,11 @@ import type {
|
|||||||
import { createLoadConfig } from './load-config';
|
import { createLoadConfig } from './load-config';
|
||||||
import { LogBuffer } from './log-buffer';
|
import { LogBuffer } from './log-buffer';
|
||||||
import { normalizeProgress } from './progress-parser';
|
import { normalizeProgress } from './progress-parser';
|
||||||
import { describeMountedInputAccess, mountJobFiles } from './virtual-fs';
|
import {
|
||||||
|
describeMountedInputAccess,
|
||||||
|
mountJobFiles,
|
||||||
|
type MountedJobFiles,
|
||||||
|
} from './virtual-fs';
|
||||||
import {
|
import {
|
||||||
parseFFmpegProgress,
|
parseFFmpegProgress,
|
||||||
progressUpdateFromFFmpeg,
|
progressUpdateFromFFmpeg,
|
||||||
@@ -32,6 +36,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 +62,38 @@ export function shouldRecycleExecutionCore(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function probeTimeoutMilliseconds(inputBytes: number): number {
|
||||||
|
if (!Number.isFinite(inputBytes) || inputBytes <= 0) {
|
||||||
|
return MIN_PROBE_TIMEOUT_MILLISECONDS;
|
||||||
|
}
|
||||||
|
const sizeAwareBudget = Math.ceil(
|
||||||
|
(inputBytes / PROBE_BUDGET_BYTES_PER_SECOND) * 1_000
|
||||||
|
);
|
||||||
|
return Math.min(
|
||||||
|
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||||
|
Math.max(MIN_PROBE_TIMEOUT_MILLISECONDS, sizeAwareBudget)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firefox can leave this reviewed Emscripten core in a state where a later
|
||||||
|
* ffprobe call stalls after otherwise successful FFmpeg executions. A fresh
|
||||||
|
* worker/core completes the same probe promptly. Keep the workaround narrow
|
||||||
|
* so other browsers retain their warm-core startup path.
|
||||||
|
*/
|
||||||
|
export function shouldRefreshCoreBeforeProbe(userAgent?: string): boolean {
|
||||||
|
const resolvedUserAgent =
|
||||||
|
userAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent);
|
||||||
|
return /(?:^|\s)(?:Firefox|FxiOS)\//u.test(resolvedUserAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canReuseProbeCapabilities(
|
||||||
|
loadedMode: EngineMode,
|
||||||
|
cachedMode: EngineMode
|
||||||
|
): boolean {
|
||||||
|
return loadedMode === cachedMode;
|
||||||
|
}
|
||||||
|
|
||||||
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
||||||
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||||
return new FFmpeg();
|
return new FFmpeg();
|
||||||
@@ -82,6 +121,7 @@ export class EngineManager {
|
|||||||
#warning: Extract<EngineState, { status: 'ready' }>['warning'];
|
#warning: Extract<EngineState, { status: 'ready' }>['warning'];
|
||||||
#loadPromise: Promise<void> | null = null;
|
#loadPromise: Promise<void> | null = null;
|
||||||
#activeJobId: string | null = null;
|
#activeJobId: string | null = null;
|
||||||
|
#preparingOperation = false;
|
||||||
#activeExpectedDurationSeconds: number | undefined;
|
#activeExpectedDurationSeconds: number | undefined;
|
||||||
#activeStartedAtMilliseconds = 0;
|
#activeStartedAtMilliseconds = 0;
|
||||||
#machineProgressLines: string[] = [];
|
#machineProgressLines: string[] = [];
|
||||||
@@ -92,8 +132,14 @@ export class EngineManager {
|
|||||||
#generation = 0;
|
#generation = 0;
|
||||||
|
|
||||||
readonly #handleLog = (event: LogEvent): void => {
|
readonly #handleLog = (event: LogEvent): void => {
|
||||||
|
// Capability discovery emits thousands of individual lines. Forwarding
|
||||||
|
// each one into React while the engine is still loading can starve
|
||||||
|
// Firefox's worker message queue long enough for the following probe to
|
||||||
|
// hit its watchdog. queryCapabilities installs its own short-lived
|
||||||
|
// listener, so application job logs only need to be retained/published
|
||||||
|
// while an operation is running.
|
||||||
|
if (this.#state.status !== 'running') return;
|
||||||
this.#logs.append(event);
|
this.#logs.append(event);
|
||||||
if (this.#state.status === 'running') {
|
|
||||||
for (const line of event.message.split(/\r?\n/gu)) {
|
for (const line of event.message.split(/\r?\n/gu)) {
|
||||||
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
|
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
|
||||||
this.#machineProgressLines.push(line);
|
this.#machineProgressLines.push(line);
|
||||||
@@ -122,7 +168,6 @@ export class EngineManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (const listener of this.#logListeners) listener(event);
|
for (const listener of this.#logListeners) listener(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,10 +203,16 @@ export class EngineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPreference(preference: EnginePreference): void {
|
setPreference(preference: EnginePreference): void {
|
||||||
if (this.#state.status === 'running') {
|
if (
|
||||||
|
this.#activeJobId ||
|
||||||
|
this.#preparingOperation ||
|
||||||
|
this.#state.status === 'loading' ||
|
||||||
|
this.#state.status === 'recovering' ||
|
||||||
|
this.#state.status === 'running'
|
||||||
|
) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'busy',
|
'busy',
|
||||||
'Engine mode cannot be changed while a job is running.'
|
'Engine mode cannot be changed while another operation is active.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (preference !== this.#preference) {
|
if (preference !== this.#preference) {
|
||||||
@@ -187,7 +238,11 @@ export class EngineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.#activeJobId || this.#state.status === 'running') {
|
if (
|
||||||
|
this.#activeJobId ||
|
||||||
|
this.#preparingOperation ||
|
||||||
|
this.#state.status === 'running'
|
||||||
|
) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'busy',
|
'busy',
|
||||||
'The FFmpeg engine cannot be initialized while another operation is active.'
|
'The FFmpeg engine cannot be initialized while another operation is active.'
|
||||||
@@ -274,7 +329,11 @@ export class EngineManager {
|
|||||||
|
|
||||||
async #loadMode(
|
async #loadMode(
|
||||||
mode: EngineMode,
|
mode: EngineMode,
|
||||||
warning?: Extract<EngineState, { status: 'ready' }>['warning']
|
warning?: Extract<EngineState, { status: 'ready' }>['warning'],
|
||||||
|
cached?: {
|
||||||
|
readonly mode: EngineMode;
|
||||||
|
readonly capabilities: FFmpegCapabilities;
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const generation = ++this.#generation;
|
const generation = ++this.#generation;
|
||||||
this.#setState({
|
this.#setState({
|
||||||
@@ -301,9 +360,15 @@ export class EngineManager {
|
|||||||
status: 'loading',
|
status: 'loading',
|
||||||
mode,
|
mode,
|
||||||
progress: 0.75,
|
progress: 0.75,
|
||||||
stage: 'Inspecting codec capabilities',
|
stage:
|
||||||
|
cached && canReuseProbeCapabilities(mode, cached.mode)
|
||||||
|
? 'Restoring codec capabilities'
|
||||||
|
: 'Inspecting codec capabilities',
|
||||||
});
|
});
|
||||||
const capabilities = await queryCapabilities(engine);
|
const capabilities =
|
||||||
|
cached && canReuseProbeCapabilities(mode, cached.mode)
|
||||||
|
? cached.capabilities
|
||||||
|
: await queryCapabilities(engine);
|
||||||
if (generation !== this.#generation) return;
|
if (generation !== this.#generation) return;
|
||||||
this.#capabilities = capabilities;
|
this.#capabilities = capabilities;
|
||||||
this.#executionsSinceLoad = 0;
|
this.#executionsSinceLoad = 0;
|
||||||
@@ -313,27 +378,54 @@ export class EngineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async runJob(request: EngineJobRequest): Promise<EngineJobResult> {
|
async runJob(request: EngineJobRequest): Promise<EngineJobResult> {
|
||||||
if (this.#activeJobId) {
|
if (this.#activeJobId || this.#preparingOperation) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'busy',
|
'busy',
|
||||||
'Another FFmpeg operation is already running.'
|
'Another FFmpeg operation is already running.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
if (this.#activeJobId || this.#preparingOperation) {
|
||||||
|
throw new EngineError(
|
||||||
|
'busy',
|
||||||
|
'Another FFmpeg operation is already running.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prepared:
|
||||||
|
| {
|
||||||
|
readonly engine: FFmpegAdapter;
|
||||||
|
readonly mode: EngineMode;
|
||||||
|
readonly mounted: MountedJobFiles;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
const coreWorkload = request.coreWorkload ?? request.operation;
|
const coreWorkload = request.coreWorkload ?? request.operation;
|
||||||
await this.#recycleExecutionCoreIfNeeded(coreWorkload);
|
this.#preparingOperation = true;
|
||||||
|
try {
|
||||||
|
await this.#recycleExecutionCoreIfNeeded(coreWorkload, true);
|
||||||
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') {
|
||||||
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const mounted = await mountJobFiles(
|
const mounted = await mountJobFiles(
|
||||||
engine,
|
engine,
|
||||||
request.id,
|
request.id,
|
||||||
request.inputs,
|
request.inputs,
|
||||||
request.outputs.map(({ name }) => name)
|
request.outputs.map(({ name }) => name)
|
||||||
);
|
);
|
||||||
|
this.#activeJobId = request.id;
|
||||||
|
prepared = { engine, mode, mounted };
|
||||||
|
} finally {
|
||||||
|
this.#preparingOperation = false;
|
||||||
|
}
|
||||||
|
if (!prepared) {
|
||||||
|
throw new EngineError(
|
||||||
|
'filesystem-failed',
|
||||||
|
'Could not prepare the FFmpeg job.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { engine, mode, mounted } = prepared;
|
||||||
const generation = this.#generation;
|
const generation = this.#generation;
|
||||||
const temporaryPaths = (request.temporaryFiles ?? []).map(
|
const temporaryPaths = (request.temporaryFiles ?? []).map(
|
||||||
(temporary, index) => {
|
(temporary, index) => {
|
||||||
@@ -342,7 +434,6 @@ export class EngineManager {
|
|||||||
return `${mounted.workDirectory}/temporary-${index}.${extension}`;
|
return `${mounted.workDirectory}/temporary-${index}.${extension}`;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.#activeJobId = request.id;
|
|
||||||
this.#activeExpectedDurationSeconds = request.expectedDurationSeconds;
|
this.#activeExpectedDurationSeconds = request.expectedDurationSeconds;
|
||||||
this.#activeStartedAtMilliseconds = performance.now();
|
this.#activeStartedAtMilliseconds = performance.now();
|
||||||
this.#machineProgressLines = [];
|
this.#machineProgressLines = [];
|
||||||
@@ -521,8 +612,18 @@ export class EngineManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async probe(file: File, timeoutMilliseconds = 30_000): Promise<ProbeResult> {
|
async probe(
|
||||||
|
file: File,
|
||||||
|
timeoutMilliseconds = probeTimeoutMilliseconds(file.size),
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ProbeResult> {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
await this.initialize();
|
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') {
|
||||||
@@ -535,18 +636,79 @@ export class EngineManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.#activeJobId || this.#preparingOperation) {
|
||||||
|
throw new EngineError(
|
||||||
|
'busy',
|
||||||
|
'Another FFmpeg operation is already running.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prepared:
|
||||||
|
| {
|
||||||
|
readonly engine: FFmpegAdapter;
|
||||||
|
readonly mode: EngineMode;
|
||||||
|
readonly mounted: MountedJobFiles;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
this.#preparingOperation = true;
|
||||||
|
try {
|
||||||
|
await this.#refreshCoreBeforeProbe(signal);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
|
const refreshedEngine = this.#engine;
|
||||||
|
const refreshedMode = this.#mode;
|
||||||
|
if (
|
||||||
|
!refreshedEngine ||
|
||||||
|
!refreshedMode ||
|
||||||
|
this.#state.status !== 'ready'
|
||||||
|
) {
|
||||||
|
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
||||||
|
}
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
|
const mounted = await mountJobFiles(
|
||||||
|
refreshedEngine,
|
||||||
|
id,
|
||||||
|
[file],
|
||||||
|
['probe.json']
|
||||||
|
);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
await mounted.cleanup();
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
|
this.#activeJobId = `probe-${id}`;
|
||||||
|
prepared = {
|
||||||
|
engine: refreshedEngine,
|
||||||
|
mode: refreshedMode,
|
||||||
|
mounted,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.#preparingOperation = false;
|
||||||
|
}
|
||||||
|
if (!prepared) {
|
||||||
|
throw new EngineError(
|
||||||
|
'filesystem-failed',
|
||||||
|
'Could not prepare the media probe.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { engine: refreshedEngine, mode: refreshedMode, mounted } = prepared;
|
||||||
const probeOutput = mounted.outputPaths[0];
|
const probeOutput = mounted.outputPaths[0];
|
||||||
const inputPath = mounted.inputPaths[0];
|
const inputPath = mounted.inputPaths[0];
|
||||||
if (!probeOutput || !inputPath) {
|
if (!probeOutput || !inputPath) {
|
||||||
|
this.#activeJobId = null;
|
||||||
|
await mounted.cleanup();
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'filesystem-failed',
|
'filesystem-failed',
|
||||||
'Could not allocate probe paths.'
|
'Could not allocate probe paths.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const activeProbeId = `probe-${id}`;
|
const activeProbeId = this.#activeJobId;
|
||||||
this.#activeJobId = activeProbeId;
|
if (!activeProbeId) {
|
||||||
|
throw new EngineError(
|
||||||
|
'filesystem-failed',
|
||||||
|
'Could not reserve the media probe.'
|
||||||
|
);
|
||||||
|
}
|
||||||
this.#activeExpectedDurationSeconds = undefined;
|
this.#activeExpectedDurationSeconds = undefined;
|
||||||
this.#activeStartedAtMilliseconds = performance.now();
|
this.#activeStartedAtMilliseconds = performance.now();
|
||||||
this.#machineProgressLines = [];
|
this.#machineProgressLines = [];
|
||||||
@@ -554,7 +716,7 @@ export class EngineManager {
|
|||||||
this.#logs.clear();
|
this.#logs.clear();
|
||||||
this.#setState({
|
this.#setState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
mode,
|
mode: refreshedMode,
|
||||||
jobId: this.#activeJobId,
|
jobId: this.#activeJobId,
|
||||||
operation: 'Inspect media',
|
operation: 'Inspect media',
|
||||||
inputMode: mounted.inputMode,
|
inputMode: mounted.inputMode,
|
||||||
@@ -565,9 +727,17 @@ 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 refreshedEngine.ffprobe([
|
||||||
'-v',
|
'-v',
|
||||||
'error',
|
'error',
|
||||||
'-show_format',
|
'-show_format',
|
||||||
@@ -582,7 +752,7 @@ export class EngineManager {
|
|||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(
|
parsed = JSON.parse(
|
||||||
textFromFileData(await engine.readFile(probeOutput))
|
textFromFileData(await refreshedEngine.readFile(probeOutput))
|
||||||
) as unknown;
|
) as unknown;
|
||||||
} catch (readError) {
|
} catch (readError) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
@@ -630,6 +800,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,20 +818,24 @@ 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 = [];
|
||||||
this.#machineProgressSeen = false;
|
this.#machineProgressSeen = false;
|
||||||
if (this.#engine === engine) {
|
if (this.#engine === refreshedEngine) {
|
||||||
const cleanupSucceeded = await mounted.cleanup();
|
const cleanupSucceeded = await mounted.cleanup();
|
||||||
if (!cleanupSucceeded && this.#engine === engine) {
|
if (!cleanupSucceeded && this.#engine === refreshedEngine) {
|
||||||
this.#logs.append({
|
this.#logs.append({
|
||||||
type: 'stderr',
|
type: 'stderr',
|
||||||
message:
|
message:
|
||||||
'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.',
|
'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.',
|
||||||
});
|
});
|
||||||
this.#terminateEngine();
|
this.#terminateEngine();
|
||||||
this.#setState({ status: 'recovering', previousMode: mode });
|
this.#setState({
|
||||||
|
status: 'recovering',
|
||||||
|
previousMode: refreshedMode,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -665,7 +846,7 @@ export class EngineManager {
|
|||||||
if (capabilities) {
|
if (capabilities) {
|
||||||
this.#setState({
|
this.#setState({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode,
|
mode: refreshedMode,
|
||||||
capabilities,
|
capabilities,
|
||||||
warning: this.#warning,
|
warning: this.#warning,
|
||||||
});
|
});
|
||||||
@@ -675,6 +856,71 @@ export class EngineManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #refreshCoreBeforeProbe(signal?: AbortSignal): Promise<void> {
|
||||||
|
if (!shouldRefreshCoreBeforeProbe()) return;
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
|
const mode = this.#mode;
|
||||||
|
const capabilities = this.#capabilities;
|
||||||
|
const warning = this.#warning;
|
||||||
|
if (!mode || !capabilities || this.#state.status !== 'ready') {
|
||||||
|
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#terminateEngine();
|
||||||
|
this.#setState({ status: 'recovering', previousMode: mode, warning });
|
||||||
|
try {
|
||||||
|
await this.#loadMode(mode, warning, { mode, capabilities });
|
||||||
|
} catch (firstError) {
|
||||||
|
if (mode !== 'multithread') {
|
||||||
|
this.#terminateEngine();
|
||||||
|
const loadError = new EngineError(
|
||||||
|
'load-failed',
|
||||||
|
'The FFmpeg core could not be refreshed for media inspection.',
|
||||||
|
{
|
||||||
|
cause: firstError,
|
||||||
|
details: describeInitializationFailure(firstError),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.#setState({ status: 'error', error: loadError });
|
||||||
|
throw loadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#terminateEngine();
|
||||||
|
const fallbackWarning = {
|
||||||
|
code: 'multithread-load-failed' as const,
|
||||||
|
message:
|
||||||
|
'The multithread core failed to reload. The app recovered with the single-thread compatibility core.',
|
||||||
|
};
|
||||||
|
this.#setState({
|
||||||
|
status: 'recovering',
|
||||||
|
previousMode: 'multithread',
|
||||||
|
warning: fallbackWarning,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Capabilities are mode-specific; discover a fresh single-thread set.
|
||||||
|
await this.#loadMode('single-thread', fallbackWarning);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
const details = [
|
||||||
|
`Multithread core: ${describeInitializationFailure(firstError)}`,
|
||||||
|
`Single-thread core: ${describeInitializationFailure(fallbackError)}`,
|
||||||
|
].join('\n');
|
||||||
|
this.#terminateEngine();
|
||||||
|
const loadError = new EngineError(
|
||||||
|
'load-failed',
|
||||||
|
'Neither FFmpeg execution core could be refreshed for media inspection.',
|
||||||
|
{ cause: fallbackError, details }
|
||||||
|
);
|
||||||
|
this.#setState({ status: 'error', error: loadError });
|
||||||
|
throw loadError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async cancelActive(): Promise<void> {
|
async cancelActive(): Promise<void> {
|
||||||
const activeJobId = this.#activeJobId;
|
const activeJobId = this.#activeJobId;
|
||||||
const previousMode = this.#mode;
|
const previousMode = this.#mode;
|
||||||
@@ -714,7 +960,10 @@ export class EngineManager {
|
|||||||
this.#contactSheetsSinceLoad = 0;
|
this.#contactSheetsSinceLoad = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #recycleExecutionCoreIfNeeded(nextOperation: string): Promise<void> {
|
async #recycleExecutionCoreIfNeeded(
|
||||||
|
nextOperation: string,
|
||||||
|
operationReserved = false
|
||||||
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!shouldRecycleExecutionCore(
|
!shouldRecycleExecutionCore(
|
||||||
this.#executionsSinceLoad,
|
this.#executionsSinceLoad,
|
||||||
@@ -743,8 +992,15 @@ export class EngineManager {
|
|||||||
});
|
});
|
||||||
this.#terminateEngine();
|
this.#terminateEngine();
|
||||||
this.#setState({ status: 'recovering', previousMode });
|
this.#setState({ status: 'recovering', previousMode });
|
||||||
|
if (operationReserved) {
|
||||||
|
this.#loadPromise = this.#initializeInternal().finally(() => {
|
||||||
|
this.#loadPromise = null;
|
||||||
|
});
|
||||||
|
await this.#loadPromise;
|
||||||
|
} else {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#setState(state: EngineState): void {
|
#setState(state: EngineState): void {
|
||||||
this.#state = state;
|
this.#state = state;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -561,12 +561,16 @@ select:disabled {
|
|||||||
color: #2c7d63 !important;
|
color: #2c7d63 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phase--idle {
|
||||||
|
color: #2c7d63 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.phase--probing {
|
.phase--probing {
|
||||||
color: #8b6a20 !important;
|
color: #8b6a20 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase--error {
|
.phase--error {
|
||||||
color: var(--toolbox-danger) !important;
|
color: #8b6a20 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-bin__empty {
|
.media-bin__empty {
|
||||||
@@ -779,6 +783,18 @@ select:disabled {
|
|||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-picker__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-picker__empty .button {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.stream-kind {
|
.stream-kind {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1759,6 +1775,15 @@ progress::-moz-progress-bar {
|
|||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inspector-section > .notice {
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-section > .notice > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.inspector-section h3 {
|
.inspector-section h3 {
|
||||||
margin: 0.45rem 0 0;
|
margin: 0.45rem 0 0;
|
||||||
}
|
}
|
||||||
|
|||||||
282
src/media/browser-metadata.ts
Normal file
282
src/media/browser-metadata.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
isAudioMimeType,
|
||||||
|
isVideoMimeType,
|
||||||
|
mimeTypeFromFileName,
|
||||||
|
} from './mime-types';
|
||||||
|
|
||||||
|
export type BrowserMediaKind = 'audio' | 'video' | 'unknown';
|
||||||
|
|
||||||
|
export type BrowserMetadataErrorCode = 'aborted' | 'load-error' | 'timeout';
|
||||||
|
|
||||||
|
export interface BrowserMediaFileInfo {
|
||||||
|
readonly name: string;
|
||||||
|
readonly sizeBytes: number;
|
||||||
|
readonly declaredMimeType?: string;
|
||||||
|
readonly inferredMimeType?: string;
|
||||||
|
readonly lastModified: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight metadata exposed by the browser's native media element.
|
||||||
|
*
|
||||||
|
* This intentionally does not pretend to replace ffprobe: codec names,
|
||||||
|
* complete stream lists, chapters, tags and dispositions remain unknown.
|
||||||
|
*/
|
||||||
|
export interface BrowserMediaMetadata {
|
||||||
|
readonly source: 'browser';
|
||||||
|
readonly file: BrowserMediaFileInfo;
|
||||||
|
readonly kind: BrowserMediaKind;
|
||||||
|
readonly durationSeconds?: number;
|
||||||
|
readonly width?: number;
|
||||||
|
readonly height?: number;
|
||||||
|
readonly hasAudio?: boolean;
|
||||||
|
readonly hasVideo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserMetadataOptions {
|
||||||
|
readonly signal?: AbortSignal;
|
||||||
|
readonly timeoutMilliseconds?: number;
|
||||||
|
/**
|
||||||
|
* Test seam for the DOM-owned media element. Production callers normally
|
||||||
|
* leave this unset.
|
||||||
|
*/
|
||||||
|
readonly createMediaElement?: (
|
||||||
|
kind: Exclude<BrowserMediaKind, 'unknown'>
|
||||||
|
) => HTMLMediaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserMetadataError extends Error {
|
||||||
|
readonly code: BrowserMetadataErrorCode;
|
||||||
|
|
||||||
|
constructor(code: BrowserMetadataErrorCode, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BrowserMetadataError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BROWSER_METADATA_TIMEOUT_MILLISECONDS = 15_000;
|
||||||
|
|
||||||
|
interface MediaElementWithAudioHints extends HTMLMediaElement {
|
||||||
|
readonly audioTracks?: { readonly length: number };
|
||||||
|
readonly mozHasAudio?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function browserMediaFileInfo(file: File): BrowserMediaFileInfo {
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: normalizeMimeType(file.type),
|
||||||
|
inferredMimeType: mimeTypeFromFileName(file.name),
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferBrowserMediaKind(file: File): BrowserMediaKind {
|
||||||
|
const declaredMimeType = normalizeMimeType(file.type);
|
||||||
|
const inferredMimeType = mimeTypeFromFileName(file.name);
|
||||||
|
const mimeType =
|
||||||
|
declaredMimeType !== undefined &&
|
||||||
|
(isAudioMimeType(declaredMimeType) || isVideoMimeType(declaredMimeType))
|
||||||
|
? declaredMimeType
|
||||||
|
: inferredMimeType;
|
||||||
|
|
||||||
|
if (mimeType === undefined) return 'unknown';
|
||||||
|
if (isVideoMimeType(mimeType)) return 'video';
|
||||||
|
if (isAudioMimeType(mimeType)) return 'audio';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads only metadata the browser can expose without FFmpeg. The supplied
|
||||||
|
* object URL remains owned by the caller and is never revoked here.
|
||||||
|
*/
|
||||||
|
export function inspectBrowserMediaMetadata(
|
||||||
|
file: File,
|
||||||
|
objectUrl: string,
|
||||||
|
options: BrowserMetadataOptions = {}
|
||||||
|
): Promise<BrowserMediaMetadata> {
|
||||||
|
const declaredKind = inferBrowserMediaKind(file);
|
||||||
|
const elementKind = declaredKind === 'audio' ? 'audio' : 'video';
|
||||||
|
const createMediaElement =
|
||||||
|
options.createMediaElement ??
|
||||||
|
((kind: 'audio' | 'video') => document.createElement(kind));
|
||||||
|
const element = createMediaElement(elementKind);
|
||||||
|
const timeoutMilliseconds =
|
||||||
|
options.timeoutMilliseconds ??
|
||||||
|
DEFAULT_BROWSER_METADATA_TIMEOUT_MILLISECONDS;
|
||||||
|
|
||||||
|
if (options.signal?.aborted === true) {
|
||||||
|
return Promise.reject(abortedError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
element.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
element.removeEventListener('error', handleError);
|
||||||
|
element.removeEventListener('abort', handleElementAbort);
|
||||||
|
options.signal?.removeEventListener('abort', handleSignalAbort);
|
||||||
|
element.removeAttribute('src');
|
||||||
|
try {
|
||||||
|
element.load();
|
||||||
|
} catch {
|
||||||
|
// The caller owns the object URL; releasing this detached element is
|
||||||
|
// best-effort and must not turn successful metadata into a failure.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finish = (
|
||||||
|
outcome:
|
||||||
|
| { readonly ok: true; readonly value: BrowserMediaMetadata }
|
||||||
|
| { readonly ok: false; readonly error: BrowserMetadataError }
|
||||||
|
) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
if (outcome.ok) resolve(outcome.value);
|
||||||
|
else reject(outcome.error);
|
||||||
|
};
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
finish({
|
||||||
|
ok: true,
|
||||||
|
value: metadataFromElement(file, declaredKind, element),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleError = () => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'load-error',
|
||||||
|
`The browser could not read metadata from ${file.name}.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleElementAbort = () => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'load-error',
|
||||||
|
`The browser stopped reading metadata from ${file.name}.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleSignalAbort = () => {
|
||||||
|
finish({ ok: false, error: abortedError() });
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
element.addEventListener('error', handleError);
|
||||||
|
element.addEventListener('abort', handleElementAbort);
|
||||||
|
options.signal?.addEventListener('abort', handleSignalAbort, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'timeout',
|
||||||
|
`Browser metadata loading exceeded ${String(
|
||||||
|
Math.ceil(timeoutMilliseconds / 1_000)
|
||||||
|
)} seconds.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Math.max(0, timeoutMilliseconds)
|
||||||
|
);
|
||||||
|
|
||||||
|
element.preload = 'metadata';
|
||||||
|
element.src = objectUrl;
|
||||||
|
try {
|
||||||
|
element.load();
|
||||||
|
} catch {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'load-error',
|
||||||
|
`The browser could not start reading metadata from ${file.name}.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataFromElement(
|
||||||
|
file: File,
|
||||||
|
declaredKind: BrowserMediaKind,
|
||||||
|
element: HTMLMediaElement
|
||||||
|
): BrowserMediaMetadata {
|
||||||
|
const isVideoElement = element instanceof HTMLVideoElement;
|
||||||
|
const width = isVideoElement
|
||||||
|
? positiveFiniteNumber(element.videoWidth)
|
||||||
|
: undefined;
|
||||||
|
const height = isVideoElement
|
||||||
|
? positiveFiniteNumber(element.videoHeight)
|
||||||
|
: undefined;
|
||||||
|
// A zero-sized <video> can still be the correct, playable element for an
|
||||||
|
// audio-only MP4 or another video container. Treat missing dimensions as
|
||||||
|
// inconclusive instead of claiming that no video-style player is usable.
|
||||||
|
const hasVideo = isVideoElement
|
||||||
|
? width !== undefined
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
: false;
|
||||||
|
const hasAudio = audioPresence(element, isVideoElement);
|
||||||
|
const kind =
|
||||||
|
hasVideo === true
|
||||||
|
? 'video'
|
||||||
|
: hasAudio === true || element instanceof HTMLAudioElement
|
||||||
|
? 'audio'
|
||||||
|
: declaredKind;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'browser',
|
||||||
|
file: browserMediaFileInfo(file),
|
||||||
|
kind,
|
||||||
|
durationSeconds: nonNegativeFiniteNumber(element.duration),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
hasAudio,
|
||||||
|
hasVideo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioPresence(
|
||||||
|
element: HTMLMediaElement,
|
||||||
|
isVideoElement: boolean
|
||||||
|
): boolean | undefined {
|
||||||
|
if (!isVideoElement) return true;
|
||||||
|
|
||||||
|
const withAudioHints = element as MediaElementWithAudioHints;
|
||||||
|
if (withAudioHints.audioTracks !== undefined) {
|
||||||
|
return withAudioHints.audioTracks.length > 0;
|
||||||
|
}
|
||||||
|
if (typeof withAudioHints.mozHasAudio === 'boolean') {
|
||||||
|
return withAudioHints.mozHasAudio;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMimeType(mimeType: string): string | undefined {
|
||||||
|
const normalized = mimeType.trim().toLowerCase().split(';')[0]?.trim();
|
||||||
|
return normalized === undefined || normalized.length === 0
|
||||||
|
? undefined
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveFiniteNumber(value: number): number | undefined {
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeFiniteNumber(value: number): number | undefined {
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortedError(): BrowserMetadataError {
|
||||||
|
return new BrowserMetadataError(
|
||||||
|
'aborted',
|
||||||
|
'Browser metadata loading was cancelled.'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './browser-playback';
|
export * from './browser-playback';
|
||||||
|
export * from './browser-metadata';
|
||||||
export * from './chapter-time-base';
|
export * from './chapter-time-base';
|
||||||
export * from './duration';
|
export * from './duration';
|
||||||
export * from './media.types';
|
export * from './media.types';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||||
"schemaVersion": 1,
|
"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.3.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",
|
||||||
@@ -37,21 +37,21 @@
|
|||||||
"telemetry": false
|
"telemetry": false
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/zemion/av-tools",
|
"repository": "https://git.add-ideas.de/lotobo/av-tools",
|
||||||
"license": "GPL-3.0-or-later"
|
"license": "GPL-3.0-or-later"
|
||||||
},
|
},
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"id": "source",
|
"id": "source",
|
||||||
"label": "Source",
|
"label": "Source",
|
||||||
"url": "https://git.add-ideas.de/zemion/av-tools"
|
"url": "https://git.add-ideas.de/lotobo/av-tools"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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.3.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;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
observeRuntime,
|
observeRuntime,
|
||||||
|
PRIMARY_FIXTURE,
|
||||||
startNestedStaticServer,
|
startNestedStaticServer,
|
||||||
type NestedStaticServer,
|
type NestedStaticServer,
|
||||||
} from './browser-helpers';
|
} from './browser-helpers';
|
||||||
@@ -119,6 +120,38 @@ test.describe('static application and Toolbox shell', () => {
|
|||||||
await expect(inspectorDrawer).toBeHidden();
|
await expect(inspectorDrawer).toBeHidden();
|
||||||
await expect(inspector).toBeFocused();
|
await expect(inspector).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows native preview metadata without loading the FFmpeg runtime', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const runtime = observeRuntime(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"][accept*="audio"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
|
||||||
|
await expect(page.locator('.quick-preview video')).toBeVisible();
|
||||||
|
const card = page
|
||||||
|
.locator('.media-card')
|
||||||
|
.filter({ hasText: 'pattern-av.mp4' });
|
||||||
|
await expect(card.locator('.phase--idle')).toContainText(
|
||||||
|
'Ready to play · Details on demand'
|
||||||
|
);
|
||||||
|
await expect(card).toContainText(/\d+:\d{2}\.\d{2}/u);
|
||||||
|
expect(
|
||||||
|
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Inspect & render timeline' })
|
||||||
|
).toBeEnabled();
|
||||||
|
expect(
|
||||||
|
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('nested static deployment and Toolbox context', () => {
|
test.describe('nested static deployment and Toolbox context', () => {
|
||||||
@@ -191,12 +224,12 @@ test.describe('nested static deployment and Toolbox context', () => {
|
|||||||
|
|
||||||
const coreScript = runtime.responses.find((response) =>
|
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({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
|
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
export const REPOSITORY_ROOT = resolve(
|
export const REPOSITORY_ROOT = resolve(
|
||||||
dirname(fileURLToPath(import.meta.url)),
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
@@ -19,6 +20,20 @@ export const GENERATED_FIXTURES = join(
|
|||||||
);
|
);
|
||||||
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
|
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
|
||||||
|
|
||||||
|
export async function inspectMediaDetails(
|
||||||
|
page: Page,
|
||||||
|
fileName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const card = page.locator('.media-card').filter({ hasText: fileName });
|
||||||
|
const inspect = page.getByRole('button', {
|
||||||
|
name: `Inspect details for ${fileName}`,
|
||||||
|
});
|
||||||
|
if ((await inspect.count()) > 0) await inspect.click();
|
||||||
|
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const NESTED_PREFIX = '/deep/nested/av/';
|
const NESTED_PREFIX = '/deep/nested/av/';
|
||||||
const SECURITY_HEADERS = {
|
const SECURITY_HEADERS = {
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
@@ -112,7 +127,7 @@ export async function startNestedStaticServer(): Promise<NestedStaticServer> {
|
|||||||
};
|
};
|
||||||
const catalog = {
|
const catalog = {
|
||||||
$schema:
|
$schema:
|
||||||
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
id: 'de.add-ideas.browser-test-toolbox',
|
id: 'de.add-ideas.browser-test-toolbox',
|
||||||
name: 'Browser Test Toolbox',
|
name: 'Browser Test Toolbox',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
import {
|
import {
|
||||||
createCancellationFixture,
|
createCancellationFixture,
|
||||||
GENERATED_FIXTURES,
|
GENERATED_FIXTURES,
|
||||||
|
inspectMediaDetails,
|
||||||
observeRuntime,
|
observeRuntime,
|
||||||
PRIMARY_FIXTURE,
|
PRIMARY_FIXTURE,
|
||||||
type TemporaryMediaFixture,
|
type TemporaryMediaFixture,
|
||||||
@@ -75,9 +76,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,16 +88,21 @@ 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);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||||
@@ -119,8 +125,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 +154,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([]);
|
||||||
@@ -175,8 +186,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await expect(page.locator('.source-summary')).toContainText(
|
await expect(page.locator('.source-summary')).toContainText(
|
||||||
@@ -247,7 +259,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);
|
||||||
@@ -264,6 +278,7 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
await mediaInput(page)
|
await mediaInput(page)
|
||||||
.first()
|
.first()
|
||||||
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
||||||
|
await inspectMediaDetails(page, 'compatible-a.mp4');
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
@@ -303,7 +318,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);
|
||||||
@@ -316,8 +333,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await selectSingleThread(page);
|
await selectSingleThread(page);
|
||||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||||
|
await inspectMediaDetails(page, basename(cancellationFixture.filePath));
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -344,8 +362,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||||
@@ -356,4 +375,49 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
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 page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: `Inspect details for ${activeName}`,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(queuedCard.locator('.phase--idle')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Remove pattern-av.mp4' }).click();
|
||||||
|
await page.getByRole('button', { name: `Remove ${activeName}` }).click();
|
||||||
|
await expect(page.locator('.media-card')).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Choose files' })
|
||||||
|
).toBeEnabled({ timeout: 120_000 });
|
||||||
|
|
||||||
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
|
'Details ready',
|
||||||
|
{ timeout: 120_000 }
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
|
assertLocalOnly(runtime);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
GENERATED_FIXTURES,
|
GENERATED_FIXTURES,
|
||||||
|
inspectMediaDetails,
|
||||||
observeRuntime,
|
observeRuntime,
|
||||||
PRIMARY_FIXTURE,
|
PRIMARY_FIXTURE,
|
||||||
type RuntimeObservation,
|
type RuntimeObservation,
|
||||||
@@ -51,8 +52,9 @@ test.describe
|
|||||||
await mediaInput(page).setInputFiles(
|
await mediaInput(page).setInputFiles(
|
||||||
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
||||||
);
|
);
|
||||||
|
await inspectMediaDetails(page, 'attached-cover.mp3');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 60_000 }
|
{ timeout: 60_000 }
|
||||||
);
|
);
|
||||||
await expect(page.locator('.source-summary')).toContainText(
|
await expect(page.locator('.source-summary')).toContainText(
|
||||||
@@ -108,13 +110,23 @@ test.describe
|
|||||||
await mediaInput(page).setInputFiles(
|
await mediaInput(page).setInputFiles(
|
||||||
join(GENERATED_FIXTURES, 'truncated.mp4')
|
join(GENERATED_FIXTURES, 'truncated.mp4')
|
||||||
);
|
);
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Inspect details for truncated.mp4',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
const malformedCard = page
|
const malformedCard = page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
.filter({ hasText: 'truncated.mp4' });
|
.filter({ hasText: 'truncated.mp4' });
|
||||||
await expect(malformedCard.locator('.phase--error')).toContainText(
|
await expect(malformedCard.locator('.phase--error')).toContainText(
|
||||||
'ffprobe did not find any media streams.',
|
'File ready · Details unavailable',
|
||||||
{ timeout: 30_000 }
|
{ timeout: 30_000 }
|
||||||
);
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'Retry detailed inspection for truncated.mp4',
|
||||||
|
})
|
||||||
|
).toHaveAttribute('title', 'ffprobe did not find any media streams.');
|
||||||
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
@@ -123,6 +135,7 @@ test.describe
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
const validCard = page
|
const validCard = page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
.filter({ hasText: 'pattern-av.mp4' });
|
.filter({ hasText: 'pattern-av.mp4' });
|
||||||
|
|||||||
71
tests/browser/large-media-preview.spec.ts
Normal file
71
tests/browser/large-media-preview.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const mediaPath = process.env.AV_TOOLS_LARGE_MEDIA;
|
||||||
|
|
||||||
|
test('plays a recognized large local video before detailed inspection', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.skip(
|
||||||
|
!mediaPath || !existsSync(mediaPath),
|
||||||
|
'Set AV_TOOLS_LARGE_MEDIA to a local video for this opt-in evidence run.'
|
||||||
|
);
|
||||||
|
test.setTimeout(360_000);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"][accept*="audio"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles(mediaPath as string);
|
||||||
|
const name = basename(mediaPath as string);
|
||||||
|
const card = page.locator('.media-card').filter({ hasText: name });
|
||||||
|
const preview = page.locator('.preview video');
|
||||||
|
await expect(preview).toBeVisible({ timeout: 15_000 });
|
||||||
|
const previewVisibleMilliseconds = Date.now() - startedAt;
|
||||||
|
expect(previewVisibleMilliseconds).toBeLessThan(15_000);
|
||||||
|
await expect(card.locator('.phase--idle')).toContainText('Details on demand');
|
||||||
|
await expect(card).toContainText(/43:42\.\d{2}/u, { timeout: 15_000 });
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
performance
|
||||||
|
.getEntriesByType('resource')
|
||||||
|
.some((entry) => entry.name.includes('/vendor/ffmpeg/'))
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
const elementIdentity = await preview.evaluate((element) => {
|
||||||
|
element.dataset.evidenceIdentity = crypto.randomUUID();
|
||||||
|
return element.dataset.evidenceIdentity;
|
||||||
|
});
|
||||||
|
await preview.evaluate(async (element) => {
|
||||||
|
const video = element as HTMLVideoElement;
|
||||||
|
video.muted = true;
|
||||||
|
await video.play();
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
preview.evaluate(
|
||||||
|
(element) => (element as HTMLVideoElement).currentTime
|
||||||
|
),
|
||||||
|
{
|
||||||
|
timeout: 15_000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: `Inspect details for ${name}` })
|
||||||
|
.click();
|
||||||
|
await expect(card.locator('.phase--probing')).toBeVisible();
|
||||||
|
await expect(card.locator('.phase--ready')).toBeVisible({ timeout: 120_000 });
|
||||||
|
await expect(preview).toHaveAttribute(
|
||||||
|
'data-evidence-identity',
|
||||||
|
elementIdentity
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -31,6 +31,9 @@ async function importMedia(
|
|||||||
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
|
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const card = page.locator('.media-card').filter({ hasText: name });
|
const card = page.locator('.media-card').filter({ hasText: name });
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: `Inspect details for ${name}` })
|
||||||
|
.click();
|
||||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
@@ -149,7 +152,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(
|
||||||
@@ -210,6 +213,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
|||||||
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
|
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
|
||||||
const sourceName = basename(cancellationFixture.filePath);
|
const sourceName = basename(cancellationFixture.filePath);
|
||||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: `Inspect details for ${sourceName}`,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
@@ -547,6 +555,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 +720,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 () => {
|
||||||
@@ -964,6 +1063,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
|||||||
mimeType: 'video/x-matroska',
|
mimeType: 'video/x-matroska',
|
||||||
buffer: readFileSync(muxed.filePath),
|
buffer: readFileSync(muxed.filePath),
|
||||||
});
|
});
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Inspect details for subtitled-source.mkv',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
@@ -988,7 +1092,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);
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ async function runMode(
|
|||||||
|
|
||||||
const probeStarted = await monotonicNow(page);
|
const probeStarted = await monotonicNow(page);
|
||||||
await mediaInput(page).setInputFiles(fixturePath);
|
await mediaInput(page).setInputFiles(fixturePath);
|
||||||
|
await page.getByRole('button', { name: /^Inspect details for /u }).click();
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
const probeMs = (await monotonicNow(page)) - probeStarted;
|
const probeMs = (await monotonicNow(page)) - probeStarted;
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
|||||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||||
|
|
||||||
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||||
|
.click();
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
||||||
@@ -47,6 +50,9 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
|||||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||||
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
||||||
await mediaInput(page).setInputFiles(compatibleB);
|
await mediaInput(page).setInputFiles(compatibleB);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||||
|
.click();
|
||||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
@@ -127,6 +133,12 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
|||||||
|
|
||||||
page.on('dialog', (dialog) => void dialog.accept());
|
page.on('dialog', (dialog) => void dialog.accept());
|
||||||
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||||
|
.click();
|
||||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
93
tests/unit/components/media-bin.test.tsx
Normal file
93
tests/unit/components/media-bin.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ImportedMediaAsset } from '../../../src/app/application-state';
|
||||||
|
import { MediaBin } from '../../../src/components/MediaBin';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('MediaBin', () => {
|
||||||
|
it('shows browser metadata and offers optional detailed inspection', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onInspect = vi.fn();
|
||||||
|
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'lecture',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:lecture',
|
||||||
|
phase: 'idle',
|
||||||
|
browserMetadata: {
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: file.type,
|
||||||
|
inferredMimeType: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 2_622.48,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
hasVideo: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MediaBin
|
||||||
|
assets={[asset]}
|
||||||
|
selectedId={asset.id}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onInspect={onInspect}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
|
||||||
|
expect(screen.getByText('Ready to play · Details on demand')).toBeVisible();
|
||||||
|
expect(screen.getByText('lecture.mp4').closest('button')).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Inspect details for lecture.mp4',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onInspect).toHaveBeenCalledWith(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a source ready to play after detailed inspection fails', () => {
|
||||||
|
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'lecture',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:lecture',
|
||||||
|
phase: 'error',
|
||||||
|
error: 'Media inspection exceeded its safety limit.',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MediaBin
|
||||||
|
assets={[asset]}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onInspect={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('File ready · Details unavailable')).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Retry detailed inspection for lecture.mp4',
|
||||||
|
})
|
||||||
|
).toBeEnabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Retry detailed inspection for lecture.mp4',
|
||||||
|
})
|
||||||
|
).toHaveAccessibleDescription(
|
||||||
|
'Media inspection exceeded its safety limit.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,99 @@ 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 · Detailed inspection runs separately.'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MediaPreview
|
||||||
|
asset={{
|
||||||
|
...videoAsset(),
|
||||||
|
id: probingAsset.id,
|
||||||
|
file: probingAsset.file,
|
||||||
|
objectUrl: probingAsset.objectUrl,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).toBe(progressiveVideo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps browser playback mounted after detailed inspection fails', () => {
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'asset-probe-error',
|
||||||
|
file: new File(['video'], 'lecture.mp4', { type: 'video/mp4' }),
|
||||||
|
objectUrl: 'blob:probe-error',
|
||||||
|
phase: 'error',
|
||||||
|
error: 'Detailed inspection timed out.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<MediaPreview asset={asset} />);
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
asset.objectUrl
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Local source preview · Detailed inspection unavailable.'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the video-container player when browser metadata has no track hints', () => {
|
||||||
|
const file = new File(['audio-only'], 'audio-only.mp4', {
|
||||||
|
type: 'video/mp4',
|
||||||
|
});
|
||||||
|
const initial: ImportedMediaAsset = {
|
||||||
|
id: 'asset-audio-only-container',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:audio-only-container',
|
||||||
|
phase: 'idle',
|
||||||
|
};
|
||||||
|
const { container, rerender } = render(<MediaPreview asset={initial} />);
|
||||||
|
const player = requiredElement(container.querySelector('video'));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MediaPreview
|
||||||
|
asset={{
|
||||||
|
...initial,
|
||||||
|
browserMetadata: {
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: file.type,
|
||||||
|
inferredMimeType: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 8,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).toBe(player);
|
||||||
|
});
|
||||||
|
|
||||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
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();
|
||||||
|
|||||||
@@ -16,6 +16,66 @@ import type { UserExportPreset } from '../../../src/presets';
|
|||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('QuickConvert', () => {
|
describe('QuickConvert', () => {
|
||||||
|
it('keeps an uninspected source usable and requests stream detection with export', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onInspect = vi.fn();
|
||||||
|
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||||
|
const file = new File(['media'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'asset-basic',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:asset-basic',
|
||||||
|
phase: 'idle',
|
||||||
|
browserMetadata: {
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: file.type,
|
||||||
|
inferredMimeType: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 2_622.48,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
hasVideo: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QuickConvert
|
||||||
|
asset={asset}
|
||||||
|
engineState={readyEngine()}
|
||||||
|
busy={false}
|
||||||
|
onInspect={onInspect}
|
||||||
|
onExport={onExport}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
|
||||||
|
expect(screen.getByText('Ready to play')).toBeVisible();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Inspect details now' })
|
||||||
|
);
|
||||||
|
expect(onInspect).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Inspect & convert' }));
|
||||||
|
expect(onExport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
operation: 'convert',
|
||||||
|
selectDetectedStreams: true,
|
||||||
|
streamSelection: {
|
||||||
|
video: [],
|
||||||
|
audio: [],
|
||||||
|
subtitles: [],
|
||||||
|
attachments: [],
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('labels a submission honestly when another operation is active', () => {
|
it('labels a submission honestly when another operation is active', () => {
|
||||||
render(
|
render(
|
||||||
<QuickConvert
|
<QuickConvert
|
||||||
@@ -138,6 +198,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,17 @@
|
|||||||
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,
|
||||||
|
canReuseProbeCapabilities,
|
||||||
engineJobTimeoutMilliseconds,
|
engineJobTimeoutMilliseconds,
|
||||||
prepareEngineArguments,
|
prepareEngineArguments,
|
||||||
|
probeTimeoutMilliseconds,
|
||||||
|
shouldRefreshCoreBeforeProbe,
|
||||||
shouldRecycleExecutionCore,
|
shouldRecycleExecutionCore,
|
||||||
} from '../../src/ffmpeg/EngineManager';
|
} from '../../src/ffmpeg/EngineManager';
|
||||||
|
|
||||||
@@ -90,6 +96,73 @@ describe('engineJobTimeoutMilliseconds', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('probeTimeoutMilliseconds', () => {
|
||||||
|
it('keeps small probes responsive, scales real media, and caps the watchdog', () => {
|
||||||
|
expect(probeTimeoutMilliseconds(34_057)).toBe(
|
||||||
|
MIN_PROBE_TIMEOUT_MILLISECONDS
|
||||||
|
);
|
||||||
|
expect(probeTimeoutMilliseconds(153_019_068)).toBe(72_966);
|
||||||
|
expect(probeTimeoutMilliseconds(2 * 1024 * 1024 * 1024)).toBe(
|
||||||
|
MAX_PROBE_TIMEOUT_MILLISECONDS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-cancelled probe before loading an engine', async () => {
|
||||||
|
const factory = vi.fn(async () => {
|
||||||
|
throw new Error('The engine factory must not be called.');
|
||||||
|
});
|
||||||
|
const manager = new EngineManager(factory);
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.probe(
|
||||||
|
new File(['media'], 'cancelled.mp4', { type: 'video/mp4' }),
|
||||||
|
undefined,
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'cancelled',
|
||||||
|
message: 'The media probe was cancelled.',
|
||||||
|
});
|
||||||
|
expect(factory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Firefox probe core refresh policy', () => {
|
||||||
|
it('refreshes only affected Firefox-family runtimes', () => {
|
||||||
|
expect(
|
||||||
|
shouldRefreshCoreBeforeProbe(
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldRefreshCoreBeforeProbe(
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) FxiOS/142.0'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldRefreshCoreBeforeProbe(
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36'
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(shouldRefreshCoreBeforeProbe('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses cached capabilities only for the identical engine mode', () => {
|
||||||
|
expect(canReuseProbeCapabilities('multithread', 'multithread')).toBe(true);
|
||||||
|
expect(canReuseProbeCapabilities('single-thread', 'single-thread')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(canReuseProbeCapabilities('multithread', 'single-thread')).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(canReuseProbeCapabilities('single-thread', 'multithread')).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('execution core recycling policy', () => {
|
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', () => {
|
||||||
|
|||||||
187
tests/unit/media/browser-metadata.test.ts
Normal file
187
tests/unit/media/browser-metadata.test.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrowserMetadataError,
|
||||||
|
browserMediaFileInfo,
|
||||||
|
inferBrowserMediaKind,
|
||||||
|
inspectBrowserMediaMetadata,
|
||||||
|
} from '../../../src/media/browser-metadata';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('browser media metadata', () => {
|
||||||
|
it('returns file information synchronously and infers useful media kinds', () => {
|
||||||
|
const file = new File(['media'], 'lecture.MP4', {
|
||||||
|
type: 'application/octet-stream',
|
||||||
|
lastModified: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(browserMediaFileInfo(file)).toEqual({
|
||||||
|
name: 'lecture.MP4',
|
||||||
|
sizeBytes: 5,
|
||||||
|
declaredMimeType: 'application/octet-stream',
|
||||||
|
inferredMimeType: 'video/mp4',
|
||||||
|
lastModified: 123,
|
||||||
|
});
|
||||||
|
expect(inferBrowserMediaKind(file)).toBe('video');
|
||||||
|
expect(
|
||||||
|
inferBrowserMediaKind(
|
||||||
|
new File([], 'recording.bin', { type: 'audio/ogg; codecs=opus' })
|
||||||
|
)
|
||||||
|
).toBe('audio');
|
||||||
|
expect(inferBrowserMediaKind(new File([], 'notes.txt'))).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads duration and dimensions through a native video element', async () => {
|
||||||
|
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const video = document.createElement('video');
|
||||||
|
const load = vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||||
|
defineNumber(video, 'duration', 2_622.48);
|
||||||
|
defineNumber(video, 'videoWidth', 640);
|
||||||
|
defineNumber(video, 'videoHeight', 480);
|
||||||
|
Object.defineProperty(video, 'audioTracks', {
|
||||||
|
configurable: true,
|
||||||
|
value: { length: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:lecture', {
|
||||||
|
createMediaElement: () => video,
|
||||||
|
});
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toEqual({
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: 'lecture.mp4',
|
||||||
|
sizeBytes: 5,
|
||||||
|
declaredMimeType: 'video/mp4',
|
||||||
|
inferredMimeType: 'video/mp4',
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 2_622.48,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
});
|
||||||
|
expect(video.preload).toBe('metadata');
|
||||||
|
expect(video).not.toHaveAttribute('src');
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an audio element and leaves unavailable details unknown', async () => {
|
||||||
|
const file = new File(['audio'], 'speech.flac', { type: 'audio/flac' });
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
vi.spyOn(audio, 'load').mockImplementation(() => undefined);
|
||||||
|
defineNumber(audio, 'duration', 12.5);
|
||||||
|
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:speech', {
|
||||||
|
createMediaElement: (kind) => {
|
||||||
|
expect(kind).toBe('audio');
|
||||||
|
return audio;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audio.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toMatchObject({
|
||||||
|
source: 'browser',
|
||||||
|
kind: 'audio',
|
||||||
|
durationSeconds: 12.5,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a video-container player viable when dimensions and audio hints are absent', async () => {
|
||||||
|
const file = new File(['audio-only'], 'audio-only.mp4', {
|
||||||
|
type: 'video/mp4',
|
||||||
|
});
|
||||||
|
const video = document.createElement('video');
|
||||||
|
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||||
|
defineNumber(video, 'duration', 8);
|
||||||
|
defineNumber(video, 'videoWidth', 0);
|
||||||
|
defineNumber(video, 'videoHeight', 0);
|
||||||
|
Object.defineProperty(video, 'audioTracks', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:audio-only', {
|
||||||
|
createMediaElement: () => video,
|
||||||
|
});
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toMatchObject({
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 8,
|
||||||
|
hasVideo: undefined,
|
||||||
|
hasAudio: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports load errors without revoking the caller-owned object URL', async () => {
|
||||||
|
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL');
|
||||||
|
const video = document.createElement('video');
|
||||||
|
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(
|
||||||
|
new File([], 'broken.mp4', { type: 'video/mp4' }),
|
||||||
|
'blob:broken',
|
||||||
|
{ createMediaElement: () => video }
|
||||||
|
);
|
||||||
|
|
||||||
|
video.dispatchEvent(new Event('error'));
|
||||||
|
|
||||||
|
await expect(resultPromise).rejects.toMatchObject({
|
||||||
|
name: 'BrowserMetadataError',
|
||||||
|
code: 'load-error',
|
||||||
|
});
|
||||||
|
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports cancellation and a bounded metadata timeout', async () => {
|
||||||
|
const file = new File([], 'long.mp4', { type: 'video/mp4' });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const firstVideo = document.createElement('video');
|
||||||
|
vi.spyOn(firstVideo, 'load').mockImplementation(() => undefined);
|
||||||
|
const cancelled = inspectBrowserMediaMetadata(file, 'blob:cancel', {
|
||||||
|
createMediaElement: () => firstVideo,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(cancelled).rejects.toMatchObject({
|
||||||
|
code: 'aborted',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const secondVideo = document.createElement('video');
|
||||||
|
vi.spyOn(secondVideo, 'load').mockImplementation(() => undefined);
|
||||||
|
const timedOut = inspectBrowserMediaMetadata(file, 'blob:timeout', {
|
||||||
|
createMediaElement: () => secondVideo,
|
||||||
|
timeoutMilliseconds: 25,
|
||||||
|
});
|
||||||
|
const timeoutExpectation = expect(timedOut).rejects.toEqual(
|
||||||
|
new BrowserMetadataError(
|
||||||
|
'timeout',
|
||||||
|
'Browser metadata loading exceeded 1 seconds.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
await timeoutExpectation;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function defineNumber(
|
||||||
|
target: HTMLMediaElement,
|
||||||
|
property: 'duration' | 'videoHeight' | 'videoWidth',
|
||||||
|
value: number
|
||||||
|
): void {
|
||||||
|
Object.defineProperty(target, property, {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.3.0.zip');
|
||||||
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
|
expect(line).toBe(`${digest} av-tools-0.3.0.zip\n`);
|
||||||
expect(parseChecksumLine(line)).toEqual({
|
expect(parseChecksumLine(line)).toEqual({
|
||||||
archiveName: 'av-tools-0.1.0.zip',
|
archiveName: 'av-tools-0.3.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.3.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.3.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.3.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