diff --git a/README.md b/README.md index 550d4d0..8d718ed 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ 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. @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7aa5b00..579729a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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, diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 8ace317..0a192cd 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -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 @@ -83,9 +86,11 @@ FFmpeg output does not remove these product/browser/resource limits. 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. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index b2bf61b..7b58649 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -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 diff --git a/package.json b/package.json index 48882bc..10a7d36 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 69cdda4..28d7ca6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'] }, + }, ], }); diff --git a/src/App.tsx b/src/App.tsx index 873c7f9..67fbc78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,10 +9,7 @@ import { type WorkspaceMode, } from './app/application-state'; import { AppErrorBoundary } from './app/AppErrorBoundary'; -import { - SourceInspectionRegistry, - type SourceInspectionToken, -} from './app/source-inspection-registry'; +import { SourceInspectionRegistry } from './app/source-inspection-registry'; import { FileDropZone } from './components/FileDropZone'; import { MediaBin } from './components/MediaBin'; import { @@ -23,6 +20,7 @@ import { QuickConvert, type QuickExportConfiguration, } from './components/QuickConvert'; +import { defaultQuickStreamSelection } from './components/quick-convert-selection'; import { CapabilityPanel } from './components/CapabilityPanel'; import { JobPanel } from './components/JobPanel'; import { StoragePanel } from './components/StoragePanel'; @@ -58,7 +56,11 @@ import type { EngineState, } from './ffmpeg/ffmpeg.types'; import { EngineError } from './ffmpeg/ffmpeg-error'; -import { normalizeFfprobeReport } from './media'; +import { + BrowserMetadataError, + inspectBrowserMediaMetadata, + normalizeFfprobeReport, +} from './media'; import { createEmptyProject, importProjectDocument, @@ -304,6 +306,74 @@ function resultAsFile(result: ExportResult): File { }); } +function projectWithAssetInspection( + current: AvProjectV1, + asset: ImportedMediaAsset, + probe: MediaProbe, + provisionalDuration: number | undefined +): AvProjectV1 { + if (!current.assets.some((entry) => entry.id === asset.id)) return current; + + let next = projectReducer(current, { + type: 'asset/update', + assetId: asset.id, + changes: { probe, mimeType: asset.file.type }, + updatedAt: timestamp(), + }); + const existingClip = next.timeline.find((clip) => clip.assetId === asset.id); + if ( + existingClip && + provisionalDuration !== undefined && + probe.durationSeconds !== undefined && + existingClip.sourceInSeconds === 0 && + Math.abs(existingClip.sourceOutSeconds - provisionalDuration) < 0.05 && + Math.abs(probe.durationSeconds - provisionalDuration) >= 0.001 + ) { + next = projectReducer(next, { + type: 'clip/update', + clipId: existingClip.id, + changes: { sourceOutSeconds: probe.durationSeconds }, + updatedAt: timestamp(), + }); + } else if ( + !existingClip && + probe.durationSeconds !== undefined && + Number.isFinite(probe.durationSeconds) && + probe.durationSeconds >= 0.001 + ) { + next = projectReducer(next, { + type: 'clip/add', + clip: { + id: crypto.randomUUID(), + assetId: asset.id, + sourceInSeconds: 0, + sourceOutSeconds: probe.durationSeconds, + audio: { enabled: true }, + video: { enabled: true }, + }, + updatedAt: timestamp(), + }); + } + + if (next.chapters.length === 0 && probe.chapters.length > 0) { + next = projectReducer(next, { + type: 'chapters/set', + chapters: probe.chapters.map((chapter) => ({ + id: `${asset.id}-chapter-${chapter.id}`, + startSeconds: chapter.startSeconds, + endSeconds: chapter.endSeconds, + title: chapter.title ?? `Chapter ${chapter.id + 1}`, + ...(chapter.timeBase ? { timeBase: chapter.timeBase } : {}), + ...(Object.keys(chapter.tags).length > 0 + ? { metadata: chapter.tags } + : {}), + })), + updatedAt: timestamp(), + }); + } + return next; +} + function concatSourceForClip( clip: TimelineClip, asset: ImportedMediaAsset, @@ -567,6 +637,14 @@ export function AvToolsApplication() { const sourceProbeTail = useRef>(Promise.resolve()); const sourceProbePendingRef = useRef(0); const sourceInspectionRegistry = useRef(new SourceInspectionRegistry()); + const sourceInspectionPromises = useRef( + new Map< + string, + { readonly objectUrl: string; readonly promise: Promise } + >() + ); + const browserMetadataControllers = useRef(new Map()); + const browserMetadataClipTail = useRef>(Promise.resolve()); const planExecutionTail = useRef>(Promise.resolve()); const scheduledPlanCountRef = useRef(0); const scheduledWorkRef = useRef(new Map()); @@ -717,9 +795,14 @@ export function AvToolsApplication() { useEffect(() => { const inspectionRegistry = sourceInspectionRegistry.current; + const metadataControllers = browserMetadataControllers.current; return () => { const cancelActiveInspection = inspectionRegistry.clear(); if (cancelActiveInspection) void engineManager.cancelActive(); + for (const controller of metadataControllers.values()) { + controller.abort(); + } + metadataControllers.clear(); for (const asset of assetsRef.current) { URL.revokeObjectURL(asset.objectUrl); } @@ -734,11 +817,13 @@ export function AvToolsApplication() { const updateAssetState = useCallback( (id: string, changes: Partial) => { - setAssets((current) => - current.map((asset) => + setAssets((current) => { + const next = current.map((asset) => asset.id === id ? { ...asset, ...changes } : asset - ) - ); + ); + assetsRef.current = next; + return next; + }); }, [] ); @@ -766,117 +851,189 @@ export function AvToolsApplication() { [] ); + const addDefaultClip = useCallback( + ( + current: AvProjectV1, + assetId: string, + durationSeconds: number | undefined + ): AvProjectV1 => { + if ( + durationSeconds === undefined || + !Number.isFinite(durationSeconds) || + durationSeconds < 0.001 || + current.timeline.some((clip) => clip.assetId === assetId) + ) { + return current; + } + return projectReducer(current, { + type: 'clip/add', + clip: { + id: crypto.randomUUID(), + assetId, + sourceInSeconds: 0, + sourceOutSeconds: durationSeconds, + audio: { enabled: true }, + video: { enabled: true }, + }, + updatedAt: timestamp(), + }); + }, + [] + ); + + const readBrowserMetadata = useCallback( + (asset: ImportedMediaAsset) => { + browserMetadataControllers.current.get(asset.id)?.abort(); + const controller = new AbortController(); + browserMetadataControllers.current.set(asset.id, controller); + return inspectBrowserMediaMetadata(asset.file, asset.objectUrl, { + signal: controller.signal, + }) + .then((browserMetadata) => { + const currentAsset = assetsRef.current.find( + (entry) => + entry.id === asset.id && entry.objectUrl === asset.objectUrl + ); + if (!currentAsset || controller.signal.aborted) return; + updateAssetState(asset.id, { + browserMetadata, + browserMetadataError: undefined, + }); + return browserMetadata; + }) + .catch((error: unknown) => { + if ( + controller.signal.aborted || + (error instanceof BrowserMetadataError && error.code === 'aborted') + ) { + return; + } + const currentAsset = assetsRef.current.find( + (entry) => + entry.id === asset.id && entry.objectUrl === asset.objectUrl + ); + if (!currentAsset) return; + updateAssetState(asset.id, { + browserMetadataError: + error instanceof Error + ? error.message + : 'The browser could not read basic media metadata.', + }); + return undefined; + }) + .finally(() => { + if (browserMetadataControllers.current.get(asset.id) === controller) { + browserMetadataControllers.current.delete(asset.id); + } + }); + }, + [updateAssetState] + ); + const inspectAsset = useCallback( - async ( - asset: ImportedMediaAsset, - existingAsset: boolean, - token: SourceInspectionToken - ) => { - if (!sourceInspectionRegistry.current.isCurrent(token)) return; - updateAssetState(asset.id, { + (requestedAsset: ImportedMediaAsset): Promise => { + const currentAsset = assetsRef.current.find( + (entry) => + entry.id === requestedAsset.id && + entry.objectUrl === requestedAsset.objectUrl + ); + if (!currentAsset) { + return Promise.reject( + new EngineError( + 'cancelled', + 'The media source is no longer attached.' + ) + ); + } + if (currentAsset.probe) return Promise.resolve(currentAsset.probe); + + const active = sourceInspectionPromises.current.get(currentAsset.id); + if (active?.objectUrl === currentAsset.objectUrl) return active.promise; + + const token = sourceInspectionRegistry.current.register(currentAsset.id); + updateAssetState(currentAsset.id, { phase: 'probing', error: undefined, }); - try { - const probeResult = await runSourceProbe(async () => { - if (!sourceInspectionRegistry.current.activate(token)) { - return undefined; - } - try { - return await engineManager.probe( - asset.file, - undefined, - token.signal - ); - } finally { - sourceInspectionRegistry.current.finish(token); - } - }); - if ( - !probeResult || - !sourceInspectionRegistry.current.isCurrent(token) - ) { - return; - } - const probe = normalizeFfprobeReport(probeResult.json); - const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`; - const importedChapters = probe.chapters.map((chapter) => ({ - id: `${asset.id}-chapter-${chapter.id}`, - startSeconds: chapter.startSeconds, - endSeconds: chapter.endSeconds, - title: chapter.title ?? `Chapter ${chapter.id + 1}`, - ...(chapter.timeBase ? { timeBase: chapter.timeBase } : {}), - ...(Object.keys(chapter.tags).length > 0 - ? { metadata: chapter.tags } - : {}), - })); - if (!sourceInspectionRegistry.current.isCurrent(token)) return; - updateAssetState(asset.id, { phase: 'ready', probe, rawProbe }); - setProject((current) => { - if ( - !sourceInspectionRegistry.current.isCurrent(token) || - !current.assets.some((entry) => entry.id === asset.id) - ) { - return current; - } - let next = projectReducer(current, { - type: 'asset/update', - assetId: asset.id, - changes: { probe, mimeType: asset.file.type }, - updatedAt: timestamp(), + + const promise = (async (): Promise => { + try { + const probeResult = await runSourceProbe(async () => { + if (!sourceInspectionRegistry.current.activate(token)) { + throw new EngineError( + 'cancelled', + 'The detailed media inspection was superseded.' + ); + } + try { + return await engineManager.probe( + currentAsset.file, + undefined, + token.signal + ); + } finally { + sourceInspectionRegistry.current.finish(token); + } }); - if ( - existingAsset && - next.assets.find((entry) => entry.id === asset.id)?.sourceStatus === - 'missing' - ) { - next = projectReducer(next, { - type: 'asset/set-source-status', - assetId: asset.id, - sourceStatus: 'attached', - updatedAt: timestamp(), + if (!sourceInspectionRegistry.current.isCurrent(token)) { + throw new EngineError( + 'cancelled', + 'The detailed media inspection was superseded.' + ); + } + + const probe = normalizeFfprobeReport(probeResult.json); + const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`; + const provisionalDuration = assetsRef.current.find( + (entry) => + entry.id === currentAsset.id && + entry.objectUrl === currentAsset.objectUrl + )?.browserMetadata?.durationSeconds; + updateAssetState(currentAsset.id, { + phase: 'ready', + probe, + rawProbe, + error: undefined, + }); + setProject((current) => { + if (!sourceInspectionRegistry.current.isCurrent(token)) { + return current; + } + const next = projectWithAssetInspection( + current, + currentAsset, + probe, + provisionalDuration + ); + projectRef.current = next; + return next; + }); + return probe; + } catch (error) { + if (sourceInspectionRegistry.current.isCurrent(token)) { + updateAssetState(currentAsset.id, { + phase: 'error', + error: + error instanceof Error + ? error.message + : 'Detailed media inspection failed.', }); } - const hasClip = next.timeline.some( - (clip) => clip.assetId === asset.id + throw error; + } finally { + const activeInspection = sourceInspectionPromises.current.get( + currentAsset.id ); - if ( - !hasClip && - probe.durationSeconds !== undefined && - probe.durationSeconds >= 0.001 - ) { - next = projectReducer(next, { - type: 'clip/add', - clip: { - id: crypto.randomUUID(), - assetId: asset.id, - sourceInSeconds: 0, - sourceOutSeconds: probe.durationSeconds, - audio: { enabled: true }, - video: { enabled: true }, - }, - updatedAt: timestamp(), - }); + if (activeInspection?.objectUrl === currentAsset.objectUrl) { + sourceInspectionPromises.current.delete(currentAsset.id); } - if (next.chapters.length === 0 && importedChapters.length > 0) { - next = projectReducer(next, { - type: 'chapters/set', - chapters: importedChapters, - updatedAt: timestamp(), - }); - } - return next; - }); - } catch (error) { - if (!sourceInspectionRegistry.current.isCurrent(token)) return; - updateAssetState(asset.id, { - phase: 'error', - error: - error instanceof Error - ? error.message - : 'The file could not be inspected.', - }); - } + } + })(); + sourceInspectionPromises.current.set(currentAsset.id, { + objectUrl: currentAsset.objectUrl, + promise, + }); + return promise; }, [runSourceProbe, updateAssetState] ); @@ -919,16 +1076,12 @@ export function AvToolsApplication() { .map((issue) => issue.message) .join( '\n' - )}\n\n${importDecision.estimateDisclaimer}\n\nImport and inspect anyway?` + )}\n\n${importDecision.estimateDisclaimer}\n\nImport anyway?` ) ) { return; } - const pending: Array<{ - asset: ImportedMediaAsset; - existingAsset: boolean; - token: SourceInspectionToken; - }> = []; + const pending: ImportedMediaAsset[] = []; const claimedIds = new Set(assetsRef.current.map((asset) => asset.id)); for (const file of files) { @@ -963,11 +1116,15 @@ export function AvToolsApplication() { if (reattach && match) { asset.id = match.entry.id; claimedIds.add(match.entry.id); - pending.push({ - asset, - existingAsset: true, - token: sourceInspectionRegistry.current.register(asset.id), - }); + setProject((current) => + projectReducer(current, { + type: 'asset/set-source-status', + assetId: asset.id, + sourceStatus: 'attached', + updatedAt: timestamp(), + }) + ); + pending.push(asset); } else { setProject((current) => projectReducer(current, { @@ -983,31 +1140,53 @@ export function AvToolsApplication() { updatedAt: timestamp(), }) ); - pending.push({ - asset, - existingAsset: false, - token: sourceInspectionRegistry.current.register(asset.id), - }); + pending.push(asset); } } setAssets((current) => { - const next = [...current, ...pending.map(({ asset }) => asset)]; + const next = [...current, ...pending]; assetsRef.current = next; return next; }); - setSelectedAssetId((current) => current ?? pending[0]?.asset.id); - for (const item of pending) { - await inspectAsset(item.asset, item.existingAsset, item.token); + setSelectedAssetId((current) => current ?? pending[0]?.id); + for (const asset of pending) { + const metadata = readBrowserMetadata(asset); + browserMetadataClipTail.current = browserMetadataClipTail.current + .then(async () => { + const resolved = await metadata; + if (!resolved) return; + setProject((current) => { + if (!current.assets.some((entry) => entry.id === asset.id)) { + return current; + } + const next = addDefaultClip( + current, + asset.id, + resolved.durationSeconds + ); + projectRef.current = next; + return next; + }); + }) + .catch(() => undefined); } }, - [inspectAsset, project.assets, resourceContext.policy] + [ + addDefaultClip, + project.assets, + readBrowserMetadata, + resourceContext.policy, + ] ); const removeAsset = useCallback( (id: string) => { const asset = assets.find((entry) => entry.id === id); if (asset) URL.revokeObjectURL(asset.objectUrl); + browserMetadataControllers.current.get(id)?.abort(); + browserMetadataControllers.current.delete(id); + sourceInspectionPromises.current.delete(id); const cancelActiveInspection = sourceInspectionRegistry.current.remove(id); setAssets((current) => { @@ -1048,6 +1227,33 @@ export function AvToolsApplication() { [applyProjectAction, assets, selectedAssetId] ); + const requestDetailedInspection = useCallback( + (assetId: string) => { + const asset = assetsRef.current.find((entry) => entry.id === assetId); + if (!asset) return; + setOperationError(undefined); + if ( + scheduledPlanCountRef.current > 0 || + (engineManager.state.status === 'running' && + sourceProbePendingRef.current === 0) + ) { + setOperationError( + 'Wait for the active export or analysis job before inspecting another source.' + ); + return; + } + void inspectAsset(asset).catch((error: unknown) => { + if (error instanceof EngineError && error.code === 'cancelled') return; + setOperationError( + error instanceof Error + ? `Detailed inspection failed: ${error.message}` + : 'Detailed inspection failed.' + ); + }); + }, + [inspectAsset] + ); + const selectedAsset = assets.find((asset) => asset.id === selectedAssetId); const selectedClip = project.timeline.find( (clip) => clip.id === selectedClipId @@ -1625,9 +1831,18 @@ export function AvToolsApplication() { const exportQuick = useCallback( async (configuration: QuickExportConfiguration) => { - if (!selectedAsset?.probe) return; + if (!selectedAsset) return; const jobId = crypto.randomUUID(); try { + const probe = + selectedAsset.probe ?? (await inspectAsset(selectedAsset)); + const streamSelection = configuration.selectDetectedStreams + ? defaultQuickStreamSelection( + probe, + configuration.operation, + configuration.preset + ) + : configuration.streamSelection; let plan: FFmpegCommandPlan; if (configuration.operation === 'remux') { const container = configuration.remuxContainer ?? 'matroska'; @@ -1646,7 +1861,7 @@ export function AvToolsApplication() { ? 'ogg' : container, description: { - streams: selectedAsset.probe.streams.flatMap((stream) => + streams: probe.streams.flatMap((stream) => stream.type === 'video' || stream.type === 'audio' || stream.type === 'subtitle' || @@ -1661,13 +1876,13 @@ export function AvToolsApplication() { ] : [] ), - hasChapters: selectedAsset.probe.chapters.length > 0, - hasMetadata: Object.keys(selectedAsset.probe.tags).length > 0, + hasChapters: probe.chapters.length > 0, + hasMetadata: Object.keys(probe.tags).length > 0, }, - streamSelection: configuration.streamSelection, + streamSelection, metadataPolicy: configuration.removeMetadata ? 'remove' : 'copy', chapterPolicy: configuration.removeChapters ? 'remove' : 'keep', - expectedDurationSeconds: selectedAsset.probe.durationSeconds, + expectedDurationSeconds: probe.durationSeconds, }); } else { if (!configuration.preset) return; @@ -1683,8 +1898,8 @@ export function AvToolsApplication() { metadataPolicy: configuration.removeMetadata ? 'remove' : 'copy', chapterPolicy: configuration.removeChapters ? 'remove' : 'keep', }, - streamSelection: configuration.streamSelection, - expectedDurationSeconds: selectedAsset.probe.durationSeconds, + streamSelection, + expectedDurationSeconds: probe.durationSeconds, }); } await runPlan(plan, [selectedAsset.file], plan.operation); @@ -1698,7 +1913,7 @@ export function AvToolsApplication() { ); } }, - [runPlan, selectedAsset] + [inspectAsset, runPlan, selectedAsset] ); const extractThumbnail = useCallback( @@ -1763,14 +1978,19 @@ export function AvToolsApplication() { const createPreviewProxy = useCallback( async (asset: ImportedMediaAsset | undefined) => { - const duration = asset?.probe?.durationSeconds; - if (!asset || duration === undefined || duration <= 0) return; + if (!asset) return; try { + const probe = asset.probe ?? (await inspectAsset(asset)); + const duration = probe.durationSeconds; + if (duration === undefined || duration <= 0) { + throw new Error( + 'The source duration is unavailable after detailed inspection.' + ); + } const includeAudio = - asset.probe?.streams.some((stream) => stream.type === 'audio') === - true; + probe.streams.some((stream) => stream.type === 'audio') === true; const hasVideo = - asset.probe?.streams.some( + probe.streams.some( (stream) => stream.type === 'video' && stream.disposition.attached_pic !== true @@ -1861,6 +2081,7 @@ export function AvToolsApplication() { } }, [ + inspectAsset, previewProxySettings, project.id, refreshResults, @@ -1888,7 +2109,39 @@ export function AvToolsApplication() { } const timelineContainer = preset.container as 'mp4' | 'webm' | 'matroska'; try { - const enabledSubtitleTracks = project.subtitles.filter( + const timelineAssetIds = [ + ...new Set(project.timeline.map((clip) => clip.assetId)), + ]; + const timelineInspections = new Map< + string, + { readonly asset: ImportedMediaAsset; readonly probe: MediaProbe } + >(); + for (const assetId of timelineAssetIds) { + const asset = assetsRef.current.find((entry) => entry.id === assetId); + if (!asset) { + throw new Error( + 'Reattach every timeline source before rendering the project.' + ); + } + const probe = asset.probe ?? (await inspectAsset(asset)); + timelineInspections.set(assetId, { asset, probe }); + } + let inspectedProject = projectRef.current; + for (const { asset, probe } of timelineInspections.values()) { + const latestAsset = + assetsRef.current.find( + (entry) => + entry.id === asset.id && entry.objectUrl === asset.objectUrl + ) ?? asset; + inspectedProject = projectWithAssetInspection( + inspectedProject, + latestAsset, + probe, + latestAsset.browserMetadata?.durationSeconds + ); + } + projectRef.current = inspectedProject; + const enabledSubtitleTracks = inspectedProject.subtitles.filter( (entry) => entry.mode !== 'exclude' ); const timelineStepCount = @@ -1897,13 +2150,15 @@ export function AvToolsApplication() { enabledSubtitleTracks.filter( (entry) => (entry.offsetSeconds ?? 0) !== 0 ).length + - (project.chapters.length > 0 ? 1 : 0); + (inspectedProject.chapters.length > 0 ? 1 : 0); await runWorkflow( 'Render timeline project', timelineStepCount, async (executeStep) => { - const resolved = project.timeline.map((clip, index) => { - const asset = assets.find((entry) => entry.id === clip.assetId); + const resolved = inspectedProject.timeline.map((clip, index) => { + const asset = assetsRef.current.find( + (entry) => entry.id === clip.assetId + ); const video = asset?.probe?.streams.find( (stream) => stream.type === 'video' && @@ -2001,7 +2256,7 @@ export function AvToolsApplication() { sourceDurationSeconds: asset.probe?.durationSeconds, hasVideo: true, hasAudio: - project.output.audioEnabled && + inspectedProject.output.audioEnabled && clip.audio?.enabled !== false && asset.probe?.streams.some( (stream) => stream.type === 'audio' @@ -2035,9 +2290,13 @@ export function AvToolsApplication() { const even = (value: number) => Math.max(2, Math.round(value / 2) * 2); const requestedWidth = - project.output.width ?? timelineVideoPreset.width ?? undefined; + inspectedProject.output.width ?? + timelineVideoPreset.width ?? + undefined; const requestedHeight = - project.output.height ?? timelineVideoPreset.height ?? undefined; + inspectedProject.output.height ?? + timelineVideoPreset.height ?? + undefined; let outputWidth: number; let outputHeight: number; if (requestedWidth && requestedHeight) { @@ -2064,7 +2323,7 @@ export function AvToolsApplication() { ), ]; if ( - project.output.frameRate === undefined && + inspectedProject.output.frameRate === undefined && timelineVideoPreset.frameRate === undefined && requestedClipFrameRates.length > 1 ) { @@ -2073,28 +2332,34 @@ export function AvToolsApplication() { ); } const frameRate = - project.output.frameRate ?? + inspectedProject.output.frameRate ?? timelineVideoPreset.frameRate ?? requestedClipFrameRates[0] ?? first.sourceFrameRate ?? 30; const sampleRate = - project.output.sampleRate ?? preset.audio?.sampleRate ?? 48_000; + inspectedProject.output.sampleRate ?? + preset.audio?.sampleRate ?? + 48_000; const channelLayout = - project.output.channelLayout ?? + inspectedProject.output.channelLayout ?? (preset.audio?.channels === 1 ? 'mono' : preset.audio?.channels === 6 ? '5.1' : 'stereo'); const missingAudioPolicy = - !project.output.audioEnabled || !preset.audio + !inspectedProject.output.audioEnabled || !preset.audio ? ('drop-all' as const) - : (project.output.missingAudioPolicy ?? 'insert-silence'); - const outputFileName = safeExportFileName(project.output.fileName, { - extension: preset.fileExtension, - fallback: 'timeline', - }); + : (inspectedProject.output.missingAudioPolicy ?? + 'insert-silence'); + const outputFileName = safeExportFileName( + inspectedProject.output.fileName, + { + extension: preset.fileExtension, + fallback: 'timeline', + } + ); const plan = buildTimelineExportPlan({ jobId: crypto.randomUUID(), clips: resolved.map(({ clip }) => clip), @@ -2124,7 +2389,7 @@ export function AvToolsApplication() { 0 ); - const activeSubtitleTracks = project.subtitles + const activeSubtitleTracks = inspectedProject.subtitles .filter((entry) => entry.mode !== 'exclude') .sort((left, right) => { if (left.mode === right.mode) return 0; @@ -2165,7 +2430,7 @@ export function AvToolsApplication() { } const mediaSource = { - id: `timeline-${project.id}`, + id: `timeline-${inspectedProject.id}`, sourceIndex: 0, fileName: currentFile.name, }; @@ -2225,16 +2490,16 @@ export function AvToolsApplication() { currentFile = resultAsFile(subtitledResult); } - if (project.chapters.length > 0) { + if (inspectedProject.chapters.length > 0) { const chaptered = await executeStep( buildChapterMuxPlan({ jobId: crypto.randomUUID(), source: { - id: `timeline-${project.id}`, + id: `timeline-${inspectedProject.id}`, sourceIndex: 0, fileName: currentFile.name, }, - chapters: project.chapters.map((chapter) => ({ + chapters: inspectedProject.chapters.map((chapter) => ({ id: chapter.id, title: chapter.title, startSeconds: chapter.startSeconds, @@ -2259,12 +2524,14 @@ export function AvToolsApplication() { currentFile = resultAsFile(chapteredResult); } - const metadataEdits = metadataEditsFromProject(project.metadata); + const metadataEdits = metadataEditsFromProject( + inspectedProject.metadata + ); const finalized = await executeStep( buildMetadataEditPlan({ jobId: crypto.randomUUID(), source: { - id: `timeline-${project.id}`, + id: `timeline-${inspectedProject.id}`, sourceIndex: 0, fileName: currentFile.name, }, @@ -2295,7 +2562,7 @@ export function AvToolsApplication() { ); } }, [ - assets, + inspectAsset, loudnessByAsset, project, refreshResults, @@ -2307,6 +2574,11 @@ export function AvToolsApplication() { const resetProject = useCallback(() => { for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); + for (const controller of browserMetadataControllers.current.values()) { + controller.abort(); + } + browserMetadataControllers.current.clear(); + sourceInspectionPromises.current.clear(); const cancelActiveInspection = sourceInspectionRegistry.current.clear(); if (cancelActiveInspection) void engineManager.cancelActive(); assetsRef.current = []; @@ -2345,6 +2617,11 @@ export function AvToolsApplication() { try { const imported = importProjectDocument(await file.text()); for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); + for (const controller of browserMetadataControllers.current.values()) { + controller.abort(); + } + browserMetadataControllers.current.clear(); + sourceInspectionPromises.current.clear(); const cancelActiveInspection = sourceInspectionRegistry.current.clear(); if (cancelActiveInspection) void engineManager.cancelActive(); assetsRef.current = []; @@ -3578,10 +3855,7 @@ export function AvToolsApplication() { : requirementsForUserPreset(preset); const availability = activeCapabilities ? presetAvailability({ requirements }, activeCapabilities) - : { - status: 'unavailable' as const, - reasons: ['Initialize the engine to verify this preset.'], - }; + : undefined; const opusBlocked = preset.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED; return { @@ -3590,11 +3864,12 @@ export function AvToolsApplication() { fileExtension: preset.fileExtension, supportsAudio: preset.audio !== undefined, supportsVideo: preset.video !== undefined, - ...(availability.status === 'unavailable' || opusBlocked + ...(availability?.status === 'unavailable' || opusBlocked ? { disabledReason: opusBlocked ? 'Opus output is disabled until the reviewed core passes the non-empty ST/MT regression encode and re-probe gate.' - : availability.reasons.join(' '), + : (availability?.reasons.join(' ') ?? + 'The selected preset is unavailable.'), } : {}), }; @@ -3617,6 +3892,10 @@ export function AvToolsApplication() { : !selectedTimelinePreset?.video ? 'Choose a video export preset.' : selectedTimelinePresetOption?.disabledReason; + const timelineNeedsInspection = project.timeline.some((clip) => { + const asset = assets.find((entry) => entry.id === clip.assetId); + return asset !== undefined && asset.probe === undefined; + }); const anyAccuratePreset = advancedPresetOptions.some( (preset) => !preset.disabledReason @@ -3931,6 +4210,8 @@ export function AvToolsApplication() { scheduledPlanCount >= 8; const sourceManagementBusy = sourceProbeBusy || scheduledPlanCount > 0 || waveformProgress !== undefined; + const sourceRemovalBusy = + scheduledPlanCount > 0 || waveformProgress !== undefined; const editWorkspace = ( <> @@ -3993,6 +4274,9 @@ export function AvToolsApplication() { assets={assets} selectedId={selectedAssetId} onSelect={setSelectedAssetId} + onInspect={requestDetailedInspection} + inspectDisabled={sourceManagementBusy} + removeDisabled={sourceRemovalBusy} onRemove={removeAsset} /> @@ -4059,6 +4343,7 @@ export function AvToolsApplication() { } onSubtitlesChange={changeSubtitles} onSubtitleImport={importSubtitleFile} + onInspectDetails={requestDetailedInspection} onThumbnail={extractThumbnail} currentPreviewTimeSeconds={ inspectedClipAsset @@ -4114,6 +4399,42 @@ export function AvToolsApplication() { if (selectedClipId === clipId) setSelectedClipId(undefined); }} /> + {inspectedClipAsset && !inspectedClipAsset.probe ? ( +
+ +
+ + {inspectedClipAsset.phase === 'probing' + ? 'Inspecting source details…' + : 'Advanced tools need source details.'} + + + Playback and basic metadata stay available while codecs, streams + and chapters are inspected locally. + +
+ {inspectedClipAsset.phase !== 'probing' ? ( + + ) : null} +
+ ) : null} {engineState.status === 'running' || scheduledPlanCount > 0 ? 'Add timeline to queue' - : 'Render timeline'} + : timelineNeedsInspection + ? 'Inspect & render timeline' + : 'Render timeline'} @@ -4632,6 +4955,9 @@ export function AvToolsApplication() { assets={assets} selectedId={selectedAssetId} onSelect={setSelectedAssetId} + onInspect={requestDetailedInspection} + inspectDisabled={sourceManagementBusy} + removeDisabled={sourceRemovalBusy} onRemove={removeAsset} /> @@ -4645,6 +4971,9 @@ export function AvToolsApplication() { engineState.status === 'running' || scheduledPlanCount > 0 } presets={[...BUILT_IN_PRESETS, ...userPresets]} + onInspect={() => { + if (selectedAsset) requestDetailedInspection(selectedAsset.id); + }} onExport={exportQuick} />
@@ -4722,9 +5051,18 @@ export function AvToolsApplication() { preference={enginePreference} resourcePolicy={resourceContext.policy} deviceMemoryGiB={resourceContext.deviceMemoryGiB} + disabled={sourceManagementBusy} onPreferenceChange={(preference) => { - setEnginePreference(preference); - engineManager.setPreference(preference); + try { + engineManager.setPreference(preference); + setEnginePreference(preference); + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : 'The engine mode could not be changed.' + ); + } }} onInitialize={() => { if (engineManager.state.status === 'error') { diff --git a/src/app/application-state.ts b/src/app/application-state.ts index 5ac1851..c9c0d8f 100644 --- a/src/app/application-state.ts +++ b/src/app/application-state.ts @@ -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`; diff --git a/src/components/CapabilityPanel.tsx b/src/components/CapabilityPanel.tsx index fdfde15..aea4036 100644 --- a/src/components/CapabilityPanel.tsx +++ b/src/components/CapabilityPanel.tsx @@ -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