Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597ea044b6 | |||
| f2c0fc12d2 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -3,6 +3,23 @@
|
|||||||
All notable changes to av-tools are documented here. The project follows
|
All notable changes to av-tools are documented here. The project follows
|
||||||
semantic versioning once a first release is legally publishable.
|
semantic versioning once a first release is legally publishable.
|
||||||
|
|
||||||
|
## 0.3.0 — 2026-07-27
|
||||||
|
|
||||||
|
- Make imported media and browser-native duration/dimensions available without
|
||||||
|
loading FFmpeg, so recognized files can start playback immediately.
|
||||||
|
- Move authoritative codec, stream, chapter and tag inspection to explicit or
|
||||||
|
operation-driven demand; keep playback usable after inspection failure and
|
||||||
|
expose bounded retry controls.
|
||||||
|
- Inspect automatically when Quick Convert, preview proxies, timeline renders
|
||||||
|
or advanced tools require authoritative details, while preserving
|
||||||
|
deterministic multi-file timeline order.
|
||||||
|
- Recover Firefox probes with a fresh same-mode core, safe MT-to-ST fallback
|
||||||
|
and operation reservations that prevent concurrent mounts or preference
|
||||||
|
changes.
|
||||||
|
- Add Chromium and Firefox evidence proving the supplied 153 MB, 43-minute
|
||||||
|
lecture plays before detailed inspection and retains the same media element
|
||||||
|
afterward.
|
||||||
|
|
||||||
## 0.2.1 — 2026-07-27
|
## 0.2.1 — 2026-07-27
|
||||||
|
|
||||||
- Move the canonical source, manifest schema links, and package registry from
|
- Move the canonical source, manifest schema links, and package registry from
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -8,8 +8,8 @@ telemetry, runtime CDN, remote-media fetch or third-party script.
|
|||||||
It is also a Toolbox SDK application. The same static build runs directly or
|
It is also a Toolbox SDK application. The same static build runs directly or
|
||||||
under Toolbox Portal with shared shell, theme, help and app switching.
|
under Toolbox Portal with shared shell, theme, help and app switching.
|
||||||
|
|
||||||
> **Release status:** 0.2.1 is licensed GPL-3.0-or-later. Its canonical
|
> **Release status:** 0.3.0 is licensed GPL-3.0-or-later. Its canonical
|
||||||
> release consists of the exact reviewed `v0.2.1` source, production
|
> release consists of the exact reviewed `v0.3.0` source, production
|
||||||
> ZIP/checksum and Corresponding Source bundle published together. See
|
> ZIP/checksum and Corresponding Source bundle published together. See
|
||||||
> [Licensing](#licensing).
|
> [Licensing](#licensing).
|
||||||
|
|
||||||
@@ -17,13 +17,13 @@ under Toolbox Portal with shared shell, theme, help and app switching.
|
|||||||
|
|
||||||
Two workflows keep the product intentionally smaller than a nonlinear editor:
|
Two workflows keep the product intentionally smaller than a nonlinear editor:
|
||||||
|
|
||||||
- **Quick Convert:** drop/import media, inspect streams, choose a
|
- **Quick Convert:** drop/import media, play it and read browser-native file
|
||||||
capability-checked preset/streams, convert, preview when the browser can, and
|
information immediately, then inspect streams on demand when an export needs
|
||||||
download.
|
authoritative details.
|
||||||
- **Edit:** a media bin and sequential non-overlapping clip timeline with
|
- **Edit:** a media bin and sequential non-overlapping clip timeline with
|
||||||
non-destructive settings, one export at a time.
|
non-destructive settings, one export at a time.
|
||||||
|
|
||||||
The 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,
|
fast/accurate trim, split, compatible/normalized concatenate, reorder, crop,
|
||||||
resize, frame-rate change, audio extraction/stream removal, waveform,
|
resize, frame-rate change, audio extraction/stream removal, waveform,
|
||||||
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
|
peak/two-pass loudness normalization, gain, audio/video fades, metadata,
|
||||||
@@ -225,10 +225,12 @@ profile.
|
|||||||
|
|
||||||
`AV_TOOLS_LARGE_MEDIA=/absolute/video.mp4 npm run evidence:large-preview`
|
`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
|
performs the opt-in large-file regression: the native video element must mount
|
||||||
and advance before bounded inspection finishes, remain the same element after
|
and advance while detailed inspection remains idle, browser-native
|
||||||
the authoritative probe completes and produce no browser error.
|
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
|
tests and two conditional skips**. It includes broad structured-command
|
||||||
execution against native FFmpeg 7.1.3; the skipped native subtitle burn-in and
|
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
|
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
|
and completes a real non-empty, re-probeable H.264/AAC conversion; the same
|
||||||
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
|
path is benchmarked with 2-second 160×90 and 6-second 640×360 inputs. Nested
|
||||||
static/Toolbox-context loading is also covered. This evidence is
|
static/Toolbox-context loading is also covered. This evidence is
|
||||||
Chromium-specific: Firefox, Safari, mobile and exhaustive MT operation parity
|
Chromium-specific. Firefox now covers the opt-in large-file
|
||||||
remain unrecorded. The exact one-run performance record and its limitations are
|
play-before-inspection regression and its explicit authoritative-probe
|
||||||
in [performance observations](docs/PERFORMANCE.md).
|
transition; broad Firefox operation parity, Safari, mobile and exhaustive MT
|
||||||
|
operation parity remain unrecorded. The exact one-run performance record and
|
||||||
|
its limitations are in [performance observations](docs/PERFORMANCE.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
14
SOURCE.md
14
SOURCE.md
@@ -1,6 +1,6 @@
|
|||||||
# Source and reproducibility record
|
# Source and reproducibility record
|
||||||
|
|
||||||
This file identifies the source corresponding to av-tools 0.2.1. It is not a
|
This file identifies the source corresponding to av-tools 0.3.0. It is not a
|
||||||
substitute for satisfying a component's licence or for delivering complete
|
substitute for satisfying a component's licence or for delivering complete
|
||||||
Corresponding Source with an object-code release.
|
Corresponding Source with an object-code release.
|
||||||
|
|
||||||
@@ -9,9 +9,9 @@ Corresponding Source with an object-code release.
|
|||||||
- Canonical repository:
|
- Canonical repository:
|
||||||
`git@git.add-ideas.de:lotobo/av-tools.git`
|
`git@git.add-ideas.de:lotobo/av-tools.git`
|
||||||
- Browser source: `https://git.add-ideas.de/lotobo/av-tools`
|
- Browser source: `https://git.add-ideas.de/lotobo/av-tools`
|
||||||
- Version: `0.2.1`
|
- Version: `0.3.0`
|
||||||
- Release source identity: immutable annotated tag `v0.2.1`
|
- Release source identity: immutable annotated tag `v0.3.0`
|
||||||
(`refs/tags/v0.2.1`) in the canonical repository.
|
(`refs/tags/v0.3.0`) in the canonical repository.
|
||||||
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
|
- Application licence: `GPL-3.0-or-later`; full GPL version 3 terms in
|
||||||
`LICENSE`.
|
`LICENSE`.
|
||||||
- Copyright: Copyright © 2026 Albrecht Degering.
|
- Copyright: Copyright © 2026 Albrecht Degering.
|
||||||
@@ -22,10 +22,10 @@ the tag exists, is annotated, points to the reviewed release commit and is
|
|||||||
available without authentication:
|
available without authentication:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
test "$(git cat-file -t v0.2.1)" = tag
|
test "$(git cat-file -t v0.3.0)" = tag
|
||||||
git rev-parse v0.2.1^{commit}
|
git rev-parse v0.3.0^{commit}
|
||||||
git ls-remote --tags \
|
git ls-remote --tags \
|
||||||
https://git.add-ideas.de/lotobo/av-tools.git refs/tags/v0.2.1
|
https://git.add-ideas.de/lotobo/av-tools.git refs/tags/v0.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
The Toolbox manifest names the canonical repository and SPDX licence. Publish
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
av-tools 0.2.1 is Copyright © 2026 Albrecht Degering and licensed
|
av-tools 0.3.0 is Copyright © 2026 Albrecht Degering and licensed
|
||||||
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
GPL-3.0-or-later; see the root `LICENSE`. This inventory describes third-party
|
||||||
inputs. It is informational, not legal advice, and does not replace their
|
inputs. It is informational, not legal advice, and does not replace their
|
||||||
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
terms. Files under `LICENSES/` reproduce available terms. A packaged ZIP also
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ Browser storage boundary
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `src/toolbox/` owns the one typed manifest and suite integration.
|
- `src/toolbox/` owns the one typed manifest and suite integration.
|
||||||
- `src/media/` normalizes ffprobe JSON, duration/timecode, stream selection,
|
- `src/media/` reads lightweight browser-native metadata, normalizes
|
||||||
playback MIME decisions and safe names.
|
authoritative ffprobe JSON, and owns duration/timecode, stream-selection,
|
||||||
|
playback-MIME and safe-name decisions. Browser metadata and probe data are
|
||||||
|
deliberately separate models.
|
||||||
- `src/project/` owns the versioned, non-destructive project document and
|
- `src/project/` owns the versioned, non-destructive project document and
|
||||||
migrations. A source is represented by identity/metadata and must be
|
migrations. A source is represented by identity/metadata and must be
|
||||||
reattached after load; the current application does not persist source bytes.
|
reattached after load; the current application does not persist source bytes.
|
||||||
@@ -51,20 +53,35 @@ raw command field.
|
|||||||
|
|
||||||
## FFmpeg lifecycle
|
## FFmpeg lifecycle
|
||||||
|
|
||||||
1. Inspect secure-context, worker, `crossOriginIsolated` and
|
1. Import creates an object URL immediately. A detached native media element
|
||||||
|
reads duration/dimensions where supported; playback and file identity do not
|
||||||
|
initialize FFmpeg.
|
||||||
|
2. Detailed inspection remains idle until the user requests it or an operation
|
||||||
|
requires codecs, complete streams, chapters, tags or dispositions.
|
||||||
|
3. Inspect secure-context, worker, `crossOriginIsolated` and
|
||||||
`SharedArrayBuffer` availability.
|
`SharedArrayBuffer` availability.
|
||||||
2. Select MT only when all MT prerequisites are true (or ST when explicitly
|
4. Select MT only when all MT prerequisites are true (or ST when explicitly
|
||||||
selected). A failed MT load is terminated and retried once with ST.
|
selected). A failed MT load is terminated and retried once with ST.
|
||||||
3. Load wrapper/core assets via base-relative, same-origin URLs.
|
5. Load wrapper/core assets via base-relative, same-origin URLs.
|
||||||
4. Query version/build configuration/formats/codecs/encoders/filters
|
6. Query version/build configuration/formats/codecs/encoders/filters
|
||||||
sequentially and publish immutable capability state.
|
sequentially and publish immutable capability state.
|
||||||
5. For one job, validate resource estimates and structured settings, allocate
|
7. For one job, validate resource estimates and structured settings, allocate
|
||||||
a generated directory, mount/write inputs, run a generated argument array,
|
a generated directory, mount/write inputs, run a generated argument array,
|
||||||
read only declared outputs, and re-probe applicable results.
|
read only declared outputs, and re-probe applicable results.
|
||||||
6. Return result bytes and an export report, then unmount/delete virtual files.
|
8. Return result bytes and an export report, then unmount/delete virtual files.
|
||||||
7. Retain the idle engine for the next sequential job; terminate it on mode
|
9. Retain the idle engine for the next sequential job; terminate it on mode
|
||||||
changes, unrecoverable errors or cancellation.
|
changes, unrecoverable errors or cancellation.
|
||||||
|
|
||||||
|
A detailed-probe failure changes only the inspection state. The File, object
|
||||||
|
URL, browser metadata and provisional timeline clip remain available, and the
|
||||||
|
user can retry. An authoritative probe supersedes browser duration/dimensions
|
||||||
|
and reconciles an untouched provisional full-source clip.
|
||||||
|
|
||||||
|
The reviewed Emscripten runtime can stall when Firefox probes on a core that
|
||||||
|
has retained prior application state. Before an explicit Firefox probe, the
|
||||||
|
manager recreates the identical ST/MT core and reuses only that mode's already
|
||||||
|
validated capability inventory. Chromium retains the normal warm-core path.
|
||||||
|
|
||||||
The FFmpeg instance is a single process. `maxConcurrentFFmpegJobs` is one and
|
The FFmpeg instance is a single process. `maxConcurrentFFmpegJobs` is one and
|
||||||
no probe/export operations overlap on it. The application-facing scheduler is
|
no probe/export operations overlap on it. The application-facing scheduler is
|
||||||
also bounded: it retains at most eight scheduled operations in memory,
|
also bounded: it retains at most eight scheduled operations in memory,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ extensions or this document. The app queries muxers, demuxers, decoders,
|
|||||||
encoders and filters; a preset is disabled with missing components listed.
|
encoders and filters; a preset is disabled with missing components listed.
|
||||||
Browser playback is a separate capability checked with MIME/media APIs.
|
Browser playback is a separate capability checked with MIME/media APIs.
|
||||||
|
|
||||||
## 0.2.1 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
|
All 15 files are generated and re-probed with native FFmpeg 7.1.3 for test
|
||||||
preparation and pass the checksum/probe verifier. That proves fixture
|
preparation and pass the checksum/probe verifier. That proves fixture
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Known limitations
|
# Known limitations
|
||||||
|
|
||||||
This is the conservative 0.2.1 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.
|
FFmpeg output does not remove these product/browser/resource limits.
|
||||||
|
|
||||||
## Functional
|
## Functional
|
||||||
@@ -45,6 +45,9 @@ FFmpeg output does not remove these product/browser/resource limits.
|
|||||||
presets from runtime formats/codecs/encoders/filters.
|
presets from runtime formats/codecs/encoders/filters.
|
||||||
- FFmpeg-readable output may be download-only because browser media elements
|
- FFmpeg-readable output may be download-only because browser media elements
|
||||||
support fewer container/codec combinations.
|
support fewer container/codec combinations.
|
||||||
|
- Browser-native basic information intentionally omits codecs, complete stream
|
||||||
|
lists, chapters, tags, subtitles, attachments and dispositions. Features
|
||||||
|
that need those details request an authoritative local FFprobe pass.
|
||||||
- Browser support, codec policy and memory differ across Chromium, Firefox and
|
- Browser support, codec policy and memory differ across Chromium, Firefox and
|
||||||
Safari and across desktop/mobile versions.
|
Safari and across desktop/mobile versions.
|
||||||
- The reviewed 0.12.10 core profile passes the non-empty Opus encode/re-probe
|
- 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.
|
verification against the live container.
|
||||||
- The application is GPL-3.0-or-later and its native legal files and pinned
|
- The application is GPL-3.0-or-later and its native legal files and pinned
|
||||||
source identities are included in the release record. Publish the production
|
source identities are included in the release record. Publish the production
|
||||||
ZIP/checksum, exact `v0.2.1` source and FFmpeg Corresponding Source bundle
|
ZIP/checksum, exact `v0.3.0` source and FFmpeg Corresponding Source bundle
|
||||||
together; development-only smoke artifacts must not be distributed.
|
together; development-only smoke artifacts must not be distributed.
|
||||||
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
|
- Chromium covers a broad real pinned-core ST operation matrix, a real MT
|
||||||
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
|
H.264/AAC conversion, MT-to-ST fallback, nested static/Toolbox-context
|
||||||
loading and one two-size ST/MT performance baseline. Firefox, Safari, mobile,
|
loading and one two-size ST/MT performance baseline. Firefox covers the
|
||||||
exhaustive MT feature parity and repeated performance measurements remain
|
opt-in large-file browser-metadata/playback-to-authoritative-probe
|
||||||
unrecorded. Live Portal container/header verification is a deployment gate.
|
regression. Broad Firefox operation parity, Safari, mobile, exhaustive MT
|
||||||
|
feature parity and repeated performance measurements remain unrecorded. Live
|
||||||
|
Portal container/header verification is a deployment gate.
|
||||||
|
|
||||||
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
|
See `docs/FORMAT_SUPPORT.md`, `docs/PERFORMANCE.md`,
|
||||||
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.
|
`docs/PORTAL_REQUIREMENTS.md` and `docs/RELEASE.md` for testable detail.
|
||||||
|
|||||||
@@ -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
|
but real limits can be substantially lower on mobile or memory-constrained
|
||||||
browsers.
|
browsers.
|
||||||
|
|
||||||
Source inspection has a bounded, size-aware watchdog: 30 seconds for small
|
Import does not start FFmpeg. Recognized local audio/video can begin browser
|
||||||
files, a two-MiB-per-second scan budget for larger files, and a five-minute
|
playback from its temporary object URL while a native media element reads
|
||||||
ceiling. Recognized local audio/video can begin browser playback from its
|
lightweight duration/dimensions. Detailed FFprobe inspection starts only when
|
||||||
temporary object URL while that worker inspection continues; completed probe
|
requested or required for an export and has a bounded, size-aware watchdog:
|
||||||
metadata remains authoritative for editing and export.
|
30 seconds for small files, a two-MiB-per-second budget for larger files, and a
|
||||||
|
five-minute ceiling. A detailed-inspection timeout is nonfatal; browser
|
||||||
|
playback and basic file information remain available. Completed probe metadata
|
||||||
|
remains authoritative for stream mapping, remux safety, chapters, tags,
|
||||||
|
subtitles/attachments and export validation.
|
||||||
|
|
||||||
Lowering resolution, duration, frame rate, passes and output count reduces
|
Lowering resolution, duration, frame rate, passes and output count reduces
|
||||||
work. MT can be faster but consumes more memory/CPU and requires isolation; ST
|
work. MT can be faster but consumes more memory/CPU and requires isolation; ST
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ The repository already declares the application licence and immutable
|
|||||||
third-party source identities. Before any distribution:
|
third-party source identities. Before any distribution:
|
||||||
|
|
||||||
1. commit the reviewed GPL-3.0-or-later source;
|
1. commit the reviewed GPL-3.0-or-later source;
|
||||||
2. create and verify annotated tag `v0.2.1` at that exact commit;
|
2. create and verify annotated tag `v0.3.0` at that exact commit;
|
||||||
3. push the commit and tag to the canonical public repository;
|
3. push the commit and tag to the canonical public repository;
|
||||||
4. assemble, review and publish corresponding source and exact external
|
4. assemble, review and publish corresponding source and exact external
|
||||||
notices for both reviewed core profiles and linked libraries;
|
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:
|
Pass `--force` only to replace the exact same-version ZIP and sidecar. Output:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
release/av-tools-0.2.1.zip
|
release/av-tools-0.3.0.zip
|
||||||
release/av-tools-0.2.1.zip.sha256
|
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
|
||||||
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
|
release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256
|
||||||
```
|
```
|
||||||
@@ -68,7 +68,7 @@ Verify independently:
|
|||||||
```sh
|
```sh
|
||||||
node scripts/checksum-release.mjs
|
node scripts/checksum-release.mjs
|
||||||
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
|
(cd release && sha256sum -c ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz.sha256)
|
||||||
unzip -l release/av-tools-0.2.1.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
|
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
|
```sh
|
||||||
npm run portal:smoke -- \
|
npm run portal:smoke -- \
|
||||||
--artifact release/av-tools-0.2.1.zip \
|
--artifact release/av-tools-0.3.0.zip \
|
||||||
--portal-root ../toolbox-portal
|
--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
|
## 5. Publish only reviewed bytes
|
||||||
|
|
||||||
From a clean `v0.2.1` checkout, rerun all checks, create the ZIP once, verify
|
From a clean `v0.3.0` checkout, rerun all checks, create the ZIP once, verify
|
||||||
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
|
the sidecar, assemble that exact ZIP through Portal, and publish the immutable
|
||||||
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
|
ZIP, `.zip.sha256`, manifest, changelog, Corresponding Source and notices. Put
|
||||||
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never
|
its exact lowercase digest/version/id/target in a reviewed Portal lock. Never
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.2.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "av-tools",
|
"name": "av-tools",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
|
"description": "Local-first browser audio and video conversion and light editing with ffmpeg.wasm.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:browser": "playwright test",
|
"test:browser": "playwright test",
|
||||||
"evidence:large-preview": "playwright test tests/browser/large-media-preview.spec.ts --project=chromium --workers=1",
|
"evidence: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",
|
"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",
|
"test:opus-regression": "npm run vendor:ffmpeg && npm run vendor:verify-runtime && node scripts/opus-regression.mjs --require-pass",
|
||||||
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
|
"benchmark:browser": "AV_BENCHMARK=1 playwright test tests/browser/performance.spec.ts --project=chromium --workers=1",
|
||||||
|
|||||||
@@ -21,5 +21,10 @@ export default defineConfig({
|
|||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox-large-media',
|
||||||
|
testMatch: 'large-media-preview.spec.ts',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.av-tools",
|
"id": "de.add-ideas.av-tools",
|
||||||
"name": "Audio & Video Tools",
|
"name": "Audio & Video Tools",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
|
|||||||
698
src/App.tsx
698
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,21 @@
|
|||||||
import type { MediaProbe } from '../media';
|
import type { BrowserMediaMetadata, MediaProbe } from '../media';
|
||||||
|
|
||||||
export type AssetPhase = 'queued' | 'probing' | 'ready' | 'error';
|
/**
|
||||||
|
* State of the optional authoritative FFprobe inspection.
|
||||||
|
*
|
||||||
|
* The File and its object URL are usable in every phase. In particular,
|
||||||
|
* `error` means only that detailed metadata is unavailable; it never marks the
|
||||||
|
* local source or browser playback as failed.
|
||||||
|
*/
|
||||||
|
export type AssetPhase = 'idle' | 'probing' | 'ready' | 'error';
|
||||||
|
|
||||||
export interface ImportedMediaAsset {
|
export interface ImportedMediaAsset {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
objectUrl: string;
|
objectUrl: string;
|
||||||
phase: AssetPhase;
|
phase: AssetPhase;
|
||||||
|
browserMetadata?: BrowserMediaMetadata;
|
||||||
|
browserMetadataError?: string;
|
||||||
probe?: MediaProbe;
|
probe?: MediaProbe;
|
||||||
rawProbe?: string;
|
rawProbe?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -29,10 +38,47 @@ export function createAsset(file: File): ImportedMediaAsset {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
file,
|
file,
|
||||||
objectUrl: URL.createObjectURL(file),
|
objectUrl: URL.createObjectURL(file),
|
||||||
phase: 'queued',
|
phase: 'idle',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assetDurationSeconds(
|
||||||
|
asset: ImportedMediaAsset | undefined
|
||||||
|
): number | undefined {
|
||||||
|
return (
|
||||||
|
asset?.probe?.durationSeconds ?? asset?.browserMetadata?.durationSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetHasVideo(
|
||||||
|
asset: ImportedMediaAsset | undefined
|
||||||
|
): boolean | undefined {
|
||||||
|
if (asset?.probe) {
|
||||||
|
return asset.probe.streams.some(
|
||||||
|
(stream) =>
|
||||||
|
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (asset?.browserMetadata?.hasVideo !== undefined) {
|
||||||
|
return asset.browserMetadata.hasVideo;
|
||||||
|
}
|
||||||
|
if (asset?.browserMetadata?.kind === 'video') return true;
|
||||||
|
if (asset?.browserMetadata?.kind === 'audio') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetHasAudio(
|
||||||
|
asset: ImportedMediaAsset | undefined
|
||||||
|
): boolean | undefined {
|
||||||
|
if (asset?.probe) {
|
||||||
|
return asset.probe.streams.some((stream) => stream.type === 'audio');
|
||||||
|
}
|
||||||
|
if (asset?.browserMetadata?.hasAudio !== undefined) {
|
||||||
|
return asset.browserMetadata.hasAudio;
|
||||||
|
}
|
||||||
|
return asset?.browserMetadata?.kind === 'audio' ? true : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
|
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown size';
|
||||||
if (bytes < 1_000) return `${bytes} B`;
|
if (bytes < 1_000) return `${bytes} B`;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface CapabilityPanelProps {
|
|||||||
preference: EnginePreference;
|
preference: EnginePreference;
|
||||||
resourcePolicy: ResourcePolicy;
|
resourcePolicy: ResourcePolicy;
|
||||||
deviceMemoryGiB?: number;
|
deviceMemoryGiB?: number;
|
||||||
|
disabled?: boolean;
|
||||||
onPreferenceChange: (preference: EnginePreference) => void;
|
onPreferenceChange: (preference: EnginePreference) => void;
|
||||||
onInitialize: () => void;
|
onInitialize: () => void;
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export function CapabilityPanel({
|
|||||||
preference,
|
preference,
|
||||||
resourcePolicy,
|
resourcePolicy,
|
||||||
deviceMemoryGiB,
|
deviceMemoryGiB,
|
||||||
|
disabled = false,
|
||||||
onPreferenceChange,
|
onPreferenceChange,
|
||||||
onInitialize,
|
onInitialize,
|
||||||
}: CapabilityPanelProps) {
|
}: CapabilityPanelProps) {
|
||||||
@@ -58,7 +60,12 @@ export function CapabilityPanel({
|
|||||||
Engine mode
|
Engine mode
|
||||||
<select
|
<select
|
||||||
value={preference}
|
value={preference}
|
||||||
disabled={state.status === 'running'}
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
state.status === 'loading' ||
|
||||||
|
state.status === 'recovering' ||
|
||||||
|
state.status === 'running'
|
||||||
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onPreferenceChange(event.currentTarget.value as EnginePreference)
|
onPreferenceChange(event.currentTarget.value as EnginePreference)
|
||||||
}
|
}
|
||||||
@@ -188,7 +195,11 @@ export function CapabilityPanel({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
{state.status === 'idle' ? (
|
{state.status === 'idle' ? (
|
||||||
<button className="button button--secondary" onClick={onInitialize}>
|
<button
|
||||||
|
className="button button--secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onInitialize}
|
||||||
|
>
|
||||||
Initialize engine
|
Initialize engine
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -60,10 +60,9 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
<span className="step-number">1</span>
|
<span className="step-number">1</span>
|
||||||
<h3>Add media</h3>
|
<h3>Add media</h3>
|
||||||
<p>
|
<p>
|
||||||
Pick or drop local files. The browser gives FFmpeg direct,
|
Pick or drop local files. Playback, file identity, duration and
|
||||||
read-only access when WORKERFS is available. If it must use the
|
dimensions use the browser first; FFmpeg stays unloaded until
|
||||||
bounded memory-copy fallback, the job log reports the exact copied
|
detailed streams or an export are requested.
|
||||||
byte count.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -71,7 +70,8 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
<h3>Choose an operation</h3>
|
<h3>Choose an operation</h3>
|
||||||
<p>
|
<p>
|
||||||
Quick Convert produces one output. Edit arranges non-destructive
|
Quick Convert produces one output. Edit arranges non-destructive
|
||||||
clips on a sequential timeline.
|
clips on a sequential timeline. Detailed inspection adds codecs,
|
||||||
|
complete streams, chapters and tags only when needed.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
import { formatBytes, formatDuration } from '../app/application-state';
|
import {
|
||||||
|
assetDurationSeconds,
|
||||||
|
assetHasVideo,
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
} from '../app/application-state';
|
||||||
import type {
|
import type {
|
||||||
AvProjectV1,
|
AvProjectV1,
|
||||||
FadeCurve,
|
FadeCurve,
|
||||||
@@ -39,6 +44,7 @@ export interface InspectorProps {
|
|||||||
onChaptersChange: (chapters: ProjectChapter[]) => void;
|
onChaptersChange: (chapters: ProjectChapter[]) => void;
|
||||||
onSubtitlesChange: (tracks: ProjectSubtitleTrack[]) => void;
|
onSubtitlesChange: (tracks: ProjectSubtitleTrack[]) => void;
|
||||||
onSubtitleImport?: (file: File) => void;
|
onSubtitleImport?: (file: File) => void;
|
||||||
|
onInspectDetails?: (assetId: string) => void;
|
||||||
onThumbnail: (request: ThumbnailRequest) => void;
|
onThumbnail: (request: ThumbnailRequest) => void;
|
||||||
currentPreviewTimeSeconds?: number;
|
currentPreviewTimeSeconds?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -74,6 +80,7 @@ export function Inspector({
|
|||||||
onChaptersChange,
|
onChaptersChange,
|
||||||
onSubtitlesChange,
|
onSubtitlesChange,
|
||||||
onSubtitleImport,
|
onSubtitleImport,
|
||||||
|
onInspectDetails,
|
||||||
onThumbnail,
|
onThumbnail,
|
||||||
currentPreviewTimeSeconds,
|
currentPreviewTimeSeconds,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -119,7 +126,13 @@ export function Inspector({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="inspector__body">
|
<div className="inspector__body">
|
||||||
{tab === 'media' ? <MediaInspector asset={asset} /> : null}
|
{tab === 'media' ? (
|
||||||
|
<MediaInspector
|
||||||
|
asset={asset}
|
||||||
|
disabled={disabled}
|
||||||
|
onInspectDetails={onInspectDetails}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{tab === 'transform' ? (
|
{tab === 'transform' ? (
|
||||||
<TransformInspector
|
<TransformInspector
|
||||||
clip={clip}
|
clip={clip}
|
||||||
@@ -159,7 +172,7 @@ export function Inspector({
|
|||||||
) : null}
|
) : null}
|
||||||
{tab === 'thumbnails' ? (
|
{tab === 'thumbnails' ? (
|
||||||
<ThumbnailInspector
|
<ThumbnailInspector
|
||||||
duration={asset?.probe?.durationSeconds}
|
duration={assetDurationSeconds(asset)}
|
||||||
currentPreviewTimeSeconds={currentPreviewTimeSeconds}
|
currentPreviewTimeSeconds={currentPreviewTimeSeconds}
|
||||||
disabled={disabled || !asset}
|
disabled={disabled || !asset}
|
||||||
onCreate={onThumbnail}
|
onCreate={onThumbnail}
|
||||||
@@ -170,20 +183,30 @@ export function Inspector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
function MediaInspector({
|
||||||
|
asset,
|
||||||
|
disabled,
|
||||||
|
onInspectDetails,
|
||||||
|
}: {
|
||||||
|
asset?: ImportedMediaAsset;
|
||||||
|
disabled: boolean;
|
||||||
|
onInspectDetails?: (assetId: string) => void;
|
||||||
|
}) {
|
||||||
if (!asset) return <p className="panel-empty">Select a media source.</p>;
|
if (!asset) return <p className="panel-empty">Select a media source.</p>;
|
||||||
const probe = asset.probe;
|
const probe = asset.probe;
|
||||||
|
const durationSeconds = assetDurationSeconds(asset);
|
||||||
|
const hasVideo = assetHasVideo(asset) === true;
|
||||||
|
const width =
|
||||||
|
probe?.streams.find((stream) => stream.type === 'video')?.width ??
|
||||||
|
asset.browserMetadata?.width;
|
||||||
|
const height =
|
||||||
|
probe?.streams.find((stream) => stream.type === 'video')?.height ??
|
||||||
|
asset.browserMetadata?.height;
|
||||||
return (
|
return (
|
||||||
<div className="inspector-section">
|
<div className="inspector-section">
|
||||||
<div className="inspector-file">
|
<div className="inspector-file">
|
||||||
<span>
|
<span>
|
||||||
<Icon
|
<Icon name={hasVideo ? 'video' : 'audio'} />
|
||||||
name={
|
|
||||||
probe?.streams.some((stream) => stream.type === 'video')
|
|
||||||
? 'video'
|
|
||||||
: 'audio'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{asset.file.name}</strong>
|
<strong>{asset.file.name}</strong>
|
||||||
@@ -191,6 +214,18 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>File type</dt>
|
||||||
|
<dd>
|
||||||
|
{asset.browserMetadata?.file.declaredMimeType &&
|
||||||
|
asset.browserMetadata.file.declaredMimeType !==
|
||||||
|
'application/octet-stream'
|
||||||
|
? asset.browserMetadata.file.declaredMimeType
|
||||||
|
: (asset.browserMetadata?.file.inferredMimeType ??
|
||||||
|
asset.browserMetadata?.file.declaredMimeType ??
|
||||||
|
(asset.file.type || '—'))}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Container</dt>
|
<dt>Container</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -199,8 +234,21 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Duration</dt>
|
<dt>Duration</dt>
|
||||||
<dd>{formatDuration(probe?.durationSeconds)}</dd>
|
<dd>
|
||||||
|
{formatDuration(durationSeconds)}
|
||||||
|
{!probe && durationSeconds !== undefined ? ' · browser' : ''}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
{hasVideo ? (
|
||||||
|
<div>
|
||||||
|
<dt>Dimensions</dt>
|
||||||
|
<dd>
|
||||||
|
{width !== undefined && height !== undefined
|
||||||
|
? `${width}×${height}${probe ? '' : ' · browser'}`
|
||||||
|
: '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<dt>Bit rate</dt>
|
<dt>Bit rate</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -210,6 +258,41 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{!probe ? (
|
||||||
|
<div
|
||||||
|
className={`notice ${
|
||||||
|
asset.phase === 'error' ? 'notice--warning' : ''
|
||||||
|
}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<Icon name={asset.phase === 'error' ? 'warning' : 'info'} />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{asset.phase === 'probing'
|
||||||
|
? 'Inspecting detailed media information…'
|
||||||
|
: asset.phase === 'error'
|
||||||
|
? 'Detailed inspection is unavailable.'
|
||||||
|
: 'Detailed information has not been inspected.'}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{asset.phase === 'error'
|
||||||
|
? asset.error
|
||||||
|
: 'Codecs, complete streams, chapters and tags are loaded only when needed.'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{asset.phase !== 'probing' && onInspectDetails ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onInspectDetails(asset.id)}
|
||||||
|
>
|
||||||
|
<Icon name="info" />
|
||||||
|
{asset.phase === 'error' ? 'Retry inspection' : 'Inspect details'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{probe && Object.keys(probe.tags).length > 0 ? (
|
{probe && Object.keys(probe.tags).length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<h3>Container tags</h3>
|
<h3>Container tags</h3>
|
||||||
@@ -223,66 +306,69 @@ function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) {
|
|||||||
</dl>
|
</dl>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<h3>Streams</h3>
|
{probe ? <h3>Streams</h3> : null}
|
||||||
<ol className="inspector-streams">
|
{probe ? (
|
||||||
{probe?.streams.map((stream) => (
|
<ol className="inspector-streams">
|
||||||
<li key={stream.index}>
|
{probe?.streams.map((stream) => (
|
||||||
<span className="stream-kind">{stream.type}</span>
|
<li key={stream.index}>
|
||||||
<div>
|
<span className="stream-kind">{stream.type}</span>
|
||||||
<strong>
|
<div>
|
||||||
#{stream.index} · {stream.codecName ?? 'unknown'}
|
<strong>
|
||||||
</strong>
|
#{stream.index} · {stream.codecName ?? 'unknown'}
|
||||||
{stream.type === 'video' ? (
|
</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>
|
<small>
|
||||||
Encoded pixels {stream.width ?? '?'}×{stream.height ?? '?'}
|
{stream.type === 'audio'
|
||||||
{stream.codedWidth !== undefined &&
|
? `${stream.sampleRate ?? '?'} Hz · ${stream.channelLayout ?? `${stream.channels ?? '?'} channels`}`
|
||||||
stream.codedHeight !== undefined
|
: (stream.language ??
|
||||||
? ` · coded ${stream.codedWidth}×${stream.codedHeight}`
|
stream.title ??
|
||||||
: ''}
|
'No additional details')}
|
||||||
{' · '}
|
|
||||||
{stream.frameRate?.toFixed(3) ?? '?'} fps
|
|
||||||
</small>
|
</small>
|
||||||
|
)}
|
||||||
|
{Object.keys(stream.disposition).length > 0 ? (
|
||||||
<small>
|
<small>
|
||||||
SAR {stream.sampleAspectRatio ?? '—'} · DAR{' '}
|
Disposition:{' '}
|
||||||
{stream.displayAspectRatio ?? '—'} · rotation{' '}
|
{Object.entries(stream.disposition)
|
||||||
{stream.rotationDegrees === undefined
|
.filter(([, enabled]) => enabled)
|
||||||
? '—'
|
.map(([name]) => name)
|
||||||
: `${stream.rotationDegrees}°`}
|
.join(', ') || 'none'}
|
||||||
</small>
|
</small>
|
||||||
</>
|
) : null}
|
||||||
) : (
|
{Object.keys(stream.tags).length > 0 ? (
|
||||||
<small>
|
<dl className="stream-tag-list">
|
||||||
{stream.type === 'audio'
|
{Object.entries(stream.tags).map(([key, value]) => (
|
||||||
? `${stream.sampleRate ?? '?'} Hz · ${stream.channelLayout ?? `${stream.channels ?? '?'} channels`}`
|
<div key={key}>
|
||||||
: (stream.language ??
|
<dt>{key}</dt>
|
||||||
stream.title ??
|
<dd>{value}</dd>
|
||||||
'No additional details')}
|
</div>
|
||||||
</small>
|
))}
|
||||||
)}
|
</dl>
|
||||||
{Object.keys(stream.disposition).length > 0 ? (
|
) : null}
|
||||||
<small>
|
</div>
|
||||||
Disposition:{' '}
|
</li>
|
||||||
{Object.entries(stream.disposition)
|
))}
|
||||||
.filter(([, enabled]) => enabled)
|
</ol>
|
||||||
.map(([name]) => name)
|
) : null}
|
||||||
.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>
|
|
||||||
{probe && probe.chapters.length > 0 ? (
|
{probe && probe.chapters.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<h3>Source chapters</h3>
|
<h3>Source chapters</h3>
|
||||||
@@ -333,8 +419,8 @@ function TransformInspector({
|
|||||||
}) {
|
}) {
|
||||||
if (!clip) return <p className="panel-empty">Select a timeline clip.</p>;
|
if (!clip) return <p className="panel-empty">Select a timeline clip.</p>;
|
||||||
const video = asset?.probe?.streams.find((stream) => stream.type === 'video');
|
const video = asset?.probe?.streams.find((stream) => stream.type === 'video');
|
||||||
const sourceWidth = video?.width;
|
const sourceWidth = video?.width ?? asset?.browserMetadata?.width;
|
||||||
const sourceHeight = video?.height;
|
const sourceHeight = video?.height ?? asset?.browserMetadata?.height;
|
||||||
const canShowVisualCrop =
|
const canShowVisualCrop =
|
||||||
asset !== undefined &&
|
asset !== undefined &&
|
||||||
isPositiveSafeInteger(sourceWidth) &&
|
isPositiveSafeInteger(sourceWidth) &&
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
import { formatBytes, formatDuration } from '../app/application-state';
|
import {
|
||||||
|
assetDurationSeconds,
|
||||||
|
assetHasVideo,
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
} from '../app/application-state';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
export interface MediaBinProps {
|
export interface MediaBinProps {
|
||||||
assets: readonly ImportedMediaAsset[];
|
assets: readonly ImportedMediaAsset[];
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
onInspect: (id: string) => void;
|
||||||
|
inspectDisabled?: boolean;
|
||||||
|
removeDisabled?: boolean;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,6 +21,9 @@ export function MediaBin({
|
|||||||
assets,
|
assets,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onInspect,
|
||||||
|
inspectDisabled = false,
|
||||||
|
removeDisabled = false,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: MediaBinProps) {
|
}: MediaBinProps) {
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
@@ -27,9 +38,13 @@ export function MediaBin({
|
|||||||
return (
|
return (
|
||||||
<ul className="media-list" aria-label="Imported media">
|
<ul className="media-list" aria-label="Imported media">
|
||||||
{assets.map((asset) => {
|
{assets.map((asset) => {
|
||||||
|
const hasVideo = assetHasVideo(asset) === true;
|
||||||
const video = asset.probe?.streams.find(
|
const video = asset.probe?.streams.find(
|
||||||
(stream) => stream.type === 'video'
|
(stream) => stream.type === 'video'
|
||||||
);
|
);
|
||||||
|
const durationSeconds = assetDurationSeconds(asset);
|
||||||
|
const width = video?.width ?? asset.browserMetadata?.width;
|
||||||
|
const height = video?.height ?? asset.browserMetadata?.height;
|
||||||
const audioCount =
|
const audioCount =
|
||||||
asset.probe?.streams.filter((stream) => stream.type === 'audio')
|
asset.probe?.streams.filter((stream) => stream.type === 'audio')
|
||||||
.length ?? 0;
|
.length ?? 0;
|
||||||
@@ -43,34 +58,67 @@ export function MediaBin({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
|
aria-pressed={selectedId === asset.id}
|
||||||
onClick={() => onSelect(asset.id)}
|
onClick={() => onSelect(asset.id)}
|
||||||
>
|
>
|
||||||
<span className="media-card__type">
|
<span className="media-card__type">
|
||||||
<Icon name={video ? 'video' : 'audio'} />
|
<Icon name={hasVideo ? 'video' : 'audio'} />
|
||||||
</span>
|
</span>
|
||||||
<span className="media-card__content">
|
<span className="media-card__content">
|
||||||
<strong title={asset.file.name}>{asset.file.name}</strong>
|
<strong title={asset.file.name}>{asset.file.name}</strong>
|
||||||
<small>
|
<small>
|
||||||
{formatBytes(asset.file.size)}
|
{formatBytes(asset.file.size)}
|
||||||
{asset.probe?.durationSeconds !== undefined
|
{durationSeconds !== undefined
|
||||||
? ` · ${formatDuration(asset.probe.durationSeconds)}`
|
? ` · ${formatDuration(durationSeconds)}`
|
||||||
: ''}
|
: ''}
|
||||||
</small>
|
</small>
|
||||||
<small className={`phase phase--${asset.phase}`}>
|
<small className={`phase phase--${asset.phase}`}>
|
||||||
{asset.phase === 'probing'
|
{asset.phase === 'probing'
|
||||||
? 'Inspecting locally…'
|
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Inspecting details…`
|
||||||
: asset.phase === 'ready'
|
: asset.phase === 'ready'
|
||||||
? `${video ? `${video.width ?? '?'}×${video.height ?? '?'}` : 'Audio'} · ${audioCount} audio`
|
? `${hasVideo ? `${width ?? '?'}×${height ?? '?'}` : 'Audio'} · ${audioCount} audio`
|
||||||
: asset.phase === 'error'
|
: asset.phase === 'error'
|
||||||
? asset.error
|
? `${asset.browserMetadata ? 'Ready to play' : 'File ready'} · Details unavailable`
|
||||||
: 'Queued'}
|
: asset.browserMetadataError
|
||||||
|
? 'File ready · Basic metadata unavailable'
|
||||||
|
: asset.browserMetadata
|
||||||
|
? 'Ready to play · Details on demand'
|
||||||
|
: 'File ready · Reading basic metadata…'}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{asset.phase !== 'ready' ? (
|
||||||
|
<button
|
||||||
|
className="icon-action"
|
||||||
|
type="button"
|
||||||
|
aria-label={`${asset.phase === 'error' ? 'Retry detailed inspection for' : 'Inspect details for'} ${asset.file.name}`}
|
||||||
|
title={
|
||||||
|
asset.phase === 'error'
|
||||||
|
? asset.error
|
||||||
|
: 'Read codecs, streams, chapters and tags'
|
||||||
|
}
|
||||||
|
disabled={inspectDisabled || asset.phase === 'probing'}
|
||||||
|
{...(asset.error
|
||||||
|
? { 'aria-describedby': `inspection-error-${asset.id}` }
|
||||||
|
: {})}
|
||||||
|
onClick={() => onInspect(asset.id)}
|
||||||
|
>
|
||||||
|
<Icon name={asset.phase === 'probing' ? 'queue' : 'info'} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{asset.error ? (
|
||||||
|
<span
|
||||||
|
className="visually-hidden"
|
||||||
|
id={`inspection-error-${asset.id}`}
|
||||||
|
>
|
||||||
|
{asset.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="icon-action"
|
className="icon-action"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${asset.file.name}`}
|
aria-label={`Remove ${asset.file.name}`}
|
||||||
|
disabled={removeDisabled}
|
||||||
onClick={() => onRemove(asset.id)}
|
onClick={() => onRemove(asset.id)}
|
||||||
>
|
>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
|
import { assetHasAudio, assetHasVideo } from '../app/application-state';
|
||||||
import {
|
import {
|
||||||
assessBrowserPlayback,
|
assessBrowserPlayback,
|
||||||
assessGeneratedResultPreview,
|
assessGeneratedResultPreview,
|
||||||
@@ -60,18 +61,15 @@ export function MediaPreview({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const hasVideo = result
|
const hasVideo = result
|
||||||
? result.mimeType.startsWith('video/')
|
? result.mimeType.startsWith('video/')
|
||||||
: asset?.probe
|
: assetHasVideo(asset) !== undefined
|
||||||
? asset.probe.streams.some(
|
? assetHasVideo(asset) === true
|
||||||
(stream) =>
|
|
||||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
|
||||||
)
|
|
||||||
: declaredSourceMimeType
|
: declaredSourceMimeType
|
||||||
? isVideoMimeType(declaredSourceMimeType)
|
? isVideoMimeType(declaredSourceMimeType)
|
||||||
: false;
|
: false;
|
||||||
const hasAudio = result
|
const hasAudio = result
|
||||||
? result.mimeType.startsWith('audio/')
|
? result.mimeType.startsWith('audio/')
|
||||||
: asset?.probe
|
: assetHasAudio(asset) !== undefined
|
||||||
? asset.probe.streams.some((stream) => stream.type === 'audio')
|
? assetHasAudio(asset) === true
|
||||||
: declaredSourceMimeType
|
: declaredSourceMimeType
|
||||||
? isAudioMimeType(declaredSourceMimeType)
|
? isAudioMimeType(declaredSourceMimeType)
|
||||||
: false;
|
: false;
|
||||||
@@ -207,8 +205,10 @@ export function MediaPreview({
|
|||||||
: `Generated result · ${result.name}`
|
: `Generated result · ${result.name}`
|
||||||
: (playback?.reason ??
|
: (playback?.reason ??
|
||||||
(asset?.phase === 'probing'
|
(asset?.phase === 'probing'
|
||||||
? 'Local source preview · Media inspection continues in the background.'
|
? 'Local source preview · Detailed inspection runs separately.'
|
||||||
: 'Local source preview'))}
|
: asset?.phase === 'error'
|
||||||
|
? 'Local source preview · Detailed inspection unavailable.'
|
||||||
|
: 'Local source preview · Basic browser metadata'))}
|
||||||
</span>
|
</span>
|
||||||
{needsProxy && !proxySettings ? (
|
{needsProxy && !proxySettings ? (
|
||||||
<button type="button" className="text-button" onClick={onCreateProxy}>
|
<button type="button" className="text-button" onClick={onCreateProxy}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ImportedMediaAsset } from '../app/application-state';
|
import type { ImportedMediaAsset } from '../app/application-state';
|
||||||
|
import { assetDurationSeconds, assetHasVideo } from '../app/application-state';
|
||||||
import type { EngineState } from '../ffmpeg/ffmpeg.types';
|
import type { EngineState } from '../ffmpeg/ffmpeg.types';
|
||||||
import type { MediaStreamProbe } from '../media';
|
|
||||||
import type { StreamSelection } from '../commands/command-utils';
|
import type { StreamSelection } from '../commands/command-utils';
|
||||||
import {
|
import {
|
||||||
validateRemuxCompatibility,
|
validateRemuxCompatibility,
|
||||||
@@ -18,8 +18,12 @@ import {
|
|||||||
import { formatBytes, formatDuration } from '../app/application-state';
|
import { formatBytes, formatDuration } from '../app/application-state';
|
||||||
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
|
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
import {
|
||||||
|
quickStreamUnavailableReason,
|
||||||
|
type QuickOperation,
|
||||||
|
} from './quick-convert-selection';
|
||||||
|
|
||||||
export type QuickOperation = 'convert' | 'remux';
|
export type { QuickOperation } from './quick-convert-selection';
|
||||||
|
|
||||||
export interface QuickExportConfiguration {
|
export interface QuickExportConfiguration {
|
||||||
operation: QuickOperation;
|
operation: QuickOperation;
|
||||||
@@ -28,6 +32,11 @@ export interface QuickExportConfiguration {
|
|||||||
streamSelection: StreamSelection;
|
streamSelection: StreamSelection;
|
||||||
removeMetadata: boolean;
|
removeMetadata: boolean;
|
||||||
removeChapters: boolean;
|
removeChapters: boolean;
|
||||||
|
/**
|
||||||
|
* The source was not authoritatively inspected when the user submitted the
|
||||||
|
* form. Resolve a safe default selection from the on-demand probe.
|
||||||
|
*/
|
||||||
|
selectDetectedStreams?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuickConvertProps {
|
export interface QuickConvertProps {
|
||||||
@@ -36,6 +45,7 @@ export interface QuickConvertProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
queueing?: boolean;
|
queueing?: boolean;
|
||||||
presets?: readonly ExportPreset[];
|
presets?: readonly ExportPreset[];
|
||||||
|
onInspect?: () => void;
|
||||||
onExport: (configuration: QuickExportConfiguration) => void;
|
onExport: (configuration: QuickExportConfiguration) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +55,7 @@ export function QuickConvert({
|
|||||||
busy,
|
busy,
|
||||||
queueing = false,
|
queueing = false,
|
||||||
presets = BUILT_IN_PRESETS,
|
presets = BUILT_IN_PRESETS,
|
||||||
|
onInspect,
|
||||||
onExport,
|
onExport,
|
||||||
}: QuickConvertProps) {
|
}: QuickConvertProps) {
|
||||||
const [operation, setOperation] = useState<QuickOperation>('convert');
|
const [operation, setOperation] = useState<QuickOperation>('convert');
|
||||||
@@ -59,6 +70,7 @@ export function QuickConvert({
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const preset = presets.find((entry) => entry.id === presetId);
|
const preset = presets.find((entry) => entry.id === presetId);
|
||||||
|
const hasDetailedInformation = asset?.probe !== undefined;
|
||||||
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
|
const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]);
|
||||||
const requestedStreams = useMemo(
|
const requestedStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -143,7 +155,9 @@ export function QuickConvert({
|
|||||||
const opusBlockedByPinnedCore =
|
const opusBlockedByPinnedCore =
|
||||||
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
||||||
const presetNeedsMissingAudio =
|
const presetNeedsMissingAudio =
|
||||||
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
|
hasDetailedInformation &&
|
||||||
|
preset?.kind === 'audio' &&
|
||||||
|
effectiveSelection.audio.length === 0;
|
||||||
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
||||||
stream.type === 'video' ||
|
stream.type === 'video' ||
|
||||||
stream.type === 'audio' ||
|
stream.type === 'audio' ||
|
||||||
@@ -159,16 +173,17 @@ export function QuickConvert({
|
|||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
const remuxDiagnostics = remuxContainer
|
const remuxDiagnostics =
|
||||||
? validateRemuxCompatibility(
|
remuxContainer && hasDetailedInformation
|
||||||
{
|
? validateRemuxCompatibility(
|
||||||
streams: remuxStreams,
|
{
|
||||||
hasChapters: Boolean(asset?.probe?.chapters.length),
|
streams: remuxStreams,
|
||||||
},
|
hasChapters: Boolean(asset?.probe?.chapters.length),
|
||||||
remuxContainer,
|
},
|
||||||
effectiveSelection
|
remuxContainer,
|
||||||
)
|
effectiveSelection
|
||||||
: [];
|
)
|
||||||
|
: [];
|
||||||
const remuxErrors = remuxDiagnostics.filter(
|
const remuxErrors = remuxDiagnostics.filter(
|
||||||
(diagnostic) => diagnostic.severity === 'error'
|
(diagnostic) => diagnostic.severity === 'error'
|
||||||
);
|
);
|
||||||
@@ -179,9 +194,9 @@ export function QuickConvert({
|
|||||||
const remuxUnavailableReason = remuxMuxerMissing
|
const remuxUnavailableReason = remuxMuxerMissing
|
||||||
? `The loaded core is missing muxer ${remuxContainer}.`
|
? `The loaded core is missing muxer ${remuxContainer}.`
|
||||||
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
|
: remuxErrors.map((diagnostic) => diagnostic.message).join(' ');
|
||||||
const hasSelectedStream = selectedStreams.size > 0;
|
const hasSelectedStream = !hasDetailedInformation || selectedStreams.size > 0;
|
||||||
const canExport =
|
const canExport =
|
||||||
asset?.phase === 'ready' &&
|
asset !== undefined &&
|
||||||
!busy &&
|
!busy &&
|
||||||
hasSelectedStream &&
|
hasSelectedStream &&
|
||||||
(operation === 'remux'
|
(operation === 'remux'
|
||||||
@@ -228,27 +243,23 @@ export function QuickConvert({
|
|||||||
{asset ? (
|
{asset ? (
|
||||||
<div className="source-summary">
|
<div className="source-summary">
|
||||||
<span className="source-summary__icon">
|
<span className="source-summary__icon">
|
||||||
<Icon
|
<Icon name={assetHasVideo(asset) === true ? 'video' : 'audio'} />
|
||||||
name={
|
|
||||||
asset.probe?.streams.some((stream) => stream.type === 'video')
|
|
||||||
? 'video'
|
|
||||||
: 'audio'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{asset.file.name}</strong>
|
<strong>{asset.file.name}</strong>
|
||||||
<span>
|
<span>
|
||||||
{formatBytes(asset.file.size)} ·{' '}
|
{formatBytes(asset.file.size)} ·{' '}
|
||||||
{formatDuration(asset.probe?.durationSeconds)}
|
{formatDuration(assetDurationSeconds(asset))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`phase phase--${asset.phase}`}>
|
<span className={`phase phase--${asset.phase}`}>
|
||||||
{asset.phase === 'ready'
|
{asset.phase === 'ready'
|
||||||
? 'Ready'
|
? 'Details ready'
|
||||||
: asset.phase === 'error'
|
: asset.phase === 'probing'
|
||||||
? 'Could not inspect'
|
? 'Inspecting details'
|
||||||
: 'Preparing'}
|
: asset.phase === 'error'
|
||||||
|
? 'Ready to play'
|
||||||
|
: 'Ready to play'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -340,14 +351,16 @@ export function QuickConvert({
|
|||||||
['ogg', 'Ogg (.ogg)'],
|
['ogg', 'Ogg (.ogg)'],
|
||||||
] as const
|
] as const
|
||||||
).map(([container, label]) => {
|
).map(([container, label]) => {
|
||||||
const diagnostics = validateRemuxCompatibility(
|
const diagnostics = hasDetailedInformation
|
||||||
{
|
? validateRemuxCompatibility(
|
||||||
streams: remuxStreams,
|
{
|
||||||
hasChapters: Boolean(asset?.probe?.chapters.length),
|
streams: remuxStreams,
|
||||||
},
|
hasChapters: Boolean(asset?.probe?.chapters.length),
|
||||||
container,
|
},
|
||||||
effectiveSelection
|
container,
|
||||||
);
|
effectiveSelection
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const missingMuxer =
|
const missingMuxer =
|
||||||
engineState.status === 'ready' &&
|
engineState.status === 'ready' &&
|
||||||
!engineState.capabilities.muxers.has(container);
|
!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>
|
</fieldset>
|
||||||
|
|
||||||
@@ -451,6 +482,13 @@ export function QuickConvert({
|
|||||||
Select at least one audio stream for an audio-only preset.
|
Select at least one audio stream for an audio-only preset.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{asset?.phase === 'error' && !asset.probe ? (
|
||||||
|
<p className="notice notice--warning">
|
||||||
|
<Icon name="warning" />
|
||||||
|
Playback and basic file information remain available. Detailed
|
||||||
|
inspection can be retried when an export needs it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{!hasSelectedStream && streams.length > 0 ? (
|
{!hasSelectedStream && streams.length > 0 ? (
|
||||||
<p className="notice notice--error">
|
<p className="notice notice--error">
|
||||||
<Icon name="warning" />
|
<Icon name="warning" />
|
||||||
@@ -474,7 +512,11 @@ export function QuickConvert({
|
|||||||
? 'Validated user preset'
|
? 'Validated user preset'
|
||||||
: 'Stream copy'}
|
: 'Stream copy'}
|
||||||
</strong>
|
</strong>
|
||||||
<span>Resource estimates are checked before allocation.</span>
|
<span>
|
||||||
|
{hasDetailedInformation
|
||||||
|
? 'Resource estimates are checked before allocation.'
|
||||||
|
: 'Detailed stream inspection will run before this export.'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="button button--primary button--large"
|
className="button button--primary button--large"
|
||||||
@@ -488,6 +530,9 @@ export function QuickConvert({
|
|||||||
streamSelection: effectiveSelection,
|
streamSelection: effectiveSelection,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
removeChapters,
|
removeChapters,
|
||||||
|
...(!hasDetailedInformation
|
||||||
|
? { selectDetectedStreams: true as const }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -497,47 +542,14 @@ export function QuickConvert({
|
|||||||
: queueing
|
: queueing
|
||||||
? 'Add to queue'
|
? 'Add to queue'
|
||||||
: operation === 'remux'
|
: operation === 'remux'
|
||||||
? 'Remux'
|
? hasDetailedInformation
|
||||||
: 'Convert'}
|
? 'Remux'
|
||||||
|
: 'Inspect & remux'
|
||||||
|
: hasDetailedInformation
|
||||||
|
? 'Convert'
|
||||||
|
: 'Inspect & convert'}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickStreamUnavailableReason(
|
|
||||||
stream: MediaStreamProbe,
|
|
||||||
operation: QuickOperation,
|
|
||||||
preset: ExportPreset | undefined
|
|
||||||
): string | undefined {
|
|
||||||
if (stream.type === 'unknown') {
|
|
||||||
return 'unknown stream kinds cannot be mapped safely.';
|
|
||||||
}
|
|
||||||
if (operation === 'remux') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (!preset) {
|
|
||||||
return 'choose an export preset first.';
|
|
||||||
}
|
|
||||||
if (stream.type === 'video' && !preset.video) {
|
|
||||||
return 'the selected preset creates audio only.';
|
|
||||||
}
|
|
||||||
if (stream.type === 'audio' && !preset.audio) {
|
|
||||||
return 'the selected preset creates video or images without audio.';
|
|
||||||
}
|
|
||||||
if (stream.type === 'subtitle') {
|
|
||||||
if (preset.subtitlePolicy === 'none') {
|
|
||||||
return 'the selected preset explicitly omits subtitles.';
|
|
||||||
}
|
|
||||||
if (preset.subtitlePolicy === 'burn-in') {
|
|
||||||
return 'burn-in requires an explicitly attached subtitle source.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(stream.type === 'attachment' || stream.type === 'data') &&
|
|
||||||
preset.container !== 'matroska'
|
|
||||||
) {
|
|
||||||
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
70
src/components/quick-convert-selection.ts
Normal file
70
src/components/quick-convert-selection.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { StreamSelection } from '../commands/command-utils';
|
||||||
|
import type { MediaProbe, MediaStreamProbe } from '../media';
|
||||||
|
import type { ExportPreset } from '../commands/convert';
|
||||||
|
|
||||||
|
export type QuickOperation = 'convert' | 'remux';
|
||||||
|
|
||||||
|
export function defaultQuickStreamSelection(
|
||||||
|
probe: MediaProbe,
|
||||||
|
operation: QuickOperation,
|
||||||
|
preset: ExportPreset | undefined
|
||||||
|
): StreamSelection {
|
||||||
|
const selected = probe.streams.filter(
|
||||||
|
(stream) =>
|
||||||
|
quickStreamUnavailableReason(stream, operation, preset) === undefined
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
video: selected
|
||||||
|
.filter((stream) => stream.type === 'video')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
audio: selected
|
||||||
|
.filter((stream) => stream.type === 'audio')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
subtitles: selected
|
||||||
|
.filter((stream) => stream.type === 'subtitle')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
attachments: selected
|
||||||
|
.filter((stream) => stream.type === 'attachment')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
data: selected
|
||||||
|
.filter((stream) => stream.type === 'data')
|
||||||
|
.map((stream) => stream.index),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quickStreamUnavailableReason(
|
||||||
|
stream: MediaStreamProbe,
|
||||||
|
operation: QuickOperation,
|
||||||
|
preset: ExportPreset | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (stream.type === 'unknown') {
|
||||||
|
return 'unknown stream kinds cannot be mapped safely.';
|
||||||
|
}
|
||||||
|
if (operation === 'remux') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!preset) {
|
||||||
|
return 'choose an export preset first.';
|
||||||
|
}
|
||||||
|
if (stream.type === 'video' && !preset.video) {
|
||||||
|
return 'the selected preset creates audio only.';
|
||||||
|
}
|
||||||
|
if (stream.type === 'audio' && !preset.audio) {
|
||||||
|
return 'the selected preset creates video or images without audio.';
|
||||||
|
}
|
||||||
|
if (stream.type === 'subtitle') {
|
||||||
|
if (preset.subtitlePolicy === 'none') {
|
||||||
|
return 'the selected preset explicitly omits subtitles.';
|
||||||
|
}
|
||||||
|
if (preset.subtitlePolicy === 'burn-in') {
|
||||||
|
return 'burn-in requires an explicitly attached subtitle source.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(stream.type === 'attachment' || stream.type === 'data') &&
|
||||||
|
preset.container !== 'matroska'
|
||||||
|
) {
|
||||||
|
return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -23,7 +23,11 @@ import type {
|
|||||||
import { createLoadConfig } from './load-config';
|
import { createLoadConfig } from './load-config';
|
||||||
import { LogBuffer } from './log-buffer';
|
import { LogBuffer } from './log-buffer';
|
||||||
import { normalizeProgress } from './progress-parser';
|
import { normalizeProgress } from './progress-parser';
|
||||||
import { describeMountedInputAccess, mountJobFiles } from './virtual-fs';
|
import {
|
||||||
|
describeMountedInputAccess,
|
||||||
|
mountJobFiles,
|
||||||
|
type MountedJobFiles,
|
||||||
|
} from './virtual-fs';
|
||||||
import {
|
import {
|
||||||
parseFFmpegProgress,
|
parseFFmpegProgress,
|
||||||
progressUpdateFromFFmpeg,
|
progressUpdateFromFFmpeg,
|
||||||
@@ -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> {
|
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
||||||
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||||
return new FFmpeg();
|
return new FFmpeg();
|
||||||
@@ -98,6 +121,7 @@ export class EngineManager {
|
|||||||
#warning: Extract<EngineState, { status: 'ready' }>['warning'];
|
#warning: Extract<EngineState, { status: 'ready' }>['warning'];
|
||||||
#loadPromise: Promise<void> | null = null;
|
#loadPromise: Promise<void> | null = null;
|
||||||
#activeJobId: string | null = null;
|
#activeJobId: string | null = null;
|
||||||
|
#preparingOperation = false;
|
||||||
#activeExpectedDurationSeconds: number | undefined;
|
#activeExpectedDurationSeconds: number | undefined;
|
||||||
#activeStartedAtMilliseconds = 0;
|
#activeStartedAtMilliseconds = 0;
|
||||||
#machineProgressLines: string[] = [];
|
#machineProgressLines: string[] = [];
|
||||||
@@ -108,35 +132,40 @@ export class EngineManager {
|
|||||||
#generation = 0;
|
#generation = 0;
|
||||||
|
|
||||||
readonly #handleLog = (event: LogEvent): void => {
|
readonly #handleLog = (event: LogEvent): void => {
|
||||||
|
// Capability discovery emits thousands of individual lines. Forwarding
|
||||||
|
// each one into React while the engine is still loading can starve
|
||||||
|
// Firefox's worker message queue long enough for the following probe to
|
||||||
|
// hit its watchdog. queryCapabilities installs its own short-lived
|
||||||
|
// listener, so application job logs only need to be retained/published
|
||||||
|
// while an operation is running.
|
||||||
|
if (this.#state.status !== 'running') return;
|
||||||
this.#logs.append(event);
|
this.#logs.append(event);
|
||||||
if (this.#state.status === 'running') {
|
for (const line of event.message.split(/\r?\n/gu)) {
|
||||||
for (const line of event.message.split(/\r?\n/gu)) {
|
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
|
||||||
if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) {
|
this.#machineProgressLines.push(line);
|
||||||
this.#machineProgressLines.push(line);
|
}
|
||||||
}
|
if (/^progress=(?:continue|end)$/u.test(line)) {
|
||||||
if (/^progress=(?:continue|end)$/u.test(line)) {
|
const parsed = parseFFmpegProgress(
|
||||||
const parsed = parseFFmpegProgress(
|
this.#machineProgressLines.join('\n')
|
||||||
this.#machineProgressLines.join('\n')
|
);
|
||||||
);
|
const update = progressUpdateFromFFmpeg(
|
||||||
const update = progressUpdateFromFFmpeg(
|
parsed,
|
||||||
parsed,
|
this.#activeExpectedDurationSeconds
|
||||||
this.#activeExpectedDurationSeconds
|
);
|
||||||
);
|
this.#machineProgressLines = [];
|
||||||
this.#machineProgressLines = [];
|
this.#machineProgressSeen = true;
|
||||||
this.#machineProgressSeen = true;
|
this.#setState({
|
||||||
this.#setState({
|
...this.#state,
|
||||||
...this.#state,
|
...(update.progress !== undefined
|
||||||
...(update.progress !== undefined
|
? { progress: update.progress }
|
||||||
? { progress: update.progress }
|
: {}),
|
||||||
: {}),
|
...(update.speed !== undefined ? { speed: update.speed } : {}),
|
||||||
...(update.speed !== undefined ? { speed: update.speed } : {}),
|
...(update.frame !== undefined ? { frame: update.frame } : {}),
|
||||||
...(update.frame !== undefined ? { frame: update.frame } : {}),
|
elapsedSeconds: Math.max(
|
||||||
elapsedSeconds: Math.max(
|
0,
|
||||||
0,
|
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
|
||||||
(performance.now() - this.#activeStartedAtMilliseconds) / 1_000
|
),
|
||||||
),
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const listener of this.#logListeners) listener(event);
|
for (const listener of this.#logListeners) listener(event);
|
||||||
@@ -174,10 +203,16 @@ export class EngineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPreference(preference: EnginePreference): void {
|
setPreference(preference: EnginePreference): void {
|
||||||
if (this.#state.status === 'running') {
|
if (
|
||||||
|
this.#activeJobId ||
|
||||||
|
this.#preparingOperation ||
|
||||||
|
this.#state.status === 'loading' ||
|
||||||
|
this.#state.status === 'recovering' ||
|
||||||
|
this.#state.status === 'running'
|
||||||
|
) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'busy',
|
'busy',
|
||||||
'Engine mode cannot be changed while a job is running.'
|
'Engine mode cannot be changed while another operation is active.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (preference !== this.#preference) {
|
if (preference !== this.#preference) {
|
||||||
@@ -203,7 +238,11 @@ export class EngineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.#activeJobId || this.#state.status === 'running') {
|
if (
|
||||||
|
this.#activeJobId ||
|
||||||
|
this.#preparingOperation ||
|
||||||
|
this.#state.status === 'running'
|
||||||
|
) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'busy',
|
'busy',
|
||||||
'The FFmpeg engine cannot be initialized while another operation is active.'
|
'The FFmpeg engine cannot be initialized while another operation is active.'
|
||||||
@@ -290,7 +329,11 @@ export class EngineManager {
|
|||||||
|
|
||||||
async #loadMode(
|
async #loadMode(
|
||||||
mode: EngineMode,
|
mode: EngineMode,
|
||||||
warning?: Extract<EngineState, { status: 'ready' }>['warning']
|
warning?: Extract<EngineState, { status: 'ready' }>['warning'],
|
||||||
|
cached?: {
|
||||||
|
readonly mode: EngineMode;
|
||||||
|
readonly capabilities: FFmpegCapabilities;
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const generation = ++this.#generation;
|
const generation = ++this.#generation;
|
||||||
this.#setState({
|
this.#setState({
|
||||||
@@ -317,9 +360,15 @@ export class EngineManager {
|
|||||||
status: 'loading',
|
status: 'loading',
|
||||||
mode,
|
mode,
|
||||||
progress: 0.75,
|
progress: 0.75,
|
||||||
stage: 'Inspecting codec capabilities',
|
stage:
|
||||||
|
cached && canReuseProbeCapabilities(mode, cached.mode)
|
||||||
|
? 'Restoring codec capabilities'
|
||||||
|
: 'Inspecting codec capabilities',
|
||||||
});
|
});
|
||||||
const capabilities = await queryCapabilities(engine);
|
const capabilities =
|
||||||
|
cached && canReuseProbeCapabilities(mode, cached.mode)
|
||||||
|
? cached.capabilities
|
||||||
|
: await queryCapabilities(engine);
|
||||||
if (generation !== this.#generation) return;
|
if (generation !== this.#generation) return;
|
||||||
this.#capabilities = capabilities;
|
this.#capabilities = capabilities;
|
||||||
this.#executionsSinceLoad = 0;
|
this.#executionsSinceLoad = 0;
|
||||||
@@ -329,27 +378,54 @@ export class EngineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async runJob(request: EngineJobRequest): Promise<EngineJobResult> {
|
async runJob(request: EngineJobRequest): Promise<EngineJobResult> {
|
||||||
if (this.#activeJobId) {
|
if (this.#activeJobId || this.#preparingOperation) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'busy',
|
'busy',
|
||||||
'Another FFmpeg operation is already running.'
|
'Another FFmpeg operation is already running.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
const coreWorkload = request.coreWorkload ?? request.operation;
|
if (this.#activeJobId || this.#preparingOperation) {
|
||||||
await this.#recycleExecutionCoreIfNeeded(coreWorkload);
|
throw new EngineError(
|
||||||
const engine = this.#engine;
|
'busy',
|
||||||
const mode = this.#mode;
|
'Another FFmpeg operation is already running.'
|
||||||
if (!engine || !mode || this.#state.status !== 'ready') {
|
);
|
||||||
throw new EngineError('load-failed', 'The FFmpeg engine is not ready.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mounted = await mountJobFiles(
|
let prepared:
|
||||||
engine,
|
| {
|
||||||
request.id,
|
readonly engine: FFmpegAdapter;
|
||||||
request.inputs,
|
readonly mode: EngineMode;
|
||||||
request.outputs.map(({ name }) => name)
|
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 generation = this.#generation;
|
||||||
const temporaryPaths = (request.temporaryFiles ?? []).map(
|
const temporaryPaths = (request.temporaryFiles ?? []).map(
|
||||||
(temporary, index) => {
|
(temporary, index) => {
|
||||||
@@ -358,7 +434,6 @@ export class EngineManager {
|
|||||||
return `${mounted.workDirectory}/temporary-${index}.${extension}`;
|
return `${mounted.workDirectory}/temporary-${index}.${extension}`;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.#activeJobId = request.id;
|
|
||||||
this.#activeExpectedDurationSeconds = request.expectedDurationSeconds;
|
this.#activeExpectedDurationSeconds = request.expectedDurationSeconds;
|
||||||
this.#activeStartedAtMilliseconds = performance.now();
|
this.#activeStartedAtMilliseconds = performance.now();
|
||||||
this.#machineProgressLines = [];
|
this.#machineProgressLines = [];
|
||||||
@@ -561,22 +636,79 @@ export class EngineManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
if (this.#activeJobId || this.#preparingOperation) {
|
||||||
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
|
throw new EngineError(
|
||||||
if (signal?.aborted) {
|
'busy',
|
||||||
await mounted.cleanup();
|
'Another FFmpeg operation is already running.'
|
||||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 probeOutput = mounted.outputPaths[0];
|
||||||
const inputPath = mounted.inputPaths[0];
|
const inputPath = mounted.inputPaths[0];
|
||||||
if (!probeOutput || !inputPath) {
|
if (!probeOutput || !inputPath) {
|
||||||
|
this.#activeJobId = null;
|
||||||
|
await mounted.cleanup();
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
'filesystem-failed',
|
'filesystem-failed',
|
||||||
'Could not allocate probe paths.'
|
'Could not allocate probe paths.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const activeProbeId = `probe-${id}`;
|
const activeProbeId = this.#activeJobId;
|
||||||
this.#activeJobId = activeProbeId;
|
if (!activeProbeId) {
|
||||||
|
throw new EngineError(
|
||||||
|
'filesystem-failed',
|
||||||
|
'Could not reserve the media probe.'
|
||||||
|
);
|
||||||
|
}
|
||||||
this.#activeExpectedDurationSeconds = undefined;
|
this.#activeExpectedDurationSeconds = undefined;
|
||||||
this.#activeStartedAtMilliseconds = performance.now();
|
this.#activeStartedAtMilliseconds = performance.now();
|
||||||
this.#machineProgressLines = [];
|
this.#machineProgressLines = [];
|
||||||
@@ -584,7 +716,7 @@ export class EngineManager {
|
|||||||
this.#logs.clear();
|
this.#logs.clear();
|
||||||
this.#setState({
|
this.#setState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
mode,
|
mode: refreshedMode,
|
||||||
jobId: this.#activeJobId,
|
jobId: this.#activeJobId,
|
||||||
operation: 'Inspect media',
|
operation: 'Inspect media',
|
||||||
inputMode: mounted.inputMode,
|
inputMode: mounted.inputMode,
|
||||||
@@ -605,7 +737,7 @@ export class EngineManager {
|
|||||||
};
|
};
|
||||||
signal?.addEventListener('abort', cancelOnAbort, { once: true });
|
signal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||||
try {
|
try {
|
||||||
const exitCode = await engine.ffprobe([
|
const exitCode = await refreshedEngine.ffprobe([
|
||||||
'-v',
|
'-v',
|
||||||
'error',
|
'error',
|
||||||
'-show_format',
|
'-show_format',
|
||||||
@@ -620,7 +752,7 @@ export class EngineManager {
|
|||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(
|
parsed = JSON.parse(
|
||||||
textFromFileData(await engine.readFile(probeOutput))
|
textFromFileData(await refreshedEngine.readFile(probeOutput))
|
||||||
) as unknown;
|
) as unknown;
|
||||||
} catch (readError) {
|
} catch (readError) {
|
||||||
throw new EngineError(
|
throw new EngineError(
|
||||||
@@ -691,16 +823,19 @@ export class EngineManager {
|
|||||||
this.#activeJobId = null;
|
this.#activeJobId = null;
|
||||||
this.#machineProgressLines = [];
|
this.#machineProgressLines = [];
|
||||||
this.#machineProgressSeen = false;
|
this.#machineProgressSeen = false;
|
||||||
if (this.#engine === engine) {
|
if (this.#engine === refreshedEngine) {
|
||||||
const cleanupSucceeded = await mounted.cleanup();
|
const cleanupSucceeded = await mounted.cleanup();
|
||||||
if (!cleanupSucceeded && this.#engine === engine) {
|
if (!cleanupSucceeded && this.#engine === refreshedEngine) {
|
||||||
this.#logs.append({
|
this.#logs.append({
|
||||||
type: 'stderr',
|
type: 'stderr',
|
||||||
message:
|
message:
|
||||||
'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.',
|
'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.',
|
||||||
});
|
});
|
||||||
this.#terminateEngine();
|
this.#terminateEngine();
|
||||||
this.#setState({ status: 'recovering', previousMode: mode });
|
this.#setState({
|
||||||
|
status: 'recovering',
|
||||||
|
previousMode: refreshedMode,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -711,7 +846,7 @@ export class EngineManager {
|
|||||||
if (capabilities) {
|
if (capabilities) {
|
||||||
this.#setState({
|
this.#setState({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode,
|
mode: refreshedMode,
|
||||||
capabilities,
|
capabilities,
|
||||||
warning: this.#warning,
|
warning: this.#warning,
|
||||||
});
|
});
|
||||||
@@ -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> {
|
async cancelActive(): Promise<void> {
|
||||||
const activeJobId = this.#activeJobId;
|
const activeJobId = this.#activeJobId;
|
||||||
const previousMode = this.#mode;
|
const previousMode = this.#mode;
|
||||||
@@ -760,7 +960,10 @@ export class EngineManager {
|
|||||||
this.#contactSheetsSinceLoad = 0;
|
this.#contactSheetsSinceLoad = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #recycleExecutionCoreIfNeeded(nextOperation: string): Promise<void> {
|
async #recycleExecutionCoreIfNeeded(
|
||||||
|
nextOperation: string,
|
||||||
|
operationReserved = false
|
||||||
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!shouldRecycleExecutionCore(
|
!shouldRecycleExecutionCore(
|
||||||
this.#executionsSinceLoad,
|
this.#executionsSinceLoad,
|
||||||
@@ -789,7 +992,14 @@ export class EngineManager {
|
|||||||
});
|
});
|
||||||
this.#terminateEngine();
|
this.#terminateEngine();
|
||||||
this.#setState({ status: 'recovering', previousMode });
|
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 {
|
#setState(state: EngineState): void {
|
||||||
|
|||||||
@@ -561,12 +561,16 @@ select:disabled {
|
|||||||
color: #2c7d63 !important;
|
color: #2c7d63 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phase--idle {
|
||||||
|
color: #2c7d63 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.phase--probing {
|
.phase--probing {
|
||||||
color: #8b6a20 !important;
|
color: #8b6a20 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase--error {
|
.phase--error {
|
||||||
color: var(--toolbox-danger) !important;
|
color: #8b6a20 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-bin__empty {
|
.media-bin__empty {
|
||||||
@@ -779,6 +783,18 @@ select:disabled {
|
|||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-picker__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-picker__empty .button {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.stream-kind {
|
.stream-kind {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1759,6 +1775,15 @@ progress::-moz-progress-bar {
|
|||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inspector-section > .notice {
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-section > .notice > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.inspector-section h3 {
|
.inspector-section h3 {
|
||||||
margin: 0.45rem 0 0;
|
margin: 0.45rem 0 0;
|
||||||
}
|
}
|
||||||
|
|||||||
282
src/media/browser-metadata.ts
Normal file
282
src/media/browser-metadata.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
isAudioMimeType,
|
||||||
|
isVideoMimeType,
|
||||||
|
mimeTypeFromFileName,
|
||||||
|
} from './mime-types';
|
||||||
|
|
||||||
|
export type BrowserMediaKind = 'audio' | 'video' | 'unknown';
|
||||||
|
|
||||||
|
export type BrowserMetadataErrorCode = 'aborted' | 'load-error' | 'timeout';
|
||||||
|
|
||||||
|
export interface BrowserMediaFileInfo {
|
||||||
|
readonly name: string;
|
||||||
|
readonly sizeBytes: number;
|
||||||
|
readonly declaredMimeType?: string;
|
||||||
|
readonly inferredMimeType?: string;
|
||||||
|
readonly lastModified: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight metadata exposed by the browser's native media element.
|
||||||
|
*
|
||||||
|
* This intentionally does not pretend to replace ffprobe: codec names,
|
||||||
|
* complete stream lists, chapters, tags and dispositions remain unknown.
|
||||||
|
*/
|
||||||
|
export interface BrowserMediaMetadata {
|
||||||
|
readonly source: 'browser';
|
||||||
|
readonly file: BrowserMediaFileInfo;
|
||||||
|
readonly kind: BrowserMediaKind;
|
||||||
|
readonly durationSeconds?: number;
|
||||||
|
readonly width?: number;
|
||||||
|
readonly height?: number;
|
||||||
|
readonly hasAudio?: boolean;
|
||||||
|
readonly hasVideo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserMetadataOptions {
|
||||||
|
readonly signal?: AbortSignal;
|
||||||
|
readonly timeoutMilliseconds?: number;
|
||||||
|
/**
|
||||||
|
* Test seam for the DOM-owned media element. Production callers normally
|
||||||
|
* leave this unset.
|
||||||
|
*/
|
||||||
|
readonly createMediaElement?: (
|
||||||
|
kind: Exclude<BrowserMediaKind, 'unknown'>
|
||||||
|
) => HTMLMediaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserMetadataError extends Error {
|
||||||
|
readonly code: BrowserMetadataErrorCode;
|
||||||
|
|
||||||
|
constructor(code: BrowserMetadataErrorCode, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BrowserMetadataError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BROWSER_METADATA_TIMEOUT_MILLISECONDS = 15_000;
|
||||||
|
|
||||||
|
interface MediaElementWithAudioHints extends HTMLMediaElement {
|
||||||
|
readonly audioTracks?: { readonly length: number };
|
||||||
|
readonly mozHasAudio?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function browserMediaFileInfo(file: File): BrowserMediaFileInfo {
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: normalizeMimeType(file.type),
|
||||||
|
inferredMimeType: mimeTypeFromFileName(file.name),
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferBrowserMediaKind(file: File): BrowserMediaKind {
|
||||||
|
const declaredMimeType = normalizeMimeType(file.type);
|
||||||
|
const inferredMimeType = mimeTypeFromFileName(file.name);
|
||||||
|
const mimeType =
|
||||||
|
declaredMimeType !== undefined &&
|
||||||
|
(isAudioMimeType(declaredMimeType) || isVideoMimeType(declaredMimeType))
|
||||||
|
? declaredMimeType
|
||||||
|
: inferredMimeType;
|
||||||
|
|
||||||
|
if (mimeType === undefined) return 'unknown';
|
||||||
|
if (isVideoMimeType(mimeType)) return 'video';
|
||||||
|
if (isAudioMimeType(mimeType)) return 'audio';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads only metadata the browser can expose without FFmpeg. The supplied
|
||||||
|
* object URL remains owned by the caller and is never revoked here.
|
||||||
|
*/
|
||||||
|
export function inspectBrowserMediaMetadata(
|
||||||
|
file: File,
|
||||||
|
objectUrl: string,
|
||||||
|
options: BrowserMetadataOptions = {}
|
||||||
|
): Promise<BrowserMediaMetadata> {
|
||||||
|
const declaredKind = inferBrowserMediaKind(file);
|
||||||
|
const elementKind = declaredKind === 'audio' ? 'audio' : 'video';
|
||||||
|
const createMediaElement =
|
||||||
|
options.createMediaElement ??
|
||||||
|
((kind: 'audio' | 'video') => document.createElement(kind));
|
||||||
|
const element = createMediaElement(elementKind);
|
||||||
|
const timeoutMilliseconds =
|
||||||
|
options.timeoutMilliseconds ??
|
||||||
|
DEFAULT_BROWSER_METADATA_TIMEOUT_MILLISECONDS;
|
||||||
|
|
||||||
|
if (options.signal?.aborted === true) {
|
||||||
|
return Promise.reject(abortedError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
element.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
element.removeEventListener('error', handleError);
|
||||||
|
element.removeEventListener('abort', handleElementAbort);
|
||||||
|
options.signal?.removeEventListener('abort', handleSignalAbort);
|
||||||
|
element.removeAttribute('src');
|
||||||
|
try {
|
||||||
|
element.load();
|
||||||
|
} catch {
|
||||||
|
// The caller owns the object URL; releasing this detached element is
|
||||||
|
// best-effort and must not turn successful metadata into a failure.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finish = (
|
||||||
|
outcome:
|
||||||
|
| { readonly ok: true; readonly value: BrowserMediaMetadata }
|
||||||
|
| { readonly ok: false; readonly error: BrowserMetadataError }
|
||||||
|
) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
if (outcome.ok) resolve(outcome.value);
|
||||||
|
else reject(outcome.error);
|
||||||
|
};
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
finish({
|
||||||
|
ok: true,
|
||||||
|
value: metadataFromElement(file, declaredKind, element),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleError = () => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'load-error',
|
||||||
|
`The browser could not read metadata from ${file.name}.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleElementAbort = () => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'load-error',
|
||||||
|
`The browser stopped reading metadata from ${file.name}.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleSignalAbort = () => {
|
||||||
|
finish({ ok: false, error: abortedError() });
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
element.addEventListener('error', handleError);
|
||||||
|
element.addEventListener('abort', handleElementAbort);
|
||||||
|
options.signal?.addEventListener('abort', handleSignalAbort, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'timeout',
|
||||||
|
`Browser metadata loading exceeded ${String(
|
||||||
|
Math.ceil(timeoutMilliseconds / 1_000)
|
||||||
|
)} seconds.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Math.max(0, timeoutMilliseconds)
|
||||||
|
);
|
||||||
|
|
||||||
|
element.preload = 'metadata';
|
||||||
|
element.src = objectUrl;
|
||||||
|
try {
|
||||||
|
element.load();
|
||||||
|
} catch {
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: new BrowserMetadataError(
|
||||||
|
'load-error',
|
||||||
|
`The browser could not start reading metadata from ${file.name}.`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataFromElement(
|
||||||
|
file: File,
|
||||||
|
declaredKind: BrowserMediaKind,
|
||||||
|
element: HTMLMediaElement
|
||||||
|
): BrowserMediaMetadata {
|
||||||
|
const isVideoElement = element instanceof HTMLVideoElement;
|
||||||
|
const width = isVideoElement
|
||||||
|
? positiveFiniteNumber(element.videoWidth)
|
||||||
|
: undefined;
|
||||||
|
const height = isVideoElement
|
||||||
|
? positiveFiniteNumber(element.videoHeight)
|
||||||
|
: undefined;
|
||||||
|
// A zero-sized <video> can still be the correct, playable element for an
|
||||||
|
// audio-only MP4 or another video container. Treat missing dimensions as
|
||||||
|
// inconclusive instead of claiming that no video-style player is usable.
|
||||||
|
const hasVideo = isVideoElement
|
||||||
|
? width !== undefined
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
: false;
|
||||||
|
const hasAudio = audioPresence(element, isVideoElement);
|
||||||
|
const kind =
|
||||||
|
hasVideo === true
|
||||||
|
? 'video'
|
||||||
|
: hasAudio === true || element instanceof HTMLAudioElement
|
||||||
|
? 'audio'
|
||||||
|
: declaredKind;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'browser',
|
||||||
|
file: browserMediaFileInfo(file),
|
||||||
|
kind,
|
||||||
|
durationSeconds: nonNegativeFiniteNumber(element.duration),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
hasAudio,
|
||||||
|
hasVideo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioPresence(
|
||||||
|
element: HTMLMediaElement,
|
||||||
|
isVideoElement: boolean
|
||||||
|
): boolean | undefined {
|
||||||
|
if (!isVideoElement) return true;
|
||||||
|
|
||||||
|
const withAudioHints = element as MediaElementWithAudioHints;
|
||||||
|
if (withAudioHints.audioTracks !== undefined) {
|
||||||
|
return withAudioHints.audioTracks.length > 0;
|
||||||
|
}
|
||||||
|
if (typeof withAudioHints.mozHasAudio === 'boolean') {
|
||||||
|
return withAudioHints.mozHasAudio;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMimeType(mimeType: string): string | undefined {
|
||||||
|
const normalized = mimeType.trim().toLowerCase().split(';')[0]?.trim();
|
||||||
|
return normalized === undefined || normalized.length === 0
|
||||||
|
? undefined
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveFiniteNumber(value: number): number | undefined {
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeFiniteNumber(value: number): number | undefined {
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortedError(): BrowserMetadataError {
|
||||||
|
return new BrowserMetadataError(
|
||||||
|
'aborted',
|
||||||
|
'Browser metadata loading was cancelled.'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './browser-playback';
|
export * from './browser-playback';
|
||||||
|
export * from './browser-metadata';
|
||||||
export * from './chapter-time-base';
|
export * from './chapter-time-base';
|
||||||
export * from './duration';
|
export * from './duration';
|
||||||
export * from './media.types';
|
export * from './media.types';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.av-tools",
|
"id": "de.add-ideas.av-tools",
|
||||||
"name": "Audio & Video Tools",
|
"name": "Audio & Video Tools",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const APP_VERSION = '0.2.1';
|
export const APP_VERSION = '0.3.0';
|
||||||
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
|
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
|
||||||
export const FFMPEG_UTIL_VERSION = '0.12.2';
|
export const FFMPEG_UTIL_VERSION = '0.12.2';
|
||||||
export const FFMPEG_CORE_VERSION = '0.12.10';
|
export const FFMPEG_CORE_VERSION = '0.12.10';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
observeRuntime,
|
observeRuntime,
|
||||||
|
PRIMARY_FIXTURE,
|
||||||
startNestedStaticServer,
|
startNestedStaticServer,
|
||||||
type NestedStaticServer,
|
type NestedStaticServer,
|
||||||
} from './browser-helpers';
|
} from './browser-helpers';
|
||||||
@@ -119,6 +120,38 @@ test.describe('static application and Toolbox shell', () => {
|
|||||||
await expect(inspectorDrawer).toBeHidden();
|
await expect(inspectorDrawer).toBeHidden();
|
||||||
await expect(inspector).toBeFocused();
|
await expect(inspector).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows native preview metadata without loading the FFmpeg runtime', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const runtime = observeRuntime(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"][accept*="audio"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
|
||||||
|
await expect(page.locator('.quick-preview video')).toBeVisible();
|
||||||
|
const card = page
|
||||||
|
.locator('.media-card')
|
||||||
|
.filter({ hasText: 'pattern-av.mp4' });
|
||||||
|
await expect(card.locator('.phase--idle')).toContainText(
|
||||||
|
'Ready to play · Details on demand'
|
||||||
|
);
|
||||||
|
await expect(card).toContainText(/\d+:\d{2}\.\d{2}/u);
|
||||||
|
expect(
|
||||||
|
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Inspect & render timeline' })
|
||||||
|
).toBeEnabled();
|
||||||
|
expect(
|
||||||
|
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('nested static deployment and Toolbox context', () => {
|
test.describe('nested static deployment and Toolbox context', () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
|
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
export const REPOSITORY_ROOT = resolve(
|
export const REPOSITORY_ROOT = resolve(
|
||||||
dirname(fileURLToPath(import.meta.url)),
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
@@ -19,6 +20,20 @@ export const GENERATED_FIXTURES = join(
|
|||||||
);
|
);
|
||||||
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
|
export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4');
|
||||||
|
|
||||||
|
export async function inspectMediaDetails(
|
||||||
|
page: Page,
|
||||||
|
fileName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const card = page.locator('.media-card').filter({ hasText: fileName });
|
||||||
|
const inspect = page.getByRole('button', {
|
||||||
|
name: `Inspect details for ${fileName}`,
|
||||||
|
});
|
||||||
|
if ((await inspect.count()) > 0) await inspect.click();
|
||||||
|
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const NESTED_PREFIX = '/deep/nested/av/';
|
const NESTED_PREFIX = '/deep/nested/av/';
|
||||||
const SECURITY_HEADERS = {
|
const SECURITY_HEADERS = {
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
import {
|
import {
|
||||||
createCancellationFixture,
|
createCancellationFixture,
|
||||||
GENERATED_FIXTURES,
|
GENERATED_FIXTURES,
|
||||||
|
inspectMediaDetails,
|
||||||
observeRuntime,
|
observeRuntime,
|
||||||
PRIMARY_FIXTURE,
|
PRIMARY_FIXTURE,
|
||||||
type TemporaryMediaFixture,
|
type TemporaryMediaFixture,
|
||||||
@@ -99,8 +100,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||||
@@ -184,8 +186,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await expect(page.locator('.source-summary')).toContainText(
|
await expect(page.locator('.source-summary')).toContainText(
|
||||||
@@ -275,6 +278,7 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
await mediaInput(page)
|
await mediaInput(page)
|
||||||
.first()
|
.first()
|
||||||
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
||||||
|
await inspectMediaDetails(page, 'compatible-a.mp4');
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
@@ -329,8 +333,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await selectSingleThread(page);
|
await selectSingleThread(page);
|
||||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||||
|
await inspectMediaDetails(page, basename(cancellationFixture.filePath));
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -357,8 +362,9 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||||
@@ -388,10 +394,15 @@ test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
|||||||
const queuedCard = page.locator('.media-card').filter({
|
const queuedCard = page.locator('.media-card').filter({
|
||||||
hasText: 'pattern-av.mp4',
|
hasText: 'pattern-av.mp4',
|
||||||
});
|
});
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: `Inspect details for ${activeName}`,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
await expect(activeCard.locator('.phase--probing')).toBeVisible({
|
||||||
timeout: 30_000,
|
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 pattern-av.mp4' }).click();
|
||||||
await page.getByRole('button', { name: `Remove ${activeName}` }).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 });
|
).toBeEnabled({ timeout: 120_000 });
|
||||||
|
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
GENERATED_FIXTURES,
|
GENERATED_FIXTURES,
|
||||||
|
inspectMediaDetails,
|
||||||
observeRuntime,
|
observeRuntime,
|
||||||
PRIMARY_FIXTURE,
|
PRIMARY_FIXTURE,
|
||||||
type RuntimeObservation,
|
type RuntimeObservation,
|
||||||
@@ -51,8 +52,9 @@ test.describe
|
|||||||
await mediaInput(page).setInputFiles(
|
await mediaInput(page).setInputFiles(
|
||||||
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
||||||
);
|
);
|
||||||
|
await inspectMediaDetails(page, 'attached-cover.mp3');
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 60_000 }
|
{ timeout: 60_000 }
|
||||||
);
|
);
|
||||||
await expect(page.locator('.source-summary')).toContainText(
|
await expect(page.locator('.source-summary')).toContainText(
|
||||||
@@ -108,13 +110,23 @@ test.describe
|
|||||||
await mediaInput(page).setInputFiles(
|
await mediaInput(page).setInputFiles(
|
||||||
join(GENERATED_FIXTURES, 'truncated.mp4')
|
join(GENERATED_FIXTURES, 'truncated.mp4')
|
||||||
);
|
);
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Inspect details for truncated.mp4',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
const malformedCard = page
|
const malformedCard = page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
.filter({ hasText: 'truncated.mp4' });
|
.filter({ hasText: 'truncated.mp4' });
|
||||||
await expect(malformedCard.locator('.phase--error')).toContainText(
|
await expect(malformedCard.locator('.phase--error')).toContainText(
|
||||||
'ffprobe did not find any media streams.',
|
'File ready · Details unavailable',
|
||||||
{ timeout: 30_000 }
|
{ timeout: 30_000 }
|
||||||
);
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'Retry detailed inspection for truncated.mp4',
|
||||||
|
})
|
||||||
|
).toHaveAttribute('title', 'ffprobe did not find any media streams.');
|
||||||
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
@@ -123,6 +135,7 @@ test.describe
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||||
|
await inspectMediaDetails(page, 'pattern-av.mp4');
|
||||||
const validCard = page
|
const validCard = page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
.filter({ hasText: 'pattern-av.mp4' });
|
.filter({ hasText: 'pattern-av.mp4' });
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
const mediaPath = process.env.AV_TOOLS_LARGE_MEDIA;
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
@@ -27,7 +27,15 @@ test('plays a recognized large local video while inspection continues', async ({
|
|||||||
await expect(preview).toBeVisible({ timeout: 15_000 });
|
await expect(preview).toBeVisible({ timeout: 15_000 });
|
||||||
const previewVisibleMilliseconds = Date.now() - startedAt;
|
const previewVisibleMilliseconds = Date.now() - startedAt;
|
||||||
expect(previewVisibleMilliseconds).toBeLessThan(15_000);
|
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) => {
|
const elementIdentity = await preview.evaluate((element) => {
|
||||||
element.dataset.evidenceIdentity = crypto.randomUUID();
|
element.dataset.evidenceIdentity = crypto.randomUUID();
|
||||||
@@ -50,9 +58,11 @@ test('plays a recognized large local video while inspection continues', async ({
|
|||||||
)
|
)
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
await page
|
||||||
timeout: 330_000,
|
.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(
|
await expect(preview).toHaveAttribute(
|
||||||
'data-evidence-identity',
|
'data-evidence-identity',
|
||||||
elementIdentity
|
elementIdentity
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ async function importMedia(
|
|||||||
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
|
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const card = page.locator('.media-card').filter({ hasText: name });
|
const card = page.locator('.media-card').filter({ hasText: name });
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: `Inspect details for ${name}` })
|
||||||
|
.click();
|
||||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
@@ -210,6 +213,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
|||||||
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
|
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
|
||||||
const sourceName = basename(cancellationFixture.filePath);
|
const sourceName = basename(cancellationFixture.filePath);
|
||||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: `Inspect details for ${sourceName}`,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
@@ -1055,6 +1063,11 @@ test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
|||||||
mimeType: 'video/x-matroska',
|
mimeType: 'video/x-matroska',
|
||||||
buffer: readFileSync(muxed.filePath),
|
buffer: readFileSync(muxed.filePath),
|
||||||
});
|
});
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Inspect details for subtitled-source.mkv',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('.media-card')
|
.locator('.media-card')
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ async function runMode(
|
|||||||
|
|
||||||
const probeStarted = await monotonicNow(page);
|
const probeStarted = await monotonicNow(page);
|
||||||
await mediaInput(page).setInputFiles(fixturePath);
|
await mediaInput(page).setInputFiles(fixturePath);
|
||||||
|
await page.getByRole('button', { name: /^Inspect details for /u }).click();
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
const probeMs = (await monotonicNow(page)) - probeStarted;
|
const probeMs = (await monotonicNow(page)) - probeStarted;
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
|||||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||||
|
|
||||||
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||||
|
.click();
|
||||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||||
'Ready',
|
'Details ready',
|
||||||
{ timeout: 120_000 }
|
{ timeout: 120_000 }
|
||||||
);
|
);
|
||||||
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
||||||
@@ -47,6 +50,9 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
|||||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||||
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
||||||
await mediaInput(page).setInputFiles(compatibleB);
|
await mediaInput(page).setInputFiles(compatibleB);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||||
|
.click();
|
||||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
@@ -127,6 +133,12 @@ test('covers drop, editor keyboard/drag controls, cache, and project reattachmen
|
|||||||
|
|
||||||
page.on('dialog', (dialog) => void dialog.accept());
|
page.on('dialog', (dialog) => void dialog.accept());
|
||||||
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
|
||||||
|
.click();
|
||||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
|
|||||||
93
tests/unit/components/media-bin.test.tsx
Normal file
93
tests/unit/components/media-bin.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ImportedMediaAsset } from '../../../src/app/application-state';
|
||||||
|
import { MediaBin } from '../../../src/components/MediaBin';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('MediaBin', () => {
|
||||||
|
it('shows browser metadata and offers optional detailed inspection', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onInspect = vi.fn();
|
||||||
|
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'lecture',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:lecture',
|
||||||
|
phase: 'idle',
|
||||||
|
browserMetadata: {
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: file.type,
|
||||||
|
inferredMimeType: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 2_622.48,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
hasVideo: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MediaBin
|
||||||
|
assets={[asset]}
|
||||||
|
selectedId={asset.id}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onInspect={onInspect}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
|
||||||
|
expect(screen.getByText('Ready to play · Details on demand')).toBeVisible();
|
||||||
|
expect(screen.getByText('lecture.mp4').closest('button')).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Inspect details for lecture.mp4',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onInspect).toHaveBeenCalledWith(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a source ready to play after detailed inspection fails', () => {
|
||||||
|
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'lecture',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:lecture',
|
||||||
|
phase: 'error',
|
||||||
|
error: 'Media inspection exceeded its safety limit.',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MediaBin
|
||||||
|
assets={[asset]}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onInspect={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('File ready · Details unavailable')).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Retry detailed inspection for lecture.mp4',
|
||||||
|
})
|
||||||
|
).toBeEnabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Retry detailed inspection for lecture.mp4',
|
||||||
|
})
|
||||||
|
).toHaveAccessibleDescription(
|
||||||
|
'Media inspection exceeded its safety limit.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,7 @@ describe('MediaPreview', () => {
|
|||||||
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
|
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
'Local source preview · Media inspection continues in the background.'
|
'Local source preview · Detailed inspection runs separately.'
|
||||||
)
|
)
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
@@ -61,6 +61,64 @@ describe('MediaPreview', () => {
|
|||||||
expect(container.querySelector('video')).toBe(progressiveVideo);
|
expect(container.querySelector('video')).toBe(progressiveVideo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps browser playback mounted after detailed inspection fails', () => {
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'asset-probe-error',
|
||||||
|
file: new File(['video'], 'lecture.mp4', { type: 'video/mp4' }),
|
||||||
|
objectUrl: 'blob:probe-error',
|
||||||
|
phase: 'error',
|
||||||
|
error: 'Detailed inspection timed out.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<MediaPreview asset={asset} />);
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
asset.objectUrl
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Local source preview · Detailed inspection unavailable.'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the video-container player when browser metadata has no track hints', () => {
|
||||||
|
const file = new File(['audio-only'], 'audio-only.mp4', {
|
||||||
|
type: 'video/mp4',
|
||||||
|
});
|
||||||
|
const initial: ImportedMediaAsset = {
|
||||||
|
id: 'asset-audio-only-container',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:audio-only-container',
|
||||||
|
phase: 'idle',
|
||||||
|
};
|
||||||
|
const { container, rerender } = render(<MediaPreview asset={initial} />);
|
||||||
|
const player = requiredElement(container.querySelector('video'));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MediaPreview
|
||||||
|
asset={{
|
||||||
|
...initial,
|
||||||
|
browserMetadata: {
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: file.type,
|
||||||
|
inferredMimeType: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 8,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).toBe(player);
|
||||||
|
});
|
||||||
|
|
||||||
it('offers bounded configurable proxy settings after playback fails', async () => {
|
it('offers bounded configurable proxy settings after playback fails', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onCreateProxy = vi.fn();
|
const onCreateProxy = vi.fn();
|
||||||
|
|||||||
@@ -16,6 +16,66 @@ import type { UserExportPreset } from '../../../src/presets';
|
|||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('QuickConvert', () => {
|
describe('QuickConvert', () => {
|
||||||
|
it('keeps an uninspected source usable and requests stream detection with export', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onInspect = vi.fn();
|
||||||
|
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
|
||||||
|
const file = new File(['media'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const asset: ImportedMediaAsset = {
|
||||||
|
id: 'asset-basic',
|
||||||
|
file,
|
||||||
|
objectUrl: 'blob:asset-basic',
|
||||||
|
phase: 'idle',
|
||||||
|
browserMetadata: {
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
declaredMimeType: file.type,
|
||||||
|
inferredMimeType: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 2_622.48,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
hasVideo: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QuickConvert
|
||||||
|
asset={asset}
|
||||||
|
engineState={readyEngine()}
|
||||||
|
busy={false}
|
||||||
|
onInspect={onInspect}
|
||||||
|
onExport={onExport}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/43:42\.48/u)).toBeVisible();
|
||||||
|
expect(screen.getByText('Ready to play')).toBeVisible();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Inspect details now' })
|
||||||
|
);
|
||||||
|
expect(onInspect).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Inspect & convert' }));
|
||||||
|
expect(onExport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
operation: 'convert',
|
||||||
|
selectDetectedStreams: true,
|
||||||
|
streamSelection: {
|
||||||
|
video: [],
|
||||||
|
audio: [],
|
||||||
|
subtitles: [],
|
||||||
|
attachments: [],
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('labels a submission honestly when another operation is active', () => {
|
it('labels a submission honestly when another operation is active', () => {
|
||||||
render(
|
render(
|
||||||
<QuickConvert
|
<QuickConvert
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||||
MIN_PROBE_TIMEOUT_MILLISECONDS,
|
MIN_PROBE_TIMEOUT_MILLISECONDS,
|
||||||
MULTITHREAD_CODEC_THREAD_LIMIT,
|
MULTITHREAD_CODEC_THREAD_LIMIT,
|
||||||
|
canReuseProbeCapabilities,
|
||||||
engineJobTimeoutMilliseconds,
|
engineJobTimeoutMilliseconds,
|
||||||
prepareEngineArguments,
|
prepareEngineArguments,
|
||||||
probeTimeoutMilliseconds,
|
probeTimeoutMilliseconds,
|
||||||
|
shouldRefreshCoreBeforeProbe,
|
||||||
shouldRecycleExecutionCore,
|
shouldRecycleExecutionCore,
|
||||||
} from '../../src/ffmpeg/EngineManager';
|
} 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', () => {
|
describe('execution core recycling policy', () => {
|
||||||
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
|
it('refreshes only after the bounded idle-core job budget is exhausted', () => {
|
||||||
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
|
expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe(
|
||||||
|
|||||||
187
tests/unit/media/browser-metadata.test.ts
Normal file
187
tests/unit/media/browser-metadata.test.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrowserMetadataError,
|
||||||
|
browserMediaFileInfo,
|
||||||
|
inferBrowserMediaKind,
|
||||||
|
inspectBrowserMediaMetadata,
|
||||||
|
} from '../../../src/media/browser-metadata';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('browser media metadata', () => {
|
||||||
|
it('returns file information synchronously and infers useful media kinds', () => {
|
||||||
|
const file = new File(['media'], 'lecture.MP4', {
|
||||||
|
type: 'application/octet-stream',
|
||||||
|
lastModified: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(browserMediaFileInfo(file)).toEqual({
|
||||||
|
name: 'lecture.MP4',
|
||||||
|
sizeBytes: 5,
|
||||||
|
declaredMimeType: 'application/octet-stream',
|
||||||
|
inferredMimeType: 'video/mp4',
|
||||||
|
lastModified: 123,
|
||||||
|
});
|
||||||
|
expect(inferBrowserMediaKind(file)).toBe('video');
|
||||||
|
expect(
|
||||||
|
inferBrowserMediaKind(
|
||||||
|
new File([], 'recording.bin', { type: 'audio/ogg; codecs=opus' })
|
||||||
|
)
|
||||||
|
).toBe('audio');
|
||||||
|
expect(inferBrowserMediaKind(new File([], 'notes.txt'))).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads duration and dimensions through a native video element', async () => {
|
||||||
|
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
||||||
|
const video = document.createElement('video');
|
||||||
|
const load = vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||||
|
defineNumber(video, 'duration', 2_622.48);
|
||||||
|
defineNumber(video, 'videoWidth', 640);
|
||||||
|
defineNumber(video, 'videoHeight', 480);
|
||||||
|
Object.defineProperty(video, 'audioTracks', {
|
||||||
|
configurable: true,
|
||||||
|
value: { length: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:lecture', {
|
||||||
|
createMediaElement: () => video,
|
||||||
|
});
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toEqual({
|
||||||
|
source: 'browser',
|
||||||
|
file: {
|
||||||
|
name: 'lecture.mp4',
|
||||||
|
sizeBytes: 5,
|
||||||
|
declaredMimeType: 'video/mp4',
|
||||||
|
inferredMimeType: 'video/mp4',
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
},
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 2_622.48,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: true,
|
||||||
|
});
|
||||||
|
expect(video.preload).toBe('metadata');
|
||||||
|
expect(video).not.toHaveAttribute('src');
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an audio element and leaves unavailable details unknown', async () => {
|
||||||
|
const file = new File(['audio'], 'speech.flac', { type: 'audio/flac' });
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
vi.spyOn(audio, 'load').mockImplementation(() => undefined);
|
||||||
|
defineNumber(audio, 'duration', 12.5);
|
||||||
|
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:speech', {
|
||||||
|
createMediaElement: (kind) => {
|
||||||
|
expect(kind).toBe('audio');
|
||||||
|
return audio;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audio.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toMatchObject({
|
||||||
|
source: 'browser',
|
||||||
|
kind: 'audio',
|
||||||
|
durationSeconds: 12.5,
|
||||||
|
hasAudio: true,
|
||||||
|
hasVideo: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a video-container player viable when dimensions and audio hints are absent', async () => {
|
||||||
|
const file = new File(['audio-only'], 'audio-only.mp4', {
|
||||||
|
type: 'video/mp4',
|
||||||
|
});
|
||||||
|
const video = document.createElement('video');
|
||||||
|
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||||
|
defineNumber(video, 'duration', 8);
|
||||||
|
defineNumber(video, 'videoWidth', 0);
|
||||||
|
defineNumber(video, 'videoHeight', 0);
|
||||||
|
Object.defineProperty(video, 'audioTracks', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:audio-only', {
|
||||||
|
createMediaElement: () => video,
|
||||||
|
});
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toMatchObject({
|
||||||
|
kind: 'video',
|
||||||
|
durationSeconds: 8,
|
||||||
|
hasVideo: undefined,
|
||||||
|
hasAudio: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports load errors without revoking the caller-owned object URL', async () => {
|
||||||
|
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL');
|
||||||
|
const video = document.createElement('video');
|
||||||
|
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
||||||
|
const resultPromise = inspectBrowserMediaMetadata(
|
||||||
|
new File([], 'broken.mp4', { type: 'video/mp4' }),
|
||||||
|
'blob:broken',
|
||||||
|
{ createMediaElement: () => video }
|
||||||
|
);
|
||||||
|
|
||||||
|
video.dispatchEvent(new Event('error'));
|
||||||
|
|
||||||
|
await expect(resultPromise).rejects.toMatchObject({
|
||||||
|
name: 'BrowserMetadataError',
|
||||||
|
code: 'load-error',
|
||||||
|
});
|
||||||
|
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports cancellation and a bounded metadata timeout', async () => {
|
||||||
|
const file = new File([], 'long.mp4', { type: 'video/mp4' });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const firstVideo = document.createElement('video');
|
||||||
|
vi.spyOn(firstVideo, 'load').mockImplementation(() => undefined);
|
||||||
|
const cancelled = inspectBrowserMediaMetadata(file, 'blob:cancel', {
|
||||||
|
createMediaElement: () => firstVideo,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(cancelled).rejects.toMatchObject({
|
||||||
|
code: 'aborted',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const secondVideo = document.createElement('video');
|
||||||
|
vi.spyOn(secondVideo, 'load').mockImplementation(() => undefined);
|
||||||
|
const timedOut = inspectBrowserMediaMetadata(file, 'blob:timeout', {
|
||||||
|
createMediaElement: () => secondVideo,
|
||||||
|
timeoutMilliseconds: 25,
|
||||||
|
});
|
||||||
|
const timeoutExpectation = expect(timedOut).rejects.toEqual(
|
||||||
|
new BrowserMetadataError(
|
||||||
|
'timeout',
|
||||||
|
'Browser metadata loading exceeded 1 seconds.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
await timeoutExpectation;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function defineNumber(
|
||||||
|
target: HTMLMediaElement,
|
||||||
|
property: 'duration' | 'videoHeight' | 'videoWidth',
|
||||||
|
value: number
|
||||||
|
): void {
|
||||||
|
Object.defineProperty(target, property, {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -165,30 +165,30 @@ describe('licence release gate', () => {
|
|||||||
describe('release checksum', () => {
|
describe('release checksum', () => {
|
||||||
it('formats and parses the portable sidecar form', () => {
|
it('formats and parses the portable sidecar form', () => {
|
||||||
const digest = 'a'.repeat(64);
|
const digest = 'a'.repeat(64);
|
||||||
const line = checksumLine(digest, 'av-tools-0.2.1.zip');
|
const line = checksumLine(digest, 'av-tools-0.3.0.zip');
|
||||||
expect(line).toBe(`${digest} av-tools-0.2.1.zip\n`);
|
expect(line).toBe(`${digest} av-tools-0.3.0.zip\n`);
|
||||||
expect(parseChecksumLine(line)).toEqual({
|
expect(parseChecksumLine(line)).toEqual({
|
||||||
archiveName: 'av-tools-0.2.1.zip',
|
archiveName: 'av-tools-0.3.0.zip',
|
||||||
digest,
|
digest,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes and verifies a checksum sidecar', async () => {
|
it('writes and verifies a checksum sidecar', async () => {
|
||||||
const directory = await temporaryDirectory();
|
const directory = await temporaryDirectory();
|
||||||
const archive = path.join(directory, 'av-tools-0.2.1.zip');
|
const archive = path.join(directory, 'av-tools-0.3.0.zip');
|
||||||
await writeFile(archive, 'deterministic bytes');
|
await writeFile(archive, 'deterministic bytes');
|
||||||
const result = await writeChecksum(archive);
|
const result = await writeChecksum(archive);
|
||||||
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
|
expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
|
||||||
result.digest
|
result.digest
|
||||||
);
|
);
|
||||||
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
|
expect(await readFile(result.sidecarPath, 'utf8')).toContain(
|
||||||
' av-tools-0.2.1.zip'
|
' av-tools-0.3.0.zip'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects checksum mismatches', async () => {
|
it('rejects checksum mismatches', async () => {
|
||||||
const directory = await temporaryDirectory();
|
const directory = await temporaryDirectory();
|
||||||
const archive = path.join(directory, 'av-tools-0.2.1.zip');
|
const archive = path.join(directory, 'av-tools-0.3.0.zip');
|
||||||
const sidecar = `${archive}.sha256`;
|
const sidecar = `${archive}.sha256`;
|
||||||
await writeFile(archive, 'changed');
|
await writeFile(archive, 'changed');
|
||||||
await mkdir(path.dirname(sidecar), { recursive: true });
|
await mkdir(path.dirname(sidecar), { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user