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-ssr
|
||||
release
|
||||
.core-build
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
public/vendor/ffmpeg
|
||||
vendor/*.tmp
|
||||
*.local
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
- Scaffold a local-first React/Vite audio and video toolbox application.
|
||||
|
||||
92
LICENSES/DEJAVU_FONTS.txt
Normal file
92
LICENSES/DEJAVU_FONTS.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below).
|
||||
|
||||
Bitstream Vera Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||
a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Arev Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute
|
||||
the modifications to the Bitstream Vera Font Software, including without
|
||||
limitation the rights to use, copy, merge, publish, distribute, and/or sell
|
||||
copies of the Font Software, and to permit persons to whom the Font Software
|
||||
is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Tavmjong Bah" or the
|
||||
word "Arev".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Tavmjong Bah
|
||||
Arev" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL TAVMJONG BAH BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT
|
||||
OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT
|
||||
SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of Tavmjong Bah shall not be
|
||||
used in advertising or otherwise to promote the sale, use or other dealings
|
||||
in this Font Software without prior written authorization from Tavmjong Bah.
|
||||
For further information, contact: tavmjong @ free . fr.
|
||||
@@ -13,9 +13,8 @@ or patent considerations of bundled third-party components.
|
||||
`v12.15` source at revision
|
||||
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`; it covers the wrapper/util
|
||||
source.
|
||||
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2. The published
|
||||
`@ffmpeg/core` and `@ffmpeg/core-mt` 0.12.10 package manifests declare
|
||||
`GPL-2.0-or-later`; “or later” comes from those manifests. The legal code
|
||||
- `GPL-2.0-or-later.txt` reproduces GNU GPL version 2 for GPL-enabled FFmpeg
|
||||
and linked GPL components in the reviewed WebAssembly cores. The legal code
|
||||
was checked against the standard GPL-2.0 text in the installed Freedesktop
|
||||
SDK licence corpus and the SPDX licence-list text.
|
||||
- `CC0-1.0.txt` applies only to generated bytes under
|
||||
|
||||
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
|
||||
under Toolbox Portal with shared shell, theme, help and app switching.
|
||||
|
||||
> **Release status:** 0.1.0 is licensed GPL-3.0-or-later. Its canonical
|
||||
> release consists of the exact reviewed `v0.1.0` source, production
|
||||
> **Release status:** 0.3.0 is licensed GPL-3.0-or-later. Its canonical
|
||||
> release consists of the exact reviewed `v0.3.0` source, production
|
||||
> ZIP/checksum and Corresponding Source bundle published together. See
|
||||
> [Licensing](#licensing).
|
||||
|
||||
@@ -17,13 +17,13 @@ under Toolbox Portal with shared shell, theme, help and app switching.
|
||||
|
||||
Two workflows keep the product intentionally smaller than a nonlinear editor:
|
||||
|
||||
- **Quick Convert:** drop/import media, inspect streams, choose a
|
||||
capability-checked preset/streams, convert, preview when the browser can, and
|
||||
download.
|
||||
- **Quick Convert:** drop/import media, play it and read browser-native file
|
||||
information immediately, then inspect streams on demand when an export needs
|
||||
authoritative details.
|
||||
- **Edit:** a media bin and sequential non-overlapping clip timeline with
|
||||
non-destructive settings, one export at a time.
|
||||
|
||||
The planned 0.1 line covers typed/capability-gated conversion and remux,
|
||||
The 0.3 release covers typed/capability-gated conversion and remux,
|
||||
fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
|
||||
resize, frame-rate change, audio extraction/stream removal, waveform,
|
||||
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
|
||||
@@ -34,10 +34,18 @@ diagnostics and explicit browser export.
|
||||
The Edit workspace now exposes structural exports directly: fast stream-copy
|
||||
or preset-backed accurate trim uses the selected clip's timeline range, while
|
||||
fast concatenate requires explicit compatibility diagnostics and normalized
|
||||
concatenate requires a preset plus an explicit missing-audio policy.
|
||||
concatenate requires a preset plus an explicit missing-audio policy. Normalized
|
||||
joins can apply a uniform, capability-gated 0.04–10 second audio/video
|
||||
crossfade at every edit, with clip-material checks before queueing.
|
||||
Operations enter a bounded, in-memory application queue with at most eight
|
||||
scheduled operations and one FFmpeg execution at a time.
|
||||
|
||||
Thumbnail series and contact sheets support even, interval, chapter, marker or
|
||||
bounded scene-change selection. Scene analysis records exact source
|
||||
presentation timestamps before extraction. Contact-sheet labels use only the
|
||||
bundled, SHA-256-verified DejaVu Sans 2.37 file and remain disabled unless the
|
||||
active core exposes `drawtext`; there is no remote/system-font fallback.
|
||||
|
||||
When a source cannot be played directly, the preview panel can create a
|
||||
disposable H.264/AAC MP4 proxy. The user can bound it to 1–300 seconds and
|
||||
choose a maximum width from 480–1920 pixels. A proxy is reused from the local
|
||||
@@ -53,14 +61,15 @@ Exact runtime packages:
|
||||
|
||||
- `@ffmpeg/ffmpeg` 0.12.15;
|
||||
- `@ffmpeg/util` 0.12.2;
|
||||
- `@ffmpeg/core` 0.12.10 (single-thread);
|
||||
- `@ffmpeg/core-mt` 0.12.10 (multithread).
|
||||
- reviewed FFmpeg core build `0.12.10-reviewed-stack5m.1` in single-thread and
|
||||
multithread profiles.
|
||||
|
||||
The published cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
|
||||
The reviewed cores contain **FFmpeg n5.1.4** (libavcodec 59.37.100), built
|
||||
with x264, x265, libvpx, LAME, Ogg/Theora/Opus/Vorbis, zlib, libwebp,
|
||||
FreeType/FriBidi/HarfBuzz/libass and zimg. Both ~32 MiB WASM cores and their
|
||||
JS/worker files are copied from exact npm packages and served from the app's
|
||||
own origin. `npm run build` does not download them.
|
||||
JS/worker files are verified against a tracked build lock, unpacked from a
|
||||
deterministic repository artifact and served from the app's own origin.
|
||||
`npm run build` does not download them.
|
||||
|
||||
Every engine load queries actual demuxers, muxers, decoders, encoders and
|
||||
filters. Presets remain disabled when requirements are absent; an extension or
|
||||
@@ -92,10 +101,12 @@ WebM/VP8/Vorbis, MP3 and WAV are common preview candidates, but actual codec,
|
||||
browser and platform support is checked at runtime. Matroska and many other
|
||||
valid outputs may be download-only. A preview proxy may require re-encoding.
|
||||
|
||||
The pinned core has an unresolved Opus encoding regression risk (zero-byte or
|
||||
out-of-bounds behavior reported around upstream stack-size fixes). Opus
|
||||
presets must stay unavailable unless a real non-empty, re-probeable ST/MT smoke
|
||||
passes. Details are in [FFmpeg build](docs/FFMPEG_BUILD.md).
|
||||
The 2026-07-26 Chromium regression harness ran the real built-in `audio-opus`
|
||||
plan against both reviewed cores. Each selected the requested ST/MT mode,
|
||||
produced non-empty Opus and passed codec/duration re-probing. Opus presets are
|
||||
therefore enabled when runtime capability discovery also succeeds. Exact
|
||||
commands and evidence boundaries are in
|
||||
[FFmpeg build](docs/FFMPEG_BUILD.md).
|
||||
|
||||
## Execution modes
|
||||
|
||||
@@ -156,7 +167,7 @@ the conditional native command-plan integration suite; it is not a production
|
||||
runtime dependency.
|
||||
|
||||
```sh
|
||||
git clone git@git.add-ideas.de:zemion/av-tools.git
|
||||
git clone git@git.add-ideas.de:lotobo/av-tools.git
|
||||
cd av-tools
|
||||
npm ci
|
||||
npm run dev
|
||||
@@ -169,9 +180,14 @@ Core assets are generated local build inputs:
|
||||
|
||||
```sh
|
||||
npm run vendor:ffmpeg
|
||||
npm run vendor:verify
|
||||
npm run vendor:verify-runtime
|
||||
```
|
||||
|
||||
The runtime verifier checks the tracked deterministic core artifact, source
|
||||
lock, build driver and generated same-origin files. The stricter
|
||||
`npm run vendor:verify` publication gate additionally requires and hashes the
|
||||
ignored Corresponding Source archive generated by `npm run source:package`.
|
||||
|
||||
Regenerate or verify the tiny synthetic fixtures:
|
||||
|
||||
```sh
|
||||
@@ -183,7 +199,7 @@ npm run fixtures:verify
|
||||
|
||||
```sh
|
||||
npm run manifest:check
|
||||
npm run vendor:verify
|
||||
npm run vendor:verify-runtime
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
@@ -191,6 +207,8 @@ npm test
|
||||
npm run build
|
||||
npm run toolbox:check
|
||||
npm run test:browser
|
||||
npm run evidence:large-preview # set AV_TOOLS_LARGE_MEDIA first
|
||||
npm run evidence:opus
|
||||
```
|
||||
|
||||
`npm run check` combines the non-browser production checks. Browser tests cover
|
||||
@@ -198,20 +216,38 @@ the real pinned cores, MT-to-ST fallback, cancellation/recovery, generated
|
||||
fixtures, nested deployment, Toolbox context and the absence of runtime CDN
|
||||
traffic.
|
||||
|
||||
The latest recorded local Vitest run on 2026-07-24 completed **303 passing
|
||||
tests and one conditional skip**. It includes broad structured-command
|
||||
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in case
|
||||
requires a `subtitles` filter unavailable in that host build.
|
||||
`npm run evidence:opus` independently contains each ST/MT browser process,
|
||||
prints one JSON report and exits normally even when the result is unsafe.
|
||||
`npm run test:opus-regression` runs the same evidence as the positive Opus
|
||||
enablement gate and exits nonzero unless both modes produce non-empty,
|
||||
re-probeable Opus. It is a mandatory release gate for the reviewed core
|
||||
profile.
|
||||
|
||||
`AV_TOOLS_LARGE_MEDIA=/absolute/video.mp4 npm run evidence:large-preview`
|
||||
performs the opt-in large-file regression: the native video element must mount
|
||||
and advance while detailed inspection remains idle, browser-native
|
||||
duration/dimensions must appear without loading FFmpeg, and the same element
|
||||
must survive a subsequently requested authoritative probe. The command runs
|
||||
that evidence in both Chromium and Firefox.
|
||||
|
||||
The latest recorded local Vitest run on 2026-07-27 completed **332 passing
|
||||
tests and two conditional skips**. It includes broad structured-command
|
||||
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in and
|
||||
labeled contact-sheet cases require `subtitles` and `drawtext` filters that are
|
||||
unavailable in that host build.
|
||||
|
||||
The Chromium suite exercises the pinned ST core across conversion,
|
||||
trim/concat/split, analysis, waveform, image, metadata, chapter, subtitle,
|
||||
trim/concat/split, bounded crossfades, analysis, waveform, scene-selected image
|
||||
generation, bundled-font contact-sheet labels, metadata, chapter, subtitle,
|
||||
queue and recovery workflows. The MT core is directly initialized and queried,
|
||||
and completes a real non-empty, re-probeable H.264/AAC conversion; the same
|
||||
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
|
||||
static/Toolbox-context loading is also covered. This evidence is
|
||||
Chromium-specific: Firefox, Safari, mobile and exhaustive MT operation parity
|
||||
remain unrecorded. The exact one-run performance record and its limitations are
|
||||
in [performance observations](docs/PERFORMANCE.md).
|
||||
Chromium-specific. Firefox now covers the opt-in large-file
|
||||
play-before-inspection regression and its explicit authoritative-probe
|
||||
transition; broad Firefox operation parity, Safari, mobile and exhaustive MT
|
||||
operation parity remain unrecorded. The exact one-run performance record and
|
||||
its limitations are in [performance observations](docs/PERFORMANCE.md).
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -243,12 +279,12 @@ repository and the `GPL-3.0-or-later` SPDX licence.
|
||||
|
||||
Portal must consume an independently built ZIP; it must not build or import
|
||||
av-tools source. Immutable Portal revision
|
||||
`bda9da044d61c109afd9969d357cc27f76648938` has isolation and WASM CSP
|
||||
permissions but lacks `media-src blob:`, which blocks Blob previews. The
|
||||
developer's active Toolbox Portal worktree currently contains the narrow local
|
||||
`media-src 'self' blob:` patch; that uncommitted worktree state does not change
|
||||
the immutable reference or release evidence. The exact distinction, smallest
|
||||
change and fallback are documented in
|
||||
`5c052bc6d1073a66600cfddc44c0e16da34bede8` (`v0.6.0`) provides the required
|
||||
isolation, narrow WebAssembly CSP, Blob media policy and explicit WASM MIME
|
||||
mapping. The active Portal worktree additionally extends immutable caching to
|
||||
the exact reviewed FFmpeg build profile without accepting mutable-looking
|
||||
suffixes. Its exact reviewed and staged
|
||||
boundaries are documented in
|
||||
[Portal requirements](docs/PORTAL_REQUIREMENTS.md).
|
||||
|
||||
## Release
|
||||
@@ -272,11 +308,11 @@ metadata:
|
||||
```sh
|
||||
npm run package:release
|
||||
npm run portal:smoke -- \
|
||||
--allow-known-header-gap
|
||||
--portal-root ../toolbox-portal
|
||||
```
|
||||
|
||||
The header-gap override remains development-only until the Portal CSP change is
|
||||
committed. Full commands and safety behavior are in
|
||||
The smoke harness clones the selected Portal `HEAD`; it never consumes
|
||||
uncommitted Portal files silently. Full commands and safety behavior are in
|
||||
[release documentation](docs/RELEASE.md).
|
||||
|
||||
## Licensing
|
||||
@@ -286,11 +322,11 @@ and licensed **GPL-3.0-or-later**. See [LICENSE](LICENSE). The application
|
||||
provides a visible licence, source and no-warranty notice in its Help dialog.
|
||||
Third-party components retain their copyright and licence terms.
|
||||
|
||||
The wrapper/util are MIT. Core/core-mt 0.12.10 declare
|
||||
GPL-2.0-or-later and statically include FFmpeg/external codec code with
|
||||
additional terms. The branch-based x264/LAME inputs have been resolved to
|
||||
immutable commits in the source record. A binary publication must include
|
||||
equivalent access to the reviewed Corresponding Source and notices. See:
|
||||
The wrapper/util are MIT. The reviewed 0.12.10 cores statically include
|
||||
GPL-enabled FFmpeg/external codec code with additional terms. The branch-based
|
||||
x264/LAME inputs have been resolved to immutable commits in the source record.
|
||||
A binary publication must include equivalent access to the reviewed
|
||||
Corresponding Source and notices. See:
|
||||
|
||||
- [third-party notices](THIRD_PARTY_NOTICES.md)
|
||||
- [source record](SOURCE.md)
|
||||
@@ -317,19 +353,18 @@ The auditable list is in
|
||||
- Metadata removal is best effort, not an anonymity guarantee.
|
||||
- Browser preview supports fewer combinations than FFmpeg.
|
||||
- MT depends on isolation; ST can still hit memory/time limits.
|
||||
- Opus output is release-gated for the pinned core.
|
||||
- Opus enablement is supported by Chromium ST/MT fixture evidence, not a
|
||||
cross-browser or arbitrary-input guarantee.
|
||||
- Projects do not contain source bytes by default and require reattachment.
|
||||
- Cross-browser/mobile and exhaustive MT performance matrices remain
|
||||
unrecorded; published support claims must stay within the tested evidence.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. Complete Quick Convert and structural-export browser verification with the
|
||||
real pinned ST/MT cores, including fallback and queued execution.
|
||||
2. Verify waveform, loudness normalization and combined filters in browsers.
|
||||
3. Verify metadata/chapter/subtitle round trips in browsers.
|
||||
4. Verify bounded thumbnails/contact sheets, projects, derivative-cache reuse
|
||||
and export in browsers.
|
||||
5. Complete accessibility, mobile, performance and nested Portal matrices.
|
||||
6. Commit/tag exact source, publish its reviewed Corresponding Source, assemble
|
||||
the exact production ZIP through Portal and verify deployed headers.
|
||||
1. Extend the evidence matrix beyond Chromium to Firefox, Safari and mobile;
|
||||
broaden MT operation parity and replace the one-run performance observation
|
||||
with repeated cold/warm measurements.
|
||||
2. Evaluate optional editing breadth without weakening local-first guarantees:
|
||||
additional reviewed transition shapes, richer scene-ranking controls and
|
||||
explicit transition audio-curve choices beyond the current bounded uniform
|
||||
crossfade.
|
||||
|
||||
97
SOURCE.md
97
SOURCE.md
@@ -1,17 +1,17 @@
|
||||
# Source and reproducibility record
|
||||
|
||||
This file identifies the source corresponding to av-tools 0.1.0. It is not a
|
||||
This file identifies the source corresponding to av-tools 0.3.0. It is not a
|
||||
substitute for satisfying a component's licence or for delivering complete
|
||||
Corresponding Source with an object-code release.
|
||||
|
||||
## av-tools
|
||||
|
||||
- Canonical repository:
|
||||
`git@git.add-ideas.de:zemion/av-tools.git`
|
||||
- Browser source: `https://git.add-ideas.de/zemion/av-tools`
|
||||
- Version: `0.1.0`
|
||||
- Release source identity: immutable annotated tag `v0.1.0`
|
||||
(`refs/tags/v0.1.0`) in the canonical repository.
|
||||
`git@git.add-ideas.de:lotobo/av-tools.git`
|
||||
- Browser source: `https://git.add-ideas.de/lotobo/av-tools`
|
||||
- Version: `0.3.0`
|
||||
- Release source identity: immutable annotated tag `v0.3.0`
|
||||
(`refs/tags/v0.3.0`) in the canonical repository.
|
||||
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
|
||||
`LICENSE`.
|
||||
- Copyright: Copyright © 2026 Albrecht Degering.
|
||||
@@ -22,10 +22,10 @@ the tag exists, is annotated, points to the reviewed release commit and is
|
||||
available without authentication:
|
||||
|
||||
```sh
|
||||
test "$(git cat-file -t v0.1.0)" = tag
|
||||
git rev-parse v0.1.0^{commit}
|
||||
test "$(git cat-file -t v0.3.0)" = tag
|
||||
git rev-parse v0.3.0^{commit}
|
||||
git ls-remote --tags \
|
||||
https://git.add-ideas.de/zemion/av-tools.git refs/tags/v0.1.0
|
||||
https://git.add-ideas.de/lotobo/av-tools.git refs/tags/v0.3.0
|
||||
```
|
||||
|
||||
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
||||
@@ -40,34 +40,66 @@ the tag/source archive beside the binary release and checksum.
|
||||
| `@ffmpeg/ffmpeg` | 0.12.15 | `sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==` |
|
||||
| `@ffmpeg/util` | 0.12.2 | `sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==` |
|
||||
| `@ffmpeg/types` (transitive) | 0.12.4 | `sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==` |
|
||||
| `@ffmpeg/core` | 0.12.10 | `sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==` |
|
||||
| `@ffmpeg/core-mt` | 0.12.10 | `sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==` |
|
||||
|
||||
Install exact packages and recreate the copied same-origin assets without a
|
||||
Install exact packages and recreate the verified same-origin assets without a
|
||||
network fetch during the production build:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run vendor:ffmpeg
|
||||
npm run vendor:verify
|
||||
npm run vendor:verify-runtime
|
||||
npm run build
|
||||
```
|
||||
|
||||
`scripts/copy-ffmpeg-assets.mjs` copies the ESM distributions from the
|
||||
installed core packages; it does not download them. The copied bytes are:
|
||||
`scripts/copy-ffmpeg-assets.mjs` fails closed against
|
||||
`scripts/reviewed-ffmpeg-core-lock.json`, verifies the deterministic tracked
|
||||
ZIP and unpacks only its five declared entries. It does not download them or
|
||||
fall back to published core packages. The reviewed bytes are:
|
||||
|
||||
| Release path | Bytes | SHA-256 |
|
||||
| ------------------------------------------------ | -------: | ------------------------------------------------------------------ |
|
||||
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
|
||||
| `vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
|
||||
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
|
||||
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
|
||||
| `vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
|
||||
For a publication check, generate the build-qualified Corresponding Source
|
||||
archive and run `npm run vendor:verify`; that stricter gate hashes the archive
|
||||
as well as every runtime input.
|
||||
|
||||
| Release path | Bytes | SHA-256 |
|
||||
| ---------------------------------------------------------------------------- | -------: | ------------------------------------------------------------------ |
|
||||
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
|
||||
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
|
||||
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
|
||||
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
|
||||
| `vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
|
||||
| `vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip` (deterministic artifact) | 21338974 | `ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f` |
|
||||
|
||||
## Bundled contact-sheet font
|
||||
|
||||
Contact-sheet labels use one unmodified DejaVu Sans 2.37 TrueType file. It is
|
||||
served same-origin and mounted read-only for the duration of a labelled job;
|
||||
the browser verifies its size and SHA-256 before use.
|
||||
|
||||
| Release path | Bytes | SHA-256 |
|
||||
| --------------------------- | ------: | ------------------------------------------------------------------ |
|
||||
| `fonts/DejaVuSans.ttf` | 757,076 | `7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954` |
|
||||
| `LICENSES/DEJAVU_FONTS.txt` | n/a | licence text embedded in the reviewed font and official archive |
|
||||
|
||||
The byte-for-byte source is `ttf/DejaVuSans.ttf` in the official
|
||||
`dejavu-fonts-ttf-2.37.tar.bz2` release:
|
||||
|
||||
- project download record: `https://dejavu-fonts.github.io/Download.html`;
|
||||
- archive URL:
|
||||
`https://downloads.sourceforge.net/project/dejavu/dejavu/2.37/dejavu-fonts-ttf-2.37.tar.bz2`;
|
||||
- archive SHA-256:
|
||||
`fa9ca4d13871dd122f61258a80d01751d603b4d3ee14095d65453b4e846e17d7`;
|
||||
- corresponding upstream tag `version_2_37`, commit
|
||||
`0eda8a319c08835009849583cd090bb5b141ce25`.
|
||||
|
||||
The release archive and the bundled file were independently hashed and the
|
||||
font's internal version string is `Version 2.37`. The file is not modified or
|
||||
subsetted. Its Bitstream Vera/Arev permission terms are reproduced verbatim in
|
||||
`LICENSES/DEJAVU_FONTS.txt`.
|
||||
|
||||
## Corresponding ffmpeg.wasm and FFmpeg source
|
||||
|
||||
- Upstream ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15,
|
||||
`@ffmpeg/core` 0.12.10 and `@ffmpeg/core-mt` 0.12.10:
|
||||
- Base ffmpeg.wasm release tag containing `@ffmpeg/ffmpeg` 0.12.15 and the
|
||||
source configuration for core 0.12.10:
|
||||
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1`.
|
||||
- FFmpeg source selected by that revision's `Dockerfile`: tag `n5.1.4`,
|
||||
peeled Git commit `4729204c17f756e186d622060088371d10b34f7e`.
|
||||
@@ -88,6 +120,14 @@ The upstream build control files are `Dockerfile`, `Makefile`,
|
||||
`build/` at the ffmpeg.wasm release revision. `make prd` builds single-thread;
|
||||
`make prd-mt` builds multithread.
|
||||
|
||||
The shipped profile is rebuilt by
|
||||
`scripts/build-reviewed-ffmpeg-core.sh`. It applies upstream ffmpeg.wasm commit
|
||||
`b409e36475bc21f0451b5b1e1d126fa82871439a` (`-sSTACK_SIZE=5MB`), uses exact
|
||||
Emscripten 3.1.40 and CMake 3.27.9 identities, makes upstream container-stage
|
||||
isolation explicit and refuses maintainer-local paths in output. The exact
|
||||
source archive, toolchain, patch, asset and Opus-gate identities are locked in
|
||||
`scripts/reviewed-ffmpeg-core-lock.json`.
|
||||
|
||||
The Dockerfile uses branch names for two inputs. Repository history resolves
|
||||
those refs to tips that predate the ffmpeg.wasm v12.15 release and have not
|
||||
changed since:
|
||||
@@ -121,10 +161,13 @@ npm run source:package
|
||||
```
|
||||
|
||||
The command writes
|
||||
`release/ffmpeg-core-0.12.10-corresponding-source.tar.xz` and its
|
||||
`release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz`
|
||||
and its
|
||||
`.sha256` sidecar. Pass `-- --force` only to replace those exact outputs.
|
||||
The npm packages contain the resulting binaries but not every linked
|
||||
component's source and legal file, so publish this reviewed bundle and the
|
||||
The reviewed archive for this build profile is 43,050,052 bytes with SHA-256
|
||||
`2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a`.
|
||||
The reviewed object-code archive does not contain every linked component's
|
||||
source and legal file, so publish this Corresponding Source bundle and the
|
||||
notices beside the object code. A homepage link alone is not asserted to
|
||||
satisfy GPL source obligations.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Third-party notices
|
||||
|
||||
av-tools 0.1.0 is Copyright © 2026 Albrecht Degering and licensed
|
||||
av-tools 0.3.0 is Copyright © 2026 Albrecht Degering and licensed
|
||||
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
||||
inputs. It is informational, not legal advice, and does not replace their
|
||||
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
||||
@@ -9,30 +9,33 @@ receives licence files shipped by installed runtime npm packages under
|
||||
|
||||
## Browser runtime
|
||||
|
||||
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
|
||||
| -------------------------------- | ------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -------------------------------------- |
|
||||
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | `https://git.add-ideas.de/zemion/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
|
||||
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
|
||||
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
|
||||
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
|
||||
| `@ffmpeg/core` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | single-thread FFmpeg JavaScript and WebAssembly | shipped verbatim as self-hosted assets |
|
||||
| `@ffmpeg/core-mt` | 0.12.10 | GPL-2.0-or-later package | `https://github.com/ffmpegwasm/ffmpeg.wasm` | multithread FFmpeg JavaScript, WebAssembly and pthread worker | shipped verbatim as self-hosted assets |
|
||||
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
|
||||
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
|
||||
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
|
||||
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
|
||||
| Package | Version | Licence declared upstream | Project source | Role | Distribution |
|
||||
| -------------------------------- | -------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | `https://git.add-ideas.de/lotobo/toolbox-sdk` | typed manifest/context contract | shipped, bundled |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | `https://git.add-ideas.de/lotobo/toolbox-sdk` | shared Toolbox application shell | shipped, bundled |
|
||||
| `@ffmpeg/ffmpeg` | 0.12.15 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | browser worker and high-level FFmpeg API | shipped, bundled |
|
||||
| `@ffmpeg/util` | 0.12.2 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | byte/blob helpers | shipped, bundled |
|
||||
| `@ffmpeg/types` | 0.12.4 | MIT | `https://github.com/ffmpegwasm/ffmpeg.wasm` | transitive runtime API types | shipped as compiled/bundled references |
|
||||
| reviewed FFmpeg WASM cores | 0.12.10-reviewed-stack5m.1 | GPL-enabled combined work | `https://github.com/ffmpegwasm/ffmpeg.wasm` | ST/MT FFmpeg JavaScript, WebAssembly and pthread worker | rebuilt from locked source and shipped as verified assets |
|
||||
| `fflate` | 0.8.3 | MIT | `https://github.com/101arrowz/fflate` | in-browser ZIP creation | shipped, bundled |
|
||||
| DejaVu Sans | 2.37 | Bitstream Vera/Arev terms | `https://dejavu-fonts.github.io/` | deterministic local contact-sheet labels | shipped unmodified as reviewed TTF |
|
||||
| `react` | 19.2.7 | MIT | `https://github.com/facebook/react` | user-interface runtime | shipped, bundled |
|
||||
| `react-dom` | 19.2.7 | MIT | `https://github.com/facebook/react` | browser rendering | shipped, bundled |
|
||||
| `scheduler` | 0.27.0 | MIT | `https://github.com/facebook/react` | transitive React scheduling runtime | shipped, bundled |
|
||||
|
||||
The MIT text inspected in the ffmpeg.wasm source is
|
||||
`LICENSES/ffmpeg.wasm-MIT.txt`. The core npm tarballs do not contain a licence
|
||||
file, but their package manifests declare `GPL-2.0-or-later`;
|
||||
`LICENSES/GPL-2.0-or-later.txt` reproduces GPL version 2. These notices do not
|
||||
make the complete application “MIT-only.”
|
||||
`LICENSES/ffmpeg.wasm-MIT.txt`. The reviewed cores enable GPL FFmpeg features
|
||||
and statically link GPL components; `LICENSES/GPL-2.0-or-later.txt` reproduces
|
||||
GPL version 2. These notices do not make the complete application “MIT-only.”
|
||||
|
||||
The reviewed DejaVu file is identified byte-for-byte in `SOURCE.md`; its full
|
||||
embedded terms are in `LICENSES/DEJAVU_FONTS.txt`. No remote or system font is
|
||||
used as a runtime fallback.
|
||||
|
||||
## Native components linked into the WebAssembly cores
|
||||
|
||||
The following list is derived from the `v12.15` ffmpeg.wasm Dockerfile, the
|
||||
configuration embedded in the copied 0.12.10 WASM and the resolved histories
|
||||
configuration embedded in the reviewed 0.12.10 WASM and the resolved histories
|
||||
of its source repositories. Branch-based inputs are recorded below as exact
|
||||
commits. The combined core is GPL-enabled. Individual projects retain their
|
||||
own terms, notices, patent considerations and corresponding-source
|
||||
@@ -70,7 +73,7 @@ shipped in the static ZIP:
|
||||
|
||||
| Package | Exact version | Licence | Role |
|
||||
| ----------------------------- | ------------- | ---------- | ------------------------------------- |
|
||||
| `@add-ideas/toolbox-testkit` | 0.2.2 | Apache-2.0 | manifest and nested-path test helpers |
|
||||
| `@add-ideas/toolbox-testkit` | 0.2.3 | Apache-2.0 | manifest and nested-path test helpers |
|
||||
| `@eslint/js` | 10.0.1 | MIT | lint rules |
|
||||
| `@playwright/test` | 1.61.1 | Apache-2.0 | browser tests |
|
||||
| `@testing-library/jest-dom` | 6.9.1 | MIT | DOM assertions |
|
||||
|
||||
@@ -14,7 +14,7 @@ tools.example.test {
|
||||
@wasm path_regexp wasm \.wasm$
|
||||
header @wasm Content-Type "application/wasm"
|
||||
|
||||
@immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$
|
||||
@immutable path_regexp immutable ^/av/(?:vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)|assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+)$
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
redir /av /av/ 308
|
||||
|
||||
@@ -32,7 +32,7 @@ cross-origin resource policy and is outside the supported deployment profile.
|
||||
|
||||
Use `no-cache` for `index.html`, `toolbox-app.json`, notices and other stable
|
||||
names. The files under the exact version directory
|
||||
`vendor/ffmpeg/0.12.10/{st,mt}/` are content-verified and may use:
|
||||
`vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` are content-verified and may use:
|
||||
|
||||
```text
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
@@ -55,7 +55,7 @@ For a host with a `_headers`-style configuration, adapt:
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; font-src 'self'; style-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
|
||||
Cache-Control: no-cache
|
||||
|
||||
/av/vendor/ffmpeg/0.12.10/*
|
||||
/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/av/assets/*
|
||||
@@ -68,7 +68,7 @@ real response rather than relying only on configuration:
|
||||
|
||||
```sh
|
||||
curl -sSI https://tools.example.test/av/ | sed -n '1,30p'
|
||||
curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm | sed -n '1,30p'
|
||||
curl -sSI https://tools.example.test/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm | sed -n '1,30p'
|
||||
```
|
||||
|
||||
In browser developer tools, also verify `window.crossOriginIsolated === true`,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
map $uri $av_tools_cache_control {
|
||||
default "no-cache";
|
||||
"~^/(?:.*/)?vendor/ffmpeg/0\.12\.10/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable";
|
||||
"~^/(?:.*/)?vendor/ffmpeg/0\.12\.10-reviewed-stack5m\.1/(?:st|mt)/ffmpeg-core(?:\.worker)?\.(?:js|wasm)$" "public, max-age=31536000, immutable";
|
||||
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,10 @@ Browser storage boundary
|
||||
```
|
||||
|
||||
- `src/toolbox/` owns the one typed manifest and suite integration.
|
||||
- `src/media/` normalizes ffprobe JSON, duration/timecode, stream selection,
|
||||
playback MIME decisions and safe names.
|
||||
- `src/media/` reads lightweight browser-native metadata, normalizes
|
||||
authoritative ffprobe JSON, and owns duration/timecode, stream-selection,
|
||||
playback-MIME and safe-name decisions. Browser metadata and probe data are
|
||||
deliberately separate models.
|
||||
- `src/project/` owns the versioned, non-destructive project document and
|
||||
migrations. A source is represented by identity/metadata and must be
|
||||
reattached after load; the current application does not persist source bytes.
|
||||
@@ -51,20 +53,35 @@ raw command field.
|
||||
|
||||
## FFmpeg lifecycle
|
||||
|
||||
1. Inspect secure-context, worker, `crossOriginIsolated` and
|
||||
1. Import creates an object URL immediately. A detached native media element
|
||||
reads duration/dimensions where supported; playback and file identity do not
|
||||
initialize FFmpeg.
|
||||
2. Detailed inspection remains idle until the user requests it or an operation
|
||||
requires codecs, complete streams, chapters, tags or dispositions.
|
||||
3. Inspect secure-context, worker, `crossOriginIsolated` and
|
||||
`SharedArrayBuffer` availability.
|
||||
2. Select MT only when all MT prerequisites are true (or ST when explicitly
|
||||
4. Select MT only when all MT prerequisites are true (or ST when explicitly
|
||||
selected). A failed MT load is terminated and retried once with ST.
|
||||
3. Load wrapper/core assets via base-relative, same-origin URLs.
|
||||
4. Query version/build configuration/formats/codecs/encoders/filters
|
||||
5. Load wrapper/core assets via base-relative, same-origin URLs.
|
||||
6. Query version/build configuration/formats/codecs/encoders/filters
|
||||
sequentially and publish immutable capability state.
|
||||
5. For one job, validate resource estimates and structured settings, allocate
|
||||
7. For one job, validate resource estimates and structured settings, allocate
|
||||
a generated directory, mount/write inputs, run a generated argument array,
|
||||
read only declared outputs, and re-probe applicable results.
|
||||
6. Return result bytes and an export report, then unmount/delete virtual files.
|
||||
7. Retain the idle engine for the next sequential job; terminate it on mode
|
||||
8. Return result bytes and an export report, then unmount/delete virtual files.
|
||||
9. Retain the idle engine for the next sequential job; terminate it on mode
|
||||
changes, unrecoverable errors or cancellation.
|
||||
|
||||
A detailed-probe failure changes only the inspection state. The File, object
|
||||
URL, browser metadata and provisional timeline clip remain available, and the
|
||||
user can retry. An authoritative probe supersedes browser duration/dimensions
|
||||
and reconciles an untouched provisional full-source clip.
|
||||
|
||||
The reviewed Emscripten runtime can stall when Firefox probes on a core that
|
||||
has retained prior application state. Before an explicit Firefox probe, the
|
||||
manager recreates the identical ST/MT core and reuses only that mode's already
|
||||
validated capability inventory. Chromium retains the normal warm-core path.
|
||||
|
||||
The FFmpeg instance is a single process. `maxConcurrentFFmpegJobs` is one and
|
||||
no probe/export operations overlap on it. The application-facing scheduler is
|
||||
also bounded: it retains at most eight scheduled operations in memory,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# FFmpeg WebAssembly build record
|
||||
|
||||
av-tools ships the published ESM assets from `@ffmpeg/core` and
|
||||
`@ffmpeg/core-mt` **0.12.10**. The browser wrapper is `@ffmpeg/ffmpeg`
|
||||
**0.12.15** and helpers are `@ffmpeg/util` **0.12.2**. All versions are exact,
|
||||
not ranges.
|
||||
av-tools ships the locally rebuilt, reviewed core profile
|
||||
`0.12.10-reviewed-stack5m.1` in single-thread and multithread forms. The
|
||||
browser wrapper is `@ffmpeg/ffmpeg` **0.12.15** and helpers are
|
||||
`@ffmpeg/util` **0.12.2**. All versions and build inputs are exact, not ranges.
|
||||
|
||||
## Underlying build
|
||||
|
||||
The matching ffmpeg.wasm upstream release is `v12.15` at
|
||||
The base ffmpeg.wasm upstream release is `v12.15` at
|
||||
`71aa99d37c02a7b4c435275ca9ef50e612f6efa1`. Its Dockerfile selects:
|
||||
|
||||
- FFmpeg **n5.1.4** (libavcodec 59.37.100);
|
||||
@@ -15,6 +15,14 @@ The matching ffmpeg.wasm upstream release is `v12.15` at
|
||||
- production `-O3 -msimd128`;
|
||||
- pthread flags for the MT build and explicit thread disabling for ST.
|
||||
|
||||
The reviewed profile then applies upstream ffmpeg.wasm commit
|
||||
`b409e36475bc21f0451b5b1e1d126fa82871439a` to select a 5 MiB Emscripten stack.
|
||||
That upstream fix addresses the libopus zero-byte/out-of-bounds failures
|
||||
reported in issues `#591` and `#775`. The local driver additionally makes the
|
||||
upstream container-stage dependency isolation explicit, disables native x265
|
||||
assembly for the wasm32 build, bounds parallel jobs and refuses output that
|
||||
contains the maintainer's repository or SDK paths.
|
||||
|
||||
The build configuration embedded in both copied WASM files is:
|
||||
|
||||
```text
|
||||
@@ -35,8 +43,8 @@ The build configuration embedded in both copied WASM files is:
|
||||
--cxx=em++
|
||||
--objcc=emcc
|
||||
--dep-cc=emcc
|
||||
--extra-cflags='-I/opt/include -O3 -msimd128'
|
||||
--extra-cxxflags='-I/opt/include -O3 -msimd128'
|
||||
--extra-cflags='-I../../../../install/include -O3 -msimd128'
|
||||
--extra-cxxflags='-I../../../../install/include -O3 -msimd128'
|
||||
--enable-gpl
|
||||
--enable-libx264
|
||||
--enable-libx265
|
||||
@@ -76,16 +84,26 @@ respective branch tips. `SOURCE.md` records retrieval commands.
|
||||
|
||||
| Mode | File | Bytes | SHA-256 |
|
||||
| ---- | -------------------------- | -------: | ------------------------------------------------------------------ |
|
||||
| ST | `st/ffmpeg-core.js` | 111804 | `67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3` |
|
||||
| ST | `st/ffmpeg-core.wasm` | 32232419 | `9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7` |
|
||||
| MT | `mt/ffmpeg-core.js` | 128947 | `270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de` |
|
||||
| MT | `mt/ffmpeg-core.wasm` | 32718323 | `be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a` |
|
||||
| ST | `st/ffmpeg-core.js` | 111804 | `80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24` |
|
||||
| ST | `st/ffmpeg-core.wasm` | 32232580 | `fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc` |
|
||||
| MT | `mt/ffmpeg-core.js` | 128953 | `4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62` |
|
||||
| MT | `mt/ffmpeg-core.wasm` | 32718455 | `abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645` |
|
||||
| MT | `mt/ffmpeg-core.worker.js` | 2115 | `f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3` |
|
||||
|
||||
`npm run vendor:ffmpeg` copies these files from `node_modules` to
|
||||
`public/vendor/ffmpeg/0.12.10/`; `npm run vendor:verify` checks versions,
|
||||
sizes, paths and digests. Vite then copies them unchanged into `dist`. A normal
|
||||
production build performs no network download.
|
||||
The deterministic five-file repository artifact is 21,338,974 bytes with
|
||||
SHA-256
|
||||
`ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f`.
|
||||
|
||||
`npm run vendor:ffmpeg` verifies and unpacks the deterministic tracked core
|
||||
artifact to
|
||||
`public/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/`;
|
||||
`npm run vendor:verify-runtime` checks the build profile,
|
||||
source/toolchain/patch identity, tracked artifact, generated paths, sizes and
|
||||
digests without requiring a release-directory download. The stricter
|
||||
`npm run vendor:verify` release gate also hashes the build-qualified
|
||||
Corresponding Source archive. Vite then copies the verified bytes into `dist`.
|
||||
A normal production build performs no network download and cannot fall back
|
||||
to the older published npm core bytes.
|
||||
|
||||
## Multithread Chromium guardrail
|
||||
|
||||
@@ -118,26 +136,42 @@ This is a conservative browser-memory guardrail, not a guarantee that sixteen
|
||||
large jobs or three large contact sheets fit in memory. Cancellation and
|
||||
failed filesystem-cleanup recovery continue to recreate the core immediately.
|
||||
|
||||
## Opus regression risk
|
||||
## Opus regression evidence
|
||||
|
||||
Pinned core 0.12.10 predates upstream ffmpeg.wasm fixes associated with stack
|
||||
size and Opus zero-byte/out-of-bounds failures (including upstream issues
|
||||
`#591` and `#775`). Therefore:
|
||||
Published core 0.12.10 predates the upstream stack-size fix and stalled in the
|
||||
real Opus preset path even though it advertised the `opus` muxer and
|
||||
`libopus` encoder. Capability discovery alone was therefore not accepted as
|
||||
enablement evidence.
|
||||
|
||||
- decoder/encoder listing is not enough to certify Opus;
|
||||
- real ST and MT fixture encodes must produce a non-empty, re-probeable output;
|
||||
- the Opus audio and VP9/Opus presets must remain disabled with a diagnostic
|
||||
if that smoke fails;
|
||||
- a core upgrade requires new checksums, capability snapshots, legal review
|
||||
and regression tests.
|
||||
On 2026-07-26 the Chromium harness used the checked
|
||||
`tests/fixtures/generated/tone.wav` fixture (32,102 bytes, SHA-256
|
||||
`0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c`)
|
||||
and the real built-in `audio-opus` preset/command planner. After rebuilding
|
||||
with the reviewed stack profile, the isolated ST and MT runs each:
|
||||
|
||||
This is a known pinned-core risk, not a claim that every Opus job fails.
|
||||
- loaded the requested mode rather than a fallback;
|
||||
- mapped PCM audio to `opus (libopus)`;
|
||||
- opened the declared Opus output and reported its libopus encoder;
|
||||
- returned non-empty bytes within its independent containment limit;
|
||||
- re-probed the result as positive-duration Opus audio.
|
||||
|
||||
```sh
|
||||
# Record JSON evidence.
|
||||
npm run evidence:opus
|
||||
|
||||
# Positive Opus enablement/release gate.
|
||||
npm run test:opus-regression
|
||||
```
|
||||
|
||||
The Opus audio and VP9/Opus presets are enabled only when the reviewed-core
|
||||
lock records both passing modes and runtime discovery finds every preset
|
||||
requirement. This is exact Chromium fixture evidence, not a claim about every
|
||||
browser, input or possible Opus build.
|
||||
|
||||
## Licence consequence
|
||||
|
||||
The core packages declare GPL-2.0-or-later and the actual build enables GPL and
|
||||
links external codecs. The wrapper/util are MIT. av-tools is
|
||||
GPL-3.0-or-later, and the distributed application must not be described as
|
||||
MIT-only. Publish the reviewed Corresponding Source and external notices with
|
||||
the binary release. See `LICENSE`, `LICENSES/`, `THIRD_PARTY_NOTICES.md` and
|
||||
`SOURCE.md`.
|
||||
The actual core build enables GPL and links external codecs. The wrapper/util
|
||||
are MIT. av-tools is GPL-3.0-or-later, and the distributed application must
|
||||
not be described as MIT-only. Publish the reviewed Corresponding Source and
|
||||
external notices with the binary release. See `LICENSE`, `LICENSES/`,
|
||||
`THIRD_PARTY_NOTICES.md` and `SOURCE.md`.
|
||||
|
||||
@@ -5,7 +5,7 @@ extensions or this document. The app queries muxers, demuxers, decoders,
|
||||
encoders and filters; a preset is disabled with missing components listed.
|
||||
Browser playback is a separate capability checked with MIME/media APIs.
|
||||
|
||||
## 0.1.0 candidate fixture matrix
|
||||
## 0.3.0 release fixture matrix
|
||||
|
||||
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
|
||||
preparation and pass the checksum/probe verifier. That proves fixture
|
||||
@@ -41,20 +41,20 @@ browser may still be unable to preview the result.
|
||||
|
||||
Preset availability always depends on detected runtime requirements:
|
||||
|
||||
| Preset family | Required examples | Browser result preview |
|
||||
| ------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
||||
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
|
||||
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | browser-dependent; Opus core regression gate applies |
|
||||
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
|
||||
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
|
||||
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
|
||||
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
|
||||
| MP3 | `mp3`, `libmp3lame` | commonly playable |
|
||||
| M4A/AAC | `ipod`, `aac` | commonly playable |
|
||||
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
|
||||
| Opus | `opus`, `libopus` | browser-dependent; pinned-core regression gate applies |
|
||||
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
|
||||
| FLAC | `flac`, `flac` | browser-dependent |
|
||||
| Preset family | Required examples | Browser result preview |
|
||||
| ------------------ | ---------------------------------------- | -------------------------------------------------------- |
|
||||
| MP4 H.264/AAC | `mp4`, `libx264`, `aac` | usually, but verify actual output |
|
||||
| WebM VP9/Opus | `webm`, `libvpx-vp9`, `libopus` | enabled when the loaded core advertises all requirements |
|
||||
| WebM VP8/Vorbis | `webm`, `libvpx`, `libvorbis` | commonly Chromium/Firefox |
|
||||
| Matroska H.264/AAC | `matroska`, `libx264`, `aac` | generally download-only in browsers |
|
||||
| animated WebP | `webp`, `libwebp_anim` | browser-dependent image preview |
|
||||
| GIF | `gif`, `gif`, `palettegen`, `paletteuse` | broadly image-previewable |
|
||||
| MP3 | `mp3`, `libmp3lame` | commonly playable |
|
||||
| M4A/AAC | `ipod`, `aac` | commonly playable |
|
||||
| Ogg Vorbis | `ogg`, `libvorbis` | browser-dependent |
|
||||
| Opus | `opus`, `libopus` | Chromium ST/MT encode and re-probe recorded |
|
||||
| WAV/PCM | `wav`, `pcm_s16le` | commonly playable |
|
||||
| FLAC | `flac`, `flac` | browser-dependent |
|
||||
|
||||
“Lossless” applies only to a lossless audio codec such as PCM/FLAC relative to
|
||||
its decoded samples. It does not make a lossy source lossless. Stream-copy
|
||||
@@ -77,11 +77,30 @@ remux avoids re-encoding compatible streams but may change container metadata.
|
||||
- Normalized concat uses an explicit export preset and requires an explicit
|
||||
insert-silence, drop-all-audio or reject policy when clips have mismatched
|
||||
audio presence.
|
||||
- Normalized concat can overlap every edit with one uniform 0.04–10 second
|
||||
`xfade` and, when audio is retained, `acrossfade`. End clips must contain at
|
||||
least one transition duration and internal clips two; the active core must
|
||||
expose each required filter.
|
||||
- Crop, resize, frame-rate conversion, fades, normalization, burn-in and
|
||||
contact sheets require specific filters discovered at runtime.
|
||||
- Even-dimension correction is applied when the selected pixel format/encoder
|
||||
requires it and is reported in the plan/result.
|
||||
|
||||
## Images and scene selection
|
||||
|
||||
Thumbnail/contact-sheet scene mode first performs a bounded full-source
|
||||
analysis with `select`/`showinfo`, stopping after at most 100 qualifying
|
||||
frames. Extraction then selects those exact source PTS values; no guessed time
|
||||
rounding or remote analysis is used. If no frame meets the 0.01–0.95 threshold,
|
||||
the operation reports that result and asks for a lower threshold or a
|
||||
deterministic selection mode.
|
||||
|
||||
Contact-sheet labels require runtime `drawtext`. The app mounts only its
|
||||
same-origin DejaVu Sans 2.37 asset after verifying 757,076 bytes and SHA-256
|
||||
`7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954`.
|
||||
No remote or host-system font fallback is used. `SOURCE.md` and
|
||||
`LICENSES/DEJAVU_FONTS.txt` provide provenance and terms.
|
||||
|
||||
## Subtitles
|
||||
|
||||
The text fixtures cover SRT, WebVTT and ASS import with explicit time shift.
|
||||
@@ -115,8 +134,9 @@ elements do not reliably expose them, so ffprobe round-trip is authoritative.
|
||||
- frame-accurate stream-copy trimming;
|
||||
- complete metadata erasure/anonymity;
|
||||
- browser preview of every FFmpeg-readable result;
|
||||
- Opus presets until real non-empty ST/MT encoding smoke passes for core
|
||||
0.12.10;
|
||||
- Opus on browsers, inputs or deployments outside the exact reviewed
|
||||
Chromium regression: both ST and MT pass the non-empty encode/re-probe gate,
|
||||
but runtime capability discovery and normal per-job limits still apply;
|
||||
- any capability omitted by the active core's runtime query.
|
||||
|
||||
Update this file only from re-probed generated/public-domain fixtures and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Known limitations
|
||||
|
||||
This is the conservative 0.1.0 development baseline. A capability appearing in
|
||||
This is the conservative 0.3.0 release baseline. A capability appearing in
|
||||
FFmpeg output does not remove these product/browser/resource limits.
|
||||
|
||||
## Functional
|
||||
@@ -13,6 +13,9 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
frame-accurate. Accurate mode requires decoding/re-encoding.
|
||||
- Stream-copy concatenate works only when selected streams and codec/container
|
||||
parameters are compatible. Other input needs normalized re-encoding.
|
||||
- Crossfades are uniform fade/triangular-curve transitions on normalized
|
||||
sequential joins, not arbitrary transition effects or overlapping timeline
|
||||
tracks. They require enough material on both sides of every internal edit.
|
||||
- Re-encoding is not lossless. PCM/FLAC can preserve decoded audio samples but
|
||||
cannot restore information already lost in a source.
|
||||
- Metadata removal is best effort. Muxers may add structural/encoder fields,
|
||||
@@ -26,6 +29,12 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
page discards queued work.
|
||||
- A file-generating batch and its managed result collection are bounded to 255
|
||||
outputs, leaving the 256th ZIP entry available for an optional export report.
|
||||
- Scene analysis scans the source until it finds the requested changes or
|
||||
reaches the end, retains at most 100 exact PTS results and can therefore take
|
||||
about as long as a full decode. It does not begin an export implicitly when
|
||||
no frame meets the chosen threshold.
|
||||
- Labelled contact sheets require a core with `drawtext`; the reviewed local
|
||||
font does not make that filter available on a core that omits it.
|
||||
- Preview proxies are bounded, lossy H.264/AAC derivatives for local playback;
|
||||
they do not replace the source or guarantee that every browser can play the
|
||||
result.
|
||||
@@ -36,10 +45,14 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
presets from runtime formats/codecs/encoders/filters.
|
||||
- FFmpeg-readable output may be download-only because browser media elements
|
||||
support fewer container/codec combinations.
|
||||
- Browser-native basic information intentionally omits codecs, complete stream
|
||||
lists, chapters, tags, subtitles, attachments and dispositions. Features
|
||||
that need those details request an authoritative local FFprobe pass.
|
||||
- Browser support, codec policy and memory differ across Chromium, Firefox and
|
||||
Safari and across desktop/mobile versions.
|
||||
- Core 0.12.10 has an Opus regression risk. Opus remains release-gated until
|
||||
non-empty, re-probeable ST and MT fixture outputs pass.
|
||||
- The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe
|
||||
gate in isolated Chromium ST and MT runs. That evidence is not a
|
||||
cross-browser or every-input Opus guarantee.
|
||||
- MT needs HTTPS, COOP/COEP/CORP and `SharedArrayBuffer`. ST fallback works
|
||||
without isolation but is not a guarantee that a large job fits.
|
||||
- Top-level navigation is supported; iframe embedding is not.
|
||||
@@ -69,13 +82,15 @@ FFmpeg output does not remove these product/browser/resource limits.
|
||||
verification against the live container.
|
||||
- The application is GPL-3.0-or-later and its native legal files and pinned
|
||||
source identities are included in the release record. Publish the production
|
||||
ZIP/checksum, exact `v0.1.0` source and FFmpeg Corresponding Source bundle
|
||||
ZIP/checksum, exact `v0.3.0` source and FFmpeg Corresponding Source bundle
|
||||
together; development-only smoke artifacts must not be distributed.
|
||||
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
|
||||
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
|
||||
loading and one two-size ST/MT performance baseline. Firefox, Safari, mobile,
|
||||
exhaustive MT feature parity and repeated performance measurements remain
|
||||
unrecorded. Live Portal container/header verification is a deployment gate.
|
||||
loading and one two-size ST/MT performance baseline. Firefox covers the
|
||||
opt-in large-file browser-metadata/playback-to-authoritative-probe
|
||||
regression. Broad Firefox operation parity, Safari, mobile, exhaustive MT
|
||||
feature parity and repeated performance measurements remain unrecorded. Live
|
||||
Portal container/header verification is a deployment gate.
|
||||
|
||||
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
|
||||
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.
|
||||
|
||||
@@ -6,12 +6,13 @@ output at once. No hardware encoder is used.
|
||||
|
||||
## Fixed download footprint
|
||||
|
||||
Uncompressed self-hosted core assets for 0.12.10:
|
||||
Uncompressed self-hosted core assets for
|
||||
`0.12.10-reviewed-stack5m.1`:
|
||||
|
||||
| Mode | JavaScript/worker | WASM | Total |
|
||||
| ------------- | ------------------: | -----------: | -----------: |
|
||||
| single-thread | 111,804 B | 32,232,419 B | 32,344,223 B |
|
||||
| multithread | 128,947 B + 2,115 B | 32,718,323 B | 32,849,385 B |
|
||||
| single-thread | 111,804 B | 32,232,580 B | 32,344,384 B |
|
||||
| multithread | 128,953 B + 2,115 B | 32,718,455 B | 32,849,523 B |
|
||||
|
||||
Only the selected mode is loaded. HTTP compression may reduce JavaScript
|
||||
transfer but not necessarily WASM to the same degree; report transfer and
|
||||
@@ -72,9 +73,22 @@ Estimated output warns at 512 MiB and is blocked at 1 GiB. FFmpeg jobs are
|
||||
serial; the application retains at most eight scheduled operations in its
|
||||
in-memory queue. Timeline clips are capped at 100, streams per input at 64,
|
||||
images at 8192×8192/67,108,864 pixels, generated output files at 255,
|
||||
contact-sheet cells at 500 and runtime at four hours. The documented
|
||||
ffmpeg.wasm input ceiling is 2 GiB, but real limits can be substantially lower
|
||||
on mobile or memory-constrained browsers.
|
||||
contact-sheet cells at 500, scene-change results at 100, crossfades at 10
|
||||
seconds and runtime at four hours. Scene selection is a bounded two-step
|
||||
workflow but its analysis step can still decode the complete source when few
|
||||
frames meet the threshold. The documented ffmpeg.wasm input ceiling is 2 GiB,
|
||||
but real limits can be substantially lower on mobile or memory-constrained
|
||||
browsers.
|
||||
|
||||
Import does not start FFmpeg. Recognized local audio/video can begin browser
|
||||
playback from its temporary object URL while a native media element reads
|
||||
lightweight duration/dimensions. Detailed FFprobe inspection starts only when
|
||||
requested or required for an export and has a bounded, size-aware watchdog:
|
||||
30 seconds for small files, a two-MiB-per-second budget for larger files, and a
|
||||
five-minute ceiling. A detailed-inspection timeout is nonfatal; browser
|
||||
playback and basic file information remain available. Completed probe metadata
|
||||
remains authoritative for stream mapping, remux safety, chapters, tags,
|
||||
subtitles/attachments and export validation.
|
||||
|
||||
Lowering resolution, duration, frame rate, passes and output count reduces
|
||||
work. MT can be faster but consumes more memory/CPU and requires isolation; ST
|
||||
|
||||
@@ -18,7 +18,7 @@ X-Content-Type-Options: nosniff
|
||||
|
||||
Serve `.wasm` as `application/wasm`, JavaScript modules as a JavaScript MIME
|
||||
type and JSON as `application/json`. Test real responses under
|
||||
`/apps/av/vendor/ffmpeg/0.12.10/...`; a file-extension allowlist alone does not
|
||||
`/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/...`; a file-extension allowlist alone does not
|
||||
prove the MIME type.
|
||||
|
||||
Suggested CSP:
|
||||
@@ -43,45 +43,36 @@ form-action 'none';
|
||||
`'unsafe-eval'` is unnecessary. `worker-src blob:` covers the wrapper-created
|
||||
worker path. `media-src blob:` permits local source/result previews.
|
||||
|
||||
## Immutable reference and active-worktree state
|
||||
## Reviewed Portal state
|
||||
|
||||
Portal revision `bda9da044d61c109afd9969d357cc27f76648938` already sends COOP
|
||||
`same-origin`, COEP `require-corp`, CORP `same-origin`, `script-src 'self'
|
||||
'wasm-unsafe-eval'`, and `worker-src 'self' blob:`. Its packaged nginx CSP does
|
||||
**not** declare `media-src`; `default-src 'self'` therefore blocks local
|
||||
`blob:` audio/video URLs. The expected browser diagnostic is equivalent to:
|
||||
Portal revision `5c052bc6d1073a66600cfddc44c0e16da34bede8` (tag `v0.6.0`)
|
||||
already sends COOP `same-origin`, COEP `require-corp`, CORP `same-origin`,
|
||||
`script-src 'self' 'wasm-unsafe-eval'`, `worker-src 'self' blob:` and
|
||||
`media-src 'self' blob:`. Its nginx profile also maps `.wasm` explicitly to
|
||||
`application/wasm`. Local source and result Blob previews therefore require no
|
||||
CSP exception beyond that reviewed narrow media directive.
|
||||
|
||||
```text
|
||||
Refused to load media from 'blob:…' because it violates
|
||||
"default-src 'self'".
|
||||
```
|
||||
As inspected on 2026-07-26, the active
|
||||
`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` has an additional worktree
|
||||
patch that extends the immutable FFmpeg cache rule from plain semantic versions
|
||||
to the one exact reviewed build profile
|
||||
`0.12.10-reviewed-stack5m.1`. The corresponding deployment tests accept the
|
||||
five reviewed core files and `version.json` while rejecting mutable-looking
|
||||
suffixes, malformed versions and unexpected paths. A clean temporary clone
|
||||
containing that exact patch passed the AV release assembly smoke without a
|
||||
known-header-gap override.
|
||||
|
||||
Smallest required Portal change: append exactly
|
||||
`media-src 'self' blob:;` to that CSP. This permits only same-origin and
|
||||
in-memory Blob media; it does not permit remote media hosts, inline script or
|
||||
eval. If the Portal is not changed, conversion/download can remain available
|
||||
but Blob source/result preview must show a clear disabled reason.
|
||||
|
||||
As inspected on 2026-07-24, the active
|
||||
`/mnt/DATA/git/toolbox-portal/deploy/nginx.conf` contains that exact directive
|
||||
as a local worktree patch. The worktree still resolves to the immutable
|
||||
revision above, so the patch is useful for local deployment testing but is not
|
||||
a committed Portal revision and must not be cited as published release
|
||||
evidence. The clean-reference assembly smoke therefore continues to require
|
||||
`--allow-known-header-gap` until a reviewed Portal commit contains the fix.
|
||||
|
||||
The nginx profile includes `.wasm` in its static allowlist/gzip types but
|
||||
relies on the base image `mime.types`. Verify `Content-Type:
|
||||
application/wasm` against the pinned image; add an explicit mapping if the
|
||||
response differs.
|
||||
Until the cache extension is committed, cite it only as staged integration
|
||||
evidence, not as a published Portal revision. The clean `v0.6.0` rule leaves
|
||||
the reviewed profile at `no-cache`; that affects performance, not loading,
|
||||
MIME type, isolation or preview correctness.
|
||||
|
||||
## Caching
|
||||
|
||||
The immutable Portal reference's cache map makes Vite content-hashed assets
|
||||
immutable but leaves versioned FFmpeg cores at `no-cache`. Correctness is
|
||||
unaffected.
|
||||
For performance, the exact directory
|
||||
`apps/av/vendor/ffmpeg/0.12.10/{st,mt}/` may receive:
|
||||
The staged Portal cache map makes Vite content-hashed assets, plain semantic
|
||||
versions and the explicitly reviewed profile immutable. For performance, the
|
||||
exact directory
|
||||
`apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/{st,mt}/` may receive:
|
||||
|
||||
```text
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
# Reference implementations
|
||||
|
||||
Inspection date: **2026-07-24**.
|
||||
Inspection dates: **2026-07-24** (initial implementation) and
|
||||
**2026-07-26** (remaining-slice integration).
|
||||
|
||||
The references were cloned beside av-tools under
|
||||
`/mnt/DATA/git/references/`; no reference repository was nested in this
|
||||
working tree. Revisions are full immutable commit ids.
|
||||
|
||||
| Repository | Revision inspected | Used for | Copied or adapted |
|
||||
| -------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| av-tools, `git@git.add-ideas.de:zemion/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed |
|
||||
| Toolbox SDK, `https://git.add-ideas.de/zemion/toolbox-sdk.git` | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` (SDK 0.2.2) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.2 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
|
||||
| Toolbox Portal, `https://git.add-ideas.de/zemion/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the documented artifact contract informed an independent release packager and smoke harness |
|
||||
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree code was copied; exact published npm packages are dependencies and their five core assets are copied verbatim by the vendor script |
|
||||
| Repository | Revision inspected | Used for | Copied or adapted |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| av-tools, `git@git.add-ideas.de:lotobo/av-tools.git` | no revision: canonical `main` was unborn and had no commits | canonical destination and licence/history check | the empty repository was scaffolded in place; no prior code existed |
|
||||
| Toolbox SDK, `https://git.add-ideas.de/lotobo/toolbox-sdk.git` | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a` (SDK 0.2.3) | manifest schema/definition, `AppShell`, context behavior, shared styles, token-free registry, nested-path checks and toolchain baseline | published 0.2.3 packages are direct dependencies; documented usage patterns were followed; no SDK source file was copied |
|
||||
| Toolbox Portal, `https://git.add-ideas.de/lotobo/toolbox-portal.git` | `bda9da044d61c109afd9969d357cc27f76648938` initially; `5c052bc6d1073a66600cfddc44c0e16da34bede8` for final integration | release ZIP/sidecar/lock formats, independent assembly, catalogue behavior, path security, nested deployment and production headers | no portal application source was copied; the AV smoke harness consumes only an exact cloned Portal revision; the separate Portal worktree contains its own reviewed integration patch |
|
||||
| ffmpeg.wasm, `https://github.com/ffmpegwasm/ffmpeg.wasm.git` | `f876f907c7e9b9bf51d4ed0b913a855a63ae63fc`; pinned release baseline `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` | current API, worker lifecycle, filesystem, loading, ST/MT behavior, licence documentation and build layout | no reference-worktree source was copied; wrapper/util remain exact npm dependencies, while the five core assets are rebuilt from the recorded source/toolchain/patch profile and verified against the build lock |
|
||||
|
||||
For the **pinned** package set, the more specific upstream release tag
|
||||
`v12.15` at `71aa99d37c02a7b4c435275ca9ef50e612f6efa1` was also inspected. It
|
||||
contains `@ffmpeg/ffmpeg` 0.12.15 and core/core-mt 0.12.10. That revision's
|
||||
build selects FFmpeg n5.1.4.
|
||||
contains `@ffmpeg/ffmpeg` 0.12.15 and the source configuration for core
|
||||
0.12.10. That revision's build selects FFmpeg n5.1.4. The shipped
|
||||
`0.12.10-reviewed-stack5m.1` assets additionally apply upstream ffmpeg.wasm
|
||||
commit `b409e36475bc21f0451b5b1e1d126fa82871439a`; exact build and source
|
||||
identities are in `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
|
||||
|
||||
The user's active `/mnt/DATA/git/toolbox-portal` worktree still has Portal
|
||||
revision `bda9da044d61c109afd9969d357cc27f76648938` as `HEAD`, but currently
|
||||
contains a local `media-src 'self' blob:` nginx CSP patch. That mutable
|
||||
worktree state is documented for deployment testing only; it does not replace
|
||||
the immutable reference in the table.
|
||||
The active `/mnt/DATA/git/toolbox-portal` worktree has immutable revision
|
||||
`5c052bc6d1073a66600cfddc44c0e16da34bede8` as `HEAD`. Its staged cache-rule
|
||||
extension is documented separately and is not represented as a published
|
||||
revision.
|
||||
|
||||
Official behavior references:
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ The repository already declares the application licence and immutable
|
||||
third-party source identities. Before any distribution:
|
||||
|
||||
1. commit the reviewed GPL-3.0-or-later source;
|
||||
2. create and verify annotated tag `v0.1.0` at that exact commit;
|
||||
2. create and verify annotated tag `v0.3.0` at that exact commit;
|
||||
3. push the commit and tag to the canonical public repository;
|
||||
4. assemble, review and publish corresponding source and exact external
|
||||
notices for both core packages and linked libraries;
|
||||
notices for both reviewed core profiles and linked libraries;
|
||||
5. keep the manifest/package metadata, `SOURCE.md`, ZIP and tag version aligned.
|
||||
|
||||
`SOURCE.md` pins FFmpeg, ffmpeg.wasm, x264 and LAME by full commit. Do not
|
||||
@@ -44,6 +44,7 @@ Normal packaging validates the root GPL version 3 text and matching
|
||||
```sh
|
||||
npm run package:release
|
||||
npm run source:package
|
||||
npm run vendor:verify
|
||||
```
|
||||
|
||||
The legacy development-only escape hatch is retained only for checking an
|
||||
@@ -56,19 +57,19 @@ npm run package:release -- --allow-unlicensed-development-smoke
|
||||
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
|
||||
|
||||
```text
|
||||
release/av-tools-0.1.0.zip
|
||||
release/av-tools-0.1.0.zip.sha256
|
||||
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
|
||||
release/ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256
|
||||
release/av-tools-0.3.0.zip
|
||||
release/av-tools-0.3.0.zip.sha256
|
||||
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
|
||||
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
|
||||
```
|
||||
|
||||
Verify independently:
|
||||
|
||||
```sh
|
||||
node scripts/checksum-release.mjs
|
||||
(cd release && sha256sum -c ffmpeg-core-0.12.10-corresponding-source.tar.xz.sha256)
|
||||
unzip -l release/av-tools-0.1.0.zip
|
||||
tar -tJf release/ffmpeg-core-0.12.10-corresponding-source.tar.xz
|
||||
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
|
||||
unzip -l release/av-tools-0.3.0.zip
|
||||
tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
|
||||
```
|
||||
|
||||
The packager:
|
||||
@@ -90,13 +91,13 @@ When used against a fixture without an application licence, the escape hatch add
|
||||
|
||||
## 4. Portal assembly smoke
|
||||
|
||||
Use the clean reference clone beside av-tools, never the user's active Portal
|
||||
worktree:
|
||||
Use a reviewed Portal Git repository beside av-tools. The harness reads its
|
||||
exact `HEAD` and clones that revision into a temporary directory:
|
||||
|
||||
```sh
|
||||
npm run portal:smoke -- \
|
||||
--artifact release/av-tools-0.1.0.zip \
|
||||
--allow-known-header-gap
|
||||
--artifact release/av-tools-0.3.0.zip \
|
||||
--portal-root ../toolbox-portal
|
||||
```
|
||||
|
||||
The smoke harness clones the exact Portal HEAD to a temporary directory,
|
||||
@@ -106,19 +107,23 @@ then verifies the generated catalogue/release records and copied FFmpeg assets.
|
||||
The temporary directory is removed unless `--keep-temp` is explicitly passed.
|
||||
No machine-specific path is committed and no Portal worktree is changed.
|
||||
|
||||
Without `--allow-known-header-gap`, the clean immutable Portal reference's CSP
|
||||
omission of `media-src blob:` is a failing deployment check. The override still
|
||||
reports the gap and is only for testing archive assembly. See
|
||||
`docs/PORTAL_REQUIREMENTS.md`.
|
||||
Portal `v0.6.0`
|
||||
(`5c052bc6d1073a66600cfddc44c0e16da34bede8`) already passes the isolation,
|
||||
Blob-media CSP and explicit WASM MIME checks. Its plain-semver cache rule does
|
||||
not match the reviewed core's build-profile suffix, so the harness reports a
|
||||
cache warning. The staged Portal extension accepts only the exact reviewed
|
||||
profile in addition to plain semantic versions and rejects malformed or
|
||||
mutable-looking suffixes; a temporary clean commit containing that patch passed
|
||||
this smoke without warnings.
|
||||
|
||||
The user's active `/mnt/DATA/git/toolbox-portal` worktree contains the narrow
|
||||
directive as a local patch, but the smoke intentionally does not consume that
|
||||
mutable worktree. Until the patch exists in a reviewed immutable Portal
|
||||
revision, retain the clean-reference failure/override behavior above.
|
||||
`--allow-known-header-gap` remains available only for deliberately testing an
|
||||
older Portal revision with a known required-header error. It is unnecessary
|
||||
for `v0.6.0` and must not suppress review of new gaps. See
|
||||
`docs/PORTAL_REQUIREMENTS.md`.
|
||||
|
||||
## 5. Publish only reviewed bytes
|
||||
|
||||
From a clean `v0.1.0` checkout, rerun all checks, create the ZIP once, verify
|
||||
From a clean `v0.3.0` checkout, rerun all checks, create the ZIP once, verify
|
||||
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
|
||||
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
|
||||
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never
|
||||
|
||||
@@ -39,9 +39,10 @@ exportable diagnostics.
|
||||
Metadata is rendered as React text, never `dangerouslySetInnerHTML`. Subtitle
|
||||
text is parsed and rendered or sent through a generated local file; it is not
|
||||
inserted as HTML. Extracted URLs are inert text. The application does not open
|
||||
links from media automatically. No remote font is fetched; ASS burn-in uses
|
||||
packaged/system-generic font configuration only when the required local
|
||||
capability is available.
|
||||
links from media automatically. No remote font is fetched. Contact-sheet labels
|
||||
mount only the same-origin, size/SHA-256-verified DejaVu asset and fail closed
|
||||
without `drawtext`; ASS burn-in uses packaged/system-generic font configuration
|
||||
only when the required local capability is available.
|
||||
|
||||
Preview/download URLs are `blob:` values made from local bytes. They are
|
||||
revoked on replacement, project close and unmount. Browser support for a
|
||||
|
||||
@@ -13,6 +13,7 @@ export default tseslint.config(
|
||||
'test-results',
|
||||
'playwright-report',
|
||||
'public/vendor/ffmpeg',
|
||||
'.core-build',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
52
package-lock.json
generated
52
package-lock.json
generated
@@ -1,18 +1,16 @@
|
||||
{
|
||||
"name": "av-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "av-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
||||
"@ffmpeg/core": "0.12.10",
|
||||
"@ffmpeg/core-mt": "0.12.10",
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@ffmpeg/ffmpeg": "0.12.15",
|
||||
"@ffmpeg/util": "0.12.2",
|
||||
"fflate": "0.8.3",
|
||||
@@ -20,7 +18,7 @@
|
||||
"react-dom": "19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.2",
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -46,18 +44,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.2/toolbox-contract-0.2.2.tgz",
|
||||
"integrity": "sha512-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.2/toolbox-shell-react-0.2.2.tgz",
|
||||
"integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2"
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -65,13 +63,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.2/toolbox-testkit-0.2.2.tgz",
|
||||
"integrity": "sha512-ZxrRYiJI/j5aXwcd3mQItgDl1iOJ6N26+MpIG18qVZNwKKFCTCfSTzgzRooPt8rRc1MQYTPo4c+l/QpOWuTXxA==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2"
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
@@ -718,24 +716,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/core": {
|
||||
"version": "0.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
|
||||
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/core-mt": {
|
||||
"version": "0.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz",
|
||||
"integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg/ffmpeg": {
|
||||
"version": "0.12.15",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
|
||||
|
||||
25
package.json
25
package.json
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "av-tools",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/zemion/av-tools.git"
|
||||
"url": "git+https://git.add-ideas.de/lotobo/av-tools.git"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/zemion/av-tools",
|
||||
"homepage": "https://git.add-ideas.de/lotobo/av-tools",
|
||||
"bugs": {
|
||||
"url": "https://git.add-ideas.de/zemion/av-tools/issues"
|
||||
"url": "https://git.add-ideas.de/lotobo/av-tools/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -22,6 +22,8 @@
|
||||
"dev": "vite",
|
||||
"vendor:ffmpeg": "node scripts/copy-ffmpeg-assets.mjs",
|
||||
"vendor:verify": "node scripts/verify-ffmpeg-assets.mjs",
|
||||
"vendor:verify-runtime": "node scripts/verify-ffmpeg-assets.mjs --runtime-only",
|
||||
"vendor:package": "node scripts/package-reviewed-ffmpeg-core.mjs",
|
||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||
"prebuild": "npm run vendor:ffmpeg && npm run manifest:generate",
|
||||
@@ -34,6 +36,9 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:browser": "playwright test",
|
||||
"evidence:large-preview": "playwright test tests/browser/large-media-preview.spec.ts --project=chromium --project=firefox-large-media --workers=1",
|
||||
"evidence:opus": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs",
|
||||
"test:opus-regression": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs --require-pass",
|
||||
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
|
||||
"fixtures:generate": "sh scripts/generate-fixtures.sh",
|
||||
"fixtures:verify": "cd tests/fixtures/generated && sha256sum -c SHA256SUMS",
|
||||
@@ -42,14 +47,12 @@
|
||||
"source:package": "node scripts/package-ffmpeg-source.mjs",
|
||||
"checksum:release": "node scripts/checksum-release.mjs",
|
||||
"portal:smoke": "node scripts/portal-assembly-smoke.mjs",
|
||||
"check": "npm run manifest:check && npm run vendor:verify && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release"
|
||||
"check": "npm run manifest:check && npm run vendor:verify-runtime && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
||||
"release:artifact": "npm run check && npm run vendor:verify && npm run test:opus-regression && npm run test:browser && npm run package:release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.2",
|
||||
"@ffmpeg/core": "0.12.10",
|
||||
"@ffmpeg/core-mt": "0.12.10",
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@ffmpeg/ffmpeg": "0.12.15",
|
||||
"@ffmpeg/util": "0.12.2",
|
||||
"fflate": "0.8.3",
|
||||
@@ -57,7 +60,7 @@
|
||||
"react-dom": "19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.2",
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
@@ -21,5 +21,10 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox-large-media',
|
||||
testMatch: 'large-media-preview.spec.ts',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
public/fonts/DejaVuSans.ttf
Normal file
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,
|
||||
"id": "de.add-ideas.av-tools",
|
||||
"name": "Audio & Video Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -44,21 +44,21 @@
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/zemion/av-tools",
|
||||
"repository": "https://git.add-ideas.de/lotobo/av-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/zemion/av-tools"
|
||||
"url": "https://git.add-ideas.de/lotobo/av-tools"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
|
||||
]
|
||||
}
|
||||
|
||||
420
scripts/build-reviewed-ffmpeg-core.sh
Normal file
420
scripts/build-reviewed-ffmpeg-core.sh
Normal file
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/build-reviewed-ffmpeg-core.sh st|mt SOURCE_ARCHIVE EMSDK_ROOT [JOBS]
|
||||
|
||||
Builds one private, staged FFmpeg WebAssembly core from the exact
|
||||
0.12.10 Corresponding Source archive. The only upstream source change is
|
||||
ffmpeg.wasm commit b409e36475bc21f0451b5b1e1d126fa82871439a:
|
||||
`-sSTACK_SIZE=5MB`, which fixes upstream issues #591 and #775.
|
||||
|
||||
Output remains below .core-build/reviewed-stack5m and is never copied into the
|
||||
application automatically. Re-running resumes at verified component stamps.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 3 || $# -gt 4 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
MODE=$1
|
||||
SOURCE_ARCHIVE=$2
|
||||
EMSDK_ROOT=$3
|
||||
JOBS=${4:-2}
|
||||
|
||||
if [[ "$MODE" != "st" && "$MODE" != "mt" ]]; then
|
||||
echo "Mode must be st or mt." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$JOBS" =~ ^[1-8]$ ]]; then
|
||||
echo "JOBS must be an integer from 1 to 8." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SCRIPT_DIRECTORY=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
REPOSITORY_ROOT=$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)
|
||||
SOURCE_ARCHIVE=$(realpath -- "$SOURCE_ARCHIVE")
|
||||
EMSDK_ROOT=$(realpath -- "$EMSDK_ROOT")
|
||||
|
||||
EXPECTED_ARCHIVE_SHA256=2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a
|
||||
EXPECTED_EMCC_SHA256=b0c9551f9155fa9c028efa24591acdfd30f02f32fb247cf9266f5f0f2ede7589
|
||||
EXPECTED_CMAKE_SHA256=ade9326f83f4996b17e2cb1bbfe4848df30a237ef5769cd3c00239b9f0d3ec58
|
||||
EXPECTED_SOURCE_ROOT=ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source
|
||||
SOURCE_DATE_EPOCH=1736208000
|
||||
|
||||
if [[ ! -f "$SOURCE_ARCHIVE" || -L "$SOURCE_ARCHIVE" ]]; then
|
||||
echo "SOURCE_ARCHIVE must be a regular, non-symlink file." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$EMSDK_ROOT" || -L "$EMSDK_ROOT" ]]; then
|
||||
echo "EMSDK_ROOT must be a real directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
actual_archive_sha256=$(sha256sum -- "$SOURCE_ARCHIVE" | cut -d' ' -f1)
|
||||
if [[ "$actual_archive_sha256" != "$EXPECTED_ARCHIVE_SHA256" ]]; then
|
||||
echo "Corresponding Source archive SHA-256 mismatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$EMSDK_ROOT/emsdk_env.sh" >/dev/null
|
||||
export PATH="$REPOSITORY_ROOT/scripts/build-tools:$PATH"
|
||||
if [[ -z "${AV_REVIEWED_CMAKE_BIN:-}" ]]; then
|
||||
echo "AV_REVIEWED_CMAKE_BIN must identify the exact CMake 3.27.9 binary directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
AV_REVIEWED_CMAKE_BIN=$(realpath -- "$AV_REVIEWED_CMAKE_BIN")
|
||||
if [[ ! -d "$AV_REVIEWED_CMAKE_BIN" || -L "$AV_REVIEWED_CMAKE_BIN" ]]; then
|
||||
echo "AV_REVIEWED_CMAKE_BIN must be a real directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $(sha256sum -- "$AV_REVIEWED_CMAKE_BIN/cmake" | cut -d' ' -f1) != "$EXPECTED_CMAKE_SHA256" ]]; then
|
||||
echo "CMake binary SHA-256 mismatch; exact PyPI CMake 3.27.9 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PATH="$AV_REVIEWED_CMAKE_BIN:$PATH"
|
||||
EMCC=$(realpath -- "$EMSDK_ROOT/upstream/emscripten/emcc")
|
||||
actual_emcc_sha256=$(sha256sum -- "$EMCC" | cut -d' ' -f1)
|
||||
if [[ "$actual_emcc_sha256" != "$EXPECTED_EMCC_SHA256" ]]; then
|
||||
echo "Emscripten compiler SHA-256 mismatch; activate exact version 3.1.40." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$EMCC" --version | head -1 | grep -Fq \
|
||||
"3.1.40 (5c27e79dd0a9c4e27ef2326841698cdd4f6b5784)"; then
|
||||
echo "Emscripten identity mismatch; exact version 3.1.40 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $(cmake --version | head -1) != "cmake version 3.27.9" ]]; then
|
||||
echo "CMake identity mismatch; exact version 3.27.9 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_PARENT="$REPOSITORY_ROOT/.core-build/reviewed-stack5m"
|
||||
MODE_ROOT="$BUILD_PARENT/$MODE"
|
||||
EXTRACT_ROOT="$MODE_ROOT/source"
|
||||
SOURCE_ROOT="$EXTRACT_ROOT/$EXPECTED_SOURCE_ROOT/sources"
|
||||
INSTALL_DIR="$MODE_ROOT/install"
|
||||
OUTPUT_DIR="$MODE_ROOT/output"
|
||||
STAMP_DIR="$MODE_ROOT/stamps"
|
||||
|
||||
case "$MODE_ROOT" in
|
||||
"$REPOSITORY_ROOT"/.core-build/reviewed-stack5m/st | \
|
||||
"$REPOSITORY_ROOT"/.core-build/reviewed-stack5m/mt) ;;
|
||||
*)
|
||||
echo "Refusing an unexpected build directory: $MODE_ROOT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p -- "$MODE_ROOT" "$STAMP_DIR"
|
||||
if [[ ! -f "$STAMP_DIR/source-prepared" ]]; then
|
||||
if [[ -e "$EXTRACT_ROOT" ]]; then
|
||||
echo "Incomplete source staging already exists at $EXTRACT_ROOT." >&2
|
||||
echo "Remove only that private mode directory to restart it." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p -- "$EXTRACT_ROOT"
|
||||
tar -xf "$SOURCE_ARCHIVE" -C "$EXTRACT_ROOT"
|
||||
|
||||
lock="$EXTRACT_ROOT/$EXPECTED_SOURCE_ROOT/SOURCE_LOCK.json"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const lock = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const expected = new Map([
|
||||
["ffmpeg.wasm", "71aa99d37c02a7b4c435275ca9ef50e612f6efa1"],
|
||||
["FFmpeg", "4729204c17f756e186d622060088371d10b34f7e"],
|
||||
["Opus", "e85ed7726db5d677c9c0677298ea0cb9c65bdd23"],
|
||||
]);
|
||||
for (const [name, revision] of expected) {
|
||||
const entry = lock.sources.find((candidate) => candidate.name === name);
|
||||
if (!entry || entry.revision !== revision) {
|
||||
throw new Error(`Unexpected ${name} source identity.`);
|
||||
}
|
||||
}
|
||||
' "$lock"
|
||||
|
||||
wasm_build_script="$SOURCE_ROOT/ffmpeg.wasm/build/ffmpeg-wasm.sh"
|
||||
if grep -Fq -- "-sSTACK_SIZE=" "$wasm_build_script"; then
|
||||
echo "Base archive unexpectedly already selects a stack size." >&2
|
||||
exit 1
|
||||
fi
|
||||
sed -i \
|
||||
'/-sUSE_SDL=2/a\ -sSTACK_SIZE=5MB # upstream b409e364: support libopus' \
|
||||
"$wasm_build_script"
|
||||
if [[ $(grep -Fc -- "-sSTACK_SIZE=5MB" "$wasm_build_script") -ne 1 ]]; then
|
||||
echo "Could not apply the reviewed one-line stack patch exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
touch "$STAMP_DIR/source-prepared"
|
||||
fi
|
||||
|
||||
# The upstream container uses unbounded `make -j`. Keep local builds bounded
|
||||
# without changing compiled source, and allow a resumed build to select a new
|
||||
# bounded job count.
|
||||
while IFS= read -r -d '' build_script; do
|
||||
sed -E -i \
|
||||
-e "s/ -j([1-8])?$/ -j$JOBS/" \
|
||||
-e "s/ -j([1-8])? / -j$JOBS /g" \
|
||||
"$build_script"
|
||||
done < <(find "$SOURCE_ROOT/ffmpeg.wasm/build" -type f -name '*.sh' -print0)
|
||||
|
||||
# The upstream container does not provide native NASM. Some development hosts
|
||||
# do; allowing CMake to discover it would produce native x86 objects that
|
||||
# cannot be linked into WebAssembly. Make the container's effective choice
|
||||
# explicit and deterministic.
|
||||
X265_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/x265.sh"
|
||||
if ! grep -Fq -- "-DENABLE_ASSEMBLY=OFF" "$X265_BUILD_SCRIPT"; then
|
||||
sed -i \
|
||||
'/-DENABLE_LIBNUMA=OFF/a\ -DENABLE_ASSEMBLY=OFF' \
|
||||
"$X265_BUILD_SCRIPT"
|
||||
fi
|
||||
if [[ $(grep -Fc -- "-DENABLE_ASSEMBLY=OFF" "$X265_BUILD_SCRIPT") -ne 1 ]]; then
|
||||
echo "Could not disable native x265 assembly exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upstream builds each dependency in an isolated container stage. A cumulative
|
||||
# local prefix would otherwise let FreeType discover the already-built zlib and
|
||||
# let HarfBuzz discover FreeType. Their resulting private dependencies are not
|
||||
# present in FFmpeg's pkg-config probes and, on a developer workstation, optional
|
||||
# libraries such as libpng may even resolve to host-native /usr objects. Make the
|
||||
# isolated-stage dependency choices explicit.
|
||||
FREETYPE_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/freetype2.sh"
|
||||
if ! grep -Fq -- "--with-zlib=no" "$FREETYPE_BUILD_SCRIPT"; then
|
||||
sed -i \
|
||||
'/--without-harfbuzz/a\ --with-zlib=no\n --with-bzip2=no\n --with-png=no\n --with-brotli=no' \
|
||||
"$FREETYPE_BUILD_SCRIPT"
|
||||
fi
|
||||
for freetype_flag in \
|
||||
"--without-harfbuzz" \
|
||||
"--with-zlib=no" \
|
||||
"--with-bzip2=no" \
|
||||
"--with-png=no" \
|
||||
"--with-brotli=no"; do
|
||||
if [[ $(grep -Fc -- "$freetype_flag" "$FREETYPE_BUILD_SCRIPT") -ne 1 ]]; then
|
||||
echo "Could not select isolated FreeType dependency flags exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
HARFBUZZ_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/harfbuzz.sh"
|
||||
for harfbuzz_flag in \
|
||||
"--with-freetype=no" \
|
||||
"--with-glib=no" \
|
||||
"--with-gobject=no" \
|
||||
"--with-cairo=no" \
|
||||
"--with-icu=no" \
|
||||
"--with-graphite2=no" \
|
||||
"--with-uniscribe=no" \
|
||||
"--with-directwrite=no" \
|
||||
"--with-coretext=no"; do
|
||||
if ! grep -Fq -- "$harfbuzz_flag" "$HARFBUZZ_BUILD_SCRIPT"; then
|
||||
sed -i \
|
||||
"/--enable-static/a\\ $harfbuzz_flag" \
|
||||
"$HARFBUZZ_BUILD_SCRIPT"
|
||||
fi
|
||||
if [[ $(grep -Fc -- "$harfbuzz_flag" "$HARFBUZZ_BUILD_SCRIPT") -ne 1 ]]; then
|
||||
echo "Could not select isolated HarfBuzz dependency flags exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p -- "$INSTALL_DIR" "$OUTPUT_DIR"
|
||||
|
||||
export INSTALL_DIR
|
||||
export EM_PKG_CONFIG_PATH="$EMSDK/upstream/emscripten/system/lib/pkgconfig:$INSTALL_DIR/lib/pkgconfig"
|
||||
export EM_TOOLCHAIN_FILE="$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake"
|
||||
export PKG_CONFIG_PATH="$INSTALL_DIR/lib/pkgconfig:$EM_PKG_CONFIG_PATH"
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
export TZ=UTC
|
||||
export SOURCE_DATE_EPOCH
|
||||
export ZERO_AR_DATE=1
|
||||
|
||||
MODE_FLAGS="-O3 -msimd128"
|
||||
if [[ "$MODE" == "st" ]]; then
|
||||
export FFMPEG_ST=yes
|
||||
unset FFMPEG_MT || true
|
||||
else
|
||||
MODE_FLAGS="$MODE_FLAGS -sUSE_PTHREADS -pthread"
|
||||
# HarfBuzz's pinned upstream build script reads this value directly under
|
||||
# `set -u`, while the other scripts use its presence to disable threading.
|
||||
# An exported empty value keeps both behaviours correct for the MT build.
|
||||
export FFMPEG_ST=
|
||||
export FFMPEG_MT=yes
|
||||
fi
|
||||
export CFLAGS="-I$INSTALL_DIR/include $MODE_FLAGS"
|
||||
export CXXFLAGS="$CFLAGS"
|
||||
export LDFLAGS="-L$INSTALL_DIR/lib $CFLAGS"
|
||||
|
||||
FFMPEG_WASM="$SOURCE_ROOT/ffmpeg.wasm"
|
||||
|
||||
build_component() {
|
||||
local stamp=$1
|
||||
local directory=$2
|
||||
local script=$3
|
||||
if [[ -f "$STAMP_DIR/$stamp" ]]; then
|
||||
echo "[reviewed-core:$MODE] reuse $stamp"
|
||||
return
|
||||
fi
|
||||
echo "[reviewed-core:$MODE] build $stamp"
|
||||
(
|
||||
cd -- "$SOURCE_ROOT/$directory"
|
||||
if [[ "$stamp" == "libvpx" ]]; then
|
||||
# libvpx embeds its configure command in the linked library. A relative
|
||||
# prefix keeps that shipped identity independent of the checkout path
|
||||
# while still installing into this mode's private prefix.
|
||||
INSTALL_DIR=../../../../install \
|
||||
CFLAGS="-I../../../../install/include $MODE_FLAGS" \
|
||||
CXXFLAGS="-I../../../../install/include $MODE_FLAGS" \
|
||||
LDFLAGS="-L../../../../install/lib -I../../../../install/include $MODE_FLAGS" \
|
||||
bash "$FFMPEG_WASM/build/$script"
|
||||
else
|
||||
bash "$FFMPEG_WASM/build/$script"
|
||||
fi
|
||||
)
|
||||
touch "$STAMP_DIR/$stamp"
|
||||
}
|
||||
|
||||
if [[ ! -f "$STAMP_DIR/sdl2" ]]; then
|
||||
if [[ "$MODE" == "st" ]]; then
|
||||
embuilder build sdl2
|
||||
else
|
||||
embuilder build sdl2-mt
|
||||
fi
|
||||
touch "$STAMP_DIR/sdl2"
|
||||
fi
|
||||
|
||||
build_component x264 x264 x264.sh
|
||||
build_component x265 x265 x265.sh
|
||||
build_component libvpx libvpx libvpx.sh
|
||||
build_component lame lame lame.sh
|
||||
build_component ogg ogg ogg.sh
|
||||
build_component theora theora theora.sh
|
||||
build_component opus opus opus.sh
|
||||
build_component vorbis vorbis vorbis.sh
|
||||
build_component zlib zlib zlib.sh
|
||||
build_component libwebp libwebp libwebp.sh
|
||||
build_component freetype2 freetype2 freetype2.sh
|
||||
build_component fribidi fribidi fribidi.sh
|
||||
build_component harfbuzz harfbuzz harfbuzz.sh
|
||||
build_component libass libass libass.sh
|
||||
build_component zimg zimg zimg.sh
|
||||
|
||||
# Configuration probes from Autotools projects must never smuggle host-native
|
||||
# include/library paths into the reviewed WebAssembly link.
|
||||
if grep -R -E -n -- \
|
||||
'/usr/(lib|include)|/lib/(x86_64|i[3-6]86)-linux-gnu' \
|
||||
"$INSTALL_DIR/lib/pkgconfig" "$INSTALL_DIR/lib"/*.la; then
|
||||
echo "A staged dependency recorded a host-native build path." >&2
|
||||
exit 1
|
||||
fi
|
||||
if emnm "$INSTALL_DIR/lib/libfreetype.a" |
|
||||
grep -F -- " U png_" >/dev/null; then
|
||||
echo "FreeType unexpectedly retained an unbundled libpng dependency." >&2
|
||||
exit 1
|
||||
fi
|
||||
if emnm "$INSTALL_DIR/lib/libfreetype.a" |
|
||||
grep -E -- ' U (inflate|BZ2_|Brotli)' >/dev/null; then
|
||||
echo "FreeType unexpectedly retained a cross-stage compression dependency." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FFMPEG_SOURCE="$SOURCE_ROOT/FFmpeg"
|
||||
# FFmpeg records --extra-cflags in `ffmpeg -buildconf`. Use a path relative to
|
||||
# its source root so the shipped runtime identity is independent of the
|
||||
# maintainer's checkout location. Dependency builds above retain their
|
||||
# absolute private staging prefix; only the final FFmpeg configuration is
|
||||
# embedded in the distributed core.
|
||||
FFMPEG_INSTALL_RELATIVE=../../../../install
|
||||
export CFLAGS="-I$FFMPEG_INSTALL_RELATIVE/include $MODE_FLAGS"
|
||||
export CXXFLAGS="$CFLAGS"
|
||||
export LDFLAGS="-L$INSTALL_DIR/lib $CFLAGS"
|
||||
if [[ ! -f "$STAMP_DIR/ffmpeg" ]]; then
|
||||
echo "[reviewed-core:$MODE] build FFmpeg"
|
||||
(
|
||||
cd -- "$FFMPEG_SOURCE"
|
||||
bash "$FFMPEG_WASM/build/ffmpeg.sh" \
|
||||
--enable-gpl \
|
||||
--enable-libx264 \
|
||||
--enable-libx265 \
|
||||
--enable-libvpx \
|
||||
--enable-libmp3lame \
|
||||
--enable-libtheora \
|
||||
--enable-libvorbis \
|
||||
--enable-libopus \
|
||||
--enable-zlib \
|
||||
--enable-libwebp \
|
||||
--enable-libfreetype \
|
||||
--enable-libfribidi \
|
||||
--enable-libass \
|
||||
--enable-libzimg
|
||||
)
|
||||
touch "$STAMP_DIR/ffmpeg"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$STAMP_DIR/core" ]]; then
|
||||
echo "[reviewed-core:$MODE] link ffmpeg-core with 5 MiB stack"
|
||||
mkdir -p -- "$FFMPEG_SOURCE/src"
|
||||
cp -a -- "$FFMPEG_WASM/src/bind" "$FFMPEG_SOURCE/src/"
|
||||
cp -a -- "$FFMPEG_WASM/src/fftools" "$FFMPEG_SOURCE/src/"
|
||||
cp -- "$FFMPEG_WASM/build/ffmpeg-wasm.sh" "$FFMPEG_SOURCE/build-reviewed-core.sh"
|
||||
(
|
||||
cd -- "$FFMPEG_SOURCE"
|
||||
bash ./build-reviewed-core.sh \
|
||||
-lx264 \
|
||||
-lx265 \
|
||||
-lvpx \
|
||||
-lmp3lame \
|
||||
-logg \
|
||||
-ltheora \
|
||||
-lvorbis \
|
||||
-lvorbisenc \
|
||||
-lvorbisfile \
|
||||
-lopus \
|
||||
-lz \
|
||||
-lwebpmux \
|
||||
-lwebp \
|
||||
-lsharpyuv \
|
||||
-lfreetype \
|
||||
-lfribidi \
|
||||
-lharfbuzz \
|
||||
-lass \
|
||||
-lzimg \
|
||||
-sEXPORT_ES6 \
|
||||
-o "$OUTPUT_DIR/ffmpeg-core.js"
|
||||
)
|
||||
touch "$STAMP_DIR/core"
|
||||
fi
|
||||
|
||||
expected_files=(ffmpeg-core.js ffmpeg-core.wasm)
|
||||
if [[ "$MODE" == "mt" ]]; then
|
||||
expected_files+=(ffmpeg-core.worker.js)
|
||||
fi
|
||||
for expected_file in "${expected_files[@]}"; do
|
||||
if [[ ! -s "$OUTPUT_DIR/$expected_file" || -L "$OUTPUT_DIR/$expected_file" ]]; then
|
||||
echo "Missing staged output: $OUTPUT_DIR/$expected_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -aFq -- "$REPOSITORY_ROOT" "$OUTPUT_DIR/$expected_file" ||
|
||||
grep -aFq -- "$EMSDK_ROOT" "$OUTPUT_DIR/$expected_file"; then
|
||||
echo "Staged output leaks a maintainer-local build path: $expected_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
(
|
||||
cd -- "$OUTPUT_DIR"
|
||||
sha256sum -- "${expected_files[@]}" >SHA256SUMS
|
||||
)
|
||||
|
||||
echo "[reviewed-core:$MODE] staged outputs"
|
||||
cat "$OUTPUT_DIR/SHA256SUMS"
|
||||
99
scripts/build-tools/install
Normal file
99
scripts/build-tools/install
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# A deliberately small `install(1)` compatibility wrapper for build trees on
|
||||
# filesystems that map every created file to a shared owner and reject chmod.
|
||||
# It is used only inside the private reviewed-core build. The final asset pack
|
||||
# is hash-verified, and executable permission bits are irrelevant to browser
|
||||
# JavaScript/WebAssembly files.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
directory_mode=false
|
||||
create_leading=false
|
||||
target_directory=
|
||||
operands=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-d)
|
||||
directory_mode=true
|
||||
shift
|
||||
;;
|
||||
-D)
|
||||
create_leading=true
|
||||
shift
|
||||
;;
|
||||
-c | -p | -s | -v)
|
||||
shift
|
||||
;;
|
||||
-m | -o | -g)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "install wrapper: $1 requires a value" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
-m* | -o* | -g*)
|
||||
shift
|
||||
;;
|
||||
-t)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "install wrapper: -t requires a directory" >&2
|
||||
exit 2
|
||||
fi
|
||||
target_directory=$2
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
operands+=("$@")
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
echo "install wrapper: unsupported option $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
operands+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if $directory_mode; then
|
||||
if [[ ${#operands[@]} -eq 0 ]]; then
|
||||
echo "install wrapper: -d requires at least one directory" >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p -- "${operands[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -n "$target_directory" ]]; then
|
||||
if [[ ${#operands[@]} -eq 0 ]]; then
|
||||
echo "install wrapper: -t requires at least one source" >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p -- "$target_directory"
|
||||
cp -f -- "${operands[@]}" "$target_directory/"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#operands[@]} -lt 2 ]]; then
|
||||
echo "install wrapper: expected source and destination" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
destination=${operands[-1]}
|
||||
unset 'operands[-1]'
|
||||
if $create_leading; then
|
||||
mkdir -p -- "$(dirname -- "$destination")"
|
||||
fi
|
||||
|
||||
if [[ ${#operands[@]} -gt 1 || -d "$destination" ]]; then
|
||||
mkdir -p -- "$destination"
|
||||
cp -f -- "${operands[@]}" "$destination/"
|
||||
else
|
||||
cp -f -- "${operands[0]}" "$destination"
|
||||
fi
|
||||
@@ -1,138 +1,203 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedVersion = '0.12.10';
|
||||
const outputRoot = join(
|
||||
repositoryRoot,
|
||||
'public',
|
||||
'vendor',
|
||||
'ffmpeg',
|
||||
expectedVersion
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
|
||||
const packages = [
|
||||
{
|
||||
packageName: '@ffmpeg/core',
|
||||
mode: 'st',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'],
|
||||
},
|
||||
{
|
||||
packageName: '@ffmpeg/core-mt',
|
||||
mode: 'mt',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedHashes = new Map([
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.js',
|
||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.wasm',
|
||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.js',
|
||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.wasm',
|
||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.worker.js',
|
||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
||||
],
|
||||
const lockPath = path.join(
|
||||
repositoryRoot,
|
||||
'scripts',
|
||||
'reviewed-ffmpeg-core-lock.json'
|
||||
);
|
||||
const maximumArchiveBytes = 64 * 1024 * 1024;
|
||||
const maximumAssetBytes = 64 * 1024 * 1024;
|
||||
const requiredAssetPaths = new Set([
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
]);
|
||||
|
||||
async function loadPackageVersion(packageName) {
|
||||
const packagePath = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'package.json'
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
if (parsed.version !== expectedVersion) {
|
||||
throw new Error(
|
||||
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
|
||||
);
|
||||
}
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
async function copyAsset(packageName, mode, fileName) {
|
||||
const source = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'dist',
|
||||
'esm',
|
||||
fileName
|
||||
);
|
||||
const destination = join(outputRoot, mode, fileName);
|
||||
const sourceBytes = await readFile(source).catch((error) => {
|
||||
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
|
||||
cause: error,
|
||||
});
|
||||
function assertSafeRelativePath(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
value.startsWith('/') ||
|
||||
path.posix.normalize(value) !== value ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function requireRealDirectory(directory, create = false) {
|
||||
let details = await lstat(directory).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
|
||||
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
|
||||
if (!expectedDigest || sourceDigest !== expectedDigest) {
|
||||
throw new Error(
|
||||
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
|
||||
);
|
||||
if (!details && create) {
|
||||
await mkdir(directory);
|
||||
details = await lstat(directory);
|
||||
}
|
||||
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
dereference: true,
|
||||
errorOnExist: false,
|
||||
force: true,
|
||||
preserveTimestamps: false,
|
||||
});
|
||||
|
||||
const destinationBytes = await readFile(destination);
|
||||
if (!sourceBytes.equals(destinationBytes)) {
|
||||
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: relative(outputRoot, destination).replaceAll('\\', '/'),
|
||||
bytes: destinationBytes.byteLength,
|
||||
sha256: sourceDigest,
|
||||
};
|
||||
}
|
||||
|
||||
await rm(outputRoot, { force: true, recursive: true });
|
||||
|
||||
const assets = [];
|
||||
for (const entry of packages) {
|
||||
await loadPackageVersion(entry.packageName);
|
||||
for (const fileName of entry.files) {
|
||||
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
|
||||
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
|
||||
throw new Error(`Required directory is unsafe: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
|
||||
const versionManifest = {
|
||||
schemaVersion: 1,
|
||||
coreVersion: expectedVersion,
|
||||
packages: {
|
||||
'@ffmpeg/core': expectedVersion,
|
||||
'@ffmpeg/core-mt': expectedVersion,
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
const lockedAssetPaths = new Set(
|
||||
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||
);
|
||||
if (
|
||||
lock.schemaVersion !== 1 ||
|
||||
typeof lock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||
lock.coreVersion !== '0.12.10' ||
|
||||
lock.profile !== 'reviewed-stack5m' ||
|
||||
lock.sourceArchive?.path !==
|
||||
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||
lock.patch?.upstreamCommit !== 'b409e36475bc21f0451b5b1e1d126fa82871439a' ||
|
||||
lock.patch?.stackSizeBytes !== 5 * 1024 * 1024 ||
|
||||
typeof lock.opusRegression?.singleThread !== 'boolean' ||
|
||||
typeof lock.opusRegression?.multiThread !== 'boolean' ||
|
||||
!Array.isArray(lock.assets) ||
|
||||
lock.assets.length !== 5 ||
|
||||
lock.assets.some(
|
||||
(asset) =>
|
||||
!requiredAssetPaths.has(asset.path) ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
asset.bytes < 1 ||
|
||||
asset.bytes > maximumAssetBytes ||
|
||||
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
|
||||
) ||
|
||||
lockedAssetPaths.size !== requiredAssetPaths.size ||
|
||||
[...requiredAssetPaths].some((assetPath) => !lockedAssetPaths.has(assetPath))
|
||||
) {
|
||||
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
const artifactRelativePath = assertSafeRelativePath(lock.artifact?.path);
|
||||
if (artifactRelativePath !== `vendor/ffmpeg-core-${lock.buildId}.zip`) {
|
||||
throw new Error('Reviewed-core artifact path and build identity disagree.');
|
||||
}
|
||||
const artifact = await readFile(
|
||||
path.join(repositoryRoot, ...artifactRelativePath.split('/'))
|
||||
);
|
||||
if (
|
||||
artifact.byteLength < 1 ||
|
||||
artifact.byteLength > maximumArchiveBytes ||
|
||||
artifact.byteLength !== lock.artifact.bytes ||
|
||||
sha256(artifact) !== lock.artifact.sha256
|
||||
) {
|
||||
throw new Error('Reviewed FFmpeg core archive failed locked verification.');
|
||||
}
|
||||
|
||||
const lockedArchiveEntries = new Map(
|
||||
lock.assets.map((asset) => [asset.path, asset])
|
||||
);
|
||||
const archiveEntries = new Set();
|
||||
const uncompressed = unzipSync(artifact, {
|
||||
filter: (entry) => {
|
||||
const relative = assertSafeRelativePath(entry.name);
|
||||
const locked = lockedArchiveEntries.get(relative);
|
||||
if (
|
||||
!locked ||
|
||||
archiveEntries.has(relative) ||
|
||||
entry.originalSize !== locked.bytes ||
|
||||
entry.originalSize < 1 ||
|
||||
entry.originalSize > maximumAssetBytes ||
|
||||
entry.size < 1 ||
|
||||
entry.size > maximumArchiveBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
|
||||
);
|
||||
}
|
||||
archiveEntries.add(relative);
|
||||
return true;
|
||||
},
|
||||
assets,
|
||||
};
|
||||
});
|
||||
if (archiveEntries.size !== lockedArchiveEntries.size) {
|
||||
throw new Error('Reviewed FFmpeg core archive is missing an expected entry.');
|
||||
}
|
||||
|
||||
const vendorRoot = path.join(repositoryRoot, 'public', 'vendor', 'ffmpeg');
|
||||
const outputRoot = path.join(vendorRoot, lock.buildId);
|
||||
await requireRealDirectory(repositoryRoot);
|
||||
await requireRealDirectory(path.join(repositoryRoot, 'public'));
|
||||
await requireRealDirectory(path.join(repositoryRoot, 'public', 'vendor'), true);
|
||||
const existingVendorRoot = await lstat(vendorRoot).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (
|
||||
existingVendorRoot &&
|
||||
(existingVendorRoot.isSymbolicLink() || !existingVendorRoot.isDirectory())
|
||||
) {
|
||||
throw new Error(`Generated FFmpeg vendor root is unsafe: ${vendorRoot}`);
|
||||
}
|
||||
await rm(vendorRoot, { force: true, recursive: true });
|
||||
|
||||
for (const asset of lock.assets) {
|
||||
const relativeAssetPath = assertSafeRelativePath(asset.path);
|
||||
const bytes = uncompressed[relativeAssetPath];
|
||||
if (
|
||||
!bytes ||
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > maximumAssetBytes ||
|
||||
bytes.byteLength !== asset.bytes ||
|
||||
sha256(bytes) !== asset.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed FFmpeg core entry failed locked verification: ${asset.path}`
|
||||
);
|
||||
}
|
||||
const destination = path.join(outputRoot, ...relativeAssetPath.split('/'));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await writeFile(destination, bytes, { flag: 'wx', mode: 0o644 });
|
||||
}
|
||||
|
||||
const versionManifest = {
|
||||
schemaVersion: 2,
|
||||
buildId: lock.buildId,
|
||||
profile: lock.profile,
|
||||
coreVersion: lock.coreVersion,
|
||||
ffmpegVersion: lock.ffmpegVersion,
|
||||
sourceArchive: lock.sourceArchive,
|
||||
sourceLock: lock.sourceLock,
|
||||
buildDriver: lock.buildDriver,
|
||||
toolchain: lock.toolchain,
|
||||
patch: lock.patch,
|
||||
opusRegression: lock.opusRegression,
|
||||
assets: lock.assets,
|
||||
};
|
||||
await writeFile(
|
||||
join(outputRoot, 'version.json'),
|
||||
`${JSON.stringify(versionManifest, null, 2)}\n`
|
||||
path.join(outputRoot, 'version.json'),
|
||||
`${JSON.stringify(versionManifest, null, 2)}\n`,
|
||||
{ flag: 'wx', mode: 0o644 }
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
|
||||
`Verified and unpacked ${lock.assets.length} reviewed FFmpeg assets to ${path.relative(repositoryRoot, outputRoot)}`
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# FFmpeg WebAssembly Corresponding Source
|
||||
|
||||
This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt`
|
||||
0.12.10 object code distributed by av-tools 0.1.0.
|
||||
This archive accompanies the reviewed single-thread and multithread FFmpeg
|
||||
WebAssembly core profile distributed by av-tools. The profile retains the
|
||||
upstream core version 0.12.10 and FFmpeg n5.1.4 identities, but it is rebuilt
|
||||
from source rather than copied from the published `@ffmpeg/core` packages.
|
||||
|
||||
`SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and
|
||||
every external library selected by the upstream v12.15 build. The source trees
|
||||
@@ -18,10 +20,15 @@ The original build-control files are in `sources/ffmpeg.wasm/`, including:
|
||||
- `build/ffmpeg-wasm.sh`;
|
||||
- the other dependency scripts under `build/`.
|
||||
|
||||
The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools
|
||||
does not modify the five published JavaScript/WebAssembly core assets. Their
|
||||
exact sizes, hashes and embedded configure arguments are recorded in the
|
||||
application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
|
||||
The build uses Emscripten SDK 3.1.40 and FFmpeg n5.1.4. Its one source patch
|
||||
applies upstream ffmpeg.wasm commit
|
||||
`b409e36475bc21f0451b5b1e1d126fa82871439a`, which raises the Emscripten stack
|
||||
to 5 MiB and fixes the libopus encode failure reported upstream. The tracked
|
||||
`scripts/build-reviewed-ffmpeg-core.sh` driver in the matching av-tools source
|
||||
release also isolates optional host dependencies and records the exact
|
||||
toolchain identities. The generated assets' exact sizes, hashes and embedded
|
||||
configure arguments are recorded in the application's `SOURCE.md`,
|
||||
`scripts/reviewed-ffmpeg-core-lock.json` and `docs/FFMPEG_BUILD.md`.
|
||||
|
||||
The source archive preserves each component's own licence and copyright
|
||||
notices. av-tools itself is distributed separately under
|
||||
@@ -37,14 +44,11 @@ test "$(git -C source-check rev-parse FETCH_HEAD)" = "<revision>"
|
||||
```
|
||||
|
||||
The included ffmpeg.wasm Dockerfile documents the historical upstream build,
|
||||
but still names remote tags and branches. It is not a turnkey offline or
|
||||
byte-for-byte reproducible build recipe. To reconstruct a pinned build,
|
||||
adapt each remote `ADD` to use the corresponding bundled source tree. Replace
|
||||
the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/`
|
||||
tree, which already contains its pinned GoogleTest submodule at
|
||||
`test/extra/googletest/`. Keep the included build scripts and Emscripten
|
||||
version unchanged. The upstream targets are `make prd` for single-thread and
|
||||
`make prd-mt` for multithread.
|
||||
but still names remote tags and branches. For the distributed reviewed
|
||||
profile, use this archive together with the matching av-tools source release
|
||||
and invoke its `scripts/build-reviewed-ffmpeg-core.sh` driver. The bundled
|
||||
`sources/zimg/` tree already contains its pinned GoogleTest submodule at
|
||||
`test/extra/googletest/`.
|
||||
|
||||
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
|
||||
3.1.40 container image is not copied into this archive.
|
||||
|
||||
@@ -23,7 +23,7 @@ if (source.version !== packageJson.version) {
|
||||
}
|
||||
|
||||
if (
|
||||
source.source?.repository !== 'https://git.add-ideas.de/zemion/av-tools' ||
|
||||
source.source?.repository !== 'https://git.add-ideas.de/lotobo/av-tools' ||
|
||||
source.source?.license !== 'GPL-3.0-or-later'
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
522
scripts/opus-regression.mjs
Normal file
522
scripts/opus-regression.mjs
Normal file
@@ -0,0 +1,522 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
||||
const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_PATH), '..');
|
||||
const FIXTURE_RELATIVE_PATH = 'tests/fixtures/generated/tone.wav';
|
||||
const FIXTURE_PATH = path.join(REPOSITORY_ROOT, FIXTURE_RELATIVE_PATH);
|
||||
const EXPECTED_FIXTURE_BYTES = 32_102;
|
||||
const EXPECTED_FIXTURE_SHA256 =
|
||||
'0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c';
|
||||
const DEFAULT_MODE_TIMEOUT_MILLISECONDS = 60_000;
|
||||
const RESULT_PREFIX = 'OPUS_RESULT ';
|
||||
const EVENT_PREFIX = 'OPUS_EVENT ';
|
||||
const MODES = Object.freeze(['single-thread', 'multithread']);
|
||||
|
||||
const argumentsSet = new Set(process.argv.slice(2));
|
||||
const option = (name) =>
|
||||
process.argv
|
||||
.slice(2)
|
||||
.find((argument) => argument.startsWith(`--${name}=`))
|
||||
?.slice(name.length + 3);
|
||||
|
||||
function boundedInteger(value, fallback, minimum, maximum) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function serializeError(error) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
...(error.stack ? { stack: error.stack.split('\n').slice(0, 8) } : {}),
|
||||
};
|
||||
}
|
||||
return { name: 'Error', message: String(error) };
|
||||
}
|
||||
|
||||
function emit(prefix, value) {
|
||||
process.stdout.write(`${prefix}${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function parsePrefixedJsonLines(text, prefix) {
|
||||
return text
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.startsWith(prefix))
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line.slice(prefix.length))];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function appendBounded(current, chunk, maximumLength = 32_768) {
|
||||
return `${current}${chunk}`.slice(-maximumLength);
|
||||
}
|
||||
|
||||
function terminateProcessGroup(child) {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
child.kill('SIGKILL');
|
||||
} else {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function runIsolatedMode(mode, origin, timeoutMilliseconds) {
|
||||
const startedAt = performance.now();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[SCRIPT_PATH, '--child', `--mode=${mode}`, `--origin=${origin}`],
|
||||
{
|
||||
cwd: REPOSITORY_ROOT,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = appendBounded(stdout, chunk);
|
||||
for (const event of parsePrefixedJsonLines(chunk, EVENT_PREFIX)) {
|
||||
if (event.stage && event.stage !== 'core-log') {
|
||||
process.stdout.write(`[opus:${mode}] ${event.stage}\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr = appendBounded(stderr, chunk);
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
terminateProcessGroup(child);
|
||||
}, timeoutMilliseconds);
|
||||
|
||||
const { code, signal } = await new Promise((resolve) => {
|
||||
child.once('close', (exitCode, exitSignal) => {
|
||||
resolve({ code: exitCode, signal: exitSignal });
|
||||
});
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
const elapsedMilliseconds = Math.round(performance.now() - startedAt);
|
||||
const events = parsePrefixedJsonLines(stdout, EVENT_PREFIX);
|
||||
const reported = parsePrefixedJsonLines(stdout, RESULT_PREFIX).at(-1);
|
||||
if (reported) {
|
||||
return {
|
||||
...reported,
|
||||
elapsedMilliseconds,
|
||||
process: { exitCode: code, signal },
|
||||
};
|
||||
}
|
||||
|
||||
const coreReady = events.find((event) => event.stage === 'core-ready');
|
||||
const lastStage = [...events]
|
||||
.reverse()
|
||||
.find((event) => event.stage && event.stage !== 'core-log');
|
||||
const relevantLogs = events
|
||||
.filter((event) => event.stage === 'core-log')
|
||||
.slice(-16)
|
||||
.map((event) => event.message);
|
||||
return {
|
||||
requestedMode: mode,
|
||||
status: 'fail',
|
||||
failure: timedOut ? 'timeout' : 'child-exited-without-result',
|
||||
message: timedOut
|
||||
? `The isolated browser did not finish within ${String(timeoutMilliseconds)} ms.`
|
||||
: 'The isolated browser exited without a structured result.',
|
||||
...(coreReady?.actualMode ? { actualMode: coreReady.actualMode } : {}),
|
||||
...(coreReady?.ffmpegVersion
|
||||
? { ffmpegVersion: coreReady.ffmpegVersion }
|
||||
: {}),
|
||||
...(coreReady?.capabilities
|
||||
? { capabilities: coreReady.capabilities }
|
||||
: {}),
|
||||
...(lastStage?.stage ? { lastStage: lastStage.stage } : {}),
|
||||
...(relevantLogs.length > 0 ? { relevantLogs } : {}),
|
||||
...(stderr.trim() ? { stderr: stderr.trim().split('\n').slice(-12) } : {}),
|
||||
elapsedMilliseconds,
|
||||
process: { exitCode: code, signal },
|
||||
};
|
||||
}
|
||||
|
||||
async function fixtureIdentity() {
|
||||
const [bytes, fixtureStat] = await Promise.all([
|
||||
readFile(FIXTURE_PATH),
|
||||
stat(FIXTURE_PATH),
|
||||
]);
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
if (
|
||||
fixtureStat.size !== EXPECTED_FIXTURE_BYTES ||
|
||||
sha256 !== EXPECTED_FIXTURE_SHA256
|
||||
) {
|
||||
throw new Error(
|
||||
`Opus fixture identity mismatch: expected ${String(EXPECTED_FIXTURE_BYTES)} bytes/${EXPECTED_FIXTURE_SHA256}, received ${String(fixtureStat.size)} bytes/${sha256}.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: FIXTURE_RELATIVE_PATH,
|
||||
bytes: fixtureStat.size,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMain() {
|
||||
const timeoutMilliseconds = boundedInteger(
|
||||
option('timeout-ms') ?? process.env.AV_OPUS_REGRESSION_TIMEOUT_MS,
|
||||
DEFAULT_MODE_TIMEOUT_MILLISECONDS,
|
||||
10_000,
|
||||
10 * 60_000
|
||||
);
|
||||
const [fixture, packageText, coreLockText] = await Promise.all([
|
||||
fixtureIdentity(),
|
||||
readFile(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
||||
readFile(
|
||||
path.join(REPOSITORY_ROOT, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
|
||||
'utf8'
|
||||
),
|
||||
]);
|
||||
const packageManifest = JSON.parse(packageText);
|
||||
const coreLock = JSON.parse(coreLockText);
|
||||
const { createServer } = await import('vite');
|
||||
const server = await createServer({
|
||||
configFile: path.join(REPOSITORY_ROOT, 'vite.config.ts'),
|
||||
logLevel: 'warn',
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
strictPort: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await server.listen();
|
||||
const address = server.httpServer?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Vite did not expose a local TCP address.');
|
||||
}
|
||||
const origin = `http://127.0.0.1:${String(address.port)}`;
|
||||
const modes = [];
|
||||
for (const mode of MODES) {
|
||||
modes.push(await runIsolatedMode(mode, origin, timeoutMilliseconds));
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 2,
|
||||
recordedAt: new Date().toISOString(),
|
||||
browser: 'chromium',
|
||||
wrapperVersion: packageManifest.dependencies['@ffmpeg/ffmpeg'],
|
||||
coreVersion: coreLock.coreVersion,
|
||||
coreBuildId: coreLock.buildId,
|
||||
coreProfile: coreLock.profile,
|
||||
fixture,
|
||||
perModeTimeoutMilliseconds: timeoutMilliseconds,
|
||||
modes,
|
||||
safe: modes.every((result) => result.status === 'pass'),
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (argumentsSet.has('--require-pass') && !report.safe) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runBrowserMode(mode, origin) {
|
||||
const { chromium } = await import('@playwright/test');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.exposeFunction('__reportOpusEvent', (event) => {
|
||||
emit(EVENT_PREFIX, event);
|
||||
});
|
||||
await page.goto(origin, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return await page.evaluate(
|
||||
async ({ fixtureUrl, requestedMode }) => {
|
||||
const report = async (event) => {
|
||||
await globalThis.__reportOpusEvent(event);
|
||||
};
|
||||
const replacePaths = (plan, context) => {
|
||||
const replacements = new Map();
|
||||
plan.inputs.forEach((input, index) => {
|
||||
const actual = context.inputPaths[index];
|
||||
if (actual) replacements.set(input.path, actual);
|
||||
});
|
||||
plan.outputs.forEach((output, index) => {
|
||||
const actual = context.outputPaths[index];
|
||||
if (actual) replacements.set(output.path, actual);
|
||||
});
|
||||
plan.temporaryFiles.forEach((temporary, index) => {
|
||||
const actual = context.temporaryPaths[index];
|
||||
if (actual) replacements.set(temporary.path, actual);
|
||||
});
|
||||
return plan.args.map((argument) =>
|
||||
[...replacements].reduce(
|
||||
(resolved, [virtualPath, actualPath]) =>
|
||||
resolved.replaceAll(virtualPath, actualPath),
|
||||
argument
|
||||
)
|
||||
);
|
||||
};
|
||||
const errorShape = (error) =>
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
...(error.stack
|
||||
? { stack: error.stack.split('\n').slice(0, 8) }
|
||||
: {}),
|
||||
}
|
||||
: { name: 'Error', message: String(error) };
|
||||
let manager;
|
||||
const relevantLogs = [];
|
||||
try {
|
||||
await report({ stage: 'module-import' });
|
||||
const [{ EngineManager }, { buildConvertPlan }, { AUDIO_PRESETS }] =
|
||||
await Promise.all([
|
||||
import('/src/ffmpeg/EngineManager.ts'),
|
||||
import('/src/commands/convert.ts'),
|
||||
import('/src/presets/audio-presets.ts'),
|
||||
]);
|
||||
manager = new EngineManager();
|
||||
manager.setPreference(
|
||||
requestedMode === 'single-thread'
|
||||
? 'force-single-thread'
|
||||
: 'prefer-multithread'
|
||||
);
|
||||
manager.onLog((event) => {
|
||||
for (const line of event.message.split(/\r?\n/u)) {
|
||||
if (
|
||||
relevantLogs.length < 32 &&
|
||||
/libopus|Output #|Stream mapping|Aborted\(\)|(?:^|\s)(?:size|progress)=|error|failed/iu.test(
|
||||
line
|
||||
)
|
||||
) {
|
||||
const message = line.slice(0, 600);
|
||||
relevantLogs.push(message);
|
||||
void report({ stage: 'core-log', message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await report({ stage: 'fixture-fetch' });
|
||||
const fixtureResponse = await fetch(fixtureUrl);
|
||||
if (!fixtureResponse.ok) {
|
||||
throw new Error(
|
||||
`Fixture request failed with HTTP ${String(fixtureResponse.status)}.`
|
||||
);
|
||||
}
|
||||
const fixtureBytes = new Uint8Array(
|
||||
await fixtureResponse.arrayBuffer()
|
||||
);
|
||||
const sourceFile = new File([fixtureBytes], 'tone.wav', {
|
||||
type: 'audio/wav',
|
||||
});
|
||||
|
||||
await report({ stage: 'core-initializing' });
|
||||
await manager.initialize();
|
||||
const ready = manager.state;
|
||||
if (ready.status !== 'ready') {
|
||||
throw new Error('The FFmpeg engine did not reach ready state.');
|
||||
}
|
||||
const capabilities = {
|
||||
opusMuxer: ready.capabilities.muxers.has('opus'),
|
||||
libopusEncoder: ready.capabilities.encoders.has('libopus'),
|
||||
};
|
||||
await report({
|
||||
stage: 'core-ready',
|
||||
requestedMode,
|
||||
actualMode: ready.mode,
|
||||
ffmpegVersion: ready.capabilities.versionText,
|
||||
capabilities,
|
||||
});
|
||||
if (ready.mode !== requestedMode) {
|
||||
throw new Error(
|
||||
`Requested ${requestedMode}, but the engine selected ${ready.mode}.`
|
||||
);
|
||||
}
|
||||
if (!capabilities.opusMuxer || !capabilities.libopusEncoder) {
|
||||
throw new Error(
|
||||
'The pinned core does not advertise the Opus muxer and libopus encoder.'
|
||||
);
|
||||
}
|
||||
|
||||
const preset = AUDIO_PRESETS.find(
|
||||
(candidate) => candidate.id === 'audio-opus'
|
||||
);
|
||||
if (!preset) {
|
||||
throw new Error('The built-in audio-opus preset is missing.');
|
||||
}
|
||||
const plan = buildConvertPlan({
|
||||
jobId: `opus-regression-${requestedMode}`,
|
||||
source: {
|
||||
id: 'tone',
|
||||
sourceIndex: 0,
|
||||
fileName: 'tone.wav',
|
||||
extension: 'wav',
|
||||
},
|
||||
preset,
|
||||
streamSelection: { audio: [0] },
|
||||
expectedDurationSeconds: 1,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
await report({
|
||||
stage: 'encode-started',
|
||||
presetId: preset.id,
|
||||
requirements: plan.requiredCapabilities,
|
||||
});
|
||||
let commandArguments = [];
|
||||
const completed = await manager.runJob({
|
||||
id: `opus-regression-${requestedMode}`,
|
||||
operation: 'Opus regression',
|
||||
coreWorkload: plan.operation,
|
||||
inputs: [sourceFile],
|
||||
outputs: plan.outputs.map((output) => ({
|
||||
name: output.fileName,
|
||||
mimeType: output.mediaType,
|
||||
})),
|
||||
expectedDurationSeconds: plan.expectedDurationSeconds,
|
||||
timeoutMilliseconds: plan.timeoutMs,
|
||||
buildArguments: (context) => {
|
||||
commandArguments = replacePaths(plan, context);
|
||||
void report({
|
||||
stage: 'encode-command',
|
||||
arguments: commandArguments,
|
||||
});
|
||||
return commandArguments;
|
||||
},
|
||||
});
|
||||
const output = completed.outputs[0];
|
||||
if (!output || output.bytes.byteLength === 0) {
|
||||
throw new Error(
|
||||
'The Opus encode did not produce non-empty output.'
|
||||
);
|
||||
}
|
||||
await report({
|
||||
stage: 'encode-finished',
|
||||
outputBytes: output.bytes.byteLength,
|
||||
});
|
||||
|
||||
const outputFile = new File([output.bytes], output.name, {
|
||||
type: output.mimeType,
|
||||
});
|
||||
await report({ stage: 'reprobe-started' });
|
||||
const probe = await manager.probe(outputFile, 15_000);
|
||||
const probeJson = probe.json;
|
||||
const streams = Array.isArray(probeJson?.streams)
|
||||
? probeJson.streams
|
||||
: [];
|
||||
const opusStream = streams.find(
|
||||
(stream) =>
|
||||
stream?.codec_type === 'audio' && stream?.codec_name === 'opus'
|
||||
);
|
||||
const durationSeconds = Number(
|
||||
probeJson?.format?.duration ?? opusStream?.duration
|
||||
);
|
||||
if (!opusStream || !(durationSeconds > 0)) {
|
||||
throw new Error(
|
||||
'The generated file did not re-probe as positive-duration Opus audio.'
|
||||
);
|
||||
}
|
||||
const digest = new Uint8Array(
|
||||
await crypto.subtle.digest('SHA-256', output.bytes)
|
||||
);
|
||||
const outputSha256 = [...digest]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
await report({ stage: 'reprobe-finished', durationSeconds });
|
||||
return {
|
||||
requestedMode,
|
||||
actualMode: ready.mode,
|
||||
status: 'pass',
|
||||
ffmpegVersion: ready.capabilities.versionText,
|
||||
capabilities,
|
||||
presetId: preset.id,
|
||||
commandArguments,
|
||||
output: {
|
||||
name: output.name,
|
||||
mimeType: output.mimeType,
|
||||
bytes: output.bytes.byteLength,
|
||||
sha256: outputSha256,
|
||||
codecName: opusStream.codec_name,
|
||||
codecType: opusStream.codec_type,
|
||||
durationSeconds,
|
||||
},
|
||||
relevantLogs,
|
||||
};
|
||||
} catch (error) {
|
||||
const state = manager?.state;
|
||||
return {
|
||||
requestedMode,
|
||||
...(state?.mode ? { actualMode: state.mode } : {}),
|
||||
status: 'fail',
|
||||
failure: 'browser-operation-failed',
|
||||
error: errorShape(error),
|
||||
relevantLogs,
|
||||
};
|
||||
} finally {
|
||||
manager?.dispose();
|
||||
}
|
||||
},
|
||||
{
|
||||
fixtureUrl: `${origin}/${FIXTURE_RELATIVE_PATH}`,
|
||||
requestedMode: mode,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runChild() {
|
||||
const mode = option('mode');
|
||||
const origin = option('origin');
|
||||
if (!MODES.includes(mode) || !origin) {
|
||||
throw new Error('Child mode requires valid --mode and --origin options.');
|
||||
}
|
||||
emit(EVENT_PREFIX, { stage: 'browser-launching', requestedMode: mode });
|
||||
try {
|
||||
const result = await runBrowserMode(mode, origin);
|
||||
emit(RESULT_PREFIX, result);
|
||||
} catch (error) {
|
||||
emit(RESULT_PREFIX, {
|
||||
requestedMode: mode,
|
||||
status: 'fail',
|
||||
failure: 'browser-process-failed',
|
||||
error: serializeError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (argumentsSet.has('--child')) {
|
||||
await runChild();
|
||||
} else {
|
||||
await runMain();
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`Opus regression harness failed: ${JSON.stringify(serializeError(error))}\n`
|
||||
);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import { createReadStream } from 'node:fs';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
|
||||
const reviewedLockPath = join(
|
||||
repositoryRoot,
|
||||
'scripts',
|
||||
'reviewed-ffmpeg-core-lock.json'
|
||||
);
|
||||
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
|
||||
const releaseDirectory = join(repositoryRoot, 'release');
|
||||
const force = process.argv.includes('--force');
|
||||
@@ -204,7 +209,19 @@ async function publishSourcePair({
|
||||
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
assertLock(lock);
|
||||
const archiveName = `ffmpeg-core-${lock.coreVersion}-corresponding-source.tar.xz`;
|
||||
const reviewedLock = JSON.parse(await readFile(reviewedLockPath, 'utf8'));
|
||||
if (
|
||||
reviewedLock?.schemaVersion !== 1 ||
|
||||
reviewedLock.coreVersion !== lock.coreVersion ||
|
||||
typeof reviewedLock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/u.test(reviewedLock.buildId) ||
|
||||
!reviewedLock.buildId.startsWith(`${lock.coreVersion}-`)
|
||||
) {
|
||||
throw new Error(
|
||||
'The reviewed-core lock does not identify a build-qualified source bundle.'
|
||||
);
|
||||
}
|
||||
const archiveName = `ffmpeg-core-${reviewedLock.buildId}-corresponding-source.tar.xz`;
|
||||
const archivePath = join(releaseDirectory, archiveName);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
await ensureReleaseDirectory();
|
||||
|
||||
@@ -23,9 +23,9 @@ const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
||||
const maximumEntryBytes = 256 * 1024 * 1024;
|
||||
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
|
||||
const applicationLicenseSpdx = 'GPL-3.0-or-later';
|
||||
const canonicalRepository = 'https://git.add-ideas.de/zemion/av-tools';
|
||||
const canonicalRepository = 'https://git.add-ideas.de/lotobo/av-tools';
|
||||
const canonicalPackageRepository =
|
||||
'git+https://git.add-ideas.de/zemion/av-tools.git';
|
||||
'git+https://git.add-ideas.de/lotobo/av-tools.git';
|
||||
const requiredNativeLegalFiles = [
|
||||
'LICENSES/native/FFmpeg-LICENSE.md',
|
||||
'LICENSES/native/FreeType-LICENSE.txt',
|
||||
@@ -52,8 +52,6 @@ const ffmpegLicenseCoverage = new Map([
|
||||
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/core', 'LICENSES/GPL-2.0-or-later.txt'],
|
||||
['@ffmpeg/core-mt', 'LICENSES/GPL-2.0-or-later.txt'],
|
||||
]);
|
||||
|
||||
function compareText(left, right) {
|
||||
@@ -479,7 +477,8 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
||||
}
|
||||
}
|
||||
|
||||
const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json';
|
||||
const vendorBuildId = '0.12.10-reviewed-stack5m.1';
|
||||
const vendorManifestPath = `vendor/ffmpeg/${vendorBuildId}/version.json`;
|
||||
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
|
||||
if (!vendorManifestEntry) {
|
||||
throw new Error(
|
||||
@@ -488,10 +487,12 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
||||
}
|
||||
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
|
||||
if (
|
||||
vendorManifest.schemaVersion !== 1 ||
|
||||
vendorManifest.schemaVersion !== 2 ||
|
||||
vendorManifest.buildId !== vendorBuildId ||
|
||||
vendorManifest.profile !== 'reviewed-stack5m' ||
|
||||
vendorManifest.coreVersion !== '0.12.10' ||
|
||||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' ||
|
||||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' ||
|
||||
vendorManifest.opusRegression?.singleThread !== true ||
|
||||
vendorManifest.opusRegression?.multiThread !== true ||
|
||||
!Array.isArray(vendorManifest.assets) ||
|
||||
vendorManifest.assets.length !== 5
|
||||
) {
|
||||
@@ -511,7 +512,7 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
||||
);
|
||||
}
|
||||
const assetPath = safeArchivePath(
|
||||
path.posix.join('vendor/ffmpeg/0.12.10', asset.path)
|
||||
path.posix.join(`vendor/ffmpeg/${vendorBuildId}`, asset.path)
|
||||
);
|
||||
if (verifiedVendorPaths.has(assetPath)) {
|
||||
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);
|
||||
|
||||
279
scripts/package-reviewed-ffmpeg-core.mjs
Normal file
279
scripts/package-reviewed-ffmpeg-core.mjs
Normal file
@@ -0,0 +1,279 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { unzipSync, zipSync } from 'fflate';
|
||||
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const lockPath = path.join(
|
||||
repositoryRoot,
|
||||
'scripts',
|
||||
'reviewed-ffmpeg-core-lock.json'
|
||||
);
|
||||
const maximumAssetBytes = 64 * 1024 * 1024;
|
||||
const fixedTimestamp = new Date(1980, 0, 1, 0, 0, 0, 0);
|
||||
const requiredAssetPaths = new Set([
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
]);
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
value.startsWith('/') ||
|
||||
path.posix.normalize(value) !== value ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function requireRealDirectory(directory, create = false) {
|
||||
let details = await lstat(directory).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!details && create) {
|
||||
await mkdir(directory);
|
||||
details = await lstat(directory);
|
||||
}
|
||||
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
|
||||
throw new Error(`Required directory is unsafe: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLock() {
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
const lockedAssetPaths = new Set(
|
||||
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||
);
|
||||
if (
|
||||
lock.schemaVersion !== 1 ||
|
||||
typeof lock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||
lock.coreVersion !== '0.12.10' ||
|
||||
lock.profile !== 'reviewed-stack5m' ||
|
||||
lock.sourceArchive?.path !==
|
||||
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||
!Number.isSafeInteger(lock.sourceArchive?.bytes) ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||
!Array.isArray(lock.assets) ||
|
||||
lock.assets.length !== 5 ||
|
||||
lock.assets.some(
|
||||
(asset) =>
|
||||
!requiredAssetPaths.has(asset.path) ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
asset.bytes < 1 ||
|
||||
asset.bytes > maximumAssetBytes ||
|
||||
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
|
||||
) ||
|
||||
lockedAssetPaths.size !== requiredAssetPaths.size ||
|
||||
[...requiredAssetPaths].some(
|
||||
(assetPath) => !lockedAssetPaths.has(assetPath)
|
||||
) ||
|
||||
typeof lock.artifact?.path !== 'string' ||
|
||||
!Number.isSafeInteger(lock.artifact?.bytes) ||
|
||||
lock.artifact.bytes < 1 ||
|
||||
lock.artifact.bytes > 64 * 1024 * 1024 ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '')
|
||||
) {
|
||||
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
async function readBuildAsset(asset) {
|
||||
const relativeAssetPath = assertSafeRelativePath(asset.path);
|
||||
const [mode, fileName, ...extra] = relativeAssetPath.split('/');
|
||||
if (
|
||||
extra.length > 0 ||
|
||||
!['st', 'mt'].includes(mode) ||
|
||||
!['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'].includes(
|
||||
fileName
|
||||
)
|
||||
) {
|
||||
throw new Error(`Unexpected reviewed-core asset: ${relativeAssetPath}`);
|
||||
}
|
||||
const outputDirectory = path.join(
|
||||
repositoryRoot,
|
||||
'.core-build',
|
||||
'reviewed-stack5m',
|
||||
mode,
|
||||
'output'
|
||||
);
|
||||
const source = path.join(outputDirectory, fileName);
|
||||
const details = await lstat(source);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`Reviewed-core input must be a regular file: ${source}`);
|
||||
}
|
||||
const canonicalOutput = await realpath(outputDirectory);
|
||||
const canonicalSource = await realpath(source);
|
||||
if (!canonicalSource.startsWith(`${canonicalOutput}${path.sep}`)) {
|
||||
throw new Error(`Reviewed-core input resolves outside output: ${source}`);
|
||||
}
|
||||
const bytes = await readFile(canonicalSource);
|
||||
if (
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > maximumAssetBytes ||
|
||||
bytes.byteLength !== asset.bytes ||
|
||||
sha256(bytes) !== asset.sha256
|
||||
) {
|
||||
throw new Error(`Reviewed-core build does not match lock: ${asset.path}`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function verifyArchiveEntries(archive, lock) {
|
||||
const lockedEntries = new Map(
|
||||
lock.assets.map((asset) => [asset.path, asset])
|
||||
);
|
||||
const seen = new Set();
|
||||
const uncompressed = unzipSync(archive, {
|
||||
filter: (entry) => {
|
||||
const relative = assertSafeRelativePath(entry.name);
|
||||
const locked = lockedEntries.get(relative);
|
||||
if (
|
||||
!locked ||
|
||||
seen.has(relative) ||
|
||||
entry.originalSize !== locked.bytes ||
|
||||
entry.originalSize < 1 ||
|
||||
entry.originalSize > maximumAssetBytes ||
|
||||
entry.size < 1 ||
|
||||
entry.size > 64 * 1024 * 1024
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed-core archive contains an unexpected entry: ${entry.name}`
|
||||
);
|
||||
}
|
||||
seen.add(relative);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (seen.size !== lockedEntries.size) {
|
||||
throw new Error('Reviewed-core archive is missing an expected entry.');
|
||||
}
|
||||
for (const asset of lock.assets) {
|
||||
const bytes = uncompressed[asset.path];
|
||||
if (
|
||||
!bytes ||
|
||||
bytes.byteLength !== asset.bytes ||
|
||||
sha256(bytes) !== asset.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed-core archive entry does not match lock: ${asset.path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lock = await readLock();
|
||||
const buildDriver = await readFile(
|
||||
path.join(repositoryRoot, ...lock.buildDriver.path.split('/'))
|
||||
);
|
||||
if (sha256(buildDriver) !== lock.buildDriver.sha256) {
|
||||
throw new Error(
|
||||
'Reviewed-core build driver does not match its locked identity.'
|
||||
);
|
||||
}
|
||||
const sourceLock = await readFile(
|
||||
path.join(repositoryRoot, ...lock.sourceLock.path.split('/'))
|
||||
);
|
||||
if (sha256(sourceLock) !== lock.sourceLock.sha256) {
|
||||
throw new Error('FFmpeg source lock does not match its locked identity.');
|
||||
}
|
||||
const sourceArchive = await readFile(
|
||||
path.join(repositoryRoot, ...lock.sourceArchive.path.split('/'))
|
||||
);
|
||||
if (
|
||||
sourceArchive.byteLength !== lock.sourceArchive.bytes ||
|
||||
sha256(sourceArchive) !== lock.sourceArchive.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
'Reviewed-core Corresponding Source archive does not match its locked identity.'
|
||||
);
|
||||
}
|
||||
const artifactRelativePath = assertSafeRelativePath(lock.artifact.path);
|
||||
const expectedArtifactRelativePath = `vendor/ffmpeg-core-${lock.buildId}.zip`;
|
||||
if (artifactRelativePath !== expectedArtifactRelativePath) {
|
||||
throw new Error(
|
||||
`Reviewed-core artifact path must be ${expectedArtifactRelativePath}.`
|
||||
);
|
||||
}
|
||||
const artifactPath = path.join(
|
||||
repositoryRoot,
|
||||
...artifactRelativePath.split('/')
|
||||
);
|
||||
const entries = {};
|
||||
for (const asset of [...lock.assets].sort((left, right) =>
|
||||
left.path.localeCompare(right.path, 'en')
|
||||
)) {
|
||||
entries[asset.path] = [
|
||||
await readBuildAsset(asset),
|
||||
{ level: 9, mtime: fixedTimestamp },
|
||||
];
|
||||
}
|
||||
const archive = Buffer.from(zipSync(entries, { level: 9 }));
|
||||
verifyArchiveEntries(archive, lock);
|
||||
const archiveDigest = sha256(archive);
|
||||
if (
|
||||
archive.byteLength !== lock.artifact.bytes ||
|
||||
archiveDigest !== lock.artifact.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Deterministic reviewed-core archive does not match its locked identity: received ${String(archive.byteLength)} bytes/${archiveDigest}.`
|
||||
);
|
||||
}
|
||||
|
||||
const artifactDirectory = path.dirname(artifactPath);
|
||||
await requireRealDirectory(repositoryRoot);
|
||||
await requireRealDirectory(artifactDirectory, true);
|
||||
const stagingRoot = await mkdtemp(
|
||||
path.join(artifactDirectory, '.reviewed-core-package-')
|
||||
);
|
||||
if (
|
||||
!stagingRoot.startsWith(
|
||||
`${path.join(artifactDirectory, '.reviewed-core-package-')}`
|
||||
)
|
||||
) {
|
||||
throw new Error(`Unexpected reviewed-core staging path: ${stagingRoot}`);
|
||||
}
|
||||
try {
|
||||
const stagedArtifact = path.join(stagingRoot, path.basename(artifactPath));
|
||||
await writeFile(stagedArtifact, archive, { flag: 'wx', mode: 0o644 });
|
||||
await rename(stagedArtifact, artifactPath);
|
||||
} finally {
|
||||
await rm(stagingRoot, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Packaged ${lock.assets.length} reviewed FFmpeg assets as ${artifactRelativePath} (${archive.byteLength} bytes, ${archiveDigest})`
|
||||
);
|
||||
@@ -16,6 +16,7 @@ const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const reviewedCoreBuildId = '0.12.10-reviewed-stack5m.1';
|
||||
|
||||
function consumeValue(argumentsList, index, option) {
|
||||
const value = argumentsList[index + 1];
|
||||
@@ -179,12 +180,21 @@ export function inspectPortalHeaders(configuration) {
|
||||
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') &&
|
||||
!configuration.includes('vendor/ffmpeg/0.12.10')
|
||||
) {
|
||||
const immutableCoreRule = configuration
|
||||
.split(/\r?\n/u)
|
||||
.find(
|
||||
(line) =>
|
||||
/vendor\/ffmpeg\//iu.test(line) &&
|
||||
/ffmpeg-core/iu.test(line) &&
|
||||
/max-age=31536000/iu.test(line) &&
|
||||
/immutable/iu.test(line)
|
||||
);
|
||||
const supportsReviewedCore =
|
||||
immutableCoreRule !== undefined &&
|
||||
immutableCoreRule.replaceAll('\\', '').includes(reviewedCoreBuildId);
|
||||
if (!supportsReviewedCore) {
|
||||
warnings.push(
|
||||
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
|
||||
`The config does not give reviewed FFmpeg build ${reviewedCoreBuildId} an explicit immutable cache rule.`
|
||||
);
|
||||
}
|
||||
return { errors, warnings };
|
||||
@@ -229,11 +239,11 @@ async function verifyAssembly(output, appVersion, digest) {
|
||||
}
|
||||
for (const reference of [
|
||||
'./index.html',
|
||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||
]) {
|
||||
const relative = reference.replace(/^\.\//, '');
|
||||
await requireRegularFile(
|
||||
|
||||
73
scripts/reviewed-ffmpeg-core-lock.json
Normal file
73
scripts/reviewed-ffmpeg-core-lock.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"buildId": "0.12.10-reviewed-stack5m.1",
|
||||
"profile": "reviewed-stack5m",
|
||||
"coreVersion": "0.12.10",
|
||||
"ffmpegVersion": "n5.1.4",
|
||||
"sourceArchive": {
|
||||
"path": "release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz",
|
||||
"bytes": 43050052,
|
||||
"sha256": "2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a"
|
||||
},
|
||||
"sourceLock": {
|
||||
"path": "scripts/ffmpeg-source-lock.json",
|
||||
"sha256": "f521ecab59942f899b117e4570bf3a71710fcab6281fe6d796c9bdd22a1fc986"
|
||||
},
|
||||
"buildDriver": {
|
||||
"path": "scripts/build-reviewed-ffmpeg-core.sh",
|
||||
"sha256": "d3551fe12e7883191c41b922850d9bcf7ea093e21ceaa3dec99570db7ea25e2e"
|
||||
},
|
||||
"toolchain": {
|
||||
"emscriptenVersion": "3.1.40",
|
||||
"emscriptenCommit": "5c27e79dd0a9c4e27ef2326841698cdd4f6b5784",
|
||||
"emccSha256": "b0c9551f9155fa9c028efa24591acdfd30f02f32fb247cf9266f5f0f2ede7589",
|
||||
"cmakeVersion": "3.27.9",
|
||||
"cmakeBinarySha256": "ade9326f83f4996b17e2cb1bbfe4848df30a237ef5769cd3c00239b9f0d3ec58"
|
||||
},
|
||||
"patch": {
|
||||
"upstreamCommit": "b409e36475bc21f0451b5b1e1d126fa82871439a",
|
||||
"stackSizeBytes": 5242880,
|
||||
"change": "build/ffmpeg-wasm.sh: add -sSTACK_SIZE=5MB"
|
||||
},
|
||||
"opusRegression": {
|
||||
"fixturePath": "tests/fixtures/generated/tone.wav",
|
||||
"fixtureBytes": 32102,
|
||||
"fixtureSha256": "0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c",
|
||||
"presetId": "audio-opus",
|
||||
"expectedCodec": "opus",
|
||||
"singleThread": true,
|
||||
"multiThread": true
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"path": "st/ffmpeg-core.js",
|
||||
"bytes": 111804,
|
||||
"sha256": "80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24"
|
||||
},
|
||||
{
|
||||
"path": "st/ffmpeg-core.wasm",
|
||||
"bytes": 32232580,
|
||||
"sha256": "fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc"
|
||||
},
|
||||
{
|
||||
"path": "mt/ffmpeg-core.js",
|
||||
"bytes": 128953,
|
||||
"sha256": "4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62"
|
||||
},
|
||||
{
|
||||
"path": "mt/ffmpeg-core.wasm",
|
||||
"bytes": 32718455,
|
||||
"sha256": "abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645"
|
||||
},
|
||||
{
|
||||
"path": "mt/ffmpeg-core.worker.js",
|
||||
"bytes": 2115,
|
||||
"sha256": "f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3"
|
||||
}
|
||||
],
|
||||
"artifact": {
|
||||
"path": "vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip",
|
||||
"bytes": 21338974,
|
||||
"sha256": "ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f"
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,146 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedVersion = '0.12.10';
|
||||
const outputRoot = join(
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const argumentsList = process.argv.slice(2);
|
||||
const runtimeOnly = argumentsList.includes('--runtime-only');
|
||||
if (
|
||||
argumentsList.some((argument) => argument !== '--runtime-only') ||
|
||||
(runtimeOnly && argumentsList.length !== 1)
|
||||
) {
|
||||
throw new Error(
|
||||
'Usage: node scripts/verify-ffmpeg-assets.mjs [--runtime-only]'
|
||||
);
|
||||
}
|
||||
const lock = JSON.parse(
|
||||
await readFile(
|
||||
path.join(repositoryRoot, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
if (
|
||||
lock.schemaVersion !== 1 ||
|
||||
typeof lock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||
lock.coreVersion !== '0.12.10' ||
|
||||
lock.profile !== 'reviewed-stack5m' ||
|
||||
lock.sourceArchive?.path !==
|
||||
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||
!Number.isSafeInteger(lock.sourceArchive?.bytes) ||
|
||||
lock.sourceArchive.bytes < 1 ||
|
||||
lock.sourceArchive.bytes > 512 * 1024 * 1024 ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||
lock.artifact?.path !== `vendor/ffmpeg-core-${lock.buildId}.zip` ||
|
||||
!Number.isSafeInteger(lock.artifact?.bytes) ||
|
||||
lock.artifact.bytes < 1 ||
|
||||
lock.artifact.bytes > 64 * 1024 * 1024 ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '') ||
|
||||
!Array.isArray(lock.assets) ||
|
||||
lock.assets.length !== 5
|
||||
) {
|
||||
throw new Error('Invalid reviewed FFmpeg core lock.');
|
||||
}
|
||||
const outputRoot = path.join(
|
||||
repositoryRoot,
|
||||
'public',
|
||||
'vendor',
|
||||
'ffmpeg',
|
||||
expectedVersion
|
||||
lock.buildId
|
||||
);
|
||||
const manifestPath = join(outputRoot, 'version.json');
|
||||
const manifestPath = path.join(outputRoot, 'version.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const safeAssetPath = (value) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
value.startsWith('/') ||
|
||||
path.posix.normalize(value) !== value ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe FFmpeg asset path: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const verifyLockedFile = async (record, label) => {
|
||||
const relative = safeAssetPath(record.path);
|
||||
const absolute = path.join(repositoryRoot, ...relative.split('/'));
|
||||
const details = await lstat(absolute).catch((error) => {
|
||||
throw new Error(`${label} is missing: ${relative}`, { cause: error });
|
||||
});
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`${label} must be a regular file: ${relative}`);
|
||||
}
|
||||
const bytes = await readFile(absolute);
|
||||
if (
|
||||
(record.bytes !== undefined && bytes.byteLength !== record.bytes) ||
|
||||
sha256(bytes) !== record.sha256
|
||||
) {
|
||||
throw new Error(`${label} does not match its locked identity: ${relative}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.coreVersion !== expectedVersion ||
|
||||
manifest.schemaVersion !== 2 ||
|
||||
manifest.buildId !== lock.buildId ||
|
||||
manifest.profile !== lock.profile ||
|
||||
manifest.coreVersion !== lock.coreVersion ||
|
||||
manifest.ffmpegVersion !== lock.ffmpegVersion ||
|
||||
!isDeepStrictEqual(manifest.sourceArchive, lock.sourceArchive) ||
|
||||
!isDeepStrictEqual(manifest.sourceLock, lock.sourceLock) ||
|
||||
!isDeepStrictEqual(manifest.buildDriver, lock.buildDriver) ||
|
||||
!isDeepStrictEqual(manifest.toolchain, lock.toolchain) ||
|
||||
!isDeepStrictEqual(manifest.patch, lock.patch) ||
|
||||
!isDeepStrictEqual(manifest.opusRegression, lock.opusRegression) ||
|
||||
!Array.isArray(manifest.assets) ||
|
||||
manifest.assets.length !== 5
|
||||
manifest.assets.length !== lock.assets.length
|
||||
) {
|
||||
throw new Error(`Invalid FFmpeg asset manifest: ${manifestPath}`);
|
||||
throw new Error(`Invalid reviewed FFmpeg asset manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const expectedPaths = new Set([
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
]);
|
||||
const expectedHashes = new Map([
|
||||
[
|
||||
'st/ffmpeg-core.js',
|
||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
||||
],
|
||||
[
|
||||
'st/ffmpeg-core.wasm',
|
||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.js',
|
||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
||||
],
|
||||
]);
|
||||
if (!runtimeOnly) {
|
||||
await verifyLockedFile(lock.sourceArchive, 'FFmpeg Corresponding Source');
|
||||
}
|
||||
await verifyLockedFile(lock.sourceLock, 'FFmpeg source lock');
|
||||
await verifyLockedFile(lock.buildDriver, 'Reviewed-core build driver');
|
||||
await verifyLockedFile(lock.artifact, 'Reviewed-core deterministic artifact');
|
||||
|
||||
const lockedAssets = new Map(lock.assets.map((asset) => [asset.path, asset]));
|
||||
const seen = new Set();
|
||||
for (const asset of manifest.assets) {
|
||||
if (!expectedPaths.delete(asset.path)) {
|
||||
throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`);
|
||||
}
|
||||
const bytes = await readFile(join(outputRoot, asset.path));
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
safeAssetPath(asset.path);
|
||||
const locked = lockedAssets.get(asset.path);
|
||||
if (
|
||||
asset.bytes !== bytes.byteLength ||
|
||||
asset.sha256 !== digest ||
|
||||
expectedHashes.get(asset.path) !== digest
|
||||
!locked ||
|
||||
seen.has(asset.path) ||
|
||||
asset.bytes !== locked.bytes ||
|
||||
asset.sha256 !== locked.sha256
|
||||
) {
|
||||
throw new Error(`Unexpected reviewed FFmpeg asset: ${asset.path}`);
|
||||
}
|
||||
seen.add(asset.path);
|
||||
const bytes = await readFile(path.join(outputRoot, ...asset.path.split('/')));
|
||||
if (bytes.byteLength !== locked.bytes || sha256(bytes) !== locked.sha256) {
|
||||
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedPaths.size > 0) {
|
||||
throw new Error(
|
||||
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
|
||||
);
|
||||
if (seen.size !== lockedAssets.size) {
|
||||
throw new Error('One or more reviewed FFmpeg assets are missing.');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}`
|
||||
`Verified ${seen.size} FFmpeg ${lock.buildId} assets in ${path.relative(repositoryRoot, outputRoot)}${runtimeOnly ? ' (runtime inputs only)' : ' with Corresponding Source'}`
|
||||
);
|
||||
|
||||
893
src/App.tsx
893
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 {
|
||||
id: string;
|
||||
file: File;
|
||||
objectUrl: string;
|
||||
phase: AssetPhase;
|
||||
browserMetadata?: BrowserMediaMetadata;
|
||||
browserMetadataError?: string;
|
||||
probe?: MediaProbe;
|
||||
rawProbe?: string;
|
||||
error?: string;
|
||||
@@ -29,10 +38,47 @@ export function createAsset(file: File): ImportedMediaAsset {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
objectUrl: URL.createObjectURL(file),
|
||||
phase: 'queued',
|
||||
phase: 'idle',
|
||||
};
|
||||
}
|
||||
|
||||
export function assetDurationSeconds(
|
||||
asset: ImportedMediaAsset | undefined
|
||||
): number | undefined {
|
||||
return (
|
||||
asset?.probe?.durationSeconds ?? asset?.browserMetadata?.durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function assetHasVideo(
|
||||
asset: ImportedMediaAsset | undefined
|
||||
): boolean | undefined {
|
||||
if (asset?.probe) {
|
||||
return asset.probe.streams.some(
|
||||
(stream) =>
|
||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||
);
|
||||
}
|
||||
if (asset?.browserMetadata?.hasVideo !== undefined) {
|
||||
return asset.browserMetadata.hasVideo;
|
||||
}
|
||||
if (asset?.browserMetadata?.kind === 'video') return true;
|
||||
if (asset?.browserMetadata?.kind === 'audio') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function assetHasAudio(
|
||||
asset: ImportedMediaAsset | undefined
|
||||
): boolean | undefined {
|
||||
if (asset?.probe) {
|
||||
return asset.probe.streams.some((stream) => stream.type === 'audio');
|
||||
}
|
||||
if (asset?.browserMetadata?.hasAudio !== undefined) {
|
||||
return asset.browserMetadata.hasAudio;
|
||||
}
|
||||
return asset?.browserMetadata?.kind === 'audio' ? true : undefined;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
|
||||
if (bytes < 1_000) return `${bytes} B`;
|
||||
|
||||
76
src/app/source-inspection-registry.ts
Normal file
76
src/app/source-inspection-registry.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface SourceInspectionToken {
|
||||
readonly assetId: string;
|
||||
readonly generation: number;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the exact runtime import that owns queued or active inspection work.
|
||||
*
|
||||
* Project asset IDs can be reused when a missing source is reattached, so an
|
||||
* ID-only check is insufficient: a late result from the old File must not be
|
||||
* applied to the replacement. Tokens are intentionally kept outside React
|
||||
* state so removal invalidates work synchronously.
|
||||
*/
|
||||
export class SourceInspectionRegistry {
|
||||
readonly #currentByAsset = new Map<
|
||||
string,
|
||||
{ readonly generation: number; readonly controller: AbortController }
|
||||
>();
|
||||
#nextGeneration = 0;
|
||||
#activeToken: SourceInspectionToken | undefined;
|
||||
|
||||
register(assetId: string): SourceInspectionToken {
|
||||
const controller = new AbortController();
|
||||
const token = Object.freeze({
|
||||
assetId,
|
||||
generation: ++this.#nextGeneration,
|
||||
signal: controller.signal,
|
||||
});
|
||||
this.#currentByAsset.get(assetId)?.controller.abort();
|
||||
this.#currentByAsset.set(assetId, {
|
||||
generation: token.generation,
|
||||
controller,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
isCurrent(token: SourceInspectionToken): boolean {
|
||||
return (
|
||||
this.#currentByAsset.get(token.assetId)?.generation === token.generation
|
||||
);
|
||||
}
|
||||
|
||||
activate(token: SourceInspectionToken): boolean {
|
||||
if (!this.isCurrent(token)) return false;
|
||||
this.#activeToken = token;
|
||||
return true;
|
||||
}
|
||||
|
||||
finish(token: SourceInspectionToken): void {
|
||||
if (
|
||||
this.#activeToken?.assetId === token.assetId &&
|
||||
this.#activeToken.generation === token.generation
|
||||
) {
|
||||
this.#activeToken = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
remove(assetId: string): boolean {
|
||||
const current = this.#currentByAsset.get(assetId);
|
||||
current?.controller.abort();
|
||||
this.#currentByAsset.delete(assetId);
|
||||
return (
|
||||
this.#activeToken?.assetId === assetId &&
|
||||
this.#activeToken.generation === current?.generation
|
||||
);
|
||||
}
|
||||
|
||||
clear(): boolean {
|
||||
for (const current of this.#currentByAsset.values()) {
|
||||
current.controller.abort();
|
||||
}
|
||||
this.#currentByAsset.clear();
|
||||
return this.#activeToken !== undefined;
|
||||
}
|
||||
}
|
||||
54
src/assets/bundled-font.ts
Normal file
54
src/assets/bundled-font.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const BUNDLED_CONTACT_SHEET_FONT = Object.freeze({
|
||||
family: 'DejaVu Sans',
|
||||
fileName: 'DejaVuSans.ttf',
|
||||
byteLength: 757_076,
|
||||
sha256: '7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954',
|
||||
licenseFile: 'LICENSES/DEJAVU_FONTS.txt',
|
||||
});
|
||||
|
||||
let fontPromise: Promise<File> | undefined;
|
||||
|
||||
/**
|
||||
* Loads the same-origin reviewed font and verifies its release identity before
|
||||
* it is mounted read-only for FFmpeg. A failed check is never silently replaced
|
||||
* with a system or remote font.
|
||||
*/
|
||||
export function loadBundledContactSheetFont(): Promise<File> {
|
||||
fontPromise ??= loadAndVerifyFont().catch((error: unknown) => {
|
||||
fontPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return fontPromise;
|
||||
}
|
||||
|
||||
async function loadAndVerifyFont(): Promise<File> {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.BASE_URL}fonts/${BUNDLED_CONTACT_SHEET_FONT.fileName}`,
|
||||
{
|
||||
cache: 'force-cache',
|
||||
credentials: 'same-origin',
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`The bundled contact-sheet font could not be loaded (${response.status}).`
|
||||
);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength !== BUNDLED_CONTACT_SHEET_FONT.byteLength) {
|
||||
throw new Error('The bundled contact-sheet font has an unexpected size.');
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const sha256 = [...new Uint8Array(digest)]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
if (sha256 !== BUNDLED_CONTACT_SHEET_FONT.sha256) {
|
||||
throw new Error(
|
||||
'The bundled contact-sheet font failed its SHA-256 identity check.'
|
||||
);
|
||||
}
|
||||
return new File([bytes], BUNDLED_CONTACT_SHEET_FONT.fileName, {
|
||||
type: 'font/ttf',
|
||||
lastModified: 0,
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export interface ConcatSource extends SourceReference {
|
||||
|
||||
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
|
||||
const MAX_CONCAT_SOURCES = 100;
|
||||
export const MIN_CROSSFADE_SECONDS = 0.04;
|
||||
export const MAX_CROSSFADE_SECONDS = 10;
|
||||
|
||||
export interface FastConcatOptions {
|
||||
readonly jobId: string;
|
||||
@@ -59,6 +61,11 @@ export interface NormalizedConcatOptions extends FastConcatOptions {
|
||||
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
|
||||
readonly missingAudioPolicy: MissingAudioPolicy;
|
||||
readonly subtitlePolicy?: 'drop-all' | 'reject';
|
||||
/**
|
||||
* Uniform overlap at every edit. Zero/undefined keeps hard cuts. The command
|
||||
* layer bounds both the transition and the available material around edits.
|
||||
*/
|
||||
readonly crossfadeSeconds?: number;
|
||||
}
|
||||
|
||||
export function validateConcatCompatibility(
|
||||
@@ -334,6 +341,8 @@ export function buildNormalizedConcatPlan(
|
||||
}
|
||||
const includeAudio =
|
||||
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
|
||||
const crossfadeSeconds = options.crossfadeSeconds ?? 0;
|
||||
validateCrossfade(crossfadeSeconds, ranges);
|
||||
const inputs = options.sources.map((source) =>
|
||||
createPlannedInput(options.jobId, source)
|
||||
);
|
||||
@@ -360,12 +369,35 @@ export function buildNormalizedConcatPlan(
|
||||
}
|
||||
}
|
||||
});
|
||||
const labels = options.sources
|
||||
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
|
||||
.join('');
|
||||
graphParts.push(
|
||||
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
|
||||
);
|
||||
if (crossfadeSeconds > 0) {
|
||||
let videoLabel = 'v0';
|
||||
let audioLabel = 'a0';
|
||||
let effectiveDuration = ranges[0]?.durationSeconds ?? 0;
|
||||
for (let index = 1; index < options.sources.length; index += 1) {
|
||||
const finalTransition = index === options.sources.length - 1;
|
||||
const nextVideoLabel = finalTransition ? 'vout' : `vxf${index}`;
|
||||
graphParts.push(
|
||||
`[${videoLabel}][v${index}]xfade=transition=fade:duration=${normalizeSeconds(crossfadeSeconds)}:offset=${normalizeSeconds(effectiveDuration - crossfadeSeconds)}[${nextVideoLabel}]`
|
||||
);
|
||||
videoLabel = nextVideoLabel;
|
||||
if (includeAudio) {
|
||||
const nextAudioLabel = finalTransition ? 'aout' : `axf${index}`;
|
||||
graphParts.push(
|
||||
`[${audioLabel}][a${index}]acrossfade=d=${normalizeSeconds(crossfadeSeconds)}:c1=tri:c2=tri[${nextAudioLabel}]`
|
||||
);
|
||||
audioLabel = nextAudioLabel;
|
||||
}
|
||||
effectiveDuration +=
|
||||
(ranges[index]?.durationSeconds ?? 0) - crossfadeSeconds;
|
||||
}
|
||||
} else {
|
||||
const labels = options.sources
|
||||
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
|
||||
.join('');
|
||||
graphParts.push(
|
||||
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
|
||||
);
|
||||
}
|
||||
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
|
||||
if (includeAudio) {
|
||||
args.push('-map', '[aout]');
|
||||
@@ -397,10 +429,9 @@ export function buildNormalizedConcatPlan(
|
||||
temporaryFiles: [],
|
||||
args,
|
||||
outputs: [output],
|
||||
expectedDurationSeconds: ranges.reduce(
|
||||
(sum, range) => sum + range.durationSeconds,
|
||||
0
|
||||
),
|
||||
expectedDurationSeconds:
|
||||
ranges.reduce((sum, range) => sum + range.durationSeconds, 0) -
|
||||
crossfadeSeconds * (ranges.length - 1),
|
||||
requiredCapabilities: mergeRequirements(presetRequirements, {
|
||||
muxers: [options.targetMuxer],
|
||||
filters: [
|
||||
@@ -411,7 +442,8 @@ export function buildNormalizedConcatPlan(
|
||||
'setsar',
|
||||
'fps',
|
||||
'format',
|
||||
'concat',
|
||||
...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']),
|
||||
...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []),
|
||||
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
|
||||
...(mixedAudioPresence &&
|
||||
options.missingAudioPolicy === 'insert-silence'
|
||||
@@ -423,7 +455,9 @@ export function buildNormalizedConcatPlan(
|
||||
diagnostic(
|
||||
'normalized-concat',
|
||||
'info',
|
||||
'Clips are normalized to common video and audio properties, then re-encoded.'
|
||||
crossfadeSeconds > 0
|
||||
? `Clips are normalized and joined with a ${normalizeSeconds(crossfadeSeconds)} second audio/video crossfade at every edit.`
|
||||
: 'Clips are normalized to common video and audio properties, then re-encoded.'
|
||||
),
|
||||
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
|
||||
? [
|
||||
@@ -447,6 +481,34 @@ export function buildNormalizedConcatPlan(
|
||||
});
|
||||
}
|
||||
|
||||
function validateCrossfade(
|
||||
crossfadeSeconds: number,
|
||||
ranges: readonly { readonly durationSeconds: number }[]
|
||||
): void {
|
||||
if (!Number.isFinite(crossfadeSeconds) || crossfadeSeconds < 0) {
|
||||
throw new RangeError('Crossfade duration must be finite and nonnegative');
|
||||
}
|
||||
if (crossfadeSeconds === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
crossfadeSeconds < MIN_CROSSFADE_SECONDS ||
|
||||
crossfadeSeconds > MAX_CROSSFADE_SECONDS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Crossfade duration must be ${MIN_CROSSFADE_SECONDS}–${MAX_CROSSFADE_SECONDS} seconds`
|
||||
);
|
||||
}
|
||||
for (const [index, range] of ranges.entries()) {
|
||||
const required = index === 0 || index === ranges.length - 1 ? 1 : 2;
|
||||
if (range.durationSeconds < crossfadeSeconds * required) {
|
||||
throw new RangeError(
|
||||
`Clip ${index + 1} needs at least ${normalizeSeconds(crossfadeSeconds * required)} seconds for the requested crossfade material`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function comparableKeys(
|
||||
kind: ConcatStreamDescription['kind']
|
||||
): readonly (keyof ConcatStreamDescription)[] {
|
||||
|
||||
@@ -14,6 +14,16 @@ import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
|
||||
|
||||
export type ImageFormat = 'png' | 'jpeg' | 'webp';
|
||||
|
||||
export const MAX_SCENE_CHANGE_FRAMES = 100;
|
||||
export const MIN_SCENE_CHANGE_THRESHOLD = 0.01;
|
||||
export const MAX_SCENE_CHANGE_THRESHOLD = 0.95;
|
||||
|
||||
export interface SceneChangeFrame {
|
||||
/** Exact source presentation timestamp in the source video time base. */
|
||||
readonly pts: number;
|
||||
readonly timeSeconds: number;
|
||||
}
|
||||
|
||||
export interface ThumbnailSize {
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
@@ -41,6 +51,21 @@ export interface ThumbnailSeriesOptions {
|
||||
readonly quality?: number;
|
||||
}
|
||||
|
||||
export interface SceneChangeAnalysisOptions {
|
||||
readonly jobId: string;
|
||||
readonly source: SourceReference;
|
||||
readonly durationSeconds: number;
|
||||
readonly threshold: number;
|
||||
readonly maxFrames: number;
|
||||
}
|
||||
|
||||
export interface SceneThumbnailSeriesOptions extends Omit<
|
||||
ThumbnailSeriesOptions,
|
||||
'timesSeconds' | 'intervalSeconds' | 'count'
|
||||
> {
|
||||
readonly frames: readonly SceneChangeFrame[];
|
||||
}
|
||||
|
||||
export interface ContactSheetOptions {
|
||||
readonly jobId: string;
|
||||
readonly source: SourceReference;
|
||||
@@ -57,6 +82,10 @@ export interface ContactSheetOptions {
|
||||
readonly format: ImageFormat;
|
||||
readonly quality?: number;
|
||||
readonly drawtextAvailable?: boolean;
|
||||
/** Reviewed local font mounted as a second, read-only job input. */
|
||||
readonly font?: SourceReference;
|
||||
/** Exact frames returned by a preceding bounded scene-change analysis. */
|
||||
readonly sceneFrames?: readonly SceneChangeFrame[];
|
||||
}
|
||||
|
||||
export function buildSingleThumbnailPlan(
|
||||
@@ -164,6 +193,148 @@ export function buildThumbnailSeriesPlan(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSceneChangeAnalysisPlan(
|
||||
options: SceneChangeAnalysisOptions
|
||||
): FFmpegCommandPlan {
|
||||
validateSceneAnalysisOptions(options);
|
||||
const input = createPlannedInput(options.jobId, options.source);
|
||||
return freezeCommandPlan({
|
||||
id: `${options.jobId}:scene-change-analysis`,
|
||||
operation: 'scene-change-analysis',
|
||||
inputs: [input],
|
||||
temporaryFiles: [],
|
||||
args: [
|
||||
'-i',
|
||||
input.path,
|
||||
'-an',
|
||||
'-vf',
|
||||
`select='gt(scene\\,${normalizedSceneThreshold(options.threshold)})',showinfo`,
|
||||
'-frames:v',
|
||||
String(options.maxFrames),
|
||||
'-f',
|
||||
'null',
|
||||
'-',
|
||||
],
|
||||
outputs: [],
|
||||
expectedDurationSeconds: options.durationSeconds,
|
||||
requiredCapabilities: {
|
||||
muxers: ['null'],
|
||||
filters: ['select', 'showinfo'],
|
||||
},
|
||||
diagnostics: [
|
||||
diagnostic(
|
||||
'bounded-scene-analysis',
|
||||
'info',
|
||||
`Scene analysis stops after ${options.maxFrames} qualifying frames and retains no source media.`
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSceneChangeFrames(
|
||||
logText: string
|
||||
): readonly SceneChangeFrame[] {
|
||||
const frames: SceneChangeFrame[] = [];
|
||||
const seenPts = new Set<number>();
|
||||
const linePattern =
|
||||
/^[^\n]*\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/gimu;
|
||||
for (const match of logText.matchAll(linePattern)) {
|
||||
const pts = Number(match[1]);
|
||||
const timeSeconds = Number(match[2]);
|
||||
if (
|
||||
!Number.isSafeInteger(pts) ||
|
||||
pts < 0 ||
|
||||
!Number.isFinite(timeSeconds) ||
|
||||
timeSeconds < 0 ||
|
||||
seenPts.has(pts)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seenPts.add(pts);
|
||||
frames.push({ pts, timeSeconds });
|
||||
if (frames.length >= MAX_SCENE_CHANGE_FRAMES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Object.freeze(
|
||||
frames
|
||||
.sort((left, right) => left.timeSeconds - right.timeSeconds)
|
||||
.map((frame) => Object.freeze({ ...frame }))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSceneThumbnailSeriesPlan(
|
||||
options: SceneThumbnailSeriesOptions
|
||||
): FFmpegCommandPlan {
|
||||
const frames = validateSceneFrames(
|
||||
options.frames,
|
||||
options.durationSeconds,
|
||||
'Thumbnail series'
|
||||
);
|
||||
const input = createPlannedInput(options.jobId, options.source);
|
||||
const extension = extensionForImageFormat(options.format);
|
||||
const outputs = frames.map((frame, index) =>
|
||||
createPlannedOutput(options.jobId, {
|
||||
id: `thumbnail-${index + 1}`,
|
||||
baseName: options.source.fileName,
|
||||
operation: 'thumbnail',
|
||||
suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(frame.timeSeconds * 1000)}ms`,
|
||||
extension,
|
||||
role: 'thumbnail',
|
||||
})
|
||||
);
|
||||
const size = scaleFilter(options.size);
|
||||
const selected = frames.map((frame) => `eq(pts\\,${frame.pts})`).join('+');
|
||||
const splitOutputs = frames
|
||||
.map((_, index) => `[scene-source-${index}]`)
|
||||
.join('');
|
||||
const filtergraph = [
|
||||
`[0:v]select='${selected}',split=${frames.length}${splitOutputs}`,
|
||||
...frames.map((_, index) => {
|
||||
const filters = [
|
||||
`select='eq(n\\,${index})'`,
|
||||
'setpts=N/FRAME_RATE/TB',
|
||||
...(size ? [size] : []),
|
||||
];
|
||||
return `[scene-source-${index}]${filters.join(',')}[thumb-${index}]`;
|
||||
}),
|
||||
].join(';');
|
||||
const outputArguments = outputs.flatMap((output, index) => [
|
||||
'-map',
|
||||
`[thumb-${index}]`,
|
||||
'-frames:v',
|
||||
'1',
|
||||
...imageQualityArguments(options.format, options.quality),
|
||||
output.path,
|
||||
]);
|
||||
return freezeCommandPlan({
|
||||
id: `${options.jobId}:scene-thumbnail-series`,
|
||||
operation: 'thumbnail-series',
|
||||
inputs: [input],
|
||||
temporaryFiles: [],
|
||||
args: [
|
||||
'-i',
|
||||
input.path,
|
||||
'-filter_complex',
|
||||
filtergraph,
|
||||
...outputArguments,
|
||||
],
|
||||
outputs,
|
||||
expectedDurationSeconds: options.durationSeconds,
|
||||
requiredCapabilities: {
|
||||
...imageRequirements(options.format),
|
||||
filters: ['select', 'split', 'setpts', ...(size ? ['scale'] : [])],
|
||||
},
|
||||
diagnostics: [
|
||||
diagnostic(
|
||||
'scene-change-selection',
|
||||
'info',
|
||||
`${frames.length} frames are selected by exact presentation timestamp from a preceding bounded scene analysis.`
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveThumbnailTimes(
|
||||
options: Pick<
|
||||
ThumbnailSeriesOptions,
|
||||
@@ -271,7 +442,22 @@ export function buildContactSheetPlan(
|
||||
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
|
||||
throw new TypeError('Background must be a named colour or #RRGGBB');
|
||||
}
|
||||
const sceneFrames = options.sceneFrames
|
||||
? validateSceneFrames(
|
||||
options.sceneFrames,
|
||||
options.durationSeconds,
|
||||
'Contact sheet'
|
||||
)
|
||||
: undefined;
|
||||
if (sceneFrames && sceneFrames.length !== options.frameCount) {
|
||||
throw new RangeError(
|
||||
'Contact sheet frame count must match the analyzed scene selection'
|
||||
);
|
||||
}
|
||||
const input = createPlannedInput(options.jobId, options.source);
|
||||
const fontInput = options.font
|
||||
? createPlannedInput(options.jobId, options.font, 'font')
|
||||
: undefined;
|
||||
const output = createPlannedOutput(options.jobId, {
|
||||
baseName: options.source.fileName,
|
||||
operation: 'contact-sheet',
|
||||
@@ -280,14 +466,20 @@ export function buildContactSheetPlan(
|
||||
});
|
||||
const fps = options.frameCount / options.durationSeconds;
|
||||
const filters = [
|
||||
`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`,
|
||||
...(sceneFrames
|
||||
? [
|
||||
`select='${sceneFrames
|
||||
.map((frame) => `eq(pts\\,${frame.pts})`)
|
||||
.join('+')}'`,
|
||||
]
|
||||
: [`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`]),
|
||||
options.maintainAspect === false
|
||||
? `scale=${options.cellWidth}:${options.cellWidth}`
|
||||
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
|
||||
];
|
||||
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
|
||||
const diagnostics = [];
|
||||
if (labelsEnabled && !options.drawtextAvailable) {
|
||||
if (labelsEnabled && (!options.drawtextAvailable || !fontInput)) {
|
||||
labelsEnabled = false;
|
||||
diagnostics.push(
|
||||
diagnostic(
|
||||
@@ -302,16 +494,19 @@ export function buildContactSheetPlan(
|
||||
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
|
||||
: '%{pts\\:hms}';
|
||||
filters.push(
|
||||
`drawtext=text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
|
||||
`drawtext=fontfile='${fontInput?.path ?? ''}':text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
|
||||
);
|
||||
}
|
||||
if (sceneFrames) {
|
||||
filters.push('setpts=N/FRAME_RATE/TB');
|
||||
}
|
||||
filters.push(
|
||||
`tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}`
|
||||
`tile=${options.columns}x${options.rows}:nb_frames=${options.frameCount}:padding=${spacing}:margin=${spacing}:color=${background}`
|
||||
);
|
||||
return freezeCommandPlan({
|
||||
id: `${options.jobId}:contact-sheet`,
|
||||
operation: 'contact-sheet',
|
||||
inputs: [input],
|
||||
inputs: [input, ...(fontInput ? [fontInput] : [])],
|
||||
temporaryFiles: [],
|
||||
args: [
|
||||
'-i',
|
||||
@@ -327,12 +522,101 @@ export function buildContactSheetPlan(
|
||||
expectedDurationSeconds: options.durationSeconds,
|
||||
requiredCapabilities: {
|
||||
...imageRequirements(options.format),
|
||||
filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])],
|
||||
filters: [
|
||||
sceneFrames ? 'select' : 'fps',
|
||||
'scale',
|
||||
...(sceneFrames ? ['setpts'] : []),
|
||||
'tile',
|
||||
...(labelsEnabled ? ['drawtext'] : []),
|
||||
],
|
||||
},
|
||||
diagnostics,
|
||||
diagnostics: [
|
||||
...diagnostics,
|
||||
...(sceneFrames
|
||||
? [
|
||||
diagnostic(
|
||||
'scene-change-selection',
|
||||
'info',
|
||||
`${sceneFrames.length} analyzed scene changes populate this contact sheet.`
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneAnalysisOptions(
|
||||
options: Pick<
|
||||
SceneChangeAnalysisOptions,
|
||||
'durationSeconds' | 'threshold' | 'maxFrames'
|
||||
>
|
||||
): void {
|
||||
if (
|
||||
!Number.isFinite(options.durationSeconds) ||
|
||||
options.durationSeconds <= 0
|
||||
) {
|
||||
throw new RangeError('Scene analysis source duration must be positive');
|
||||
}
|
||||
normalizedSceneThreshold(options.threshold);
|
||||
if (
|
||||
!Number.isSafeInteger(options.maxFrames) ||
|
||||
options.maxFrames < 1 ||
|
||||
options.maxFrames > MAX_SCENE_CHANGE_FRAMES
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Scene analysis frame limit must be an integer from 1 to ${MAX_SCENE_CHANGE_FRAMES}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedSceneThreshold(value: number): string {
|
||||
if (
|
||||
!Number.isFinite(value) ||
|
||||
value < MIN_SCENE_CHANGE_THRESHOLD ||
|
||||
value > MAX_SCENE_CHANGE_THRESHOLD
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Scene threshold must be ${MIN_SCENE_CHANGE_THRESHOLD}–${MAX_SCENE_CHANGE_THRESHOLD}`
|
||||
);
|
||||
}
|
||||
return String(Number(value.toFixed(3)));
|
||||
}
|
||||
|
||||
function validateSceneFrames(
|
||||
frames: readonly SceneChangeFrame[],
|
||||
durationSeconds: number,
|
||||
label: string
|
||||
): readonly SceneChangeFrame[] {
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
throw new RangeError(`${label} source duration must be positive`);
|
||||
}
|
||||
if (
|
||||
frames.length < 1 ||
|
||||
frames.length > MAX_SCENE_CHANGE_FRAMES ||
|
||||
frames.some(
|
||||
(frame) =>
|
||||
!Number.isSafeInteger(frame.pts) ||
|
||||
frame.pts < 0 ||
|
||||
!Number.isFinite(frame.timeSeconds) ||
|
||||
frame.timeSeconds < 0 ||
|
||||
frame.timeSeconds >= durationSeconds
|
||||
)
|
||||
) {
|
||||
throw new RangeError(
|
||||
`${label} scene frames must be bounded, finite points inside the source`
|
||||
);
|
||||
}
|
||||
const uniquePts = new Set(frames.map((frame) => frame.pts));
|
||||
if (uniquePts.size !== frames.length) {
|
||||
throw new RangeError(`${label} scene frames must have unique timestamps`);
|
||||
}
|
||||
return Object.freeze(
|
||||
[...frames]
|
||||
.sort((left, right) => left.timeSeconds - right.timeSeconds)
|
||||
.map((frame) => Object.freeze({ ...frame }))
|
||||
);
|
||||
}
|
||||
|
||||
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
|
||||
const filter = scaleFilter(size);
|
||||
return filter ? ['-vf', filter] : [];
|
||||
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
GENERATED_OUTPUT_LIMIT_EXPLANATION,
|
||||
MAX_GENERATED_OUTPUT_FILES,
|
||||
} from '../limits';
|
||||
import {
|
||||
MAX_SCENE_CHANGE_FRAMES,
|
||||
MAX_SCENE_CHANGE_THRESHOLD,
|
||||
MIN_SCENE_CHANGE_THRESHOLD,
|
||||
} from '../commands/thumbnails';
|
||||
import {
|
||||
parseUserPresetJson,
|
||||
serializeUserPreset,
|
||||
@@ -58,6 +63,7 @@ export type AdvancedCapabilityKey =
|
||||
| 'thumbnail-series'
|
||||
| 'contact-sheet'
|
||||
| 'contact-sheet-labels'
|
||||
| 'scene-change-analysis'
|
||||
| 'metadata-write'
|
||||
| 'chapter-write'
|
||||
| 'subtitle-extract'
|
||||
@@ -181,6 +187,11 @@ export type AdvancedAudioRequest =
|
||||
export type ThumbnailDistribution =
|
||||
| { readonly type: 'evenly-spaced'; readonly count: number }
|
||||
| { readonly type: 'fixed-interval'; readonly intervalSeconds: number }
|
||||
| {
|
||||
readonly type: 'scene-changes';
|
||||
readonly count: number;
|
||||
readonly threshold: number;
|
||||
}
|
||||
| {
|
||||
readonly type: 'chapter-starts' | 'split-markers';
|
||||
readonly timesSeconds: readonly number[];
|
||||
@@ -206,6 +217,9 @@ export interface ContactSheetRequest {
|
||||
readonly sourceLabel: boolean;
|
||||
readonly format: ImageFormat;
|
||||
readonly quality: number;
|
||||
readonly selection:
|
||||
| { readonly type: 'evenly-spaced' }
|
||||
| { readonly type: 'scene-changes'; readonly threshold: number };
|
||||
}
|
||||
|
||||
export type MetadataExecutionRequest =
|
||||
@@ -556,6 +570,7 @@ export function AdvancedOperations({
|
||||
chapters={chapters}
|
||||
formatAvailability={imageFormatAvailability}
|
||||
labelAvailability={availability['contact-sheet-labels']}
|
||||
sceneAvailability={availability['scene-change-analysis']}
|
||||
onThumbnailSeries={onThumbnailSeries}
|
||||
onContactSheet={onContactSheet}
|
||||
action={actionContext}
|
||||
@@ -1442,6 +1457,7 @@ function ImagePanel({
|
||||
chapters,
|
||||
formatAvailability,
|
||||
labelAvailability,
|
||||
sceneAvailability,
|
||||
onThumbnailSeries,
|
||||
onContactSheet,
|
||||
action,
|
||||
@@ -1453,6 +1469,7 @@ function ImagePanel({
|
||||
Readonly<Record<ImageFormat, AdvancedCapabilityStatus>>
|
||||
>;
|
||||
readonly labelAvailability?: AdvancedCapabilityStatus;
|
||||
readonly sceneAvailability?: AdvancedCapabilityStatus;
|
||||
readonly onThumbnailSeries?: RequestHandler<ThumbnailSeriesRequest>;
|
||||
readonly onContactSheet?: RequestHandler<ContactSheetRequest>;
|
||||
readonly action: ActionContext;
|
||||
@@ -1474,6 +1491,10 @@ function ImagePanel({
|
||||
const [maintainAspect, setMaintainAspect] = useState(true);
|
||||
const [timestampLabels, setTimestampLabels] = useState(false);
|
||||
const [sourceLabel, setSourceLabel] = useState(false);
|
||||
const [sceneThreshold, setSceneThreshold] = useState('0.35');
|
||||
const [sheetSelection, setSheetSelection] = useState<
|
||||
'evenly-spaced' | 'scene-changes'
|
||||
>('evenly-spaced');
|
||||
|
||||
const videoReason = !source
|
||||
? 'Select a source first.'
|
||||
@@ -1521,6 +1542,29 @@ function ImagePanel({
|
||||
type: distribution,
|
||||
intervalSeconds: Number(interval),
|
||||
};
|
||||
} else if (distribution === 'scene-changes') {
|
||||
distributionReason =
|
||||
integerRangeReason(
|
||||
seriesCount,
|
||||
1,
|
||||
MAX_SCENE_CHANGE_FRAMES,
|
||||
'Frame count'
|
||||
) ??
|
||||
numberRangeReason(
|
||||
sceneThreshold,
|
||||
MIN_SCENE_CHANGE_THRESHOLD,
|
||||
MAX_SCENE_CHANGE_THRESHOLD,
|
||||
'Scene threshold'
|
||||
) ??
|
||||
(sceneAvailability?.available
|
||||
? undefined
|
||||
: (sceneAvailability?.reason ??
|
||||
'Scene-change analysis has not been verified.'));
|
||||
resolvedDistribution = {
|
||||
type: distribution,
|
||||
count: Number(seriesCount),
|
||||
threshold: Number(sceneThreshold),
|
||||
};
|
||||
} else if (distribution === 'chapter-starts') {
|
||||
const times = chapters.map((chapter) => chapter.startSeconds);
|
||||
distributionReason =
|
||||
@@ -1556,7 +1600,12 @@ function ImagePanel({
|
||||
'Timestamp and filename labels require verified drawtext and a safe local font.')
|
||||
: undefined;
|
||||
const gridReason =
|
||||
integerRangeReason(sheetFrames, 1, 500, 'Frame count') ??
|
||||
integerRangeReason(
|
||||
sheetFrames,
|
||||
1,
|
||||
sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500,
|
||||
'Frame count'
|
||||
) ??
|
||||
integerRangeReason(sheetRows, 1, 500, 'Rows') ??
|
||||
integerRangeReason(sheetColumns, 1, 500, 'Columns') ??
|
||||
integerRangeReason(cellWidth, 32, 4096, 'Cell width') ??
|
||||
@@ -1571,7 +1620,19 @@ function ImagePanel({
|
||||
gridReason ??
|
||||
numberRangeReason(quality, 1, 100, 'Quality') ??
|
||||
formatReason ??
|
||||
labelReason;
|
||||
labelReason ??
|
||||
(sheetSelection === 'scene-changes'
|
||||
? (numberRangeReason(
|
||||
sceneThreshold,
|
||||
MIN_SCENE_CHANGE_THRESHOLD,
|
||||
MAX_SCENE_CHANGE_THRESHOLD,
|
||||
'Scene threshold'
|
||||
) ??
|
||||
(sceneAvailability?.available
|
||||
? undefined
|
||||
: (sceneAvailability?.reason ??
|
||||
'Scene-change analysis has not been verified.')))
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<div className="advanced-operations__section-grid">
|
||||
@@ -1597,6 +1658,12 @@ function ImagePanel({
|
||||
>
|
||||
<option value="evenly-spaced">Evenly spaced</option>
|
||||
<option value="fixed-interval">Fixed interval</option>
|
||||
<option
|
||||
value="scene-changes"
|
||||
disabled={!sceneAvailability?.available}
|
||||
>
|
||||
Scene changes
|
||||
</option>
|
||||
<option value="chapter-starts" disabled={chapters.length === 0}>
|
||||
Chapter starts
|
||||
</option>
|
||||
@@ -1605,12 +1672,17 @@ function ImagePanel({
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{distribution === 'evenly-spaced' ? (
|
||||
{distribution === 'evenly-spaced' ||
|
||||
distribution === 'scene-changes' ? (
|
||||
<NumberField
|
||||
label="Number of frames"
|
||||
value={seriesCount}
|
||||
min={1}
|
||||
max={MAX_GENERATED_OUTPUT_FILES}
|
||||
max={
|
||||
distribution === 'scene-changes'
|
||||
? MAX_SCENE_CHANGE_FRAMES
|
||||
: MAX_GENERATED_OUTPUT_FILES
|
||||
}
|
||||
onChange={setSeriesCount}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1625,6 +1697,16 @@ function ImagePanel({
|
||||
onChange={setInterval}
|
||||
/>
|
||||
) : null}
|
||||
{distribution === 'scene-changes' ? (
|
||||
<NumberField
|
||||
label="Scene sensitivity threshold"
|
||||
value={sceneThreshold}
|
||||
min={MIN_SCENE_CHANGE_THRESHOLD}
|
||||
max={MAX_SCENE_CHANGE_THRESHOLD}
|
||||
step={0.01}
|
||||
onChange={setSceneThreshold}
|
||||
/>
|
||||
) : null}
|
||||
<ImageSettings
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -1673,11 +1755,32 @@ function ImagePanel({
|
||||
</div>
|
||||
</header>
|
||||
<div className="advanced-form-grid advanced-form-grid--three">
|
||||
<label className="advanced-field">
|
||||
<span>Frame selection</span>
|
||||
<select
|
||||
value={sheetSelection}
|
||||
onChange={(event) =>
|
||||
setSheetSelection(
|
||||
event.currentTarget.value as 'evenly-spaced' | 'scene-changes'
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="evenly-spaced">Evenly spaced</option>
|
||||
<option
|
||||
value="scene-changes"
|
||||
disabled={!sceneAvailability?.available}
|
||||
>
|
||||
Scene changes
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumberField
|
||||
label="Frames"
|
||||
value={sheetFrames}
|
||||
min={1}
|
||||
max={500}
|
||||
max={
|
||||
sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500
|
||||
}
|
||||
onChange={setSheetFrames}
|
||||
/>
|
||||
<NumberField
|
||||
@@ -1716,6 +1819,16 @@ function ImagePanel({
|
||||
onChange={setBackground}
|
||||
/>
|
||||
</div>
|
||||
{sheetSelection === 'scene-changes' ? (
|
||||
<NumberField
|
||||
label="Contact-sheet scene threshold"
|
||||
value={sceneThreshold}
|
||||
min={MIN_SCENE_CHANGE_THRESHOLD}
|
||||
max={MAX_SCENE_CHANGE_THRESHOLD}
|
||||
step={0.01}
|
||||
onChange={setSceneThreshold}
|
||||
/>
|
||||
) : null}
|
||||
<div className="advanced-check-grid">
|
||||
<CheckField
|
||||
label="Maintain aspect ratio"
|
||||
@@ -1769,6 +1882,13 @@ function ImagePanel({
|
||||
sourceLabel,
|
||||
format,
|
||||
quality: Number(quality),
|
||||
selection:
|
||||
sheetSelection === 'scene-changes'
|
||||
? {
|
||||
type: 'scene-changes',
|
||||
threshold: Number(sceneThreshold),
|
||||
}
|
||||
: { type: 'evenly-spaced' },
|
||||
},
|
||||
'Contact sheet added to the local queue.'
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CapabilityPanelProps {
|
||||
preference: EnginePreference;
|
||||
resourcePolicy: ResourcePolicy;
|
||||
deviceMemoryGiB?: number;
|
||||
disabled?: boolean;
|
||||
onPreferenceChange: (preference: EnginePreference) => void;
|
||||
onInitialize: () => void;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export function CapabilityPanel({
|
||||
preference,
|
||||
resourcePolicy,
|
||||
deviceMemoryGiB,
|
||||
disabled = false,
|
||||
onPreferenceChange,
|
||||
onInitialize,
|
||||
}: CapabilityPanelProps) {
|
||||
@@ -58,7 +60,12 @@ export function CapabilityPanel({
|
||||
Engine mode
|
||||
<select
|
||||
value={preference}
|
||||
disabled={state.status === 'running'}
|
||||
disabled={
|
||||
disabled ||
|
||||
state.status === 'loading' ||
|
||||
state.status === 'recovering' ||
|
||||
state.status === 'running'
|
||||
}
|
||||
onChange={(event) =>
|
||||
onPreferenceChange(event.currentTarget.value as EnginePreference)
|
||||
}
|
||||
@@ -188,7 +195,11 @@ export function CapabilityPanel({
|
||||
</p>
|
||||
</section>
|
||||
{state.status === 'idle' ? (
|
||||
<button className="button button--secondary" onClick={onInitialize}>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
disabled={disabled}
|
||||
onClick={onInitialize}
|
||||
>
|
||||
Initialize engine
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import applicationLicenseUrl from '../../LICENSE?url&no-inline';
|
||||
import dejavuFontLicenseUrl from '../../LICENSES/DEJAVU_FONTS.txt?url&no-inline';
|
||||
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
|
||||
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
|
||||
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
|
||||
@@ -7,7 +8,7 @@ import sourceRecordUrl from '../../SOURCE.md?url&no-inline';
|
||||
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
|
||||
import {
|
||||
APP_VERSION,
|
||||
FFMPEG_CORE_VERSION,
|
||||
FFMPEG_CORE_BUILD_ID,
|
||||
FFMPEG_ENGINE_VERSION,
|
||||
FFMPEG_UTIL_VERSION,
|
||||
FFMPEG_WRAPPER_VERSION,
|
||||
@@ -59,10 +60,9 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
<span className="step-number">1</span>
|
||||
<h3>Add media</h3>
|
||||
<p>
|
||||
Pick or drop local files. The browser gives FFmpeg direct,
|
||||
read-only access when WORKERFS is available. If it must use the
|
||||
bounded memory-copy fallback, the job log reports the exact copied
|
||||
byte count.
|
||||
Pick or drop local files. Playback, file identity, duration and
|
||||
dimensions use the browser first; FFmpeg stays unloaded until
|
||||
detailed streams or an export are requested.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
@@ -70,7 +70,8 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
<h3>Choose an operation</h3>
|
||||
<p>
|
||||
Quick Convert produces one output. Edit arranges non-destructive
|
||||
clips on a sequential timeline.
|
||||
clips on a sequential timeline. Detailed inspection adds codecs,
|
||||
complete streams, chapters and tags only when needed.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
@@ -101,7 +102,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>FFmpeg WASM cores</dt>
|
||||
<dd>{FFMPEG_CORE_VERSION} · ST & MT</dd>
|
||||
<dd>{FFMPEG_CORE_BUILD_ID} · ST & MT</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>ffmpeg.wasm utilities</dt>
|
||||
@@ -156,6 +157,16 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
GPL text
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
DejaVu Sans 2.37 — Bitstream Vera and Arev font terms
|
||||
<a
|
||||
href={dejavuFontLicenseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Font licence
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
FFmpeg, linked codecs, Toolbox SDK, React and other runtime
|
||||
packages retain their respective terms.
|
||||
@@ -185,7 +196,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
</aside>
|
||||
<footer>
|
||||
<a
|
||||
href="https://git.add-ideas.de/zemion/av-tools"
|
||||
href="https://git.add-ideas.de/lotobo/av-tools"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import type { ImportedMediaAsset } from '../app/application-state';
|
||||
import { formatBytes, formatDuration } from '../app/application-state';
|
||||
import {
|
||||
assetDurationSeconds,
|
||||
assetHasVideo,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
} from '../app/application-state';
|
||||
import type {
|
||||
AvProjectV1,
|
||||
FadeCurve,
|
||||
@@ -39,6 +44,7 @@ export interface InspectorProps {
|
||||
onChaptersChange: (chapters: ProjectChapter[]) => void;
|
||||
onSubtitlesChange: (tracks: ProjectSubtitleTrack[]) => void;
|
||||
onSubtitleImport?: (file: File) => void;
|
||||
onInspectDetails?: (assetId: string) => void;
|
||||
onThumbnail: (request: ThumbnailRequest) => void;
|
||||
currentPreviewTimeSeconds?: number;
|
||||
disabled?: boolean;
|
||||
@@ -74,6 +80,7 @@ export function Inspector({
|
||||
onChaptersChange,
|
||||
onSubtitlesChange,
|
||||
onSubtitleImport,
|
||||
onInspectDetails,
|
||||
onThumbnail,
|
||||
currentPreviewTimeSeconds,
|
||||
disabled = false,
|
||||
@@ -119,7 +126,13 @@ export function Inspector({
|
||||
))}
|
||||
</div>
|
||||
<div className="inspector__body">
|
||||
{tab === 'media' ? <MediaInspector asset={asset} /> : null}
|
||||
{tab === 'media' ? (
|
||||
<MediaInspector
|
||||
asset={asset}
|
||||
disabled={disabled}
|
||||
onInspectDetails={onInspectDetails}
|
||||
/>
|
||||
) : null}
|
||||
{tab === 'transform' ? (
|
||||
<TransformInspector
|
||||
clip={clip}
|
||||
@@ -159,7 +172,7 @@ export function Inspector({
|
||||
) : null}
|
||||
{tab === 'thumbnails' ? (
|
||||
<ThumbnailInspector
|
||||
duration={asset?.probe?.durationSeconds}
|
||||
duration={assetDurationSeconds(asset)}
|
||||
currentPreviewTimeSeconds={currentPreviewTimeSeconds}
|
||||
disabled={disabled || !asset}
|
||||
onCreate={onThumbnail}
|
||||
@@ -170,20 +183,30 @@ export function Inspector({
|
||||
);
|
||||
}
|
||||
|
||||
function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
||||
function MediaInspector({
|
||||
asset,
|
||||
disabled,
|
||||
onInspectDetails,
|
||||
}: {
|
||||
asset?: ImportedMediaAsset;
|
||||
disabled: boolean;
|
||||
onInspectDetails?: (assetId: string) => void;
|
||||
}) {
|
||||
if (!asset) return <p className="panel-empty">Select a media source.</p>;
|
||||
const probe = asset.probe;
|
||||
const durationSeconds = assetDurationSeconds(asset);
|
||||
const hasVideo = assetHasVideo(asset) === true;
|
||||
const width =
|
||||
probe?.streams.find((stream) => stream.type === 'video')?.width ??
|
||||
asset.browserMetadata?.width;
|
||||
const height =
|
||||
probe?.streams.find((stream) => stream.type === 'video')?.height ??
|
||||
asset.browserMetadata?.height;
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="inspector-file">
|
||||
<span>
|
||||
<Icon
|
||||
name={
|
||||
probe?.streams.some((stream) => stream.type === 'video')
|
||||
? 'video'
|
||||
: 'audio'
|
||||
}
|
||||
/>
|
||||
<Icon name={hasVideo ? 'video' : 'audio'} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{asset.file.name}</strong>
|
||||
@@ -191,6 +214,18 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
||||
</div>
|
||||
</div>
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>File type</dt>
|
||||
<dd>
|
||||
{asset.browserMetadata?.file.declaredMimeType &&
|
||||
asset.browserMetadata.file.declaredMimeType !==
|
||||
'application/octet-stream'
|
||||
? asset.browserMetadata.file.declaredMimeType
|
||||
: (asset.browserMetadata?.file.inferredMimeType ??
|
||||
asset.browserMetadata?.file.declaredMimeType ??
|
||||
(asset.file.type || '—'))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Container</dt>
|
||||
<dd>
|
||||
@@ -199,8 +234,21 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatDuration(probe?.durationSeconds)}</dd>
|
||||
<dd>
|
||||
{formatDuration(durationSeconds)}
|
||||
{!probe && durationSeconds !== undefined ? ' · browser' : ''}
|
||||
</dd>
|
||||
</div>
|
||||
{hasVideo ? (
|
||||
<div>
|
||||
<dt>Dimensions</dt>
|
||||
<dd>
|
||||
{width !== undefined && height !== undefined
|
||||
? `${width}×${height}${probe ? '' : ' · browser'}`
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<dt>Bit rate</dt>
|
||||
<dd>
|
||||
@@ -210,6 +258,41 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{!probe ? (
|
||||
<div
|
||||
className={`notice ${
|
||||
asset.phase === 'error' ? 'notice--warning' : ''
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
<Icon name={asset.phase === 'error' ? 'warning' : 'info'} />
|
||||
<div>
|
||||
<strong>
|
||||
{asset.phase === 'probing'
|
||||
? 'Inspecting detailed media information…'
|
||||
: asset.phase === 'error'
|
||||
? 'Detailed inspection is unavailable.'
|
||||
: 'Detailed information has not been inspected.'}
|
||||
</strong>
|
||||
<small>
|
||||
{asset.phase === 'error'
|
||||
? asset.error
|
||||
: 'Codecs, complete streams, chapters and tags are loaded only when needed.'}
|
||||
</small>
|
||||
</div>
|
||||
{asset.phase !== 'probing' && onInspectDetails ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--secondary"
|
||||
disabled={disabled}
|
||||
onClick={() => onInspectDetails(asset.id)}
|
||||
>
|
||||
<Icon name="info" />
|
||||
{asset.phase === 'error' ? 'Retry inspection' : 'Inspect details'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{probe && Object.keys(probe.tags).length > 0 ? (
|
||||
<>
|
||||
<h3>Container tags</h3>
|
||||
@@ -223,66 +306,69 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
||||
</dl>
|
||||
</>
|
||||
) : null}
|
||||
<h3>Streams</h3>
|
||||
<ol className="inspector-streams">
|
||||
{probe?.streams.map((stream) => (
|
||||
<li key={stream.index}>
|
||||
<span className="stream-kind">{stream.type}</span>
|
||||
<div>
|
||||
<strong>
|
||||
#{stream.index} · {stream.codecName ?? 'unknown'}
|
||||
</strong>
|
||||
{stream.type === 'video' ? (
|
||||
<>
|
||||
{probe ? <h3>Streams</h3> : null}
|
||||
{probe ? (
|
||||
<ol className="inspector-streams">
|
||||
{probe?.streams.map((stream) => (
|
||||
<li key={stream.index}>
|
||||
<span className="stream-kind">{stream.type}</span>
|
||||
<div>
|
||||
<strong>
|
||||
#{stream.index} · {stream.codecName ?? 'unknown'}
|
||||
</strong>
|
||||
{stream.type === 'video' ? (
|
||||
<>
|
||||
<small>
|
||||
Encoded pixels {stream.width ?? '?'}×
|
||||
{stream.height ?? '?'}
|
||||
{stream.codedWidth !== undefined &&
|
||||
stream.codedHeight !== undefined
|
||||
? ` · coded ${stream.codedWidth}×${stream.codedHeight}`
|
||||
: ''}
|
||||
{' · '}
|
||||
{stream.frameRate?.toFixed(3) ?? '?'} fps
|
||||
</small>
|
||||
<small>
|
||||
SAR {stream.sampleAspectRatio ?? '—'} · DAR{' '}
|
||||
{stream.displayAspectRatio ?? '—'} · rotation{' '}
|
||||
{stream.rotationDegrees === undefined
|
||||
? '—'
|
||||
: `${stream.rotationDegrees}°`}
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<small>
|
||||
Encoded pixels {stream.width ?? '?'}×{stream.height ?? '?'}
|
||||
{stream.codedWidth !== undefined &&
|
||||
stream.codedHeight !== undefined
|
||||
? ` · coded ${stream.codedWidth}×${stream.codedHeight}`
|
||||
: ''}
|
||||
{' · '}
|
||||
{stream.frameRate?.toFixed(3) ?? '?'} fps
|
||||
{stream.type === 'audio'
|
||||
? `${stream.sampleRate ?? '?'} Hz · ${stream.channelLayout ?? `${stream.channels ?? '?'} channels`}`
|
||||
: (stream.language ??
|
||||
stream.title ??
|
||||
'No additional details')}
|
||||
</small>
|
||||
)}
|
||||
{Object.keys(stream.disposition).length > 0 ? (
|
||||
<small>
|
||||
SAR {stream.sampleAspectRatio ?? '—'} · DAR{' '}
|
||||
{stream.displayAspectRatio ?? '—'} · rotation{' '}
|
||||
{stream.rotationDegrees === undefined
|
||||
? '—'
|
||||
: `${stream.rotationDegrees}°`}
|
||||
Disposition:{' '}
|
||||
{Object.entries(stream.disposition)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([name]) => name)
|
||||
.join(', ') || 'none'}
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<small>
|
||||
{stream.type === 'audio'
|
||||
? `${stream.sampleRate ?? '?'} Hz · ${stream.channelLayout ?? `${stream.channels ?? '?'} channels`}`
|
||||
: (stream.language ??
|
||||
stream.title ??
|
||||
'No additional details')}
|
||||
</small>
|
||||
)}
|
||||
{Object.keys(stream.disposition).length > 0 ? (
|
||||
<small>
|
||||
Disposition:{' '}
|
||||
{Object.entries(stream.disposition)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([name]) => name)
|
||||
.join(', ') || 'none'}
|
||||
</small>
|
||||
) : null}
|
||||
{Object.keys(stream.tags).length > 0 ? (
|
||||
<dl className="stream-tag-list">
|
||||
{Object.entries(stream.tags).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)) ?? <li>Waiting for inspection.</li>}
|
||||
</ol>
|
||||
) : null}
|
||||
{Object.keys(stream.tags).length > 0 ? (
|
||||
<dl className="stream-tag-list">
|
||||
{Object.entries(stream.tags).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
{probe && probe.chapters.length > 0 ? (
|
||||
<>
|
||||
<h3>Source chapters</h3>
|
||||
@@ -333,8 +419,8 @@ function TransformInspector({
|
||||
}) {
|
||||
if (!clip) return <p className="panel-empty">Select a timeline clip.</p>;
|
||||
const video = asset?.probe?.streams.find((stream) => stream.type === 'video');
|
||||
const sourceWidth = video?.width;
|
||||
const sourceHeight = video?.height;
|
||||
const sourceWidth = video?.width ?? asset?.browserMetadata?.width;
|
||||
const sourceHeight = video?.height ?? asset?.browserMetadata?.height;
|
||||
const canShowVisualCrop =
|
||||
asset !== undefined &&
|
||||
isPositiveSafeInteger(sourceWidth) &&
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { ImportedMediaAsset } from '../app/application-state';
|
||||
import { formatBytes, formatDuration } from '../app/application-state';
|
||||
import {
|
||||
assetDurationSeconds,
|
||||
assetHasVideo,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
} from '../app/application-state';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface MediaBinProps {
|
||||
assets: readonly ImportedMediaAsset[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
onInspect: (id: string) => void;
|
||||
inspectDisabled?: boolean;
|
||||
removeDisabled?: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -13,6 +21,9 @@ export function MediaBin({
|
||||
assets,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onInspect,
|
||||
inspectDisabled = false,
|
||||
removeDisabled = false,
|
||||
onRemove,
|
||||
}: MediaBinProps) {
|
||||
if (assets.length === 0) {
|
||||
@@ -27,9 +38,13 @@ export function MediaBin({
|
||||
return (
|
||||
<ul className="media-list" aria-label="Imported media">
|
||||
{assets.map((asset) => {
|
||||
const hasVideo = assetHasVideo(asset) === true;
|
||||
const video = asset.probe?.streams.find(
|
||||
(stream) => stream.type === 'video'
|
||||
);
|
||||
const durationSeconds = assetDurationSeconds(asset);
|
||||
const width = video?.width ?? asset.browserMetadata?.width;
|
||||
const height = video?.height ?? asset.browserMetadata?.height;
|
||||
const audioCount =
|
||||
asset.probe?.streams.filter((stream) => stream.type === 'audio')
|
||||
.length ?? 0;
|
||||
@@ -43,34 +58,67 @@ export function MediaBin({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={selectedId === asset.id}
|
||||
onClick={() => onSelect(asset.id)}
|
||||
>
|
||||
<span className="media-card__type">
|
||||
<Icon name={video ? 'video' : 'audio'} />
|
||||
<Icon name={hasVideo ? 'video' : 'audio'} />
|
||||
</span>
|
||||
<span className="media-card__content">
|
||||
<strong title={asset.file.name}>{asset.file.name}</strong>
|
||||
<small>
|
||||
{formatBytes(asset.file.size)}
|
||||
{asset.probe?.durationSeconds !== undefined
|
||||
? ` · ${formatDuration(asset.probe.durationSeconds)}`
|
||||
{durationSeconds !== undefined
|
||||
? ` · ${formatDuration(durationSeconds)}`
|
||||
: ''}
|
||||
</small>
|
||||
<small className={`phase phase--${asset.phase}`}>
|
||||
{asset.phase === 'probing'
|
||||
? 'Inspecting locally…'
|
||||
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Inspecting details…`
|
||||
: asset.phase === 'ready'
|
||||
? `${video ? `${video.width ?? '?'}×${video.height ?? '?'}` : 'Audio'} · ${audioCount} audio`
|
||||
? `${hasVideo ? `${width ?? '?'}×${height ?? '?'}` : 'Audio'} · ${audioCount} audio`
|
||||
: asset.phase === 'error'
|
||||
? asset.error
|
||||
: 'Queued'}
|
||||
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Details unavailable`
|
||||
: asset.browserMetadataError
|
||||
? 'File ready · Basic metadata unavailable'
|
||||
: asset.browserMetadata
|
||||
? 'Ready to play · Details on demand'
|
||||
: 'File ready · Reading basic metadata…'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
{asset.phase !== 'ready' ? (
|
||||
<button
|
||||
className="icon-action"
|
||||
type="button"
|
||||
aria-label={`${asset.phase === 'error' ? 'Retry detailed inspection for' : 'Inspect details for'} ${asset.file.name}`}
|
||||
title={
|
||||
asset.phase === 'error'
|
||||
? asset.error
|
||||
: 'Read codecs, streams, chapters and tags'
|
||||
}
|
||||
disabled={inspectDisabled || asset.phase === 'probing'}
|
||||
{...(asset.error
|
||||
? { 'aria-describedby': `inspection-error-${asset.id}` }
|
||||
: {})}
|
||||
onClick={() => onInspect(asset.id)}
|
||||
>
|
||||
<Icon name={asset.phase === 'probing' ? 'queue' : 'info'} />
|
||||
</button>
|
||||
) : null}
|
||||
{asset.error ? (
|
||||
<span
|
||||
className="visually-hidden"
|
||||
id={`inspection-error-${asset.id}`}
|
||||
>
|
||||
{asset.error}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
className="icon-action"
|
||||
type="button"
|
||||
aria-label={`Remove ${asset.file.name}`}
|
||||
disabled={removeDisabled}
|
||||
onClick={() => onRemove(asset.id)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ImportedMediaAsset } from '../app/application-state';
|
||||
import { assetHasAudio, assetHasVideo } from '../app/application-state';
|
||||
import {
|
||||
assessBrowserPlayback,
|
||||
assessGeneratedResultPreview,
|
||||
isAudioMimeType,
|
||||
isVideoMimeType,
|
||||
mimeTypeFromFileName,
|
||||
type MediaProbe,
|
||||
} from '../media';
|
||||
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
|
||||
@@ -47,15 +51,28 @@ export function MediaPreview({
|
||||
const mediaElement = useRef<HTMLMediaElement>(null);
|
||||
const sourceUrl = result?.objectUrl ?? asset?.objectUrl;
|
||||
const playbackFailed = sourceUrl === failedSourceUrl;
|
||||
const fileMimeType = asset?.file.type;
|
||||
const declaredSourceMimeType =
|
||||
fileMimeType &&
|
||||
(isVideoMimeType(fileMimeType) || isAudioMimeType(fileMimeType))
|
||||
? fileMimeType
|
||||
: asset
|
||||
? mimeTypeFromFileName(asset.file.name)
|
||||
: undefined;
|
||||
const hasVideo = result
|
||||
? result.mimeType.startsWith('video/')
|
||||
: asset?.probe?.streams.some(
|
||||
(stream) =>
|
||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||
) === true;
|
||||
: assetHasVideo(asset) !== undefined
|
||||
? assetHasVideo(asset) === true
|
||||
: declaredSourceMimeType
|
||||
? isVideoMimeType(declaredSourceMimeType)
|
||||
: false;
|
||||
const hasAudio = result
|
||||
? result.mimeType.startsWith('audio/')
|
||||
: asset?.probe?.streams.some((stream) => stream.type === 'audio') === true;
|
||||
: assetHasAudio(asset) !== undefined
|
||||
? assetHasAudio(asset) === true
|
||||
: declaredSourceMimeType
|
||||
? isAudioMimeType(declaredSourceMimeType)
|
||||
: false;
|
||||
const hasImage = result?.mimeType.startsWith('image/') === true;
|
||||
const resultPlayback = useMemo(() => {
|
||||
if (!result) return undefined;
|
||||
@@ -186,7 +203,12 @@ export function MediaPreview({
|
||||
? resultPlayback?.previewable === false
|
||||
? `Download only · ${result.name}`
|
||||
: `Generated result · ${result.name}`
|
||||
: (playback?.reason ?? 'Local source preview')}
|
||||
: (playback?.reason ??
|
||||
(asset?.phase === 'probing'
|
||||
? 'Local source preview · Detailed inspection runs separately.'
|
||||
: asset?.phase === 'error'
|
||||
? 'Local source preview · Detailed inspection unavailable.'
|
||||
: 'Local source preview · Basic browser metadata'))}
|
||||
</span>
|
||||
{needsProxy && !proxySettings ? (
|
||||
<button type="button" className="text-button" onClick={onCreateProxy}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ImportedMediaAsset } from '../app/application-state';
|
||||
import { assetDurationSeconds, assetHasVideo } from '../app/application-state';
|
||||
import type { EngineState } from '../ffmpeg/ffmpeg.types';
|
||||
import type { MediaStreamProbe } from '../media';
|
||||
import type { StreamSelection } from '../commands/command-utils';
|
||||
import {
|
||||
validateRemuxCompatibility,
|
||||
@@ -16,9 +16,14 @@ import {
|
||||
presetAvailability,
|
||||
} from '../presets/preset-registry';
|
||||
import { formatBytes, formatDuration } from '../app/application-state';
|
||||
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
|
||||
import { Icon } from './Icon';
|
||||
import {
|
||||
quickStreamUnavailableReason,
|
||||
type QuickOperation,
|
||||
} from './quick-convert-selection';
|
||||
|
||||
export type QuickOperation = 'convert' | 'remux';
|
||||
export type { QuickOperation } from './quick-convert-selection';
|
||||
|
||||
export interface QuickExportConfiguration {
|
||||
operation: QuickOperation;
|
||||
@@ -27,6 +32,11 @@ export interface QuickExportConfiguration {
|
||||
streamSelection: StreamSelection;
|
||||
removeMetadata: boolean;
|
||||
removeChapters: boolean;
|
||||
/**
|
||||
* The source was not authoritatively inspected when the user submitted the
|
||||
* form. Resolve a safe default selection from the on-demand probe.
|
||||
*/
|
||||
selectDetectedStreams?: true;
|
||||
}
|
||||
|
||||
export interface QuickConvertProps {
|
||||
@@ -35,6 +45,7 @@ export interface QuickConvertProps {
|
||||
busy: boolean;
|
||||
queueing?: boolean;
|
||||
presets?: readonly ExportPreset[];
|
||||
onInspect?: () => void;
|
||||
onExport: (configuration: QuickExportConfiguration) => void;
|
||||
}
|
||||
|
||||
@@ -44,6 +55,7 @@ export function QuickConvert({
|
||||
busy,
|
||||
queueing = false,
|
||||
presets = BUILT_IN_PRESETS,
|
||||
onInspect,
|
||||
onExport,
|
||||
}: QuickConvertProps) {
|
||||
const [operation, setOperation] = useState<QuickOperation>('convert');
|
||||
@@ -58,6 +70,7 @@ export function QuickConvert({
|
||||
}>();
|
||||
|
||||
const preset = presets.find((entry) => entry.id === presetId);
|
||||
const hasDetailedInformation = asset?.probe !== undefined;
|
||||
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
|
||||
const requestedStreams = useMemo(
|
||||
() =>
|
||||
@@ -139,9 +152,12 @@ export function QuickConvert({
|
||||
engineState.capabilities
|
||||
)
|
||||
: undefined;
|
||||
const opusNeedsRegressionTest = preset?.audio?.codec === 'libopus';
|
||||
const opusBlockedByPinnedCore =
|
||||
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
||||
const presetNeedsMissingAudio =
|
||||
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
|
||||
hasDetailedInformation &&
|
||||
preset?.kind === 'audio' &&
|
||||
effectiveSelection.audio.length === 0;
|
||||
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
||||
stream.type === 'video' ||
|
||||
stream.type === 'audio' ||
|
||||
@@ -157,16 +173,17 @@ export function QuickConvert({
|
||||
]
|
||||
: []
|
||||
);
|
||||
const remuxDiagnostics = remuxContainer
|
||||
? validateRemuxCompatibility(
|
||||
{
|
||||
streams: remuxStreams,
|
||||
hasChapters: Boolean(asset?.probe?.chapters.length),
|
||||
},
|
||||
remuxContainer,
|
||||
effectiveSelection
|
||||
)
|
||||
: [];
|
||||
const remuxDiagnostics =
|
||||
remuxContainer && hasDetailedInformation
|
||||
? validateRemuxCompatibility(
|
||||
{
|
||||
streams: remuxStreams,
|
||||
hasChapters: Boolean(asset?.probe?.chapters.length),
|
||||
},
|
||||
remuxContainer,
|
||||
effectiveSelection
|
||||
)
|
||||
: [];
|
||||
const remuxErrors = remuxDiagnostics.filter(
|
||||
(diagnostic) => diagnostic.severity === 'error'
|
||||
);
|
||||
@@ -177,16 +194,16 @@ export function QuickConvert({
|
||||
const remuxUnavailableReason = remuxMuxerMissing
|
||||
? `The loaded core is missing muxer ${remuxContainer}.`
|
||||
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
|
||||
const hasSelectedStream = selectedStreams.size > 0;
|
||||
const hasSelectedStream = !hasDetailedInformation || selectedStreams.size > 0;
|
||||
const canExport =
|
||||
asset?.phase === 'ready' &&
|
||||
asset !== undefined &&
|
||||
!busy &&
|
||||
hasSelectedStream &&
|
||||
(operation === 'remux'
|
||||
? !remuxMuxerMissing && remuxErrors.length === 0
|
||||
: preset !== undefined &&
|
||||
availability?.status !== 'unavailable' &&
|
||||
!opusNeedsRegressionTest &&
|
||||
!opusBlockedByPinnedCore &&
|
||||
!presetNeedsMissingAudio);
|
||||
|
||||
const presetOption = (entry: ExportPreset) => {
|
||||
@@ -199,8 +216,8 @@ export function QuickConvert({
|
||||
? presetAvailability({ requirements }, engineState.capabilities)
|
||||
: undefined;
|
||||
const reason =
|
||||
entry.audio?.codec === 'libopus'
|
||||
? 'Opus awaits a pinned-core regression test'
|
||||
entry.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED
|
||||
? 'the reviewed core has not passed the ST/MT Opus regression encode'
|
||||
: entryAvailability?.status === 'unavailable'
|
||||
? entryAvailability.reasons.join(', ')
|
||||
: undefined;
|
||||
@@ -226,27 +243,23 @@ export function QuickConvert({
|
||||
{asset ? (
|
||||
<div className="source-summary">
|
||||
<span className="source-summary__icon">
|
||||
<Icon
|
||||
name={
|
||||
asset.probe?.streams.some((stream) => stream.type === 'video')
|
||||
? 'video'
|
||||
: 'audio'
|
||||
}
|
||||
/>
|
||||
<Icon name={assetHasVideo(asset) === true ? 'video' : 'audio'} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{asset.file.name}</strong>
|
||||
<span>
|
||||
{formatBytes(asset.file.size)} ·{' '}
|
||||
{formatDuration(asset.probe?.durationSeconds)}
|
||||
{formatDuration(assetDurationSeconds(asset))}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`phase phase--${asset.phase}`}>
|
||||
{asset.phase === 'ready'
|
||||
? 'Ready'
|
||||
: asset.phase === 'error'
|
||||
? 'Could not inspect'
|
||||
: 'Preparing'}
|
||||
? 'Details ready'
|
||||
: asset.phase === 'probing'
|
||||
? 'Inspecting details'
|
||||
: asset.phase === 'error'
|
||||
? 'Ready to play'
|
||||
: 'Ready to play'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -338,14 +351,16 @@ export function QuickConvert({
|
||||
['ogg', 'Ogg (.ogg)'],
|
||||
] as const
|
||||
).map(([container, label]) => {
|
||||
const diagnostics = validateRemuxCompatibility(
|
||||
{
|
||||
streams: remuxStreams,
|
||||
hasChapters: Boolean(asset?.probe?.chapters.length),
|
||||
},
|
||||
container,
|
||||
effectiveSelection
|
||||
);
|
||||
const diagnostics = hasDetailedInformation
|
||||
? validateRemuxCompatibility(
|
||||
{
|
||||
streams: remuxStreams,
|
||||
hasChapters: Boolean(asset?.probe?.chapters.length),
|
||||
},
|
||||
container,
|
||||
effectiveSelection
|
||||
)
|
||||
: [];
|
||||
const missingMuxer =
|
||||
engineState.status === 'ready' &&
|
||||
!engineState.capabilities.muxers.has(container);
|
||||
@@ -408,7 +423,25 @@ export function QuickConvert({
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p>Streams appear after local inspection.</p>
|
||||
<div className="stream-picker__empty">
|
||||
<p>
|
||||
Streams will be identified only when you inspect details or
|
||||
start this export.
|
||||
</p>
|
||||
{asset && asset.phase !== 'probing' && onInspect ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--secondary"
|
||||
disabled={busy}
|
||||
onClick={onInspect}
|
||||
>
|
||||
<Icon name="info" />
|
||||
{asset.phase === 'error'
|
||||
? 'Retry inspection'
|
||||
: 'Inspect details now'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
@@ -449,17 +482,24 @@ export function QuickConvert({
|
||||
Select at least one audio stream for an audio-only preset.
|
||||
</p>
|
||||
) : null}
|
||||
{asset?.phase === 'error' && !asset.probe ? (
|
||||
<p className="notice notice--warning">
|
||||
<Icon name="warning" />
|
||||
Playback and basic file information remain available. Detailed
|
||||
inspection can be retried when an export needs it.
|
||||
</p>
|
||||
) : null}
|
||||
{!hasSelectedStream && streams.length > 0 ? (
|
||||
<p className="notice notice--error">
|
||||
<Icon name="warning" />
|
||||
Select at least one output stream.
|
||||
</p>
|
||||
) : null}
|
||||
{opusNeedsRegressionTest ? (
|
||||
{opusBlockedByPinnedCore ? (
|
||||
<p className="notice notice--warning">
|
||||
<Icon name="warning" />
|
||||
Opus export is held back until the pinned core passes its browser
|
||||
regression test.
|
||||
Opus export is disabled until the reviewed core passes its non-empty
|
||||
encode and re-probe gate in both engine modes.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -472,7 +512,11 @@ export function QuickConvert({
|
||||
? 'Validated user preset'
|
||||
: 'Stream copy'}
|
||||
</strong>
|
||||
<span>Resource estimates are checked before allocation.</span>
|
||||
<span>
|
||||
{hasDetailedInformation
|
||||
? 'Resource estimates are checked before allocation.'
|
||||
: 'Detailed stream inspection will run before this export.'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="button button--primary button--large"
|
||||
@@ -486,6 +530,9 @@ export function QuickConvert({
|
||||
streamSelection: effectiveSelection,
|
||||
removeMetadata,
|
||||
removeChapters,
|
||||
...(!hasDetailedInformation
|
||||
? { selectDetectedStreams: true as const }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -495,47 +542,14 @@ export function QuickConvert({
|
||||
: queueing
|
||||
? 'Add to queue'
|
||||
: operation === 'remux'
|
||||
? 'Remux'
|
||||
: 'Convert'}
|
||||
? hasDetailedInformation
|
||||
? 'Remux'
|
||||
: 'Inspect & remux'
|
||||
: hasDetailedInformation
|
||||
? 'Convert'
|
||||
: 'Inspect & convert'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function quickStreamUnavailableReason(
|
||||
stream: MediaStreamProbe,
|
||||
operation: QuickOperation,
|
||||
preset: ExportPreset | undefined
|
||||
): string | undefined {
|
||||
if (stream.type === 'unknown') {
|
||||
return 'unknown stream kinds cannot be mapped safely.';
|
||||
}
|
||||
if (operation === 'remux') {
|
||||
return undefined;
|
||||
}
|
||||
if (!preset) {
|
||||
return 'choose an export preset first.';
|
||||
}
|
||||
if (stream.type === 'video' && !preset.video) {
|
||||
return 'the selected preset creates audio only.';
|
||||
}
|
||||
if (stream.type === 'audio' && !preset.audio) {
|
||||
return 'the selected preset creates video or images without audio.';
|
||||
}
|
||||
if (stream.type === 'subtitle') {
|
||||
if (preset.subtitlePolicy === 'none') {
|
||||
return 'the selected preset explicitly omits subtitles.';
|
||||
}
|
||||
if (preset.subtitlePolicy === 'burn-in') {
|
||||
return 'burn-in requires an explicitly attached subtitle source.';
|
||||
}
|
||||
}
|
||||
if (
|
||||
(stream.type === 'attachment' || stream.type === 'data') &&
|
||||
preset.container !== 'matroska'
|
||||
) {
|
||||
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.structural-field select {
|
||||
.structural-field select,
|
||||
.structural-field input {
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.48rem 0.62rem;
|
||||
@@ -402,6 +403,7 @@
|
||||
}
|
||||
|
||||
.structural-field select:focus-visible,
|
||||
.structural-field input:focus-visible,
|
||||
.structural-operations input:focus-visible,
|
||||
.structural-operations button:focus-visible {
|
||||
outline: 3px solid var(--toolbox-focus);
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useId, useState } from 'react';
|
||||
import './StructuralOperations.css';
|
||||
|
||||
export type StructuralCapabilityKey =
|
||||
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized';
|
||||
| 'trim-fast'
|
||||
| 'trim-accurate'
|
||||
| 'concat-fast'
|
||||
| 'concat-normalized'
|
||||
| 'concat-crossfade';
|
||||
|
||||
export interface StructuralCapabilityStatus {
|
||||
readonly available: boolean;
|
||||
@@ -35,6 +39,7 @@ export interface StructuralTimelineClip {
|
||||
readonly hasAudio: boolean;
|
||||
readonly hasVideo: boolean;
|
||||
readonly hasSubtitles: boolean;
|
||||
readonly durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface StructuralCompatibilityDiagnostic {
|
||||
@@ -92,6 +97,7 @@ export type StructuralConcatRequest =
|
||||
readonly presetId: string;
|
||||
readonly missingAudioPolicy: MissingAudioPolicy;
|
||||
readonly subtitlePolicy: NormalizedSubtitlePolicy;
|
||||
readonly crossfadeSeconds: number;
|
||||
};
|
||||
|
||||
type RequestHandler<T> = (request: T) => void | Promise<void>;
|
||||
@@ -135,6 +141,7 @@ export function StructuralOperations({
|
||||
const [subtitlePolicy, setSubtitlePolicy] = useState<
|
||||
NormalizedSubtitlePolicy | ''
|
||||
>('');
|
||||
const [crossfadeSeconds, setCrossfadeSeconds] = useState('0');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [status, setStatus] = useState<string>();
|
||||
|
||||
@@ -156,6 +163,7 @@ export function StructuralOperations({
|
||||
preset: concatPreset,
|
||||
missingAudioPolicy,
|
||||
subtitlePolicy,
|
||||
crossfadeSeconds,
|
||||
availability,
|
||||
connected: Boolean(onConcat),
|
||||
busy: busy || pending,
|
||||
@@ -445,6 +453,30 @@ export function StructuralOperations({
|
||||
)}
|
||||
onChange={setSubtitlePolicy}
|
||||
/>
|
||||
<label
|
||||
className="structural-field"
|
||||
htmlFor={`${componentId}-crossfade`}
|
||||
>
|
||||
<span>Crossfade duration</span>
|
||||
<input
|
||||
id={`${componentId}-crossfade`}
|
||||
type="number"
|
||||
aria-label="Crossfade duration"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.04"
|
||||
inputMode="decimal"
|
||||
value={crossfadeSeconds}
|
||||
disabled={busy || pending}
|
||||
onChange={(event) =>
|
||||
setCrossfadeSeconds(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
0 keeps hard cuts. A value from 0.04–10 seconds overlaps video
|
||||
and, when retained, audio at every edit.
|
||||
</small>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -488,6 +520,7 @@ export function StructuralOperations({
|
||||
presetId: concatPreset.id,
|
||||
missingAudioPolicy,
|
||||
subtitlePolicy,
|
||||
crossfadeSeconds: Number(crossfadeSeconds),
|
||||
},
|
||||
'Normalized concat queued.'
|
||||
);
|
||||
@@ -856,6 +889,7 @@ function reasonForConcat({
|
||||
preset,
|
||||
missingAudioPolicy,
|
||||
subtitlePolicy,
|
||||
crossfadeSeconds,
|
||||
availability,
|
||||
connected,
|
||||
busy,
|
||||
@@ -865,6 +899,7 @@ function reasonForConcat({
|
||||
readonly preset: StructuralExportPresetOption | undefined;
|
||||
readonly missingAudioPolicy: MissingAudioPolicy | '';
|
||||
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
|
||||
readonly crossfadeSeconds: string;
|
||||
readonly availability: StructuralOperationsProps['availability'];
|
||||
readonly connected: boolean;
|
||||
readonly busy: boolean;
|
||||
@@ -924,13 +959,39 @@ function reasonForConcat({
|
||||
) {
|
||||
return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.';
|
||||
}
|
||||
const crossfade = Number(crossfadeSeconds);
|
||||
if (
|
||||
!Number.isFinite(crossfade) ||
|
||||
crossfade < 0 ||
|
||||
(crossfade > 0 && (crossfade < 0.04 || crossfade > 10))
|
||||
) {
|
||||
return 'Crossfade duration must be 0, or a value from 0.04–10 seconds.';
|
||||
}
|
||||
if (crossfade > 0) {
|
||||
for (const [index, clip] of timeline.clips.entries()) {
|
||||
if (clip.durationSeconds === undefined) {
|
||||
continue;
|
||||
}
|
||||
const required =
|
||||
index === 0 || index === timeline.clips.length - 1
|
||||
? crossfade
|
||||
: crossfade * 2;
|
||||
if (clip.durationSeconds < required) {
|
||||
return `Clip ${index + 1} is too short for ${crossfade} second crossfades at its edit boundaries.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!connected) {
|
||||
return 'This action has not been connected to the job runner.';
|
||||
}
|
||||
return capabilityReason(
|
||||
const baseReason = capabilityReason(
|
||||
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
|
||||
);
|
||||
if (baseReason || mode === 'fast' || Number(crossfadeSeconds) === 0) {
|
||||
return baseReason;
|
||||
}
|
||||
return capabilityReason(availability?.['concat-crossfade']);
|
||||
}
|
||||
|
||||
function capabilityReason(
|
||||
|
||||
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 { LogBuffer } from './log-buffer';
|
||||
import { normalizeProgress } from './progress-parser';
|
||||
import { describeMountedInputAccess, mountJobFiles } from './virtual-fs';
|
||||
import {
|
||||
describeMountedInputAccess,
|
||||
mountJobFiles,
|
||||
type MountedJobFiles,
|
||||
} from './virtual-fs';
|
||||
import {
|
||||
parseFFmpegProgress,
|
||||
progressUpdateFromFFmpeg,
|
||||
@@ -32,6 +36,9 @@ import {
|
||||
type FFmpegFactory = () => Promise<FFmpegAdapter>;
|
||||
|
||||
export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000;
|
||||
export const MIN_PROBE_TIMEOUT_MILLISECONDS = 30_000;
|
||||
export const MAX_PROBE_TIMEOUT_MILLISECONDS = 5 * 60_000;
|
||||
export const PROBE_BUDGET_BYTES_PER_SECOND = 2 * 1024 * 1024;
|
||||
export const MULTITHREAD_CODEC_THREAD_LIMIT = 2;
|
||||
/**
|
||||
* The pinned Emscripten core can retain native heap state across heterogeneous
|
||||
@@ -55,6 +62,38 @@ export function shouldRecycleExecutionCore(
|
||||
);
|
||||
}
|
||||
|
||||
export function probeTimeoutMilliseconds(inputBytes: number): number {
|
||||
if (!Number.isFinite(inputBytes) || inputBytes <= 0) {
|
||||
return MIN_PROBE_TIMEOUT_MILLISECONDS;
|
||||
}
|
||||
const sizeAwareBudget = Math.ceil(
|
||||
(inputBytes / PROBE_BUDGET_BYTES_PER_SECOND) * 1_000
|
||||
);
|
||||
return Math.min(
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||
Math.max(MIN_PROBE_TIMEOUT_MILLISECONDS, sizeAwareBudget)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Firefox can leave this reviewed Emscripten core in a state where a later
|
||||
* ffprobe call stalls after otherwise successful FFmpeg executions. A fresh
|
||||
* worker/core completes the same probe promptly. Keep the workaround narrow
|
||||
* so other browsers retain their warm-core startup path.
|
||||
*/
|
||||
export function shouldRefreshCoreBeforeProbe(userAgent?: string): boolean {
|
||||
const resolvedUserAgent =
|
||||
userAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent);
|
||||
return /(?:^|\s)(?:Firefox|FxiOS)\//u.test(resolvedUserAgent);
|
||||
}
|
||||
|
||||
export function canReuseProbeCapabilities(
|
||||
loadedMode: EngineMode,
|
||||
cachedMode: EngineMode
|
||||
): boolean {
|
||||
return loadedMode === cachedMode;
|
||||
}
|
||||
|
||||
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
||||
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||
return new FFmpeg();
|
||||
@@ -82,6 +121,7 @@ export class EngineManager {
|
||||
#warning: Extract<EngineState, { status: 'ready' }>['warning'];
|
||||
#loadPromise: Promise<void> | null = null;
|
||||
#activeJobId: string | null = null;
|
||||
#preparingOperation = false;
|
||||
#activeExpectedDurationSeconds: number | undefined;
|
||||
#activeStartedAtMilliseconds = 0;
|
||||
#machineProgressLines: string[] = [];
|
||||
@@ -92,35 +132,40 @@ export class EngineManager {
|
||||
#generation = 0;
|
||||
|
||||
readonly #handleLog = (event: LogEvent): void => {
|
||||
// Capability discovery emits thousands of individual lines. Forwarding
|
||||
// each one into React while the engine is still loading can starve
|
||||
// Firefox's worker message queue long enough for the following probe to
|
||||
// hit its watchdog. queryCapabilities installs its own short-lived
|
||||
// listener, so application job logs only need to be retained/published
|
||||
// while an operation is running.
|
||||
if (this.#state.status !== 'running') return;
|
||||
this.#logs.append(event);
|
||||
if (this.#state.status === 'running') {
|
||||
for (const line of event.message.split(/\r?\n/gu)) {
|
||||
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
|
||||
this.#machineProgressLines.push(line);
|
||||
}
|
||||
if (/^progress=(?:continue|end)$/u.test(line)) {
|
||||
const parsed = parseFFmpegProgress(
|
||||
this.#machineProgressLines.join('\n')
|
||||
);
|
||||
const update = progressUpdateFromFFmpeg(
|
||||
parsed,
|
||||
this.#activeExpectedDurationSeconds
|
||||
);
|
||||
this.#machineProgressLines = [];
|
||||
this.#machineProgressSeen = true;
|
||||
this.#setState({
|
||||
...this.#state,
|
||||
...(update.progress !== undefined
|
||||
? { progress: update.progress }
|
||||
: {}),
|
||||
...(update.speed !== undefined ? { speed: update.speed } : {}),
|
||||
...(update.frame !== undefined ? { frame: update.frame } : {}),
|
||||
elapsedSeconds: Math.max(
|
||||
0,
|
||||
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const line of event.message.split(/\r?\n/gu)) {
|
||||
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
|
||||
this.#machineProgressLines.push(line);
|
||||
}
|
||||
if (/^progress=(?:continue|end)$/u.test(line)) {
|
||||
const parsed = parseFFmpegProgress(
|
||||
this.#machineProgressLines.join('\n')
|
||||
);
|
||||
const update = progressUpdateFromFFmpeg(
|
||||
parsed,
|
||||
this.#activeExpectedDurationSeconds
|
||||
);
|
||||
this.#machineProgressLines = [];
|
||||
this.#machineProgressSeen = true;
|
||||
this.#setState({
|
||||
...this.#state,
|
||||
...(update.progress !== undefined
|
||||
? { progress: update.progress }
|
||||
: {}),
|
||||
...(update.speed !== undefined ? { speed: update.speed } : {}),
|
||||
...(update.frame !== undefined ? { frame: update.frame } : {}),
|
||||
elapsedSeconds: Math.max(
|
||||
0,
|
||||
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const listener of this.#logListeners) listener(event);
|
||||
@@ -158,10 +203,16 @@ export class EngineManager {
|
||||
}
|
||||
|
||||
setPreference(preference: EnginePreference): void {
|
||||
if (this.#state.status === 'running') {
|
||||
if (
|
||||
this.#activeJobId ||
|
||||
this.#preparingOperation ||
|
||||
this.#state.status === 'loading' ||
|
||||
this.#state.status === 'recovering' ||
|
||||
this.#state.status === 'running'
|
||||
) {
|
||||
throw new EngineError(
|
||||
'busy',
|
||||
'Engine mode cannot be changed while a job is running.'
|
||||
'Engine mode cannot be changed while another operation is active.'
|
||||
);
|
||||
}
|
||||
if (preference !== this.#preference) {
|
||||
@@ -187,7 +238,11 @@ export class EngineManager {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.#activeJobId || this.#state.status === 'running') {
|
||||
if (
|
||||
this.#activeJobId ||
|
||||
this.#preparingOperation ||
|
||||
this.#state.status === 'running'
|
||||
) {
|
||||
throw new EngineError(
|
||||
'busy',
|
||||
'The FFmpeg engine cannot be initialized while another operation is active.'
|
||||
@@ -274,7 +329,11 @@ export class EngineManager {
|
||||
|
||||
async #loadMode(
|
||||
mode: EngineMode,
|
||||
warning?: Extract<EngineState, { status: 'ready' }>['warning']
|
||||
warning?: Extract<EngineState, { status: 'ready' }>['warning'],
|
||||
cached?: {
|
||||
readonly mode: EngineMode;
|
||||
readonly capabilities: FFmpegCapabilities;
|
||||
}
|
||||
): Promise<void> {
|
||||
const generation = ++this.#generation;
|
||||
this.#setState({
|
||||
@@ -301,9 +360,15 @@ export class EngineManager {
|
||||
status: 'loading',
|
||||
mode,
|
||||
progress: 0.75,
|
||||
stage: 'Inspecting codec capabilities',
|
||||
stage:
|
||||
cached && canReuseProbeCapabilities(mode, cached.mode)
|
||||
? 'Restoring codec capabilities'
|
||||
: 'Inspecting codec capabilities',
|
||||
});
|
||||
const capabilities = await queryCapabilities(engine);
|
||||
const capabilities =
|
||||
cached && canReuseProbeCapabilities(mode, cached.mode)
|
||||
? cached.capabilities
|
||||
: await queryCapabilities(engine);
|
||||
if (generation !== this.#generation) return;
|
||||
this.#capabilities = capabilities;
|
||||
this.#executionsSinceLoad = 0;
|
||||
@@ -313,27 +378,54 @@ export class EngineManager {
|
||||
}
|
||||
|
||||
async runJob(request: EngineJobRequest): Promise<EngineJobResult> {
|
||||
if (this.#activeJobId) {
|
||||
if (this.#activeJobId || this.#preparingOperation) {
|
||||
throw new EngineError(
|
||||
'busy',
|
||||
'Another FFmpeg operation is already running.'
|
||||
);
|
||||
}
|
||||
await this.initialize();
|
||||
const coreWorkload = request.coreWorkload ?? request.operation;
|
||||
await this.#recycleExecutionCoreIfNeeded(coreWorkload);
|
||||
const engine = this.#engine;
|
||||
const mode = this.#mode;
|
||||
if (!engine || !mode || this.#state.status !== 'ready') {
|
||||
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
||||
if (this.#activeJobId || this.#preparingOperation) {
|
||||
throw new EngineError(
|
||||
'busy',
|
||||
'Another FFmpeg operation is already running.'
|
||||
);
|
||||
}
|
||||
|
||||
const mounted = await mountJobFiles(
|
||||
engine,
|
||||
request.id,
|
||||
request.inputs,
|
||||
request.outputs.map(({ name }) => name)
|
||||
);
|
||||
let prepared:
|
||||
| {
|
||||
readonly engine: FFmpegAdapter;
|
||||
readonly mode: EngineMode;
|
||||
readonly mounted: MountedJobFiles;
|
||||
}
|
||||
| undefined;
|
||||
const coreWorkload = request.coreWorkload ?? request.operation;
|
||||
this.#preparingOperation = true;
|
||||
try {
|
||||
await this.#recycleExecutionCoreIfNeeded(coreWorkload, true);
|
||||
const engine = this.#engine;
|
||||
const mode = this.#mode;
|
||||
if (!engine || !mode || this.#state.status !== 'ready') {
|
||||
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
||||
}
|
||||
const mounted = await mountJobFiles(
|
||||
engine,
|
||||
request.id,
|
||||
request.inputs,
|
||||
request.outputs.map(({ name }) => name)
|
||||
);
|
||||
this.#activeJobId = request.id;
|
||||
prepared = { engine, mode, mounted };
|
||||
} finally {
|
||||
this.#preparingOperation = false;
|
||||
}
|
||||
if (!prepared) {
|
||||
throw new EngineError(
|
||||
'filesystem-failed',
|
||||
'Could not prepare the FFmpeg job.'
|
||||
);
|
||||
}
|
||||
const { engine, mode, mounted } = prepared;
|
||||
const generation = this.#generation;
|
||||
const temporaryPaths = (request.temporaryFiles ?? []).map(
|
||||
(temporary, index) => {
|
||||
@@ -342,7 +434,6 @@ export class EngineManager {
|
||||
return `${mounted.workDirectory}/temporary-${index}.${extension}`;
|
||||
}
|
||||
);
|
||||
this.#activeJobId = request.id;
|
||||
this.#activeExpectedDurationSeconds = request.expectedDurationSeconds;
|
||||
this.#activeStartedAtMilliseconds = performance.now();
|
||||
this.#machineProgressLines = [];
|
||||
@@ -521,8 +612,18 @@ export class EngineManager {
|
||||
};
|
||||
}
|
||||
|
||||
async probe(file: File, timeoutMilliseconds = 30_000): Promise<ProbeResult> {
|
||||
async probe(
|
||||
file: File,
|
||||
timeoutMilliseconds = probeTimeoutMilliseconds(file.size),
|
||||
signal?: AbortSignal
|
||||
): Promise<ProbeResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
await this.initialize();
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
const engine = this.#engine;
|
||||
const mode = this.#mode;
|
||||
if (!engine || !mode || this.#state.status !== 'ready') {
|
||||
@@ -535,18 +636,79 @@ export class EngineManager {
|
||||
);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
|
||||
if (this.#activeJobId || this.#preparingOperation) {
|
||||
throw new EngineError(
|
||||
'busy',
|
||||
'Another FFmpeg operation is already running.'
|
||||
);
|
||||
}
|
||||
|
||||
let prepared:
|
||||
| {
|
||||
readonly engine: FFmpegAdapter;
|
||||
readonly mode: EngineMode;
|
||||
readonly mounted: MountedJobFiles;
|
||||
}
|
||||
| undefined;
|
||||
this.#preparingOperation = true;
|
||||
try {
|
||||
await this.#refreshCoreBeforeProbe(signal);
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
const refreshedEngine = this.#engine;
|
||||
const refreshedMode = this.#mode;
|
||||
if (
|
||||
!refreshedEngine ||
|
||||
!refreshedMode ||
|
||||
this.#state.status !== 'ready'
|
||||
) {
|
||||
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const mounted = await mountJobFiles(
|
||||
refreshedEngine,
|
||||
id,
|
||||
[file],
|
||||
['probe.json']
|
||||
);
|
||||
if (signal?.aborted) {
|
||||
await mounted.cleanup();
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
this.#activeJobId = `probe-${id}`;
|
||||
prepared = {
|
||||
engine: refreshedEngine,
|
||||
mode: refreshedMode,
|
||||
mounted,
|
||||
};
|
||||
} finally {
|
||||
this.#preparingOperation = false;
|
||||
}
|
||||
if (!prepared) {
|
||||
throw new EngineError(
|
||||
'filesystem-failed',
|
||||
'Could not prepare the media probe.'
|
||||
);
|
||||
}
|
||||
const { engine: refreshedEngine, mode: refreshedMode, mounted } = prepared;
|
||||
const probeOutput = mounted.outputPaths[0];
|
||||
const inputPath = mounted.inputPaths[0];
|
||||
if (!probeOutput || !inputPath) {
|
||||
this.#activeJobId = null;
|
||||
await mounted.cleanup();
|
||||
throw new EngineError(
|
||||
'filesystem-failed',
|
||||
'Could not allocate probe paths.'
|
||||
);
|
||||
}
|
||||
const activeProbeId = `probe-${id}`;
|
||||
this.#activeJobId = activeProbeId;
|
||||
const activeProbeId = this.#activeJobId;
|
||||
if (!activeProbeId) {
|
||||
throw new EngineError(
|
||||
'filesystem-failed',
|
||||
'Could not reserve the media probe.'
|
||||
);
|
||||
}
|
||||
this.#activeExpectedDurationSeconds = undefined;
|
||||
this.#activeStartedAtMilliseconds = performance.now();
|
||||
this.#machineProgressLines = [];
|
||||
@@ -554,7 +716,7 @@ export class EngineManager {
|
||||
this.#logs.clear();
|
||||
this.#setState({
|
||||
status: 'running',
|
||||
mode,
|
||||
mode: refreshedMode,
|
||||
jobId: this.#activeJobId,
|
||||
operation: 'Inspect media',
|
||||
inputMode: mounted.inputMode,
|
||||
@@ -565,9 +727,17 @@ export class EngineManager {
|
||||
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
|
||||
message: describeMountedInputAccess(mounted),
|
||||
});
|
||||
const watchdog = setTimeout(() => this.cancelActive(), timeoutMilliseconds);
|
||||
let timedOut = false;
|
||||
const watchdog = setTimeout(() => {
|
||||
timedOut = true;
|
||||
void this.cancelActive();
|
||||
}, timeoutMilliseconds);
|
||||
const cancelOnAbort = () => {
|
||||
void this.cancelActive();
|
||||
};
|
||||
signal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||
try {
|
||||
const exitCode = await engine.ffprobe([
|
||||
const exitCode = await refreshedEngine.ffprobe([
|
||||
'-v',
|
||||
'error',
|
||||
'-show_format',
|
||||
@@ -582,7 +752,7 @@ export class EngineManager {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(
|
||||
textFromFileData(await engine.readFile(probeOutput))
|
||||
textFromFileData(await refreshedEngine.readFile(probeOutput))
|
||||
) as unknown;
|
||||
} catch (readError) {
|
||||
throw new EngineError(
|
||||
@@ -630,6 +800,13 @@ export class EngineManager {
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.#cancelledJobIds.delete(activeProbeId)) {
|
||||
if (timedOut) {
|
||||
throw new EngineError(
|
||||
'probe-failed',
|
||||
`Media inspection exceeded its ${String(Math.ceil(timeoutMilliseconds / 1_000))}-second safety limit.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.', {
|
||||
cause: error,
|
||||
});
|
||||
@@ -641,20 +818,24 @@ export class EngineManager {
|
||||
details: this.#logs.text(),
|
||||
});
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancelOnAbort);
|
||||
clearTimeout(watchdog);
|
||||
this.#activeJobId = null;
|
||||
this.#machineProgressLines = [];
|
||||
this.#machineProgressSeen = false;
|
||||
if (this.#engine === engine) {
|
||||
if (this.#engine === refreshedEngine) {
|
||||
const cleanupSucceeded = await mounted.cleanup();
|
||||
if (!cleanupSucceeded && this.#engine === engine) {
|
||||
if (!cleanupSucceeded && this.#engine === refreshedEngine) {
|
||||
this.#logs.append({
|
||||
type: 'stderr',
|
||||
message:
|
||||
'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.',
|
||||
});
|
||||
this.#terminateEngine();
|
||||
this.#setState({ status: 'recovering', previousMode: mode });
|
||||
this.#setState({
|
||||
status: 'recovering',
|
||||
previousMode: refreshedMode,
|
||||
});
|
||||
try {
|
||||
await this.initialize();
|
||||
} catch {
|
||||
@@ -665,7 +846,7 @@ export class EngineManager {
|
||||
if (capabilities) {
|
||||
this.#setState({
|
||||
status: 'ready',
|
||||
mode,
|
||||
mode: refreshedMode,
|
||||
capabilities,
|
||||
warning: this.#warning,
|
||||
});
|
||||
@@ -675,6 +856,71 @@ export class EngineManager {
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshCoreBeforeProbe(signal?: AbortSignal): Promise<void> {
|
||||
if (!shouldRefreshCoreBeforeProbe()) return;
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
const mode = this.#mode;
|
||||
const capabilities = this.#capabilities;
|
||||
const warning = this.#warning;
|
||||
if (!mode || !capabilities || this.#state.status !== 'ready') {
|
||||
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
||||
}
|
||||
|
||||
this.#terminateEngine();
|
||||
this.#setState({ status: 'recovering', previousMode: mode, warning });
|
||||
try {
|
||||
await this.#loadMode(mode, warning, { mode, capabilities });
|
||||
} catch (firstError) {
|
||||
if (mode !== 'multithread') {
|
||||
this.#terminateEngine();
|
||||
const loadError = new EngineError(
|
||||
'load-failed',
|
||||
'The FFmpeg core could not be refreshed for media inspection.',
|
||||
{
|
||||
cause: firstError,
|
||||
details: describeInitializationFailure(firstError),
|
||||
}
|
||||
);
|
||||
this.#setState({ status: 'error', error: loadError });
|
||||
throw loadError;
|
||||
}
|
||||
|
||||
this.#terminateEngine();
|
||||
const fallbackWarning = {
|
||||
code: 'multithread-load-failed' as const,
|
||||
message:
|
||||
'The multithread core failed to reload. The app recovered with the single-thread compatibility core.',
|
||||
};
|
||||
this.#setState({
|
||||
status: 'recovering',
|
||||
previousMode: 'multithread',
|
||||
warning: fallbackWarning,
|
||||
});
|
||||
try {
|
||||
// Capabilities are mode-specific; discover a fresh single-thread set.
|
||||
await this.#loadMode('single-thread', fallbackWarning);
|
||||
} catch (fallbackError) {
|
||||
const details = [
|
||||
`Multithread core: ${describeInitializationFailure(firstError)}`,
|
||||
`Single-thread core: ${describeInitializationFailure(fallbackError)}`,
|
||||
].join('\n');
|
||||
this.#terminateEngine();
|
||||
const loadError = new EngineError(
|
||||
'load-failed',
|
||||
'Neither FFmpeg execution core could be refreshed for media inspection.',
|
||||
{ cause: fallbackError, details }
|
||||
);
|
||||
this.#setState({ status: 'error', error: loadError });
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
}
|
||||
|
||||
async cancelActive(): Promise<void> {
|
||||
const activeJobId = this.#activeJobId;
|
||||
const previousMode = this.#mode;
|
||||
@@ -714,7 +960,10 @@ export class EngineManager {
|
||||
this.#contactSheetsSinceLoad = 0;
|
||||
}
|
||||
|
||||
async #recycleExecutionCoreIfNeeded(nextOperation: string): Promise<void> {
|
||||
async #recycleExecutionCoreIfNeeded(
|
||||
nextOperation: string,
|
||||
operationReserved = false
|
||||
): Promise<void> {
|
||||
if (
|
||||
!shouldRecycleExecutionCore(
|
||||
this.#executionsSinceLoad,
|
||||
@@ -743,7 +992,14 @@ export class EngineManager {
|
||||
});
|
||||
this.#terminateEngine();
|
||||
this.#setState({ status: 'recovering', previousMode });
|
||||
await this.initialize();
|
||||
if (operationReserved) {
|
||||
this.#loadPromise = this.#initializeInternal().finally(() => {
|
||||
this.#loadPromise = null;
|
||||
});
|
||||
await this.#loadPromise;
|
||||
} else {
|
||||
await this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
#setState(state: EngineState): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EngineMode } from './ffmpeg.types';
|
||||
import { FFMPEG_CORE_VERSION } from '../version';
|
||||
import { FFMPEG_CORE_BUILD_ID } from '../version';
|
||||
|
||||
export function resolveFromAppBase(
|
||||
relativePath: string,
|
||||
@@ -22,8 +22,8 @@ export function getCoreAssetUrls(
|
||||
): CoreAssetUrls {
|
||||
const directory =
|
||||
mode === 'multithread'
|
||||
? `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/mt/`
|
||||
: `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/st/`;
|
||||
? `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/mt/`
|
||||
: `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/st/`;
|
||||
const base = resolver(directory);
|
||||
const urls: CoreAssetUrls = {
|
||||
coreURL: new URL('ffmpeg-core.js', base).href,
|
||||
|
||||
@@ -561,12 +561,16 @@ select:disabled {
|
||||
color: #2c7d63 !important;
|
||||
}
|
||||
|
||||
.phase--idle {
|
||||
color: #2c7d63 !important;
|
||||
}
|
||||
|
||||
.phase--probing {
|
||||
color: #8b6a20 !important;
|
||||
}
|
||||
|
||||
.phase--error {
|
||||
color: var(--toolbox-danger) !important;
|
||||
color: #8b6a20 !important;
|
||||
}
|
||||
|
||||
.media-bin__empty {
|
||||
@@ -779,6 +783,18 @@ select:disabled {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.stream-picker__empty {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stream-picker__empty .button {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.stream-kind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1759,6 +1775,15 @@ progress::-moz-progress-bar {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.inspector-section > .notice {
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspector-section > .notice > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inspector-section h3 {
|
||||
margin: 0.45rem 0 0;
|
||||
}
|
||||
|
||||
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-metadata';
|
||||
export * from './chapter-time-base';
|
||||
export * from './duration';
|
||||
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,
|
||||
"id": "de.add-ideas.av-tools",
|
||||
"name": "Audio & Video Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -37,21 +37,21 @@
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/zemion/av-tools",
|
||||
"repository": "https://git.add-ideas.de/lotobo/av-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/zemion/av-tools"
|
||||
"url": "https://git.add-ideas.de/lotobo/av-tools"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const APP_VERSION = '0.1.0';
|
||||
export const APP_VERSION = '0.3.0';
|
||||
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
|
||||
export const FFMPEG_UTIL_VERSION = '0.12.2';
|
||||
export const FFMPEG_CORE_VERSION = '0.12.10';
|
||||
export const FFMPEG_CORE_BUILD_ID = '0.12.10-reviewed-stack5m.1';
|
||||
export const FFMPEG_ENGINE_VERSION = 'n5.1.4';
|
||||
export const FFMPEG_OPUS_REGRESSION_VERIFIED = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
startNestedStaticServer,
|
||||
type NestedStaticServer,
|
||||
} from './browser-helpers';
|
||||
@@ -119,6 +120,38 @@ test.describe('static application and Toolbox shell', () => {
|
||||
await expect(inspectorDrawer).toBeHidden();
|
||||
await expect(inspector).toBeFocused();
|
||||
});
|
||||
|
||||
test('shows native preview metadata without loading the FFmpeg runtime', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
|
||||
await page
|
||||
.locator('input[type="file"][accept*="audio"]')
|
||||
.first()
|
||||
.setInputFiles(PRIMARY_FIXTURE);
|
||||
|
||||
await expect(page.locator('.quick-preview video')).toBeVisible();
|
||||
const card = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
await expect(card.locator('.phase--idle')).toContainText(
|
||||
'Ready to play · Details on demand'
|
||||
);
|
||||
await expect(card).toContainText(/\d+:\d{2}\.\d{2}/u);
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Inspect & render timeline' })
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('nested static deployment and Toolbox context', () => {
|
||||
@@ -191,12 +224,12 @@ test.describe('nested static deployment and Toolbox context', () => {
|
||||
|
||||
const coreScript = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js'
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js'
|
||||
)
|
||||
);
|
||||
const coreWasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm'
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(coreScript).toMatchObject({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const REPOSITORY_ROOT = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -19,6 +20,20 @@ export const GENERATED_FIXTURES = join(
|
||||
);
|
||||
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
|
||||
|
||||
export async function inspectMediaDetails(
|
||||
page: Page,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
const card = page.locator('.media-card').filter({ hasText: fileName });
|
||||
const inspect = page.getByRole('button', {
|
||||
name: `Inspect details for ${fileName}`,
|
||||
});
|
||||
if ((await inspect.count()) > 0) await inspect.click();
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
const NESTED_PREFIX = '/deep/nested/av/';
|
||||
const SECURITY_HEADERS = {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
@@ -112,7 +127,7 @@ export async function startNestedStaticServer(): Promise<NestedStaticServer> {
|
||||
};
|
||||
const catalog = {
|
||||
$schema:
|
||||
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
||||
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.browser-test-toolbox',
|
||||
name: 'Browser Test Toolbox',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
createCancellationFixture,
|
||||
GENERATED_FIXTURES,
|
||||
inspectMediaDetails,
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
type TemporaryMediaFixture,
|
||||
@@ -75,9 +76,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
);
|
||||
|
||||
const expectedAssets = [
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||
];
|
||||
for (const suffix of expectedAssets) {
|
||||
expect(
|
||||
@@ -87,16 +88,21 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toBe(true);
|
||||
}
|
||||
const wasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm')
|
||||
response.url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(wasm?.contentType).toContain('application/wasm');
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/'))
|
||||
runtime.requests.some((url) =>
|
||||
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/')
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
@@ -119,8 +125,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) =>
|
||||
route.abort('failed')
|
||||
await page.route(
|
||||
'**/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/**',
|
||||
(route) => route.abort('failed')
|
||||
);
|
||||
await page.goto('/');
|
||||
const environment = await page.evaluate(() => ({
|
||||
@@ -147,13 +154,17 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/'))
|
||||
runtime.requests.some((url) =>
|
||||
url.includes('/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.responses.some(
|
||||
(response) =>
|
||||
response.status === 200 &&
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
response.url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
@@ -175,8 +186,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toBe(false);
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText(
|
||||
@@ -247,7 +259,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
@@ -264,6 +278,7 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
await mediaInput(page)
|
||||
.first()
|
||||
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
||||
await inspectMediaDetails(page, 'compatible-a.mp4');
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
@@ -303,7 +318,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
).toHaveCount(4);
|
||||
expect(
|
||||
runtime.requests.filter((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toHaveLength(2);
|
||||
assertLocalOnly(runtime);
|
||||
@@ -316,8 +333,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||
await inspectMediaDetails(page, basename(cancellationFixture.filePath));
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
|
||||
@@ -344,8 +362,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
})
|
||||
.click();
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
@@ -356,4 +375,49 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('removes active and queued inspections without applying stale probe results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
|
||||
await mediaInput(page).setInputFiles([
|
||||
cancellationFixture.filePath,
|
||||
PRIMARY_FIXTURE,
|
||||
]);
|
||||
const activeName = basename(cancellationFixture.filePath);
|
||||
const activeCard = page.locator('.media-card').filter({
|
||||
hasText: activeName,
|
||||
});
|
||||
const queuedCard = page.locator('.media-card').filter({
|
||||
hasText: 'pattern-av.mp4',
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `Inspect details for ${activeName}`,
|
||||
})
|
||||
.click();
|
||||
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(queuedCard.locator('.phase--idle')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove pattern-av.mp4' }).click();
|
||||
await page.getByRole('button', { name: `Remove ${activeName}` }).click();
|
||||
await expect(page.locator('.media-card')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Choose files' })
|
||||
).toBeEnabled({ timeout: 120_000 });
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
GENERATED_FIXTURES,
|
||||
inspectMediaDetails,
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
type RuntimeObservation,
|
||||
@@ -51,8 +52,9 @@ test.describe
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
||||
);
|
||||
await inspectMediaDetails(page, 'attached-cover.mp3');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 60_000 }
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText(
|
||||
@@ -108,13 +110,23 @@ test.describe
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'truncated.mp4')
|
||||
);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Inspect details for truncated.mp4',
|
||||
})
|
||||
.click();
|
||||
const malformedCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'truncated.mp4' });
|
||||
await expect(malformedCard.locator('.phase--error')).toContainText(
|
||||
'ffprobe did not find any media streams.',
|
||||
'File ready · Details unavailable',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Retry detailed inspection for truncated.mp4',
|
||||
})
|
||||
).toHaveAttribute('title', 'ffprobe did not find any media streams.');
|
||||
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
||||
await expect(
|
||||
page
|
||||
@@ -123,6 +135,7 @@ test.describe
|
||||
).toBeVisible();
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||
const validCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
|
||||
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)));
|
||||
for (const name of names) {
|
||||
const card = page.locator('.media-card').filter({ hasText: name });
|
||||
await page
|
||||
.getByRole('button', { name: `Inspect details for ${name}` })
|
||||
.click();
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -149,7 +152,7 @@ function probe(filePath: string): {
|
||||
tags?: Record<string, string>;
|
||||
disposition?: Record<string, number>;
|
||||
}>;
|
||||
format?: { tags?: Record<string, string> };
|
||||
format?: { duration?: string; tags?: Record<string, string> };
|
||||
chapters?: Array<{ tags?: Record<string, string> }>;
|
||||
} {
|
||||
return JSON.parse(
|
||||
@@ -210,6 +213,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
|
||||
const sourceName = basename(cancellationFixture.filePath);
|
||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `Inspect details for ${sourceName}`,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
@@ -547,6 +555,31 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
|
||||
await structural.getByLabel('Crossfade duration').fill('0.2');
|
||||
const crossfadeAction = structural.getByRole('button', {
|
||||
name: 'Export normalized concat',
|
||||
});
|
||||
if (await crossfadeAction.isEnabled()) {
|
||||
const [crossfadeCard] = await runForResults(
|
||||
page,
|
||||
() => crossfadeAction.click(),
|
||||
['compatible-a-concat.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const crossfadeDuration = Number(
|
||||
probe((await downloadResult(page, crossfadeCard as Locator)).filePath)
|
||||
.format?.duration
|
||||
);
|
||||
expect(crossfadeDuration).toBeGreaterThan(1.35);
|
||||
expect(crossfadeDuration).toBeLessThan(1.75);
|
||||
} else {
|
||||
await expect(
|
||||
structural.getByText(
|
||||
/required browser and FFmpeg capabilities have not been verified|filter (?:x|a)fade/iu
|
||||
)
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
@@ -687,6 +720,72 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
|
||||
.size
|
||||
).toBeGreaterThan(500);
|
||||
|
||||
await importMedia(page, ['pattern-av.mp4']);
|
||||
const patternClip = page
|
||||
.locator('.timeline-clip')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
await patternClip.locator('.timeline-clip__main').click();
|
||||
const imageSelection = advanced.getByLabel('Frame selection').first();
|
||||
const sceneOption = imageSelection.locator(
|
||||
'option[value="scene-changes"]'
|
||||
);
|
||||
if (!(await sceneOption.isDisabled())) {
|
||||
await imageSelection.selectOption('scene-changes');
|
||||
await advanced.getByLabel('Number of frames').fill('2');
|
||||
await advanced.getByLabel('Scene sensitivity threshold').fill('0.01');
|
||||
await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
.click(),
|
||||
[
|
||||
'pattern-av-thumbnail-001-83ms.jpg',
|
||||
'pattern-av-thumbnail-002-1000ms.jpg',
|
||||
]
|
||||
);
|
||||
|
||||
const contactSelection = advanced.getByLabel('Frame selection').nth(1);
|
||||
await contactSelection.selectOption('scene-changes');
|
||||
await advanced.getByLabel('Contact-sheet scene threshold').fill('0.01');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Frames', exact: true })
|
||||
.fill('2');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Rows', exact: true })
|
||||
.fill('1');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Columns', exact: true })
|
||||
.fill('2');
|
||||
const timestampLabels = advanced.getByLabel('Timestamp labels');
|
||||
if (await timestampLabels.isEnabled()) {
|
||||
await timestampLabels.check();
|
||||
await advanced.getByLabel('Source filename label').check();
|
||||
}
|
||||
const [sceneSheetCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate contact sheet' })
|
||||
.click(),
|
||||
['pattern-av-contact-sheet.jpg']
|
||||
);
|
||||
expect(
|
||||
statSync(
|
||||
(await downloadResult(page, sceneSheetCard as Locator)).filePath
|
||||
).size
|
||||
).toBeGreaterThan(500);
|
||||
}
|
||||
|
||||
await patternClip.getByRole('button', { name: 'Remove clip' }).click();
|
||||
await expect(patternClip).toHaveCount(0);
|
||||
await page
|
||||
.locator('.timeline-clip')
|
||||
.filter({ hasText: 'compatible-a.mp4' })
|
||||
.first()
|
||||
.locator('.timeline-clip__main')
|
||||
.click();
|
||||
});
|
||||
|
||||
await test.step('single frame and a transformed timeline with audio/video fades', async () => {
|
||||
@@ -964,6 +1063,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
mimeType: 'video/x-matroska',
|
||||
buffer: readFileSync(muxed.filePath),
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Inspect details for subtitled-source.mkv',
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
@@ -988,7 +1092,9 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
url.endsWith(
|
||||
'/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm'
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
|
||||
@@ -103,8 +103,9 @@ async function runMode(
|
||||
|
||||
const probeStarted = await monotonicNow(page);
|
||||
await mediaInput(page).setInputFiles(fixturePath);
|
||||
await page.getByRole('button', { name: /^Inspect details for /u }).click();
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
const probeMs = (await monotonicNow(page)) - probeStarted;
|
||||
|
||||
@@ -21,8 +21,11 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
|
||||
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||
.click();
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
'Details ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
||||
@@ -47,6 +50,9 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
||||
await mediaInput(page).setInputFiles(compatibleB);
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||
.click();
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -127,6 +133,12 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
||||
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||
.click();
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
buildPeakNormalizationPlan,
|
||||
buildPreviewProxyPlan,
|
||||
buildRemuxPlan,
|
||||
buildSceneChangeAnalysisPlan,
|
||||
buildSceneThumbnailSeriesPlan,
|
||||
buildSingleThumbnailPlan,
|
||||
buildSplitPlan,
|
||||
buildStaticWaveformPlan,
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
createConcatListEntries,
|
||||
parseLoudnessMeasurement,
|
||||
parsePeakMeasurement,
|
||||
parseSceneChangeFrames,
|
||||
resolveThumbnailTimes,
|
||||
streamMapArguments,
|
||||
type ConcatSource,
|
||||
@@ -63,8 +66,16 @@ const NATIVE_SUBTITLES_FILTER_AVAILABLE =
|
||||
encoding: 'utf8',
|
||||
}).stdout ?? ''
|
||||
);
|
||||
const NATIVE_DRAWTEXT_FILTER_AVAILABLE =
|
||||
NATIVE_FFMPEG_AVAILABLE &&
|
||||
/\bdrawtext\b/u.test(
|
||||
spawnSync('ffmpeg', ['-hide_banner', '-filters'], {
|
||||
encoding: 'utf8',
|
||||
}).stdout ?? ''
|
||||
);
|
||||
const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip;
|
||||
const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip;
|
||||
const itNativeDrawtext = NATIVE_DRAWTEXT_FILTER_AVAILABLE ? it : it.skip;
|
||||
|
||||
interface NativeExecution {
|
||||
readonly outputPaths: readonly string[];
|
||||
@@ -752,6 +763,33 @@ describeNative('native execution of structured command plans', () => {
|
||||
Number(probe(normalized.outputPaths[0]!).format?.duration)
|
||||
).toBeCloseTo(1.6, 1);
|
||||
|
||||
const crossfaded = runNative(
|
||||
buildNormalizedConcatPlan({
|
||||
jobId: 'crossfade-concat-native',
|
||||
sources: [
|
||||
concatSource('first', 0, 'compatible-a.mp4', 128, 72),
|
||||
concatSource('second', 1, 'compatible-b.mp4', 128, 72),
|
||||
],
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
preset: preset('mp4-h264-balanced'),
|
||||
width: 128,
|
||||
height: 72,
|
||||
frameRate: 10,
|
||||
sampleRate: 16_000,
|
||||
channelLayout: 'mono',
|
||||
missingAudioPolicy: 'reject',
|
||||
crossfadeSeconds: 0.2,
|
||||
}),
|
||||
{
|
||||
first: fixture('compatible-a.mp4'),
|
||||
second: fixture('compatible-b.mp4'),
|
||||
}
|
||||
);
|
||||
expect(
|
||||
Number(probe(crossfaded.outputPaths[0]!).format?.duration)
|
||||
).toBeCloseTo(1.8, 1);
|
||||
|
||||
const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4');
|
||||
nativeCommand(
|
||||
[
|
||||
@@ -788,6 +826,7 @@ describeNative('native execution of structured command plans', () => {
|
||||
sampleRate: 16_000,
|
||||
channelLayout: 'mono',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
crossfadeSeconds: 0.2,
|
||||
}),
|
||||
{ first: fixture('compatible-a.mp4'), 'video-only': videoOnly }
|
||||
);
|
||||
@@ -1185,8 +1224,80 @@ describeNative('native execution of structured command plans', () => {
|
||||
codec_name: 'png',
|
||||
width: 166,
|
||||
});
|
||||
|
||||
const sceneAnalysis = runNative(
|
||||
buildSceneChangeAnalysisPlan({
|
||||
jobId: 'scene-analysis-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
threshold: 0.01,
|
||||
maxFrames: 4,
|
||||
}),
|
||||
{ media: mediaFile },
|
||||
'info'
|
||||
);
|
||||
const sceneFrames = parseSceneChangeFrames(sceneAnalysis.stderr);
|
||||
expect(sceneFrames.length).toBeGreaterThan(0);
|
||||
const sceneSeries = runNative(
|
||||
buildSceneThumbnailSeriesPlan({
|
||||
jobId: 'scene-series-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
frames: sceneFrames,
|
||||
size: { width: 80 },
|
||||
format: 'png',
|
||||
}),
|
||||
{ media: mediaFile }
|
||||
);
|
||||
expect(sceneSeries.outputPaths).toHaveLength(sceneFrames.length);
|
||||
|
||||
const sceneSheet = runNative(
|
||||
buildContactSheetPlan({
|
||||
jobId: 'scene-contact-sheet-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
frameCount: sceneFrames.length,
|
||||
rows: 1,
|
||||
columns: sceneFrames.length,
|
||||
cellWidth: 80,
|
||||
spacing: 2,
|
||||
sceneFrames,
|
||||
format: 'png',
|
||||
}),
|
||||
{ media: mediaFile }
|
||||
);
|
||||
expect(statSync(sceneSheet.outputPaths[0]!).size).toBeGreaterThan(500);
|
||||
}, 120_000);
|
||||
|
||||
itNativeDrawtext(
|
||||
'executes contact-sheet labels with the reviewed bundled font',
|
||||
() => {
|
||||
const media = source('media', 0, 'pattern-av.mp4');
|
||||
const labeledSheet = runNative(
|
||||
buildContactSheetPlan({
|
||||
jobId: 'labeled-contact-sheet-native',
|
||||
source: media,
|
||||
durationSeconds: 2,
|
||||
frameCount: 2,
|
||||
rows: 1,
|
||||
columns: 2,
|
||||
cellWidth: 120,
|
||||
timestampLabels: true,
|
||||
sourceLabel: true,
|
||||
drawtextAvailable: true,
|
||||
font: source('font', 1, 'DejaVuSans.ttf'),
|
||||
format: 'png',
|
||||
}),
|
||||
{
|
||||
media: fixture('pattern-av.mp4'),
|
||||
font: resolve(process.cwd(), 'public/fonts/DejaVuSans.ttf'),
|
||||
}
|
||||
);
|
||||
expect(statSync(labeledSheet.outputPaths[0]!).size).toBeGreaterThan(500);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
|
||||
it('executes a transformed, faded, normalized sequential timeline', () => {
|
||||
const execution = runNative(
|
||||
buildTimelineExportPlan({
|
||||
|
||||
@@ -283,6 +283,72 @@ describe('AdvancedOperations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('emits bounded scene analysis settings for series and contact sheets', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onThumbnailSeries = vi.fn();
|
||||
const onContactSheet = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
availability={capabilities(
|
||||
'thumbnail-series',
|
||||
'contact-sheet',
|
||||
'scene-change-analysis',
|
||||
'contact-sheet-labels'
|
||||
)}
|
||||
imageFormatAvailability={{ jpeg: AVAILABLE }}
|
||||
onThumbnailSeries={onThumbnailSeries}
|
||||
onContactSheet={onContactSheet}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
name: 'Thumbnails and contact sheets',
|
||||
})
|
||||
);
|
||||
const selectionFields = screen.getAllByLabelText('Frame selection');
|
||||
await user.selectOptions(selectionFields[0]!, 'scene-changes');
|
||||
await user.clear(screen.getByLabelText('Scene sensitivity threshold'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Scene sensitivity threshold'),
|
||||
'0.42'
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onThumbnailSeries).toHaveBeenCalledWith({
|
||||
distribution: {
|
||||
type: 'scene-changes',
|
||||
count: 12,
|
||||
threshold: 0.42,
|
||||
},
|
||||
width: 480,
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(selectionFields[1]!, 'scene-changes');
|
||||
await user.clear(screen.getByLabelText('Contact-sheet scene threshold'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Contact-sheet scene threshold'),
|
||||
'0.3'
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Generate contact sheet' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onContactSheet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
frameCount: 12,
|
||||
selection: { type: 'scene-changes', threshold: 0.3 },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubtitleAction = vi.fn();
|
||||
|
||||
31
tests/unit/bundled-font-assets.test.ts
Normal file
31
tests/unit/bundled-font-assets.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BUNDLED_CONTACT_SHEET_FONT } from '../../src/assets/bundled-font';
|
||||
|
||||
describe('bundled contact-sheet font', () => {
|
||||
it('matches its reviewed byte identity and ships the embedded licence', () => {
|
||||
const bytes = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'public/fonts',
|
||||
BUNDLED_CONTACT_SHEET_FONT.fileName
|
||||
)
|
||||
);
|
||||
expect(bytes.byteLength).toBe(BUNDLED_CONTACT_SHEET_FONT.byteLength);
|
||||
expect(createHash('sha256').update(bytes).digest('hex')).toBe(
|
||||
BUNDLED_CONTACT_SHEET_FONT.sha256
|
||||
);
|
||||
|
||||
const licence = readFileSync(
|
||||
resolve(process.cwd(), BUNDLED_CONTACT_SHEET_FONT.licenseFile),
|
||||
'utf8'
|
||||
);
|
||||
expect(licence).toContain('Bitstream Vera Fonts Copyright');
|
||||
expect(licence).toContain('Arev Fonts Copyright');
|
||||
expect(licence).toContain(
|
||||
'The above copyright and trademark notices and this permission notice'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
buildPeakAnalysisPlan,
|
||||
buildPeakNormalizationFilter,
|
||||
buildPeakNormalizationPlan,
|
||||
buildSceneChangeAnalysisPlan,
|
||||
buildSceneThumbnailSeriesPlan,
|
||||
buildSubtitleBurnPlan,
|
||||
buildSubtitleExtractionPlan,
|
||||
buildSubtitleMuxPlan,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
parseChapterJson,
|
||||
parseLoudnessMeasurement,
|
||||
parsePeakMeasurement,
|
||||
parseSceneChangeFrames,
|
||||
parseMetadataJson,
|
||||
resolveThumbnailTimes,
|
||||
serializeChapterJson,
|
||||
@@ -360,6 +363,65 @@ describe('thumbnail and contact sheet plans', () => {
|
||||
expect(plan.args.some((value) => value.includes('%03d'))).toBe(false);
|
||||
});
|
||||
|
||||
it('analyzes a bounded number of scene changes and parses exact timestamps', () => {
|
||||
const plan = buildSceneChangeAnalysisPlan({
|
||||
jobId: 'scenes',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
threshold: 0.35,
|
||||
maxFrames: 12,
|
||||
});
|
||||
expect(plan.outputs).toEqual([]);
|
||||
expect(plan.args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"select='gt(scene\\,0.35)',showinfo",
|
||||
'-frames:v',
|
||||
'12',
|
||||
'-f',
|
||||
'null',
|
||||
])
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).toEqual(['select', 'showinfo']);
|
||||
expect(
|
||||
parseSceneChangeFrames(`
|
||||
[Parsed_showinfo_1 @ 0x1] n: 0 pts: 4096 pts_time:0.25 duration:512
|
||||
n: 1 pts: 16384 pts_time:1 duration:512
|
||||
[Parsed_showinfo_1 @ 0x1] n: 2 pts: 16384 pts_time:1 duration:512
|
||||
invalid showinfo n: 3 pts: nope pts_time:2
|
||||
`)
|
||||
).toEqual([
|
||||
{ pts: 4096, timeSeconds: 0.25 },
|
||||
{ pts: 16384, timeSeconds: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds exact scene-selected thumbnails from analyzed PTS values', () => {
|
||||
const plan = buildSceneThumbnailSeriesPlan({
|
||||
jobId: 'scene-thumbs',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
frames: [
|
||||
{ pts: 4096, timeSeconds: 0.25 },
|
||||
{ pts: 16384, timeSeconds: 1 },
|
||||
],
|
||||
format: 'png',
|
||||
size: { width: 320 },
|
||||
});
|
||||
expect(plan.outputs.map((output) => output.fileName)).toEqual([
|
||||
'source-thumbnail-001-250ms.png',
|
||||
'source-thumbnail-002-1000ms.png',
|
||||
]);
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
"select='eq(pts\\,4096)+eq(pts\\,16384)',split=2"
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).toEqual([
|
||||
'select',
|
||||
'split',
|
||||
'setpts',
|
||||
'scale',
|
||||
]);
|
||||
});
|
||||
|
||||
it('degrades contact-sheet labels when drawtext is unavailable', () => {
|
||||
const plan = buildContactSheetPlan({
|
||||
jobId: 'sheet',
|
||||
@@ -381,6 +443,49 @@ describe('thumbnail and contact sheet plans', () => {
|
||||
);
|
||||
expect(plan.requiredCapabilities.filters).not.toContain('drawtext');
|
||||
});
|
||||
|
||||
it('uses a mounted reviewed font and exact scene frames for labels', () => {
|
||||
const plan = buildContactSheetPlan({
|
||||
jobId: 'scene-sheet',
|
||||
source: SOURCE,
|
||||
durationSeconds: 10,
|
||||
frameCount: 2,
|
||||
rows: 1,
|
||||
columns: 2,
|
||||
cellWidth: 240,
|
||||
timestampLabels: true,
|
||||
sourceLabel: true,
|
||||
format: 'jpeg',
|
||||
drawtextAvailable: true,
|
||||
font: {
|
||||
id: 'bundled-font',
|
||||
sourceIndex: 1,
|
||||
fileName: 'DejaVuSans.ttf',
|
||||
},
|
||||
sceneFrames: [
|
||||
{ pts: 4096, timeSeconds: 0.25 },
|
||||
{ pts: 16384, timeSeconds: 1 },
|
||||
],
|
||||
});
|
||||
expect(plan.inputs[1]).toMatchObject({
|
||||
kind: 'font',
|
||||
sourceIndex: 1,
|
||||
});
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
"select='eq(pts\\,4096)+eq(pts\\,16384)'"
|
||||
);
|
||||
expect(plan.args.join(' ')).toContain(
|
||||
"drawtext=fontfile='/input/job-scene-sheet/source-1.ttf'"
|
||||
);
|
||||
expect(plan.args.join(' ')).toContain('nb_frames=2');
|
||||
expect(plan.requiredCapabilities.filters).toEqual([
|
||||
'select',
|
||||
'scale',
|
||||
'setpts',
|
||||
'tile',
|
||||
'drawtext',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composed visual operations', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildConvertPlan,
|
||||
buildFastConcatPlan,
|
||||
buildNormalizedConcatPlan,
|
||||
buildPreviewProxyPlan,
|
||||
buildRemuxPlan,
|
||||
buildSplitPlan,
|
||||
@@ -276,6 +277,64 @@ describe('concat and transform validation', () => {
|
||||
expect(plan.expectedDurationSeconds).toBe(5);
|
||||
});
|
||||
|
||||
it('builds bounded chained audio/video crossfades for normalized concat', () => {
|
||||
const preset = findBuiltInPreset('mp4-h264-balanced');
|
||||
const sources = [
|
||||
{ ...SOURCES[0]!, durationSeconds: 4 },
|
||||
{ ...SOURCES[1]!, durationSeconds: 5 },
|
||||
{
|
||||
...SOURCES[1]!,
|
||||
id: 'three',
|
||||
sourceIndex: 2,
|
||||
fileName: 'three.mp4',
|
||||
durationSeconds: 3,
|
||||
},
|
||||
];
|
||||
const plan = buildNormalizedConcatPlan({
|
||||
jobId: 'crossfade',
|
||||
sources,
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
preset: preset!,
|
||||
width: 640,
|
||||
height: 360,
|
||||
frameRate: 25,
|
||||
sampleRate: 48_000,
|
||||
channelLayout: 'stereo',
|
||||
missingAudioPolicy: 'reject',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 0.5,
|
||||
});
|
||||
const graph = plan.args[plan.args.indexOf('-filter_complex') + 1] ?? '';
|
||||
expect(graph).toContain(
|
||||
'[v0][v1]xfade=transition=fade:duration=0.5:offset=3.5[vxf1]'
|
||||
);
|
||||
expect(graph).toContain(
|
||||
'[vxf1][v2]xfade=transition=fade:duration=0.5:offset=8[vout]'
|
||||
);
|
||||
expect(graph).toContain('[a0][a1]acrossfade=d=0.5:c1=tri:c2=tri[axf1]');
|
||||
expect(graph).not.toContain('concat=n=3');
|
||||
expect(plan.expectedDurationSeconds).toBe(11);
|
||||
expect(plan.requiredCapabilities.filters).toEqual(
|
||||
expect.arrayContaining(['xfade', 'acrossfade'])
|
||||
);
|
||||
expect(() =>
|
||||
buildNormalizedConcatPlan({
|
||||
jobId: 'too-long-crossfade',
|
||||
sources: SOURCES,
|
||||
targetExtension: 'mp4',
|
||||
targetMuxer: 'mp4',
|
||||
preset: preset!,
|
||||
width: 640,
|
||||
height: 360,
|
||||
frameRate: 25,
|
||||
missingAudioPolicy: 'reject',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 2.5,
|
||||
})
|
||||
).toThrow(/Clip 1 needs at least/iu);
|
||||
});
|
||||
|
||||
it('corrects crop to even bounds and calculates aspect-preserving resize', () => {
|
||||
expect(
|
||||
validateCrop({
|
||||
|
||||
@@ -16,7 +16,9 @@ describe('HelpDialog', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('n5.1.4')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.12.15')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.12.10 · ST & MT')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('0.12.10-reviewed-stack5m.1 · ST & MT')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/copyright © 2026 Albrecht Degering/i)
|
||||
).toBeInTheDocument();
|
||||
@@ -29,6 +31,9 @@ describe('HelpDialog', () => {
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'GPL text', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Font licence', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('DEJAVU_FONTS'));
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'Complete inventory',
|
||||
|
||||
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', () => {
|
||||
it('starts declared local video playback while inspection continues and preserves the element', () => {
|
||||
const probingAsset: ImportedMediaAsset = {
|
||||
id: 'asset-probing-video',
|
||||
file: new File(['video'], 'lecture.mp4', {
|
||||
type: 'application/octet-stream',
|
||||
}),
|
||||
objectUrl: 'blob:probing-video',
|
||||
phase: 'probing',
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<MediaPreview asset={probingAsset} />
|
||||
);
|
||||
|
||||
const progressiveVideo = requiredElement(container.querySelector('video'));
|
||||
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local source preview · Detailed inspection runs separately.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<MediaPreview
|
||||
asset={{
|
||||
...videoAsset(),
|
||||
id: probingAsset.id,
|
||||
file: probingAsset.file,
|
||||
objectUrl: probingAsset.objectUrl,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('video')).toBe(progressiveVideo);
|
||||
});
|
||||
|
||||
it('keeps browser playback mounted after detailed inspection fails', () => {
|
||||
const asset: ImportedMediaAsset = {
|
||||
id: 'asset-probe-error',
|
||||
file: new File(['video'], 'lecture.mp4', { type: 'video/mp4' }),
|
||||
objectUrl: 'blob:probe-error',
|
||||
phase: 'error',
|
||||
error: 'Detailed inspection timed out.',
|
||||
};
|
||||
|
||||
const { container } = render(<MediaPreview asset={asset} />);
|
||||
|
||||
expect(container.querySelector('video')).toHaveAttribute(
|
||||
'src',
|
||||
asset.objectUrl
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local source preview · Detailed inspection unavailable.'
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the video-container player when browser metadata has no track hints', () => {
|
||||
const file = new File(['audio-only'], 'audio-only.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
const initial: ImportedMediaAsset = {
|
||||
id: 'asset-audio-only-container',
|
||||
file,
|
||||
objectUrl: 'blob:audio-only-container',
|
||||
phase: 'idle',
|
||||
};
|
||||
const { container, rerender } = render(<MediaPreview asset={initial} />);
|
||||
const player = requiredElement(container.querySelector('video'));
|
||||
|
||||
rerender(
|
||||
<MediaPreview
|
||||
asset={{
|
||||
...initial,
|
||||
browserMetadata: {
|
||||
source: 'browser',
|
||||
file: {
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
declaredMimeType: file.type,
|
||||
inferredMimeType: file.type,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
kind: 'video',
|
||||
durationSeconds: 8,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('video')).toBe(player);
|
||||
});
|
||||
|
||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateProxy = vi.fn();
|
||||
|
||||
@@ -16,6 +16,66 @@ import type { UserExportPreset } from '../../../src/presets';
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('QuickConvert', () => {
|
||||
it('keeps an uninspected source usable and requests stream detection with export', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onInspect = vi.fn();
|
||||
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||
const file = new File(['media'], 'lecture.mp4', { type: 'video/mp4' });
|
||||
const asset: ImportedMediaAsset = {
|
||||
id: 'asset-basic',
|
||||
file,
|
||||
objectUrl: 'blob:asset-basic',
|
||||
phase: 'idle',
|
||||
browserMetadata: {
|
||||
source: 'browser',
|
||||
file: {
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
declaredMimeType: file.type,
|
||||
inferredMimeType: file.type,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
kind: 'video',
|
||||
durationSeconds: 2_622.48,
|
||||
width: 640,
|
||||
height: 480,
|
||||
hasVideo: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<QuickConvert
|
||||
asset={asset}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
onInspect={onInspect}
|
||||
onExport={onExport}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
|
||||
expect(screen.getByText('Ready to play')).toBeVisible();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Inspect details now' })
|
||||
);
|
||||
expect(onInspect).toHaveBeenCalledOnce();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Inspect & convert' }));
|
||||
expect(onExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'convert',
|
||||
selectDetectedStreams: true,
|
||||
streamSelection: {
|
||||
video: [],
|
||||
audio: [],
|
||||
subtitles: [],
|
||||
attachments: [],
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('labels a submission honestly when another operation is active', () => {
|
||||
render(
|
||||
<QuickConvert
|
||||
@@ -138,6 +198,28 @@ describe('QuickConvert', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Opus after the reviewed core passed both regression modes', () => {
|
||||
const engineState: EngineState = {
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
...capabilities(['mp4', 'opus']),
|
||||
encoders: new Set(['libx264', 'aac', 'libopus']),
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<QuickConvert
|
||||
asset={createAsset('asset-a', 'source-a.mp4')}
|
||||
engineState={engineState}
|
||||
busy={false}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Opus' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('exposes a validated user preset and returns it unchanged on export', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||
|
||||
@@ -274,6 +274,7 @@ describe('StructuralOperations', () => {
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -328,6 +329,55 @@ describe('StructuralOperations', () => {
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'drop-all',
|
||||
crossfadeSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a bounded crossfade only after its filters are verified', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConcat = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={{
|
||||
...TIMELINE,
|
||||
clips: TIMELINE.clips.map((clip) => ({
|
||||
...clip,
|
||||
durationSeconds: 3,
|
||||
})),
|
||||
}}
|
||||
exportPresets={PRESETS}
|
||||
availability={capabilities('concat-normalized', 'concat-crossfade')}
|
||||
onConcat={onConcat}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Normalized re-encode/iu })
|
||||
);
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText('Normalized concat preset'),
|
||||
'video-ready'
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Reject subtitle input/iu })
|
||||
);
|
||||
await user.clear(screen.getByLabelText(/^Crossfade duration/iu));
|
||||
await user.type(screen.getByLabelText(/^Crossfade duration/iu), '0.5');
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Export normalized concat' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConcat).toHaveBeenCalledWith({
|
||||
operation: 'concat',
|
||||
mode: 'normalized',
|
||||
clipIds: ['clip-a', 'clip-b'],
|
||||
presetId: 'video-ready',
|
||||
missingAudioPolicy: 'insert-silence',
|
||||
subtitlePolicy: 'reject',
|
||||
crossfadeSeconds: 0.5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_JOB_TIMEOUT_MILLISECONDS,
|
||||
EngineManager,
|
||||
MAX_CONTACT_SHEETS_PER_ENGINE,
|
||||
MAX_EXECUTIONS_PER_ENGINE,
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MIN_PROBE_TIMEOUT_MILLISECONDS,
|
||||
MULTITHREAD_CODEC_THREAD_LIMIT,
|
||||
canReuseProbeCapabilities,
|
||||
engineJobTimeoutMilliseconds,
|
||||
prepareEngineArguments,
|
||||
probeTimeoutMilliseconds,
|
||||
shouldRefreshCoreBeforeProbe,
|
||||
shouldRecycleExecutionCore,
|
||||
} from '../../src/ffmpeg/EngineManager';
|
||||
|
||||
@@ -90,6 +96,73 @@ describe('engineJobTimeoutMilliseconds', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeTimeoutMilliseconds', () => {
|
||||
it('keeps small probes responsive, scales real media, and caps the watchdog', () => {
|
||||
expect(probeTimeoutMilliseconds(34_057)).toBe(
|
||||
MIN_PROBE_TIMEOUT_MILLISECONDS
|
||||
);
|
||||
expect(probeTimeoutMilliseconds(153_019_068)).toBe(72_966);
|
||||
expect(probeTimeoutMilliseconds(2 * 1024 * 1024 * 1024)).toBe(
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an already-cancelled probe before loading an engine', async () => {
|
||||
const factory = vi.fn(async () => {
|
||||
throw new Error('The engine factory must not be called.');
|
||||
});
|
||||
const manager = new EngineManager(factory);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
manager.probe(
|
||||
new File(['media'], 'cancelled.mp4', { type: 'video/mp4' }),
|
||||
undefined,
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'cancelled',
|
||||
message: 'The media probe was cancelled.',
|
||||
});
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firefox probe core refresh policy', () => {
|
||||
it('refreshes only affected Firefox-family runtimes', () => {
|
||||
expect(
|
||||
shouldRefreshCoreBeforeProbe(
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRefreshCoreBeforeProbe(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) FxiOS/142.0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRefreshCoreBeforeProbe(
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(shouldRefreshCoreBeforeProbe('')).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses cached capabilities only for the identical engine mode', () => {
|
||||
expect(canReuseProbeCapabilities('multithread', 'multithread')).toBe(true);
|
||||
expect(canReuseProbeCapabilities('single-thread', 'single-thread')).toBe(
|
||||
true
|
||||
);
|
||||
expect(canReuseProbeCapabilities('multithread', 'single-thread')).toBe(
|
||||
false
|
||||
);
|
||||
expect(canReuseProbeCapabilities('single-thread', 'multithread')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution core recycling policy', () => {
|
||||
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
|
||||
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
|
||||
|
||||
@@ -20,8 +20,12 @@ describe('FFmpeg runtime utilities', () => {
|
||||
'multithread',
|
||||
(path) => `https://tools.example/deep/${path}`
|
||||
);
|
||||
expect(mt.coreURL).toContain('/0.12.10/mt/ffmpeg-core.js');
|
||||
expect(mt.workerURL).toContain('/0.12.10/mt/ffmpeg-core.worker.js');
|
||||
expect(mt.coreURL).toContain(
|
||||
'/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js'
|
||||
);
|
||||
expect(mt.workerURL).toContain(
|
||||
'/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps experimental progress and removes invalid numbers', () => {
|
||||
|
||||
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', () => {
|
||||
it('formats and parses the portable sidecar form', () => {
|
||||
const digest = 'a'.repeat(64);
|
||||
const line = checksumLine(digest, 'av-tools-0.1.0.zip');
|
||||
expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`);
|
||||
const line = checksumLine(digest, 'av-tools-0.3.0.zip');
|
||||
expect(line).toBe(`${digest} av-tools-0.3.0.zip\n`);
|
||||
expect(parseChecksumLine(line)).toEqual({
|
||||
archiveName: 'av-tools-0.1.0.zip',
|
||||
archiveName: 'av-tools-0.3.0.zip',
|
||||
digest,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes and verifies a checksum sidecar', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
||||
const archive = path.join(directory, 'av-tools-0.3.0.zip');
|
||||
await writeFile(archive, 'deterministic bytes');
|
||||
const result = await writeChecksum(archive);
|
||||
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
|
||||
result.digest
|
||||
);
|
||||
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
|
||||
' av-tools-0.1.0.zip'
|
||||
' av-tools-0.3.0.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects checksum mismatches', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const archive = path.join(directory, 'av-tools-0.1.0.zip');
|
||||
const archive = path.join(directory, 'av-tools-0.3.0.zip');
|
||||
const sidecar = `${archive}.sha256`;
|
||||
await writeFile(archive, 'changed');
|
||||
await mkdir(path.dirname(sidecar), { recursive: true });
|
||||
@@ -207,7 +207,7 @@ describe('portal assembly smoke validation', () => {
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
|
||||
types { application/wasm wasm; }
|
||||
# vendor/ffmpeg/0.12.10
|
||||
"~^/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/(?:st|mt)/ffmpeg-core\\.(?:js|wasm)$" "public, max-age=31536000, immutable";
|
||||
`;
|
||||
|
||||
it('accepts the required isolation, CSP and MIME profile', () => {
|
||||
@@ -226,6 +226,15 @@ describe('portal assembly smoke validation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mistake a plain-semver cache rule for reviewed-build coverage', () => {
|
||||
const inspection = inspectPortalHeaders(
|
||||
isolatedHeaders.replace('0.12.10-reviewed-stack5m.1', '0.12.10')
|
||||
);
|
||||
expect(inspection.warnings).toContainEqual(
|
||||
expect.stringMatching(/reviewed FFmpeg build/u)
|
||||
);
|
||||
});
|
||||
|
||||
it('parses only explicit smoke overrides and paths', () => {
|
||||
const parsed = parsePortalArguments(
|
||||
[
|
||||
|
||||
52
tests/unit/source-inspection-registry.test.ts
Normal file
52
tests/unit/source-inspection-registry.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SourceInspectionRegistry } from '../../src/app/source-inspection-registry';
|
||||
|
||||
describe('SourceInspectionRegistry', () => {
|
||||
it('invalidates queued work synchronously and does not request cancellation', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const token = registry.register('queued');
|
||||
|
||||
expect(registry.remove('queued')).toBe(false);
|
||||
expect(registry.isCurrent(token)).toBe(false);
|
||||
expect(registry.activate(token)).toBe(false);
|
||||
expect(token.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('requests cancellation for the active asset and rejects its late result', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const token = registry.register('active');
|
||||
|
||||
expect(registry.activate(token)).toBe(true);
|
||||
expect(registry.remove('active')).toBe(true);
|
||||
expect(registry.isCurrent(token)).toBe(false);
|
||||
expect(token.signal.aborted).toBe(true);
|
||||
registry.finish(token);
|
||||
expect(registry.remove('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let a late generation overwrite a replacement with the same ID', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const stale = registry.register('reattached');
|
||||
expect(registry.activate(stale)).toBe(true);
|
||||
|
||||
const replacement = registry.register('reattached');
|
||||
expect(registry.isCurrent(stale)).toBe(false);
|
||||
expect(registry.isCurrent(replacement)).toBe(true);
|
||||
expect(stale.signal.aborted).toBe(true);
|
||||
expect(replacement.signal.aborted).toBe(false);
|
||||
registry.finish(stale);
|
||||
expect(registry.activate(replacement)).toBe(true);
|
||||
});
|
||||
|
||||
it('invalidates all queued work and identifies an active probe on clear', () => {
|
||||
const registry = new SourceInspectionRegistry();
|
||||
const active = registry.register('active');
|
||||
const queued = registry.register('queued');
|
||||
registry.activate(active);
|
||||
|
||||
expect(registry.clear()).toBe(true);
|
||||
expect(registry.isCurrent(active)).toBe(false);
|
||||
expect(registry.isCurrent(queued)).toBe(false);
|
||||
});
|
||||
});
|
||||
BIN
vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip
vendored
Normal file
BIN
vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip
vendored
Normal file
Binary file not shown.
@@ -24,6 +24,11 @@ export default defineConfig({
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
restoreMocks: true,
|
||||
exclude: ['tests/browser/**', 'node_modules/**', 'dist/**'],
|
||||
exclude: [
|
||||
'tests/browser/**',
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'.core-build/**',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user