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