3 Commits
v0.2.0 ... main

47 changed files with 2295 additions and 536 deletions

2
.npmrc
View File

@@ -1 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/

View File

@@ -3,6 +3,34 @@
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

View File

@@ -8,8 +8,8 @@ telemetry, runtime CDN, remote-media fetch or third-party script.
It is also a Toolbox SDK application. The same static build runs directly or
under Toolbox Portal with shared shell, theme, help and app switching.
> **Release status:** 0.2.0 is licensed GPL-3.0-or-later. Its canonical
> release consists of the exact reviewed `v0.2.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 0.2 release 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,
@@ -167,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
@@ -225,10 +225,12 @@ profile.
`AV_TOOLS_LARGE_MEDIA=/absolute/video.mp4 npm run evidence:large-preview`
performs the opt-in large-file regression: the native video element must mount
and advance before bounded inspection finishes, remain the same element after
the authoritative probe completes and produce no browser error.
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-26 completed **313 passing
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
@@ -241,9 +243,11 @@ 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

View File

@@ -1,17 +1,17 @@
# Source and reproducibility record
This file identifies the source corresponding to av-tools 0.2.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.2.0`
- Release source identity: immutable annotated tag `v0.2.0`
(`refs/tags/v0.2.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.2.0)" = tag
git rev-parse v0.2.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.2.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

View File

@@ -1,6 +1,6 @@
# Third-party notices
av-tools 0.2.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
@@ -11,8 +11,8 @@ receives licence files shipped by installed runtime npm packages under
| 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 |
| `@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 |
@@ -73,7 +73,7 @@ shipped in the static ZIP:
| Package | Exact version | Licence | Role |
| ----------------------------- | ------------- | ---------- | ------------------------------------- |
| `@add-ideas/toolbox-testkit` | 0.2.2 | Apache-2.0 | manifest and nested-path test helpers |
| `@add-ideas/toolbox-testkit` | 0.2.3 | Apache-2.0 | manifest and nested-path test helpers |
| `@eslint/js` | 10.0.1 | MIT | lint rules |
| `@playwright/test` | 1.61.1 | Apache-2.0 | browser tests |
| `@testing-library/jest-dom` | 6.9.1 | MIT | DOM assertions |

View File

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

View File

@@ -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.2.0 release 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

View File

@@ -1,6 +1,6 @@
# Known limitations
This is the conservative 0.2.0 release 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
@@ -45,6 +45,9 @@ 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.
- The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe
@@ -79,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.2.0` source and FFmpeg Corresponding Source bundle
ZIP/checksum, exact `v0.3.0` source and FFmpeg Corresponding Source bundle
together; development-only smoke artifacts must not be distributed.
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
loading and one two-size ST/MT performance baseline. Firefox, Safari, mobile,
exhaustive MT feature parity and repeated performance measurements remain
unrecorded. Live Portal container/header verification is a deployment gate.
loading and one two-size ST/MT performance baseline. Firefox covers the
opt-in large-file browser-metadata/playback-to-authoritative-probe
regression. Broad Firefox operation parity, Safari, mobile, exhaustive MT
feature parity and repeated performance measurements remain unrecorded. Live
Portal container/header verification is a deployment gate.
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.

View File

@@ -80,11 +80,15 @@ frames meet the threshold. The documented ffmpeg.wasm input ceiling is 2 GiB,
but real limits can be substantially lower on mobile or memory-constrained
browsers.
Source inspection has a bounded, size-aware watchdog: 30 seconds for small
files, a two-MiB-per-second scan budget for larger files, and a five-minute
ceiling. Recognized local audio/video can begin browser playback from its
temporary object URL while that worker inspection continues; completed probe
metadata remains authoritative for editing and export.
Import does not start FFmpeg. Recognized local audio/video can begin browser
playback from its temporary object URL while a native media element reads
lightweight duration/dimensions. Detailed FFprobe inspection starts only when
requested or required for an export and has a bounded, size-aware watchdog:
30 seconds for small files, a two-MiB-per-second budget for larger files, and a
five-minute ceiling. A detailed-inspection timeout is nonfatal; browser
playback and basic file information remain available. Completed probe metadata
remains authoritative for stream mapping, remux safety, chapters, tags,
subtitles/attachments and export validation.
Lowering resolution, duration, frame rate, passes and output count reduces
work. MT can be faster but consumes more memory/CPU and requires isolation; ST

View File

@@ -9,9 +9,9 @@ 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` 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 |
| 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

View File

@@ -11,7 +11,7 @@ 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.2.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 reviewed core profiles and linked libraries;
@@ -57,8 +57,8 @@ 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.2.0.zip
release/av-tools-0.2.0.zip.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
```
@@ -68,7 +68,7 @@ Verify independently:
```sh
node scripts/checksum-release.mjs
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
unzip -l release/av-tools-0.2.0.zip
unzip -l release/av-tools-0.3.0.zip
tar -tJf release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz
```
@@ -96,7 +96,7 @@ exact `HEAD` and clones that revision into a temporary directory:
```sh
npm run portal:smoke -- \
--artifact release/av-tools-0.2.0.zip \
--artifact release/av-tools-0.3.0.zip \
--portal-root ../toolbox-portal
```
@@ -123,7 +123,7 @@ for `v0.6.0` and must not suppress review of new gaps. See
## 5. Publish only reviewed bytes
From a clean `v0.2.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

32
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "av-tools",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "av-tools",
"version": "0.2.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",
"@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",
@@ -18,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",
@@ -44,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",
@@ -63,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"

View File

@@ -1,17 +1,17 @@
{
"name": "av-tools",
"private": true,
"version": "0.2.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": {
@@ -36,7 +36,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:browser": "playwright test",
"evidence:large-preview": "playwright test tests/browser/large-media-preview.spec.ts --project=chromium --workers=1",
"evidence: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",
@@ -51,8 +51,8 @@
"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",
"@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",
@@ -60,7 +60,7 @@
"react-dom": "19.2.7"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.2",
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,9 +1,9 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.av-tools",
"name": "Audio & Video Tools",
"version": "0.2.0",
"version": "0.3.0",
"description": "Convert and lightly edit audio and video locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -44,14 +44,14 @@
"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": [

View File

@@ -23,7 +23,7 @@ if (source.version !== packageJson.version) {
}
if (
source.source?.repository !== 'https://git.add-ideas.de/zemion/av-tools' ||
source.source?.repository !== 'https://git.add-ideas.de/lotobo/av-tools' ||
source.source?.license !== 'GPL-3.0-or-later'
) {
throw new Error(

View File

@@ -23,9 +23,9 @@ const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const maximumEntryBytes = 256 * 1024 * 1024;
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
const applicationLicenseSpdx = 'GPL-3.0-or-later';
const canonicalRepository = 'https://git.add-ideas.de/zemion/av-tools';
const canonicalRepository = 'https://git.add-ideas.de/lotobo/av-tools';
const canonicalPackageRepository =
'git+https://git.add-ideas.de/zemion/av-tools.git';
'git+https://git.add-ideas.de/lotobo/av-tools.git';
const requiredNativeLegalFiles = [
'LICENSES/native/FFmpeg-LICENSE.md',
'LICENSES/native/FreeType-LICENSE.txt',

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -60,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>
@@ -71,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>
@@ -196,7 +196,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
</aside>
<footer>
<a
href="https://git.add-ideas.de/zemion/av-tools"
href="https://git.add-ideas.de/lotobo/av-tools"
target="_blank"
rel="noreferrer"
>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import { assetHasAudio, assetHasVideo } from '../app/application-state';
import {
assessBrowserPlayback,
assessGeneratedResultPreview,
@@ -60,18 +61,15 @@ export function MediaPreview({
: undefined;
const hasVideo = result
? result.mimeType.startsWith('video/')
: asset?.probe
? asset.probe.streams.some(
(stream) =>
stream.type === 'video' && stream.disposition.attached_pic !== true
)
: assetHasVideo(asset) !== undefined
? assetHasVideo(asset) === true
: declaredSourceMimeType
? isVideoMimeType(declaredSourceMimeType)
: false;
const hasAudio = result
? result.mimeType.startsWith('audio/')
: asset?.probe
? asset.probe.streams.some((stream) => stream.type === 'audio')
: assetHasAudio(asset) !== undefined
? assetHasAudio(asset) === true
: declaredSourceMimeType
? isAudioMimeType(declaredSourceMimeType)
: false;
@@ -207,8 +205,10 @@ export function MediaPreview({
: `Generated result · ${result.name}`
: (playback?.reason ??
(asset?.phase === 'probing'
? 'Local source preview · Media inspection continues in the background.'
: 'Local source preview'))}
? 'Local source preview · Detailed inspection runs separately.'
: asset?.phase === 'error'
? 'Local source preview · Detailed inspection unavailable.'
: 'Local source preview · Basic browser metadata'))}
</span>
{needsProxy && !proxySettings ? (
<button type="button" className="text-button" onClick={onCreateProxy}>

View File

@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { ImportedMediaAsset } from '../app/application-state';
import { assetDurationSeconds, assetHasVideo } from '../app/application-state';
import type { EngineState } from '../ffmpeg/ffmpeg.types';
import type { MediaStreamProbe } from '../media';
import type { StreamSelection } from '../commands/command-utils';
import {
validateRemuxCompatibility,
@@ -18,8 +18,12 @@ import {
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;
@@ -28,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 {
@@ -36,6 +45,7 @@ export interface QuickConvertProps {
busy: boolean;
queueing?: boolean;
presets?: readonly ExportPreset[];
onInspect?: () => void;
onExport: (configuration: QuickExportConfiguration) => void;
}
@@ -45,6 +55,7 @@ export function QuickConvert({
busy,
queueing = false,
presets = BUILT_IN_PRESETS,
onInspect,
onExport,
}: QuickConvertProps) {
const [operation, setOperation] = useState<QuickOperation>('convert');
@@ -59,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(
() =>
@@ -143,7 +155,9 @@ export function QuickConvert({
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' ||
@@ -159,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'
);
@@ -179,9 +194,9 @@ 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'
@@ -228,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>
) : (
@@ -340,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);
@@ -410,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>
@@ -451,6 +482,13 @@ 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" />
@@ -474,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"
@@ -488,6 +530,9 @@ export function QuickConvert({
streamSelection: effectiveSelection,
removeMetadata,
removeChapters,
...(!hasDetailedInformation
? { selectDetectedStreams: true as const }
: {}),
})
}
>
@@ -497,47 +542,14 @@ export function QuickConvert({
: queueing
? 'Add to queue'
: operation === 'remux'
? 'Remux'
: 'Convert'}
? hasDetailedInformation
? 'Remux'
: 'Inspect & remux'
: hasDetailedInformation
? 'Convert'
: 'Inspect & convert'}
</button>
</footer>
</section>
);
}
function quickStreamUnavailableReason(
stream: MediaStreamProbe,
operation: QuickOperation,
preset: ExportPreset | undefined
): string | undefined {
if (stream.type === 'unknown') {
return 'unknown stream kinds cannot be mapped safely.';
}
if (operation === 'remux') {
return undefined;
}
if (!preset) {
return 'choose an export preset first.';
}
if (stream.type === 'video' && !preset.video) {
return 'the selected preset creates audio only.';
}
if (stream.type === 'audio' && !preset.audio) {
return 'the selected preset creates video or images without audio.';
}
if (stream.type === 'subtitle') {
if (preset.subtitlePolicy === 'none') {
return 'the selected preset explicitly omits subtitles.';
}
if (preset.subtitlePolicy === 'burn-in') {
return 'burn-in requires an explicitly attached subtitle source.';
}
}
if (
(stream.type === 'attachment' || stream.type === 'data') &&
preset.container !== 'matroska'
) {
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
}
return undefined;
}

View File

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

View File

@@ -23,7 +23,11 @@ import type {
import { createLoadConfig } from './load-config';
import { LogBuffer } from './log-buffer';
import { normalizeProgress } from './progress-parser';
import { describeMountedInputAccess, mountJobFiles } from './virtual-fs';
import {
describeMountedInputAccess,
mountJobFiles,
type MountedJobFiles,
} from './virtual-fs';
import {
parseFFmpegProgress,
progressUpdateFromFFmpeg,
@@ -71,6 +75,25 @@ export function probeTimeoutMilliseconds(inputBytes: number): number {
);
}
/**
* 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();
@@ -98,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[] = [];
@@ -108,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);
@@ -174,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) {
@@ -203,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.'
@@ -290,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({
@@ -317,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;
@@ -329,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) => {
@@ -358,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 = [];
@@ -561,22 +636,79 @@ export class EngineManager {
);
}
const id = crypto.randomUUID();
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
if (signal?.aborted) {
await mounted.cleanup();
throw new EngineError('cancelled', 'The media probe was cancelled.');
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 = [];
@@ -584,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,
@@ -605,7 +737,7 @@ export class EngineManager {
};
signal?.addEventListener('abort', cancelOnAbort, { once: true });
try {
const exitCode = await engine.ffprobe([
const exitCode = await refreshedEngine.ffprobe([
'-v',
'error',
'-show_format',
@@ -620,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(
@@ -691,16 +823,19 @@ export class EngineManager {
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 {
@@ -711,7 +846,7 @@ export class EngineManager {
if (capabilities) {
this.#setState({
status: 'ready',
mode,
mode: refreshedMode,
capabilities,
warning: this.#warning,
});
@@ -721,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;
@@ -760,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,
@@ -789,7 +992,14 @@ export class EngineManager {
});
this.#terminateEngine();
this.#setState({ status: 'recovering', previousMode });
await this.initialize();
if (operationReserved) {
this.#loadPromise = this.#initializeInternal().finally(() => {
this.#loadPromise = null;
});
await this.#loadPromise;
} else {
await this.initialize();
}
}
#setState(state: EngineState): void {

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.av-tools",
"name": "Audio & Video Tools",
"version": "0.2.0",
"version": "0.3.0",
"description": "Convert and lightly edit audio and video locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -37,14 +37,14 @@
"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": [

View File

@@ -1,4 +1,4 @@
export const APP_VERSION = '0.2.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';

View File

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

View File

@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
import { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
import { expect } from '@playwright/test';
export const REPOSITORY_ROOT = resolve(
dirname(fileURLToPath(import.meta.url)),
@@ -19,6 +20,20 @@ export const GENERATED_FIXTURES = join(
);
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
export async function inspectMediaDetails(
page: Page,
fileName: string
): Promise<void> {
const card = page.locator('.media-card').filter({ hasText: fileName });
const inspect = page.getByRole('button', {
name: `Inspect details for ${fileName}`,
});
if ((await inspect.count()) > 0) await inspect.click();
await expect(card.locator('.phase--ready')).toBeVisible({
timeout: 120_000,
});
}
const NESTED_PREFIX = '/deep/nested/av/';
const SECURITY_HEADERS = {
'Cross-Origin-Embedder-Policy': 'require-corp',
@@ -112,7 +127,7 @@ export async function startNestedStaticServer(): Promise<NestedStaticServer> {
};
const catalog = {
$schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
schemaVersion: 1,
id: 'de.add-ideas.browser-test-toolbox',
name: 'Browser Test Toolbox',

View File

@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
import {
createCancellationFixture,
GENERATED_FIXTURES,
inspectMediaDetails,
observeRuntime,
PRIMARY_FIXTURE,
type TemporaryMediaFixture,
@@ -99,8 +100,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 page.getByRole('button', { name: 'Convert', exact: true }).click();
@@ -184,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(
@@ -275,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')
@@ -329,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 }
);
@@ -357,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();
@@ -388,10 +394,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
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--queued')).toBeVisible();
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();
@@ -401,8 +412,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
).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(
'Ready',
'Details ready',
{ timeout: 120_000 }
);
await expect(page.getByRole('alert')).toHaveCount(0);

View File

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

View File

@@ -6,7 +6,7 @@ import { expect, test } from '@playwright/test';
const mediaPath = process.env.AV_TOOLS_LARGE_MEDIA;
test('plays a recognized large local video while inspection continues', async ({
test('plays a recognized large local video before detailed inspection', async ({
page,
}) => {
test.skip(
@@ -27,7 +27,15 @@ test('plays a recognized large local video while inspection continues', async ({
await expect(preview).toBeVisible({ timeout: 15_000 });
const previewVisibleMilliseconds = Date.now() - startedAt;
expect(previewVisibleMilliseconds).toBeLessThan(15_000);
await expect(card.locator('.phase--probing')).toBeVisible();
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();
@@ -50,9 +58,11 @@ test('plays a recognized large local video while inspection continues', async ({
)
.toBeGreaterThan(0);
await expect(card.locator('.phase--ready')).toBeVisible({
timeout: 330_000,
});
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

View File

@@ -31,6 +31,9 @@ async function importMedia(
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
for (const name of names) {
const card = page.locator('.media-card').filter({ hasText: name });
await page
.getByRole('button', { name: `Inspect details for ${name}` })
.click();
await expect(card.locator('.phase--ready')).toBeVisible({
timeout: 120_000,
});
@@ -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')
@@ -1055,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')

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,66 @@ import type { UserExportPreset } from '../../../src/presets';
afterEach(cleanup);
describe('QuickConvert', () => {
it('keeps an uninspected source usable and requests stream detection with export', async () => {
const user = userEvent.setup();
const onInspect = vi.fn();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
const file = new File(['media'], 'lecture.mp4', { type: 'video/mp4' });
const asset: ImportedMediaAsset = {
id: 'asset-basic',
file,
objectUrl: 'blob:asset-basic',
phase: 'idle',
browserMetadata: {
source: 'browser',
file: {
name: file.name,
sizeBytes: file.size,
declaredMimeType: file.type,
inferredMimeType: file.type,
lastModified: file.lastModified,
},
kind: 'video',
durationSeconds: 2_622.48,
width: 640,
height: 480,
hasVideo: true,
},
};
render(
<QuickConvert
asset={asset}
engineState={readyEngine()}
busy={false}
onInspect={onInspect}
onExport={onExport}
/>
);
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
expect(screen.getByText('Ready to play')).toBeVisible();
await user.click(
screen.getByRole('button', { name: 'Inspect details now' })
);
expect(onInspect).toHaveBeenCalledOnce();
await user.click(screen.getByRole('button', { name: 'Inspect & convert' }));
expect(onExport).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'convert',
selectDetectedStreams: true,
streamSelection: {
video: [],
audio: [],
subtitles: [],
attachments: [],
data: [],
},
})
);
});
it('labels a submission honestly when another operation is active', () => {
render(
<QuickConvert

View File

@@ -7,9 +7,11 @@ import {
MAX_PROBE_TIMEOUT_MILLISECONDS,
MIN_PROBE_TIMEOUT_MILLISECONDS,
MULTITHREAD_CODEC_THREAD_LIMIT,
canReuseProbeCapabilities,
engineJobTimeoutMilliseconds,
prepareEngineArguments,
probeTimeoutMilliseconds,
shouldRefreshCoreBeforeProbe,
shouldRecycleExecutionCore,
} from '../../src/ffmpeg/EngineManager';
@@ -127,6 +129,40 @@ describe('probeTimeoutMilliseconds', () => {
});
});
describe('Firefox probe core refresh policy', () => {
it('refreshes only affected Firefox-family runtimes', () => {
expect(
shouldRefreshCoreBeforeProbe(
'Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0'
)
).toBe(true);
expect(
shouldRefreshCoreBeforeProbe(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) FxiOS/142.0'
)
).toBe(true);
expect(
shouldRefreshCoreBeforeProbe(
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36'
)
).toBe(false);
expect(shouldRefreshCoreBeforeProbe('')).toBe(false);
});
it('reuses cached capabilities only for the identical engine mode', () => {
expect(canReuseProbeCapabilities('multithread', 'multithread')).toBe(true);
expect(canReuseProbeCapabilities('single-thread', 'single-thread')).toBe(
true
);
expect(canReuseProbeCapabilities('multithread', 'single-thread')).toBe(
false
);
expect(canReuseProbeCapabilities('single-thread', 'multithread')).toBe(
false
);
});
});
describe('execution core recycling policy', () => {
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(

View File

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

View File

@@ -165,30 +165,30 @@ describe('licence release gate', () => {
describe('release checksum', () => {
it('formats and parses the portable sidecar form', () => {
const digest = 'a'.repeat(64);
const line = checksumLine(digest, 'av-tools-0.2.0.zip');
expect(line).toBe(`${digest} av-tools-0.2.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.2.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.2.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.2.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.2.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 });