Files
av-tools/src/App.tsx

4762 lines
157 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import '@add-ideas/toolbox-shell-react/styles.css';
import './index.css';
import {
createAsset,
type ImportedMediaAsset,
type WorkspaceMode,
} from './app/application-state';
import { AppErrorBoundary } from './app/AppErrorBoundary';
import {
SourceInspectionRegistry,
type SourceInspectionToken,
} from './app/source-inspection-registry';
import { FileDropZone } from './components/FileDropZone';
import { MediaBin } from './components/MediaBin';
import {
MediaPreview,
type PreviewProxySettings,
} from './components/MediaPreview';
import {
QuickConvert,
type QuickExportConfiguration,
} from './components/QuickConvert';
import { CapabilityPanel } from './components/CapabilityPanel';
import { JobPanel } from './components/JobPanel';
import { StoragePanel } from './components/StoragePanel';
import { HelpDialog } from './components/HelpDialog';
import { Icon } from './components/Icon';
import { Timeline } from './components/Timeline';
import {
AdvancedOperations,
type AdvancedAudioRequest,
type AdvancedCapabilityKey,
type AdvancedCapabilityStatus,
type AdvancedExportPresetOption,
type AdvancedSplitBatchStatus,
type AdvancedSplitRequest,
type ChapterExecutionRequest,
type ContactSheetRequest,
type MetadataExecutionRequest,
type SubtitleExecutionRequest,
type ThumbnailSeriesRequest,
type UserPresetAction,
} from './components/AdvancedOperations';
import {
StructuralOperations,
type StructuralCompatibilityDiagnostic,
type StructuralConcatRequest,
type StructuralTrimRequest,
} from './components/StructuralOperations';
import { Inspector, type ThumbnailRequest } from './components/Inspector';
import { engineManager } from './ffmpeg/EngineManager';
import type {
EngineJobContext,
EnginePreference,
EngineState,
} from './ffmpeg/ffmpeg.types';
import { EngineError } from './ffmpeg/ffmpeg-error';
import { normalizeFfprobeReport } from './media';
import {
createEmptyProject,
importProjectDocument,
projectDocumentFileName,
projectReducer,
rankReattachmentCandidates,
serializeProject,
type AvProjectV1,
type MediaAssetReference,
type OutputSettings,
type ProjectAction,
type TimelineClip,
} from './project';
import type { FFmpegCommandPlan } from './commands/command-plan';
import {
buildConvertPlan,
requirementsForUserPreset,
} from './commands/convert';
import { buildRemuxPlan, type RemuxStream } from './commands/remux';
import { buildSingleThumbnailPlan } from './commands/thumbnails';
import { buildPreviewProxyPlan } from './commands/preview-proxy';
import { buildTimelineExportPlan } from './commands/timeline-export';
import {
buildContactSheetPlan,
buildSceneChangeAnalysisPlan,
buildSceneThumbnailSeriesPlan,
buildThumbnailSeriesPlan,
parseSceneChangeFrames,
} from './commands/thumbnails';
import { buildSplitPlan } from './commands/split';
import { buildTrimPlan } from './commands/trim';
import {
buildFastConcatPlan,
buildNormalizedConcatPlan,
validateConcatCompatibility,
type ConcatSource,
} from './commands/concatenate';
import {
buildStaticWaveformPlan,
buildWaveformAnalysisPlan,
} from './commands/waveform';
import {
buildLoudnessAnalysisPlan,
buildLoudnessApplyPlan,
buildPeakAnalysisPlan,
buildPeakNormalizationPlan,
parseLoudnessMeasurement,
parsePeakMeasurement,
type LoudnessMeasurement,
} from './commands/loudness';
import { buildMetadataEditPlan, type MetadataEdits } from './commands/metadata';
import { buildChapterMuxPlan } from './commands/chapters';
import {
buildSubtitleBurnPlan,
buildSubtitleConversionPlan,
buildSubtitleExtractionPlan,
buildSubtitleMuxPlan,
} from './commands/subtitles';
import { calculateResize, validateCrop } from './commands/transform';
import {
BUILT_IN_PRESETS,
findBuiltInPreset,
presetAvailability,
} from './presets/preset-registry';
import {
IndexedDbUserPresetPersistence,
MemoryUserPresetPersistence,
UserPresetRegistry,
} from './presets/user-presets';
import type { UserExportPreset } from './presets/preset.types';
import {
collectCommandOutputs,
createExportReport,
downloadBlob,
ExportResultCollection,
saveCollectionResult,
saveExportReport,
saveResultsZip,
safeExportFileName,
type ExportReport,
type ExportResult,
} from './export';
import { manifest } from './toolbox/manifest';
import { cacheDerivative, readCachedDerivative } from './app/derived-cache';
import {
DEFAULT_RESOURCE_POLICY,
estimatePlanResources,
policyForDeviceMemory,
preflightResources,
type ResourcePolicy,
} from './storage';
import { assessGeneratedResultPreview, type MediaProbe } from './media';
import {
createWaveformRuntime,
deserializeWaveformPeaks,
serializeWaveformPeaks,
WAVEFORM_PEAKS_MEDIA_TYPE,
type WaveformPeakData,
} from './waveform';
import type { MediaJob, MediaJobStep } from './jobs';
import { loadBundledContactSheetFont } from './assets/bundled-font';
import {
FFMPEG_CORE_VERSION,
FFMPEG_OPUS_REGRESSION_VERIFIED,
} from './version';
function timestamp(): string {
return new Date().toISOString();
}
function canPreviewGeneratedResult(
result: Pick<ExportResult, 'fileName' | 'mimeType'>,
probe?: MediaProbe
): boolean {
const element = document.createElement(
result.mimeType.startsWith('video/') ? 'video' : 'audio'
);
return assessGeneratedResultPreview(
{
fileName: result.fileName,
mimeType: result.mimeType,
...(probe ? { probe } : {}),
},
(mimeType) => element.canPlayType(mimeType)
).previewable;
}
interface RuntimeResourceContext {
readonly deviceMemoryGiB?: number;
readonly policy: ResourcePolicy;
}
function createRuntimeResourceContext(): RuntimeResourceContext {
const reportedDeviceMemory =
typeof globalThis.navigator === 'undefined'
? undefined
: (
globalThis.navigator as Navigator & {
readonly deviceMemory?: unknown;
}
).deviceMemory;
const deviceMemoryGiB =
typeof reportedDeviceMemory === 'number' &&
Number.isFinite(reportedDeviceMemory) &&
reportedDeviceMemory > 0
? reportedDeviceMemory
: undefined;
return {
...(deviceMemoryGiB === undefined ? {} : { deviceMemoryGiB }),
policy: policyForDeviceMemory(DEFAULT_RESOURCE_POLICY, deviceMemoryGiB),
};
}
function fileDescriptor(file: File) {
return {
name: file.name,
size: file.size,
lastModified: file.lastModified,
mimeType: file.type,
};
}
function extensionFromName(fileName: string): string {
const extension = fileName.split('.').at(-1)?.toLowerCase();
return extension && /^[a-z0-9]{1,10}$/u.test(extension) ? extension : 'mkv';
}
function muxerForExtension(extension: string): string {
const muxers: Readonly<Record<string, string>> = {
m4a: 'ipod',
mkv: 'matroska',
jpg: 'image2',
jpeg: 'image2',
png: 'image2',
};
return muxers[extension] ?? extension;
}
function outputWithOptionalNumber(
output: OutputSettings,
field: 'width' | 'height' | 'frameRate',
rawValue: string
): OutputSettings {
const next = { ...output };
if (rawValue.trim() === '') {
delete next[field];
return next;
}
const value = Number(rawValue);
if (!Number.isFinite(value)) return output;
if (field === 'frameRate') {
next.frameRate = Math.min(240, Math.max(0.01, value));
} else {
next[field] = Math.min(8192, Math.max(2, Math.round(value / 2) * 2));
}
return next;
}
function subtitleCodecForFormat(format: 'srt' | 'vtt' | 'ass'): string {
return format === 'srt' ? 'subrip' : format === 'vtt' ? 'webvtt' : 'ass';
}
function projectMetadataFromEdits(
edits: MetadataEdits
): AvProjectV1['metadata'] {
const format = edits.format ?? {};
const text = (key: string) => {
const value = format[key];
return value === null || value === undefined ? undefined : String(value);
};
const known = new Set(['title', 'artist', 'album', 'comment']);
return {
...(text('title') ? { title: text('title') } : {}),
...(text('artist') ? { artist: text('artist') } : {}),
...(text('album') ? { album: text('album') } : {}),
...(text('comment') ? { comment: text('comment') } : {}),
custom: Object.fromEntries(
Object.entries(format)
.filter(([key, value]) => !known.has(key) && value !== null)
.map(([key, value]) => [key, String(value)])
),
};
}
function metadataEditsFromProject(
metadata: AvProjectV1['metadata']
): MetadataEdits {
return {
format: {
...(metadata.title ? { title: metadata.title } : {}),
...(metadata.artist ? { artist: metadata.artist } : {}),
...(metadata.album ? { album: metadata.album } : {}),
...(metadata.comment ? { comment: metadata.comment } : {}),
...metadata.custom,
},
};
}
function resultAsFile(result: ExportResult): File {
return new File([result.blob], result.fileName, {
type: result.mimeType,
lastModified: Date.now(),
});
}
function concatSourceForClip(
clip: TimelineClip,
asset: ImportedMediaAsset,
sourceIndex: number
): ConcatSource {
const durationSeconds = asset.probe?.durationSeconds;
if (durationSeconds === undefined || durationSeconds <= 0) {
throw new Error(`Source ${asset.file.name} has no validated duration.`);
}
const streams =
asset.probe?.streams.flatMap((stream) =>
stream.type === 'video' ||
stream.type === 'audio' ||
stream.type === 'subtitle'
? [
{
kind: stream.type,
codec: stream.codecName ?? 'unknown',
...(stream.width === undefined ? {} : { width: stream.width }),
...(stream.height === undefined ? {} : { height: stream.height }),
...(stream.pixelFormat === undefined
? {}
: { pixelFormat: stream.pixelFormat }),
...(stream.timeBase === undefined
? {}
: { timeBase: stream.timeBase }),
...(stream.sampleRate === undefined
? {}
: { sampleRate: stream.sampleRate }),
...(stream.channelLayout === undefined
? {}
: { channelLayout: stream.channelLayout }),
},
]
: []
) ?? [];
return {
id: clip.id,
sourceIndex,
fileName: asset.file.name,
durationSeconds,
sourceInSeconds: clip.sourceInSeconds,
sourceOutSeconds: clip.sourceOutSeconds,
streams,
};
}
function remuxStreamsFromProbe(probe: MediaProbe): readonly RemuxStream[] {
return probe.streams.flatMap((stream) =>
stream.type === 'video' ||
stream.type === 'audio' ||
stream.type === 'subtitle' ||
stream.type === 'attachment' ||
stream.type === 'data'
? [
{
index: stream.index,
kind: stream.type,
codec: stream.codecName ?? 'unknown',
},
]
: []
);
}
function createPresetRegistry(): {
readonly registry: UserPresetRegistry;
readonly persistent: boolean;
} {
try {
return {
registry: new UserPresetRegistry(new IndexedDbUserPresetPersistence()),
persistent: true,
};
} catch {
return {
registry: new UserPresetRegistry(new MemoryUserPresetPersistence()),
persistent: false,
};
}
}
function replaceVirtualPaths(
plan: FFmpegCommandPlan,
context: EngineJobContext
): readonly string[] {
const replacements = new Map<string, string>();
plan.inputs.forEach((input, index) => {
const actual = context.inputPaths[index];
if (actual) replacements.set(input.path, actual);
});
plan.outputs.forEach((output, index) => {
const actual = context.outputPaths[index];
if (actual) replacements.set(output.path, actual);
});
plan.temporaryFiles.forEach((temporary, index) => {
const actual = context.temporaryPaths[index];
if (actual) replacements.set(temporary.path, actual);
});
return plan.args.map((argument) =>
[...replacements].reduce(
(resolved, [virtualPath, actualPath]) =>
resolved.replaceAll(virtualPath, actualPath),
argument
)
);
}
function replaceTemporaryContentPaths(
content: string | Uint8Array,
plan: FFmpegCommandPlan,
context: EngineJobContext
): string | Uint8Array {
if (typeof content !== 'string') return content;
const replacements = new Map<string, string>();
plan.inputs.forEach((input, index) => {
const actual = context.inputPaths[index];
if (actual) replacements.set(input.path, actual);
});
plan.outputs.forEach((output, index) => {
const actual = context.outputPaths[index];
if (actual) replacements.set(output.path, actual);
});
plan.temporaryFiles.forEach((temporary, index) => {
const actual = context.temporaryPaths[index];
if (actual) replacements.set(temporary.path, actual);
});
return [...replacements].reduce(
(resolved, [virtualPath, actualPath]) =>
resolved.replaceAll(virtualPath, actualPath),
content
);
}
export interface OutputVerification {
readonly probe?: MediaProbe;
readonly warning?: string;
}
interface PlanExecution {
readonly results: readonly ExportResult[];
readonly logText: string;
readonly verifications?: Readonly<Record<string, OutputVerification>>;
readonly report?: ExportReport;
}
type ExecutePlan = (
plan: FFmpegCommandPlan,
inputs: readonly File[],
operation: string,
onStage?: (stage: 'reading-output') => void
) => Promise<PlanExecution>;
interface ScheduledWorkDefinition {
readonly operation: string;
readonly projectId: string;
readonly totalSteps: number;
readonly retryCount: number;
readonly work: (executeStep: ExecutePlan) => Promise<unknown>;
cancelled: boolean;
counted: boolean;
started: boolean;
}
function createQueuedJob(
id: string,
operation: string,
totalSteps: number,
createdAt: string,
retryCount: number
): MediaJob {
return {
id,
operation,
status: 'queued',
steps: Array.from({ length: totalSteps }, (_, index) => ({
id: `${id}:step-${index + 1}`,
name: totalSteps === 1 ? operation : `Step ${index + 1}`,
weight: 1 / totalSteps,
status: 'queued',
progress: 0,
planId: 'pending',
})),
progress: 0,
createdAt,
updatedAt: createdAt,
retryCount,
};
}
function replaceJob(
jobs: readonly MediaJob[],
id: string,
update: (job: MediaJob) => MediaJob
): readonly MediaJob[] {
return jobs.map((job) => (job.id === id ? update(job) : job));
}
export function AvToolsApplication() {
const [mode, setMode] = useState<WorkspaceMode>('quick-convert');
const [assets, setAssets] = useState<ImportedMediaAsset[]>([]);
const [selectedAssetId, setSelectedAssetId] = useState<string>();
const [selectedClipId, setSelectedClipId] = useState<string>();
const [project, setProject] = useState<AvProjectV1>(() =>
createEmptyProject()
);
const [subtitleFiles, setSubtitleFiles] = useState<ReadonlyMap<string, File>>(
() => new Map()
);
const [waveformByAsset, setWaveformByAsset] = useState<
Readonly<Record<string, WaveformPeakData>>
>({});
const [splitMarkersByAsset, setSplitMarkersByAsset] = useState<
Readonly<Record<string, readonly number[]>>
>({});
const [splitBatch, setSplitBatch] = useState<AdvancedSplitBatchStatus>();
const [loudnessByAsset, setLoudnessByAsset] = useState<
Readonly<Record<string, LoudnessMeasurement>>
>({});
const [waveformProgress, setWaveformProgress] = useState<number>();
const [playheadByAsset, setPlayheadByAsset] = useState<
Readonly<Record<string, number>>
>({});
const [previewProxySettings, setPreviewProxySettings] =
useState<PreviewProxySettings>({
maxDurationSeconds: 30,
maxWidth: 960,
});
const [presetSetup] = useState(createPresetRegistry);
const [userPresets, setUserPresets] = useState<readonly UserExportPreset[]>(
[]
);
const [waveformRuntime] = useState(createWaveformRuntime);
const [engineState, setEngineState] = useState<EngineState>(
engineManager.state
);
const [enginePreference, setEnginePreference] =
useState<EnginePreference>('automatic');
const [resourceContext] = useState(createRuntimeResourceContext);
const [logs, setLogs] = useState<string[]>([]);
const [resultCollection] = useState(() => new ExportResultCollection());
const [results, setResults] = useState<readonly ExportResult[]>([]);
const [previewResultId, setPreviewResultId] = useState<string>();
const [outputVerifications, setOutputVerifications] = useState<
Readonly<Record<string, OutputVerification>>
>({});
const [latestReport, setLatestReport] = useState<ExportReport>();
const [operationError, setOperationError] = useState<string>();
const [projectNotice, setProjectNotice] = useState<string>();
const [sourceProbeBusy, setSourceProbeBusy] = useState(false);
const [scheduledPlanCount, setScheduledPlanCount] = useState(0);
const [scheduledJobs, setScheduledJobs] = useState<readonly MediaJob[]>([]);
const [editDrawer, setEditDrawer] = useState<
'media' | 'inspector' | undefined
>();
const projectInput = useRef<HTMLInputElement>(null);
const mediaDrawerTrigger = useRef<HTMLButtonElement>(null);
const inspectorDrawerTrigger = useRef<HTMLButtonElement>(null);
const assetsRef = useRef(assets);
const projectRef = useRef(project);
const sourceProbeTail = useRef<Promise<void>>(Promise.resolve());
const sourceProbePendingRef = useRef(0);
const sourceInspectionRegistry = useRef(new SourceInspectionRegistry());
const planExecutionTail = useRef<Promise<void>>(Promise.resolve());
const scheduledPlanCountRef = useRef(0);
const scheduledWorkRef = useRef(new Map<string, ScheduledWorkDefinition>());
const activeScheduledJobIdRef = useRef<string | undefined>(undefined);
const activeScheduledStepIndexRef = useRef<number | undefined>(undefined);
useEffect(() => {
assetsRef.current = assets;
}, [assets]);
useEffect(() => {
projectRef.current = project;
}, [project]);
useEffect(() => {
const activeJobId = activeScheduledJobIdRef.current;
const activeStepIndex = activeScheduledStepIndexRef.current;
if (
!activeJobId ||
activeStepIndex === undefined ||
engineState.status !== 'running' ||
engineState.progress === undefined
) {
return;
}
setScheduledJobs((current) =>
replaceJob(current, activeJobId, (job) => {
const steps = job.steps.map((step, index) =>
index === activeStepIndex
? {
...step,
progress: Math.min(
0.99,
Math.max(0, engineState.progress ?? 0)
),
}
: step
);
const progress = Math.min(
0.99,
steps.reduce(
(sum, step) =>
sum +
step.weight * (step.status === 'completed' ? 1 : step.progress),
0
)
);
return {
...job,
status: 'running',
steps,
progress,
updatedAt: timestamp(),
};
})
);
}, [engineState]);
useEffect(() => {
const activeJobId = activeScheduledJobIdRef.current;
if (!activeJobId) return;
const status =
engineState.status === 'recovering'
? ('recovering' as const)
: engineState.status === 'loading'
? ('preparing' as const)
: undefined;
if (!status) return;
setScheduledJobs((current) =>
replaceJob(current, activeJobId, (job) => ({
...job,
status,
updatedAt: timestamp(),
}))
);
}, [engineState.status]);
const closeEditDrawer = useCallback(() => {
const closing = editDrawer;
setEditDrawer(undefined);
globalThis.queueMicrotask(() => {
(closing === 'media'
? mediaDrawerTrigger.current
: inspectorDrawerTrigger.current
)?.focus();
});
}, [editDrawer]);
const selectWorkspaceMode = useCallback((nextMode: WorkspaceMode) => {
setEditDrawer(undefined);
setMode(nextMode);
}, []);
useEffect(() => {
if (!editDrawer) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
closeEditDrawer();
}
};
globalThis.addEventListener('keydown', closeOnEscape);
const closeButton = document.querySelector<HTMLButtonElement>(
editDrawer === 'media'
? '#edit-media-drawer .mobile-edit-drawer__close'
: '#edit-inspector-drawer .mobile-edit-drawer__close'
);
closeButton?.focus();
return () => globalThis.removeEventListener('keydown', closeOnEscape);
}, [closeEditDrawer, editDrawer]);
useEffect(
() =>
engineManager.subscribe((state) => {
setEngineState(state);
}),
[]
);
useEffect(() => {
let active = true;
void presetSetup.registry
.initialize()
.then(() => {
if (active) setUserPresets(presetSetup.registry.list());
})
.catch((error: unknown) => {
if (active) {
setProjectNotice(
error instanceof Error
? `User presets could not be loaded: ${error.message}`
: 'User presets could not be loaded.'
);
}
});
return () => {
active = false;
};
}, [presetSetup]);
useEffect(
() =>
engineManager.onLog(({ message }) => {
setLogs((current) => [...current.slice(-399), message]);
}),
[]
);
useEffect(() => {
const inspectionRegistry = sourceInspectionRegistry.current;
return () => {
const cancelActiveInspection = inspectionRegistry.clear();
if (cancelActiveInspection) void engineManager.cancelActive();
for (const asset of assetsRef.current) {
URL.revokeObjectURL(asset.objectUrl);
}
resultCollection.dispose();
waveformRuntime.terminate();
};
}, [resultCollection, waveformRuntime]);
const applyProjectAction = useCallback((action: ProjectAction) => {
setProject((current) => projectReducer(current, action));
}, []);
const updateAssetState = useCallback(
(id: string, changes: Partial<ImportedMediaAsset>) => {
setAssets((current) =>
current.map((asset) =>
asset.id === id ? { ...asset, ...changes } : asset
)
);
},
[]
);
const runSourceProbe = useCallback(
<T,>(probe: () => Promise<T>): Promise<T> => {
sourceProbePendingRef.current += 1;
setSourceProbeBusy(true);
const execution = sourceProbeTail.current.then(probe, probe);
sourceProbeTail.current = execution.then(
() => undefined,
() => undefined
);
void execution
.finally(() => {
sourceProbePendingRef.current = Math.max(
0,
sourceProbePendingRef.current - 1
);
setSourceProbeBusy(sourceProbePendingRef.current > 0);
})
.catch(() => undefined);
return execution;
},
[]
);
const inspectAsset = useCallback(
async (
asset: ImportedMediaAsset,
existingAsset: boolean,
token: SourceInspectionToken
) => {
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
updateAssetState(asset.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(),
});
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(),
});
}
const hasClip = next.timeline.some(
(clip) => clip.assetId === asset.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 (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.',
});
}
},
[runSourceProbe, updateAssetState]
);
const addFiles = useCallback(
async (files: File[]) => {
setOperationError(undefined);
setProjectNotice(undefined);
if (
scheduledPlanCountRef.current > 0 ||
(engineManager.state.status === 'running' &&
sourceProbePendingRef.current === 0)
) {
setOperationError(
'Wait for the active export or analysis job before importing media.'
);
return;
}
const importDecision = preflightResources(
{
inputBytes: [
...assetsRef.current.map((asset) => asset.file.size),
...files.map((file) => file.size),
],
},
resourceContext.policy
);
if (importDecision.status === 'blocked') {
setOperationError(
importDecision.issues
.map((issue) => `${issue.message} ${issue.suggestions.join(' / ')}`)
.join(' ')
);
return;
}
if (
importDecision.status === 'warning' &&
!globalThis.confirm(
`${importDecision.issues
.map((issue) => issue.message)
.join(
'\n'
)}\n\n${importDecision.estimateDisclaimer}\n\nImport and inspect anyway?`
)
) {
return;
}
const pending: Array<{
asset: ImportedMediaAsset;
existingAsset: boolean;
token: SourceInspectionToken;
}> = [];
const claimedIds = new Set(assetsRef.current.map((asset) => asset.id));
for (const file of files) {
const candidateMatches = project.assets
.filter(
(entry) =>
entry.sourceStatus === 'missing' && !claimedIds.has(entry.id)
)
.map((entry) => ({
entry,
match: rankReattachmentCandidates(entry, [fileDescriptor(file)])[0],
}))
.filter(
(
value
): value is {
entry: MediaAssetReference;
match: NonNullable<(typeof value)['match']>;
} => value.match?.confidence !== 'none'
)
.sort((left, right) => right.match.score - left.match.score);
const match = candidateMatches[0];
const reattach =
match !== undefined &&
globalThis.confirm(
`Reattach “${file.name}” to the missing project source “${match.entry.originalName}”?\n\n${match.match.evidence
.map((evidence) => evidence.message)
.join(' ')}`
);
const asset = createAsset(file);
if (reattach && match) {
asset.id = match.entry.id;
claimedIds.add(match.entry.id);
pending.push({
asset,
existingAsset: true,
token: sourceInspectionRegistry.current.register(asset.id),
});
} else {
setProject((current) =>
projectReducer(current, {
type: 'asset/add',
asset: {
id: asset.id,
originalName: file.name,
size: file.size,
lastModified: file.lastModified,
mimeType: file.type,
sourceStatus: 'attached',
},
updatedAt: timestamp(),
})
);
pending.push({
asset,
existingAsset: false,
token: sourceInspectionRegistry.current.register(asset.id),
});
}
}
setAssets((current) => {
const next = [...current, ...pending.map(({ asset }) => asset)];
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);
}
},
[inspectAsset, project.assets, resourceContext.policy]
);
const removeAsset = useCallback(
(id: string) => {
const asset = assets.find((entry) => entry.id === id);
if (asset) URL.revokeObjectURL(asset.objectUrl);
const cancelActiveInspection =
sourceInspectionRegistry.current.remove(id);
setAssets((current) => {
const next = current.filter((entry) => entry.id !== id);
assetsRef.current = next;
return next;
});
setWaveformByAsset((current) =>
Object.fromEntries(
Object.entries(current).filter(([assetId]) => assetId !== id)
)
);
setSplitMarkersByAsset((current) =>
Object.fromEntries(
Object.entries(current).filter(([assetId]) => assetId !== id)
)
);
setLoudnessByAsset((current) =>
Object.fromEntries(
Object.entries(current).filter(([assetId]) => assetId !== id)
)
);
setPlayheadByAsset((current) =>
Object.fromEntries(
Object.entries(current).filter(([assetId]) => assetId !== id)
)
);
if (selectedAssetId === id) {
setSelectedAssetId(assets.find((entry) => entry.id !== id)?.id);
}
applyProjectAction({
type: 'asset/remove',
assetId: id,
updatedAt: timestamp(),
});
if (cancelActiveInspection) void engineManager.cancelActive();
},
[applyProjectAction, assets, selectedAssetId]
);
const selectedAsset = assets.find((asset) => asset.id === selectedAssetId);
const selectedClip = project.timeline.find(
(clip) => clip.id === selectedClipId
);
const inspectedClipAsset = selectedClip
? assets.find((asset) => asset.id === selectedClip.assetId)
: selectedAsset;
const previewOutput = results.find((entry) => entry.id === previewResultId);
const previewProbe = previewOutput
? outputVerifications[previewOutput.id]?.probe
: undefined;
const previewResult = previewOutput
? {
name: previewOutput.fileName,
mimeType: previewOutput.mimeType,
objectUrl: resultCollection.objectUrl(previewOutput.id),
...(previewProbe ? { probe: previewProbe } : {}),
}
: undefined;
const refreshResults = useCallback(() => {
const current = resultCollection.list();
setResults(current);
setPreviewResultId((selected) =>
selected && current.some((entry) => entry.id === selected)
? selected
: current.find((entry) => canPreviewGeneratedResult(entry))?.id
);
}, [resultCollection]);
const clearResult = useCallback(() => {
resultCollection.clear();
setResults([]);
setPreviewResultId(undefined);
setOutputVerifications({});
setLatestReport(undefined);
}, [resultCollection]);
const executePlan = useCallback(
async (
plan: FFmpegCommandPlan,
inputs: readonly File[],
operation: string,
onStage?: (stage: 'reading-output') => void
): Promise<PlanExecution> => {
if (
plan.diagnostics.some((diagnostic) => diagnostic.severity === 'error')
) {
throw new Error(
plan.diagnostics
.filter((diagnostic) => diagnostic.severity === 'error')
.map((diagnostic) => diagnostic.message)
.join(' ')
);
}
if (sourceProbePendingRef.current > 0) {
throw new EngineError(
'busy',
'Wait for media inspection to finish before starting an export.'
);
}
const resourceSources = inputs.map((file) => {
const asset = assetsRef.current.find((entry) => entry.file === file);
return {
size: file.size,
...(asset?.probe ? { probe: asset.probe } : {}),
};
});
const resourceDecision = preflightResources(
{
inputBytes: resourceSources.map((source) => source.size),
...estimatePlanResources(plan, resourceSources),
retainedOutputs: resultCollection.size,
streamsPerInput: resourceSources.map(
(source) => source.probe?.streams.length ?? 0
),
},
resourceContext.policy
);
if (resourceDecision.status === 'blocked') {
throw new Error(
resourceDecision.issues
.map((issue) => `${issue.message} ${issue.suggestions.join(' / ')}`)
.join(' ')
);
}
if (
resourceDecision.status === 'warning' &&
!globalThis.confirm(
`${resourceDecision.issues
.map((issue) => issue.message)
.join('\n')}\n\n${resourceDecision.estimateDisclaimer}\n\nContinue?`
)
) {
throw new EngineError('cancelled', 'The operation was not started.');
}
setOperationError(undefined);
setLogs([]);
await engineManager.initialize();
const readyState = engineManager.state;
if (readyState.status !== 'ready') {
throw new Error('The local FFmpeg engine did not become ready.');
}
const missing = (
[
['muxers', plan.requiredCapabilities.muxers],
['encoders', plan.requiredCapabilities.encoders],
['decoders', plan.requiredCapabilities.decoders],
['filters', plan.requiredCapabilities.filters],
] as const
).flatMap(([kind, required]) =>
(required ?? [])
.filter((name) => !readyState.capabilities[kind].has(name))
.map((name) => `${kind.slice(0, -1)} ${name}`)
);
if (missing.length > 0) {
throw new Error(
`The pinned FFmpeg core is missing required capabilities: ${missing.join(', ')}.`
);
}
const completed = await engineManager.runJob({
id: plan.id.split(':')[0] ?? crypto.randomUUID(),
operation,
coreWorkload: plan.operation,
inputs,
temporaryFiles: plan.temporaryFiles.map((temporary) => ({
name: temporary.path.split('/').at(-1) ?? 'temporary.dat',
content: (context: EngineJobContext) =>
replaceTemporaryContentPaths(temporary.content, plan, context),
})),
outputs: plan.outputs.map((output) => ({
name: output.fileName,
mimeType: output.mediaType,
})),
expectedDurationSeconds: plan.expectedDurationSeconds,
timeoutMilliseconds: plan.timeoutMs,
buildArguments: (context) => replaceVirtualPaths(plan, context),
});
onStage?.('reading-output');
const logText = completed.logs.map((entry) => entry.message).join('\n');
if (plan.outputs.length === 0) {
return { results: [], logText };
}
const outputMap = new Map(
completed.outputs.flatMap((output, index) => {
const planned = plan.outputs[index];
return planned
? ([[planned.id, Uint8Array.from(output.bytes)] as const] as const)
: [];
})
);
const collected = collectCommandOutputs(
plan,
outputMap,
resultCollection
);
const verifications: Record<string, OutputVerification> = {};
const probeLimit = 8;
for (const [index, output] of collected.entries()) {
if (
!output.mimeType.startsWith('video/') &&
!output.mimeType.startsWith('audio/')
) {
continue;
}
if (index >= probeLimit) {
verifications[output.id] = {
warning:
'This batch item was not re-probed because output verification is bounded to the first eight results.',
};
continue;
}
try {
const probeResult = await engineManager.probe(
new File([output.blob], output.fileName, {
type: output.mimeType,
lastModified: Date.now(),
})
);
verifications[output.id] = {
probe: normalizeFfprobeReport(probeResult.json),
};
} catch (error) {
verifications[output.id] = {
warning:
error instanceof Error
? `Output was created, but its verification probe failed: ${error.message}`
: 'Output was created, but its verification probe failed.',
};
}
}
setOutputVerifications((current) => ({
...current,
...verifications,
}));
const readyStateAfterRun = engineManager.state;
const report = createExportReport({
plan,
results: collected,
execution: {
status: 'completed',
exitCode: completed.exitCode,
elapsedSeconds:
(completed.timings.conversionMilliseconds +
completed.timings.outputReadMilliseconds +
completed.timings.cleanupMilliseconds) /
1_000,
conversionMilliseconds: completed.timings.conversionMilliseconds,
outputReadMilliseconds: completed.timings.outputReadMilliseconds,
cleanupMilliseconds: completed.timings.cleanupMilliseconds,
...(readyStateAfterRun.status === 'ready'
? {
engineMode:
readyStateAfterRun.mode === 'multithread'
? ('multi-thread' as const)
: ('single-thread' as const),
ffmpegVersion: readyStateAfterRun.capabilities.versionText,
coreVersion: FFMPEG_CORE_VERSION,
}
: {}),
},
diagnostics: Object.values(verifications).flatMap((verification) =>
verification.warning
? [
{
code: 'output-probe-failed',
severity: 'warning' as const,
message: verification.warning,
},
]
: []
),
});
setLatestReport(report);
refreshResults();
setPreviewResultId(
collected.find((entry) =>
canPreviewGeneratedResult(entry, verifications[entry.id]?.probe)
)?.id
);
return { results: collected, logText, verifications, report };
},
[refreshResults, resourceContext.policy, resultCollection]
);
const enqueueWork = useCallback(
<T,>(
operation: string,
totalSteps: number,
work: (executeStep: ExecutePlan) => Promise<T>,
retryCount = 0
): Promise<T> => {
if (sourceProbePendingRef.current > 0) {
return Promise.reject(
new EngineError(
'busy',
'Wait for media inspection to finish before adding an export.'
)
);
}
if (scheduledPlanCountRef.current >= 8) {
return Promise.reject(
new EngineError(
'busy',
'The local queue already contains eight operations. Wait for one to finish or cancel a waiting job.'
)
);
}
const id = crypto.randomUUID();
const createdAt = timestamp();
const definition: ScheduledWorkDefinition = {
operation,
projectId: projectRef.current.id,
totalSteps: Math.max(1, Math.floor(totalSteps)),
retryCount,
work: work as (executeStep: ExecutePlan) => Promise<unknown>,
cancelled: false,
counted: true,
started: false,
};
scheduledWorkRef.current.set(id, definition);
setScheduledJobs((current) => {
const retained = current.slice(-23);
const retainedIds = new Set(retained.map((job) => job.id));
for (const job of current) {
if (!retainedIds.has(job.id)) {
scheduledWorkRef.current.delete(job.id);
}
}
return [
...retained,
createQueuedJob(
id,
operation,
definition.totalSteps,
createdAt,
definition.retryCount
),
];
});
scheduledPlanCountRef.current += 1;
setScheduledPlanCount(scheduledPlanCountRef.current);
const start = async (): Promise<T> => {
definition.started = true;
activeScheduledJobIdRef.current = id;
if (definition.cancelled) {
activeScheduledJobIdRef.current = undefined;
throw new EngineError('cancelled', 'The queued job was cancelled.');
}
setScheduledJobs((current) =>
replaceJob(current, id, (job) => ({
...job,
status: 'preparing',
startedAt: timestamp(),
updatedAt: timestamp(),
}))
);
let stepIndex = 0;
const executeTrackedStep: ExecutePlan = async (
plan,
inputs,
stepOperation
) => {
const currentStepIndex = stepIndex;
stepIndex += 1;
activeScheduledStepIndexRef.current = currentStepIndex;
setScheduledJobs((current) =>
replaceJob(current, id, (job) => {
const steps = [...job.steps];
const existing = steps[currentStepIndex];
const nextStep: MediaJobStep = {
id: existing?.id ?? `${id}:step-${currentStepIndex + 1}`,
name: stepOperation,
weight: existing?.weight ?? 1 / definition.totalSteps,
status: 'running',
progress: 0,
planId: plan.id,
};
steps[currentStepIndex] = nextStep;
return {
...job,
status: 'running',
steps,
updatedAt: timestamp(),
};
})
);
try {
const result = await executePlan(
plan,
inputs,
stepOperation,
() => {
setScheduledJobs((current) =>
replaceJob(current, id, (job) => ({
...job,
status: 'reading-output',
updatedAt: timestamp(),
}))
);
}
);
setScheduledJobs((current) =>
replaceJob(current, id, (job) => {
const steps = job.steps.map((step, index) =>
index === currentStepIndex
? { ...step, status: 'completed' as const, progress: 1 }
: step
);
return {
...job,
steps,
progress: Math.min(
0.99,
steps.reduce(
(sum, step) =>
sum +
step.weight *
(step.status === 'completed' ? 1 : step.progress),
0
)
),
updatedAt: timestamp(),
};
})
);
return result;
} catch (error) {
const cancelled =
definition.cancelled ||
(error instanceof EngineError && error.code === 'cancelled');
setScheduledJobs((current) =>
replaceJob(current, id, (job) => ({
...job,
steps: job.steps.map((step, index) =>
index === currentStepIndex
? {
...step,
status: cancelled
? ('cancelled' as const)
: ('failed' as const),
progress: Math.min(step.progress, 0.99),
}
: step
),
updatedAt: timestamp(),
}))
);
throw error;
}
};
try {
const result = await work(executeTrackedStep);
setScheduledJobs((current) =>
replaceJob(current, id, (job) => ({
...job,
status: 'completed',
progress: 1,
completedAt: timestamp(),
updatedAt: timestamp(),
}))
);
return result;
} catch (error) {
const cancelled =
definition.cancelled ||
(error instanceof EngineError && error.code === 'cancelled');
setScheduledJobs((current) =>
replaceJob(current, id, (job) => ({
...job,
status: cancelled ? 'cancelled' : 'failed',
completedAt: timestamp(),
updatedAt: timestamp(),
error: cancelled
? undefined
: {
code: 'execution-failed',
message:
error instanceof Error
? error.message
: 'The local job failed.',
recoverable: true,
},
steps: job.steps.map((step) =>
step.status === 'queued'
? {
...step,
status: cancelled
? ('cancelled' as const)
: ('failed' as const),
}
: step
),
}))
);
throw error;
} finally {
if (activeScheduledJobIdRef.current === id) {
activeScheduledJobIdRef.current = undefined;
activeScheduledStepIndexRef.current = undefined;
}
}
};
const execution = planExecutionTail.current.then(start, start);
planExecutionTail.current = execution.then(
() => undefined,
() => undefined
);
void execution
.finally(() => {
if (definition.counted) {
definition.counted = false;
scheduledPlanCountRef.current = Math.max(
0,
scheduledPlanCountRef.current - 1
);
setScheduledPlanCount(scheduledPlanCountRef.current);
}
})
.catch(() => undefined);
return execution;
},
[executePlan]
);
const runPlan = useCallback(
(
plan: FFmpegCommandPlan,
inputs: readonly File[],
operation: string
): Promise<PlanExecution> =>
enqueueWork(operation, 1, (executeStep) =>
executeStep(plan, inputs, operation)
),
[enqueueWork]
);
const runWorkflow = useCallback(
<T,>(
operation: string,
totalSteps: number,
work: (executeStep: ExecutePlan) => Promise<T>
): Promise<T> => enqueueWork(operation, totalSteps, work),
[enqueueWork]
);
const cancelScheduledJob = useCallback(async (id: string) => {
const definition = scheduledWorkRef.current.get(id);
if (!definition) return;
definition.cancelled = true;
setScheduledJobs((current) =>
replaceJob(current, id, (job) => ({
...job,
status: job.status === 'queued' ? 'cancelled' : 'cancelling',
completedAt: job.status === 'queued' ? timestamp() : job.completedAt,
updatedAt: timestamp(),
steps: job.steps.map((step) =>
step.status === 'queued'
? { ...step, status: 'cancelled' as const }
: step
),
}))
);
if (!definition.started && definition.counted) {
definition.counted = false;
scheduledPlanCountRef.current = Math.max(
0,
scheduledPlanCountRef.current - 1
);
setScheduledPlanCount(scheduledPlanCountRef.current);
return;
}
if (activeScheduledJobIdRef.current === id) {
await engineManager.cancelActive();
}
}, []);
const retryScheduledJob = useCallback(
(id: string) => {
const definition = scheduledWorkRef.current.get(id);
if (!definition || definition.projectId !== projectRef.current.id) {
setOperationError(
'This job cannot be retried after its project has been replaced.'
);
return;
}
void enqueueWork(
definition.operation,
definition.totalSteps,
definition.work,
definition.retryCount + 1
).catch(() => undefined);
},
[enqueueWork]
);
const clearScheduledJobHistory = useCallback(() => {
setScheduledJobs((current) => {
const retained = current.filter(
(job) =>
job.status !== 'completed' &&
job.status !== 'failed' &&
job.status !== 'cancelled'
);
const retainedIds = new Set(retained.map((job) => job.id));
for (const id of scheduledWorkRef.current.keys()) {
if (!retainedIds.has(id)) scheduledWorkRef.current.delete(id);
}
return retained;
});
}, []);
const exportQuick = useCallback(
async (configuration: QuickExportConfiguration) => {
if (!selectedAsset?.probe) return;
const jobId = crypto.randomUUID();
try {
let plan: FFmpegCommandPlan;
if (configuration.operation === 'remux') {
const container = configuration.remuxContainer ?? 'matroska';
plan = buildRemuxPlan({
jobId,
source: {
id: selectedAsset.id,
sourceIndex: 0,
fileName: selectedAsset.file.name,
},
targetContainer: container,
fileExtension:
container === 'matroska'
? 'mkv'
: container === 'ogg'
? 'ogg'
: container,
description: {
streams: selectedAsset.probe.streams.flatMap((stream) =>
stream.type === 'video' ||
stream.type === 'audio' ||
stream.type === 'subtitle' ||
stream.type === 'attachment' ||
stream.type === 'data'
? [
{
index: stream.index,
kind: stream.type,
codec: stream.codecName ?? 'unknown',
},
]
: []
),
hasChapters: selectedAsset.probe.chapters.length > 0,
hasMetadata: Object.keys(selectedAsset.probe.tags).length > 0,
},
streamSelection: configuration.streamSelection,
metadataPolicy: configuration.removeMetadata ? 'remove' : 'copy',
chapterPolicy: configuration.removeChapters ? 'remove' : 'keep',
expectedDurationSeconds: selectedAsset.probe.durationSeconds,
});
} else {
if (!configuration.preset) return;
plan = buildConvertPlan({
jobId,
source: {
id: selectedAsset.id,
sourceIndex: 0,
fileName: selectedAsset.file.name,
},
preset: {
...configuration.preset,
metadataPolicy: configuration.removeMetadata ? 'remove' : 'copy',
chapterPolicy: configuration.removeChapters ? 'remove' : 'keep',
},
streamSelection: configuration.streamSelection,
expectedDurationSeconds: selectedAsset.probe.durationSeconds,
});
}
await runPlan(plan, [selectedAsset.file], plan.operation);
} catch (error) {
setOperationError(
error instanceof EngineError && error.code === 'cancelled'
? 'The operation was cancelled and the engine was recreated.'
: error instanceof Error
? error.message
: 'The output could not be generated.'
);
}
},
[runPlan, selectedAsset]
);
const extractThumbnail = useCallback(
async (request: ThumbnailRequest) => {
const asset = inspectedClipAsset;
if (!asset) return;
try {
const plan = buildSingleThumbnailPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
timeSeconds: request.timeSeconds,
size: {
...(request.width === undefined ? {} : { width: request.width }),
...(request.height === undefined ? {} : { height: request.height }),
maintainAspect: request.maintainAspect,
},
format: request.format === 'jpg' ? 'jpeg' : request.format,
quality: request.quality,
});
const execution = await runPlan(
plan,
[asset.file],
'Extract thumbnail'
);
const derivative = execution.results[0];
if (!derivative) {
throw new Error('The thumbnail output was not returned.');
}
try {
await cacheDerivative(
asset.file,
'thumbnail',
'thumbnail',
project.id,
derivative.blob,
{
timeSeconds: request.timeSeconds,
width: request.width,
height: request.height,
maintainAspect: request.maintainAspect,
format: request.format,
quality: request.quality,
}
);
} catch {
setProjectNotice(
'Thumbnail created, but this browser could not cache the derivative.'
);
}
} catch (error) {
setOperationError(
error instanceof Error ? error.message : 'Frame extraction failed.'
);
}
},
[inspectedClipAsset, project.id, runPlan]
);
const createPreviewProxy = useCallback(
async (asset: ImportedMediaAsset | undefined) => {
const duration = asset?.probe?.durationSeconds;
if (!asset || duration === undefined || duration <= 0) return;
try {
const includeAudio =
asset.probe?.streams.some((stream) => stream.type === 'audio') ===
true;
const hasVideo =
asset.probe?.streams.some(
(stream) =>
stream.type === 'video' &&
stream.disposition.attached_pic !== true
) === true;
const cacheSettings = {
...previewProxySettings,
includeAudio,
hasVideo,
};
const plan = buildPreviewProxyPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
sourceDurationSeconds: duration,
...previewProxySettings,
includeAudio,
hasVideo,
});
let cached: Blob | undefined;
try {
cached = await readCachedDerivative(
asset.file,
'preview-proxy',
cacheSettings
);
} catch {
// Cache availability is optional; generation remains the fallback.
}
const plannedOutput = plan.outputs[0];
if (cached && plannedOutput) {
const cachedResult = resultCollection.add({
id: `${plan.id}:${plannedOutput.id}:cached`,
planId: plan.id,
outputId: plannedOutput.id,
fileName: plannedOutput.fileName,
blob: cached,
mimeType: plannedOutput.mediaType,
role: plannedOutput.role,
});
refreshResults();
setPreviewResultId(
canPreviewGeneratedResult(cachedResult)
? cachedResult.id
: undefined
);
setOutputVerifications((current) => ({
...current,
[cachedResult.id]: {
warning:
'Reused the matching local preview derivative; it was not re-probed in this run.',
},
}));
setProjectNotice('Reused the matching local preview proxy.');
return;
}
const execution = await runPlan(
plan,
[asset.file],
'Create preview proxy'
);
const derivative = execution.results[0];
if (!derivative) {
throw new Error('The preview proxy output was not returned.');
}
try {
await cacheDerivative(
asset.file,
'preview-proxy',
'preview-proxy',
project.id,
derivative.blob,
cacheSettings
);
} catch {
setProjectNotice(
'Preview proxy created, but this browser could not cache the derivative.'
);
}
} catch (error) {
setOperationError(
error instanceof Error
? error.message
: 'The preview proxy could not be generated.'
);
}
},
[
previewProxySettings,
project.id,
refreshResults,
resultCollection,
runPlan,
]
);
const exportTimeline = useCallback(async () => {
const preset =
findBuiltInPreset(project.output.presetId) ??
userPresets.find((entry) => entry.id === project.output.presetId);
if (!preset?.video) {
setOperationError(
'Choose an available video preset in the timeline output settings.'
);
return;
}
const timelineVideoPreset = preset.video;
if (!['mp4', 'webm', 'matroska'].includes(preset.container)) {
setOperationError(
'Timeline export currently supports MP4, WebM, or Matroska video presets.'
);
return;
}
const timelineContainer = preset.container as 'mp4' | 'webm' | 'matroska';
try {
const enabledSubtitleTracks = project.subtitles.filter(
(entry) => entry.mode !== 'exclude'
);
const timelineStepCount =
2 +
enabledSubtitleTracks.length +
enabledSubtitleTracks.filter(
(entry) => (entry.offsetSeconds ?? 0) !== 0
).length +
(project.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 video = asset?.probe?.streams.find(
(stream) =>
stream.type === 'video' &&
stream.disposition.attached_pic !== true
);
if (!asset || !video || !video.width || !video.height) {
throw new Error(
`Clip ${index + 1} has no attached, inspected video source.`
);
}
if (clip.video?.enabled === false) {
throw new Error(
`Clip ${index + 1} has video disabled. The sequential video timeline requires video in every clip; use Quick Convert for an audio-only export.`
);
}
const crop = clip.crop
? validateCrop({
...clip.crop,
sourceWidth: video.width,
sourceHeight: video.height,
requireEvenDimensions: true,
})
: undefined;
const croppedWidth = crop?.width ?? video.width;
const croppedHeight = crop?.height ?? video.height;
const rotationSwapsDimensions =
clip.rotation === 90 || clip.rotation === 270;
const resizeSourceWidth = rotationSwapsDimensions
? croppedHeight
: croppedWidth;
const resizeSourceHeight = rotationSwapsDimensions
? croppedWidth
: croppedHeight;
const resize = clip.resize
? {
sourceWidth: resizeSourceWidth,
sourceHeight: resizeSourceHeight,
width: clip.resize.width,
height: clip.resize.height,
mode:
clip.resize.mode === 'fit'
? ('contain' as const)
: clip.resize.mode === 'fill'
? ('cover' as const)
: ('stretch' as const),
noUpscaling: clip.resize.allowUpscale === false,
algorithm: clip.resize.algorithm,
paddingColor: clip.resize.paddingColor,
}
: undefined;
const audioNormalization = clip.audio?.normalization
? (() => {
const measurement = loudnessByAsset[asset.id];
if (!measurement) {
throw new Error(
`Clip ${index + 1} requests two-pass loudness normalization. Measure its source in Advanced operations first.`
);
}
return {
mode: 'loudness' as const,
targets: {
integratedLufs: clip.audio.normalization.targetLufs,
truePeakDbtp: clip.audio.normalization.truePeakDb,
loudnessRangeLu:
clip.audio.normalization.loudnessRangeLufs ?? 11,
},
measurement,
};
})()
: undefined;
const calculatedResize = resize
? calculateResize(resize)
: undefined;
const targetBoxIsFinal =
resize !== undefined &&
resize.width !== undefined &&
resize.height !== undefined &&
(resize.mode === 'contain' || resize.mode === 'cover');
const transformedWidth = targetBoxIsFinal
? (resize.width as number)
: (calculatedResize?.width ?? resizeSourceWidth);
const transformedHeight = targetBoxIsFinal
? (resize.height as number)
: (calculatedResize?.height ?? resizeSourceHeight);
return {
clip: {
id: clip.id,
source: {
id: asset.id,
sourceIndex: index,
fileName: asset.file.name,
},
sourceInSeconds: clip.sourceInSeconds,
sourceOutSeconds: clip.sourceOutSeconds,
sourceDurationSeconds: asset.probe?.durationSeconds,
hasVideo: true,
hasAudio:
project.output.audioEnabled &&
clip.audio?.enabled !== false &&
asset.probe?.streams.some(
(stream) => stream.type === 'audio'
) === true,
transform:
crop || resize || clip.rotation
? {
crop,
resize,
rotation: clip.rotation,
}
: undefined,
audioGainDb: clip.audio?.gainDb,
audioNormalization,
audioFadeInSeconds: clip.audio?.fadeInSeconds,
audioFadeOutSeconds: clip.audio?.fadeOutSeconds,
audioFadeCurve: clip.audio?.fadeCurve,
videoFadeInSeconds: clip.video?.fadeInSeconds,
videoFadeOutSeconds: clip.video?.fadeOutSeconds,
videoFadeCurve: clip.video?.fadeCurve,
},
file: asset.file,
width: transformedWidth,
height: transformedHeight,
requestedFrameRate: clip.frameRate,
sourceFrameRate: video.frameRate,
};
});
const first = resolved[0];
if (!first) throw new Error('Add at least one timeline clip.');
const even = (value: number) =>
Math.max(2, Math.round(value / 2) * 2);
const requestedWidth =
project.output.width ?? timelineVideoPreset.width ?? undefined;
const requestedHeight =
project.output.height ?? timelineVideoPreset.height ?? undefined;
let outputWidth: number;
let outputHeight: number;
if (requestedWidth && requestedHeight) {
outputWidth = even(requestedWidth);
outputHeight = even(requestedHeight);
} else if (requestedWidth) {
outputWidth = even(requestedWidth);
outputHeight = even((requestedWidth * first.height) / first.width);
} else if (requestedHeight) {
outputHeight = even(requestedHeight);
outputWidth = even((requestedHeight * first.width) / first.height);
} else {
const scale = Math.min(1, 1920 / first.width, 1080 / first.height);
outputWidth = even(first.width * scale);
outputHeight = even(first.height * scale);
}
const requestedClipFrameRates = [
...new Set(
resolved.flatMap((entry) =>
entry.requestedFrameRate === undefined
? []
: [entry.requestedFrameRate]
)
),
];
if (
project.output.frameRate === undefined &&
timelineVideoPreset.frameRate === undefined &&
requestedClipFrameRates.length > 1
) {
throw new Error(
'Timeline clips contain conflicting legacy frame-rate settings. Choose one frame rate in Timeline output.'
);
}
const frameRate =
project.output.frameRate ??
timelineVideoPreset.frameRate ??
requestedClipFrameRates[0] ??
first.sourceFrameRate ??
30;
const sampleRate =
project.output.sampleRate ?? preset.audio?.sampleRate ?? 48_000;
const channelLayout =
project.output.channelLayout ??
(preset.audio?.channels === 1
? 'mono'
: preset.audio?.channels === 6
? '5.1'
: 'stereo');
const missingAudioPolicy =
!project.output.audioEnabled || !preset.audio
? ('drop-all' as const)
: (project.output.missingAudioPolicy ?? 'insert-silence');
const outputFileName = safeExportFileName(project.output.fileName, {
extension: preset.fileExtension,
fallback: 'timeline',
});
const plan = buildTimelineExportPlan({
jobId: crypto.randomUUID(),
clips: resolved.map(({ clip }) => clip),
preset,
outputWidth,
outputHeight,
frameRate,
sampleRate,
channelLayout,
missingAudioPolicy,
outputFileName,
});
const rendered = await executeStep(
plan,
resolved.map(({ file }) => file),
'Render timeline'
);
const renderedResult = rendered.results[0];
if (!renderedResult) {
throw new Error('The rendered timeline output was not returned.');
}
let currentResult = renderedResult;
let currentFile = resultAsFile(currentResult);
const totalDuration = resolved.reduce(
(sum, entry) =>
sum + entry.clip.sourceOutSeconds - entry.clip.sourceInSeconds,
0
);
const activeSubtitleTracks = project.subtitles
.filter((entry) => entry.mode !== 'exclude')
.sort((left, right) => {
if (left.mode === right.mode) return 0;
return left.mode === 'burn-in' ? -1 : 1;
});
for (const track of activeSubtitleTracks) {
const originalSubtitle = subtitleFiles.get(track.id);
if (!originalSubtitle || !track.sourceFormat) {
throw new Error(
`Reattach subtitle ${track.sourceName ?? track.id} before rendering the project.`
);
}
let subtitleFile = originalSubtitle;
let shiftedResultForCleanup: ExportResult | undefined;
if ((track.offsetSeconds ?? 0) !== 0) {
const shifted = await executeStep(
buildSubtitleConversionPlan({
jobId: crypto.randomUUID(),
subtitle: {
id: track.id,
sourceIndex: 0,
fileName: originalSubtitle.name,
},
outputFormat: track.sourceFormat,
offsetSeconds: track.offsetSeconds ?? 0,
}),
[originalSubtitle],
`Shift subtitle ${track.sourceName ?? track.id}`
);
const shiftedResult = shifted.results[0];
if (!shiftedResult) {
throw new Error(
'The shifted subtitle output was not returned.'
);
}
shiftedResultForCleanup = shiftedResult;
subtitleFile = resultAsFile(shiftedResult);
}
const mediaSource = {
id: `timeline-${project.id}`,
sourceIndex: 0,
fileName: currentFile.name,
};
const subtitleSource = {
id: track.id,
sourceIndex: 1,
fileName: subtitleFile.name,
};
const sourceStreams =
track.mode === 'burn-in'
? []
: remuxStreamsFromProbe(
normalizeFfprobeReport(
(await engineManager.probe(currentFile)).json
)
);
const subtitlePlan =
track.mode === 'burn-in'
? buildSubtitleBurnPlan({
jobId: crypto.randomUUID(),
source: mediaSource,
subtitle: subtitleSource,
preset,
expectedDurationSeconds: totalDuration,
})
: buildSubtitleMuxPlan({
jobId: crypto.randomUUID(),
source: mediaSource,
subtitle: subtitleSource,
subtitleCodec: subtitleCodecForFormat(track.sourceFormat),
sourceStreams,
targetContainer: timelineContainer,
targetExtension: preset.fileExtension,
...(track.language ? { language: track.language } : {}),
...(track.title ? { title: track.title } : {}),
default: track.default ?? false,
forced: track.forced ?? false,
expectedDurationSeconds: totalDuration,
});
const subtitled = await executeStep(
subtitlePlan,
[currentFile, subtitleFile],
track.mode === 'burn-in'
? 'Burn project subtitle'
: 'Mux project subtitle'
);
const subtitledResult = subtitled.results[0];
if (!subtitledResult) {
throw new Error(
'The subtitled timeline output was not returned.'
);
}
resultCollection.remove(currentResult.id);
if (shiftedResultForCleanup)
resultCollection.remove(shiftedResultForCleanup.id);
currentResult = subtitledResult;
currentFile = resultAsFile(subtitledResult);
}
if (project.chapters.length > 0) {
const chaptered = await executeStep(
buildChapterMuxPlan({
jobId: crypto.randomUUID(),
source: {
id: `timeline-${project.id}`,
sourceIndex: 0,
fileName: currentFile.name,
},
chapters: project.chapters.map((chapter) => ({
id: chapter.id,
title: chapter.title,
startSeconds: chapter.startSeconds,
endSeconds: chapter.endSeconds,
timeBase: chapter.timeBase ?? '1/1000',
})),
targetExtension: preset.fileExtension,
targetMuxer: preset.container,
durationSeconds: totalDuration,
}),
[currentFile],
'Mux project chapters'
);
const chapteredResult = chaptered.results[0];
if (!chapteredResult) {
throw new Error(
'The chaptered timeline output was not returned.'
);
}
resultCollection.remove(currentResult.id);
currentResult = chapteredResult;
currentFile = resultAsFile(chapteredResult);
}
const metadataEdits = metadataEditsFromProject(project.metadata);
const finalized = await executeStep(
buildMetadataEditPlan({
jobId: crypto.randomUUID(),
source: {
id: `timeline-${project.id}`,
sourceIndex: 0,
fileName: currentFile.name,
},
edits: metadataEdits,
targetExtension: preset.fileExtension,
targetMuxer: preset.container,
sourceMetadataPolicy:
preset.metadataPolicy === 'remove' ? 'remove' : 'copy',
chapterPolicy: 'keep',
expectedDurationSeconds: totalDuration,
outputFileName,
}),
[currentFile],
'Finalize timeline output'
);
if (!finalized.results[0]) {
throw new Error('The finalized timeline output was not returned.');
}
resultCollection.remove(currentResult.id);
refreshResults();
}
);
} catch (error) {
setOperationError(
error instanceof Error
? error.message
: 'The timeline could not be rendered.'
);
}
}, [
assets,
loudnessByAsset,
project,
refreshResults,
resultCollection,
runWorkflow,
subtitleFiles,
userPresets,
]);
const resetProject = useCallback(() => {
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
if (cancelActiveInspection) void engineManager.cancelActive();
assetsRef.current = [];
clearResult();
setAssets([]);
setSelectedAssetId(undefined);
setSelectedClipId(undefined);
setSubtitleFiles(new Map());
setWaveformByAsset({});
setSplitMarkersByAsset({});
setSplitBatch(undefined);
setLoudnessByAsset({});
setPlayheadByAsset({});
setScheduledJobs([]);
scheduledWorkRef.current.clear();
setProject(createEmptyProject());
setProjectNotice('Started a new empty project.');
}, [assets, clearResult]);
const exportProject = useCallback(() => {
const json = serializeProject(project, {
pretty: true,
includeUiState: true,
});
downloadBlob(
new Blob([json], { type: 'application/json' }),
projectDocumentFileName(project)
);
setProjectNotice(
'Project document downloaded. Source files are not embedded.'
);
}, [project]);
const importProject = useCallback(
async (file: File) => {
try {
const imported = importProjectDocument(await file.text());
for (const asset of assets) URL.revokeObjectURL(asset.objectUrl);
const cancelActiveInspection = sourceInspectionRegistry.current.clear();
if (cancelActiveInspection) void engineManager.cancelActive();
assetsRef.current = [];
clearResult();
setAssets([]);
setSelectedAssetId(undefined);
setSelectedClipId(imported.project.timeline[0]?.id);
setSubtitleFiles(new Map());
setWaveformByAsset({});
setSplitMarkersByAsset({});
setSplitBatch(undefined);
setLoudnessByAsset({});
setPlayheadByAsset({});
setScheduledJobs([]);
scheduledWorkRef.current.clear();
setProject(imported.project);
selectWorkspaceMode('edit');
setProjectNotice(
imported.warnings.length > 0
? imported.warnings.map((warning) => warning.message).join(' ')
: 'Project loaded.'
);
} catch (error) {
setProjectNotice(
error instanceof Error ? error.message : 'Project import failed.'
);
}
},
[assets, clearResult, selectWorkspaceMode]
);
const moveClip = useCallback(
(clipId: string, toIndex: number) => {
if (toIndex < 0 || toIndex >= project.timeline.length) return;
applyProjectAction({
type: 'clip/move',
clipId,
toIndex,
updatedAt: timestamp(),
});
},
[applyProjectAction, project.timeline.length]
);
const updateClip = useCallback(
(clipId: string, changes: Partial<TimelineClip>) => {
const clip = project.timeline.find((entry) => entry.id === clipId);
if (!clip) return;
const sourceIn = changes.sourceInSeconds ?? clip.sourceInSeconds;
const sourceOut = changes.sourceOutSeconds ?? clip.sourceOutSeconds;
if (
!Number.isFinite(sourceIn) ||
!Number.isFinite(sourceOut) ||
sourceIn < 0 ||
sourceOut - sourceIn < 0.001
) {
setProjectNotice('Clip out-point must remain after its in-point.');
return;
}
applyProjectAction({
type: 'clip/update',
clipId,
changes,
updatedAt: timestamp(),
});
},
[applyProjectAction, project.timeline]
);
const changeSubtitles = useCallback(
(subtitles: AvProjectV1['subtitles']) => {
const retainedIds = new Set(subtitles.map((track) => track.id));
setSubtitleFiles(
(current) =>
new Map([...current].filter(([trackId]) => retainedIds.has(trackId)))
);
applyProjectAction({
type: 'subtitles/set',
subtitles,
updatedAt: timestamp(),
});
},
[applyProjectAction]
);
const importSubtitleFile = useCallback(
(file: File) => {
const extension = file.name.split('.').at(-1)?.toLowerCase();
if (extension !== 'srt' && extension !== 'vtt' && extension !== 'ass') {
setProjectNotice('Choose an SRT, WebVTT, or ASS subtitle file.');
return;
}
if (file.size > 20 * 1024 * 1024) {
setProjectNotice('Subtitle files are limited to 20 MiB.');
return;
}
const missing = project.subtitles.find(
(track) =>
track.sourceStatus === 'missing' &&
track.sourceName?.toLocaleLowerCase() ===
file.name.toLocaleLowerCase()
);
const trackId = missing?.id ?? crypto.randomUUID();
const tracks: AvProjectV1['subtitles'] = missing
? project.subtitles.map((track) =>
track.id === missing.id
? {
...track,
sourceName: file.name,
sourceFormat: extension,
sourceStatus: 'attached' as const,
}
: track
)
: [
...project.subtitles,
{
id: trackId,
sourceName: file.name,
sourceFormat: extension,
mode: 'mux' as const,
sourceStatus: 'attached' as const,
},
];
setSubtitleFiles((current) => new Map(current).set(trackId, file));
applyProjectAction({
type: 'subtitles/set',
subtitles: tracks,
updatedAt: timestamp(),
});
setProjectNotice(
missing
? `Reattached subtitle ${file.name}.`
: `Imported subtitle ${file.name}.`
);
},
[applyProjectAction, project.subtitles]
);
const resolvePreset = useCallback(
(presetId: string) =>
findBuiltInPreset(presetId) ??
userPresets.find((preset) => preset.id === presetId),
[userPresets]
);
const handleStructuralTrim = useCallback(
async (request: StructuralTrimRequest) => {
const clip = project.timeline.find(
(entry) => entry.id === request.clipId
);
const asset = clip
? assets.find((entry) => entry.id === clip.assetId)
: undefined;
if (!clip || !asset?.probe) {
throw new Error('The selected clip source is not attached and probed.');
}
const preset =
request.mode === 'accurate'
? resolvePreset(request.presetId)
: undefined;
if (request.mode === 'accurate' && !preset) {
throw new Error('The accurate trim preset is unavailable.');
}
const selection =
request.mode === 'accurate' && preset
? {
video: preset.video
? asset.probe.streams
.filter(
(stream) =>
stream.type === 'video' &&
stream.disposition.attached_pic !== true
)
.slice(0, 1)
.map((stream) => stream.index)
: [],
audio: preset.audio
? asset.probe.streams
.filter((stream) => stream.type === 'audio')
.slice(0, 1)
.map((stream) => stream.index)
: [],
subtitles: [],
attachments: [],
}
: undefined;
const plan = buildTrimPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
mode: request.mode,
startSeconds: request.startSeconds,
endSeconds: request.endSeconds,
sourceDurationSeconds: asset.probe.durationSeconds,
...(selection ? { streamSelection: selection } : {}),
...(preset ? { preset } : {}),
...(request.mode === 'fast'
? { targetExtension: request.targetExtension }
: {}),
});
await runPlan(
plan,
[asset.file],
request.mode === 'fast' ? 'Fast keyframe trim' : 'Accurate trim'
);
},
[assets, project.timeline, resolvePreset, runPlan]
);
const handleStructuralConcat = useCallback(
async (request: StructuralConcatRequest) => {
const resolved = request.clipIds.map((clipId, index) => {
const clip = project.timeline.find((entry) => entry.id === clipId);
const asset = clip
? assets.find((entry) => entry.id === clip.assetId)
: undefined;
if (!clip || !asset?.probe) {
throw new Error(
`Timeline clip ${index + 1} has no attached, probed source.`
);
}
return {
clip,
asset,
source: concatSourceForClip(clip, asset, index),
};
});
if (resolved.length < 2) {
throw new Error('Concatenation requires at least two timeline clips.');
}
const plan =
request.mode === 'fast'
? buildFastConcatPlan({
jobId: crypto.randomUUID(),
sources: resolved.map((entry) => entry.source),
targetExtension: request.targetExtension,
targetMuxer: request.targetMuxer,
})
: (() => {
const preset = resolvePreset(request.presetId);
if (!preset?.video) {
throw new Error(
'The normalized concat video preset is unavailable.'
);
}
const firstVideo = resolved[0]?.asset.probe?.streams.find(
(stream) =>
stream.type === 'video' &&
stream.disposition.attached_pic !== true
);
if (!firstVideo?.width || !firstVideo.height) {
throw new Error(
'The first concat source has no validated video dimensions.'
);
}
const even = (value: number) =>
Math.max(2, Math.round(value / 2) * 2);
const scale = Math.min(
1,
1920 / firstVideo.width,
1080 / firstVideo.height
);
return buildNormalizedConcatPlan({
jobId: crypto.randomUUID(),
sources: resolved.map((entry) => entry.source),
targetExtension: preset.fileExtension,
targetMuxer: preset.container,
preset,
width: even(firstVideo.width * scale),
height: even(firstVideo.height * scale),
frameRate: firstVideo.frameRate ?? 30,
sampleRate: 48_000,
channelLayout: 'stereo',
missingAudioPolicy: request.missingAudioPolicy,
subtitlePolicy: request.subtitlePolicy,
crossfadeSeconds: request.crossfadeSeconds,
});
})();
await runPlan(
plan,
resolved.map((entry) => entry.asset.file),
request.mode === 'fast' ? 'Fast compatible concat' : 'Normalized concat'
);
},
[assets, project.timeline, resolvePreset, runPlan]
);
const handleAdvancedSplit = useCallback(
async (request: AdvancedSplitRequest) => {
const asset = inspectedClipAsset;
const duration = asset?.probe?.durationSeconds;
if (!asset || duration === undefined) {
throw new Error(
'Select an attached, inspected source before splitting.'
);
}
const sourceExtension = extensionFromName(asset.file.name);
const preset =
request.mode === 'accurate'
? resolvePreset(request.presetId)
: undefined;
if (request.mode === 'accurate' && !preset) {
throw new Error('The accurate split preset is unavailable.');
}
if (
request.mode === 'accurate' &&
preset &&
request.targetExtension !== preset.fileExtension
) {
throw new Error(
`Accurate split output must use the preset extension .${preset.fileExtension}.`
);
}
if (
request.mode === 'fast' &&
request.targetExtension !== sourceExtension
) {
throw new Error(
`Fast stream-copy split keeps the source container .${sourceExtension}; choose accurate mode to change format.`
);
}
const plan = buildSplitPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
durationSeconds: duration,
definition: request.definition,
mode: request.mode,
targetExtension: request.targetExtension,
...(preset ? { preset } : {}),
});
const pendingBatch: AdvancedSplitBatchStatus = {
planId: plan.id,
segments: plan.outputs.map((output) => {
if (!output.timeRange) {
throw new Error(
`Split output ${output.id} has no declared segment range.`
);
}
return {
outputId: output.id,
fileName: output.fileName,
startSeconds: output.timeRange.startSeconds,
endSeconds: output.timeRange.endSeconds,
state: 'queued',
};
}),
};
setSplitBatch(pendingBatch);
try {
const execution = await runWorkflow(
'Split media',
1,
async (executeStep) => {
setSplitBatch((current) =>
current?.planId === plan.id
? {
...current,
segments: current.segments.map((segment) => ({
...segment,
state: 'running',
})),
}
: current
);
return executeStep(plan, [asset.file], 'Split media');
}
);
const resultIds = new Set(
execution.results.map((result) => result.outputId)
);
setSplitBatch((current) =>
current?.planId === plan.id
? {
...current,
segments: current.segments.map((segment) => {
const result = execution.results.find(
(entry) => entry.outputId === segment.outputId
);
if (!result || !resultIds.has(segment.outputId)) {
return {
...segment,
state: 'failed',
message:
'FFmpeg returned successfully, but this expected segment was not retained.',
};
}
const verification = execution.verifications?.[result.id];
if (verification?.warning) {
return {
...segment,
state: 'warning',
message: verification.warning,
};
}
return {
...segment,
state: verification?.probe ? 'verified' : 'completed',
};
}),
}
: current
);
} catch (error) {
const cancelled =
error instanceof EngineError && error.code === 'cancelled';
const message =
error instanceof Error
? error.message
: 'The shared FFmpeg split execution failed.';
setSplitBatch((current) =>
current?.planId === plan.id
? {
...current,
segments: current.segments.map((segment) => ({
...segment,
state: cancelled ? 'cancelled' : 'failed',
message,
})),
}
: current
);
throw error;
}
},
[inspectedClipAsset, resolvePreset, runWorkflow]
);
const handleAdvancedAudio = useCallback(
async (request: AdvancedAudioRequest) => {
const asset = inspectedClipAsset;
const duration = asset?.probe?.durationSeconds;
if (!asset || duration === undefined) {
throw new Error('Select an attached, inspected source first.');
}
const firstAudioIndex = asset.probe?.streams.find(
(stream) => stream.type === 'audio'
)?.index;
const audioStreamIndex = request.audioStreamIndex ?? firstAudioIndex;
if (audioStreamIndex === undefined) {
throw new Error('The source has no selected audio stream.');
}
const source = {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
};
if (request.type === 'static-waveform') {
const plan = buildStaticWaveformPlan({
jobId: crypto.randomUUID(),
source,
audioStreamIndex,
width: request.width,
height: request.height,
color: request.foreground,
background: request.background,
expectedDurationSeconds: duration,
});
const execution = await runPlan(
plan,
[asset.file],
'Create static waveform'
);
const output = execution.results[0];
if (!output) throw new Error('The waveform image was not returned.');
try {
await cacheDerivative(
asset.file,
'waveform-image',
'waveform-image',
project.id,
output.blob,
{
audioStreamIndex,
width: request.width,
height: request.height,
foreground: request.foreground,
background: request.background,
}
);
} catch {
setProjectNotice(
'Waveform image created, but this browser could not cache the derivative.'
);
}
return;
}
if (request.type === 'waveform-peaks') {
const cacheSettings = {
audioStreamIndex,
sampleRate: request.sampleRate,
bucketCount: request.bucketCount,
};
try {
const cached = await readCachedDerivative(
asset.file,
'waveform-peaks',
cacheSettings
);
if (cached) {
const peaks = deserializeWaveformPeaks(await cached.arrayBuffer());
setWaveformByAsset((current) => ({
...current,
[asset.id]: peaks,
}));
setProjectNotice('Reused matching bounded waveform peak data.');
return;
}
} catch {
// A missing or invalid derivative is safely regenerated below.
}
const plan = buildWaveformAnalysisPlan({
jobId: crypto.randomUUID(),
source,
audioStreamIndex,
sampleRate: request.sampleRate,
expectedDurationSeconds: duration,
});
const execution = await runPlan(
plan,
[asset.file],
'Extract reduced waveform audio'
);
const output = execution.results[0];
if (!output) {
throw new Error('The reduced PCM analysis output was not returned.');
}
setWaveformProgress(0);
try {
const peaks = await waveformRuntime.aggregate(
{
container: 'wav',
data: new Uint8Array(await output.blob.arrayBuffer()),
},
{
bucketCount: request.bucketCount,
onProgress: setWaveformProgress,
}
);
setWaveformByAsset((current) => ({
...current,
[asset.id]: peaks,
}));
const serialized = serializeWaveformPeaks(peaks);
try {
await cacheDerivative(
asset.file,
'waveform-peaks',
'waveform-peaks',
project.id,
new Blob([Uint8Array.from(serialized)], {
type: WAVEFORM_PEAKS_MEDIA_TYPE,
}),
cacheSettings
);
} catch {
setProjectNotice(
'Waveform peaks created, but this browser could not cache the derivative.'
);
}
} finally {
setWaveformProgress(undefined);
resultCollection.remove(output.id);
refreshResults();
}
return;
}
if (request.type === 'loudness-measurement') {
const plan = buildLoudnessAnalysisPlan({
jobId: crypto.randomUUID(),
source,
targets: request.targets,
audioStreamIndex,
expectedDurationSeconds: duration,
});
const execution = await runPlan(
plan,
[asset.file],
'Measure EBU R128 loudness'
);
const measurement = parseLoudnessMeasurement(execution.logText);
setLoudnessByAsset((current) => ({
...current,
[asset.id]: measurement,
}));
setProjectNotice(
`Measured ${measurement.inputIntegratedLufs.toFixed(1)} LUFS, ${measurement.inputTruePeakDbtp.toFixed(1)} dBTP, LRA ${measurement.inputLoudnessRangeLu.toFixed(1)} LU.`
);
return;
}
const preset = resolvePreset(request.presetId);
if (!preset) throw new Error('The selected audio preset is unavailable.');
if (request.type === 'peak-normalization') {
const analysis = await runWorkflow(
'Peak-normalize audio',
2,
async (executeStep) => {
const measured = await executeStep(
buildPeakAnalysisPlan({
jobId: crypto.randomUUID(),
source,
audioStreamIndex,
preset,
expectedDurationSeconds: duration,
}),
[asset.file],
'Measure source peak'
);
const measurement = parsePeakMeasurement(measured.logText);
await executeStep(
buildPeakNormalizationPlan({
jobId: crypto.randomUUID(),
source,
audioStreamIndex,
targetPeakDb: request.targetPeakDb,
measurement,
preset,
expectedDurationSeconds: duration,
}),
[asset.file],
'Apply measured peak normalization'
);
return measured;
}
);
setLogs((current) =>
[
...analysis.logText.split('\n').filter(Boolean).slice(-120),
...current,
].slice(-400)
);
return;
}
const plan = buildLoudnessApplyPlan({
jobId: crypto.randomUUID(),
source,
audioStreamIndex,
targets: request.targets,
measurement: request.measurement,
preset,
expectedDurationSeconds: duration,
});
await runPlan(
plan,
[asset.file],
'Apply measured EBU R128 normalization'
);
},
[
inspectedClipAsset,
project.id,
refreshResults,
resultCollection,
resolvePreset,
runPlan,
runWorkflow,
waveformRuntime,
]
);
const handleThumbnailSeries = useCallback(
async (request: ThumbnailSeriesRequest) => {
const asset = inspectedClipAsset;
const duration = asset?.probe?.durationSeconds;
if (!asset || duration === undefined) {
throw new Error('Select an attached, inspected video source first.');
}
const source = {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
};
const size = {
...(request.width ? { width: request.width } : {}),
...(request.height ? { height: request.height } : {}),
};
const sceneDistribution =
request.distribution.type === 'scene-changes'
? request.distribution
: undefined;
const execution = sceneDistribution
? await runWorkflow(
'Create scene-change thumbnails',
2,
async (executeStep) => {
const analysis = await executeStep(
buildSceneChangeAnalysisPlan({
jobId: crypto.randomUUID(),
source,
durationSeconds: duration,
threshold: sceneDistribution.threshold,
maxFrames: sceneDistribution.count,
}),
[asset.file],
'Analyze scene changes'
);
const frames = parseSceneChangeFrames(analysis.logText).slice(
0,
sceneDistribution.count
);
if (frames.length === 0) {
throw new Error(
'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.'
);
}
return executeStep(
buildSceneThumbnailSeriesPlan({
jobId: crypto.randomUUID(),
source,
durationSeconds: duration,
frames,
size,
format: request.format,
quality: request.quality,
}),
[asset.file],
'Extract analyzed scene frames'
);
}
)
: await runPlan(
buildThumbnailSeriesPlan({
jobId: crypto.randomUUID(),
source,
durationSeconds: duration,
...(request.distribution.type === 'evenly-spaced'
? { count: request.distribution.count }
: request.distribution.type === 'fixed-interval'
? {
intervalSeconds: request.distribution.intervalSeconds,
}
: request.distribution.type === 'scene-changes'
? { count: request.distribution.count }
: { timesSeconds: request.distribution.timesSeconds }),
size,
format: request.format,
quality: request.quality,
}),
[asset.file],
'Create thumbnail series'
);
try {
await Promise.all(
execution.results.map((output, index) =>
cacheDerivative(
asset.file,
`thumbnail-series-${index}`,
'thumbnail',
project.id,
output.blob,
{
distribution: request.distribution,
index,
width: request.width ?? null,
height: request.height ?? null,
format: request.format,
}
)
)
);
} catch {
setProjectNotice(
'Thumbnail series created, but this browser could not cache every derivative.'
);
}
},
[inspectedClipAsset, project.id, runPlan, runWorkflow]
);
const handleContactSheet = useCallback(
async (request: ContactSheetRequest) => {
const asset = inspectedClipAsset;
const duration = asset?.probe?.durationSeconds;
if (!asset || duration === undefined) {
throw new Error('Select an attached, inspected video source first.');
}
const source = {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
};
const labelsRequested = request.timestampLabels || request.sourceLabel;
const font = labelsRequested
? await loadBundledContactSheetFont()
: undefined;
const buildSheet = (
sceneFrames?: ReturnType<typeof parseSceneChangeFrames>
) =>
buildContactSheetPlan({
jobId: crypto.randomUUID(),
source,
durationSeconds: duration,
...request,
...(sceneFrames
? { frameCount: sceneFrames.length, sceneFrames }
: {}),
drawtextAvailable: Boolean(font),
...(font
? {
font: {
id: 'bundled-contact-sheet-font',
sourceIndex: 1,
fileName: font.name,
},
}
: {}),
});
const inputs = font ? [asset.file, font] : [asset.file];
const sceneSelection =
request.selection.type === 'scene-changes'
? request.selection
: undefined;
const execution = sceneSelection
? await runWorkflow(
'Create scene-change contact sheet',
2,
async (executeStep) => {
const analysis = await executeStep(
buildSceneChangeAnalysisPlan({
jobId: crypto.randomUUID(),
source,
durationSeconds: duration,
threshold: sceneSelection.threshold,
maxFrames: request.frameCount,
}),
[asset.file],
'Analyze scene changes'
);
const frames = parseSceneChangeFrames(analysis.logText).slice(
0,
request.frameCount
);
if (frames.length === 0) {
throw new Error(
'No scene changes met this threshold. Lower the threshold or use evenly spaced frames.'
);
}
return executeStep(
buildSheet(frames),
inputs,
'Render scene-change contact sheet'
);
}
)
: await runPlan(buildSheet(), inputs, 'Create contact sheet');
const output = execution.results[0];
if (!output) throw new Error('The contact sheet was not returned.');
try {
await cacheDerivative(
asset.file,
'contact-sheet',
'contact-sheet',
project.id,
output.blob,
request as unknown as Readonly<Record<string, unknown>>
);
} catch {
setProjectNotice(
'Contact sheet created, but this browser could not cache the derivative.'
);
}
},
[inspectedClipAsset, project.id, runPlan, runWorkflow]
);
const handleMetadataAction = useCallback(
async (request: MetadataExecutionRequest) => {
if (request.type === 'import') {
applyProjectAction({
type: 'metadata/set',
metadata: projectMetadataFromEdits(request.edits),
updatedAt: timestamp(),
});
setProjectNotice('Imported validated metadata JSON into the project.');
return;
}
if (request.type === 'export') {
downloadBlob(
new Blob([request.json], { type: 'application/json' }),
'av-tools-metadata.json'
);
return;
}
const asset = inspectedClipAsset;
if (!asset?.probe) {
throw new Error('Select an attached, inspected source first.');
}
const extension = extensionFromName(asset.file.name);
const selection =
request.coverArtPolicy === 'remove'
? {
video: asset.probe.streams
.filter(
(stream) =>
stream.type === 'video' &&
stream.disposition.attached_pic !== true
)
.map((stream) => stream.index),
audio: asset.probe.streams
.filter((stream) => stream.type === 'audio')
.map((stream) => stream.index),
subtitles: asset.probe.streams
.filter((stream) => stream.type === 'subtitle')
.map((stream) => stream.index),
attachments: [],
}
: undefined;
const plan = buildMetadataEditPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
edits: request.edits,
targetExtension: extension,
targetMuxer: muxerForExtension(extension),
...(selection ? { streamSelection: selection } : {}),
sourceMetadataPolicy: request.sourceMetadataPolicy,
chapterPolicy: request.chapterPolicy,
expectedDurationSeconds: asset.probe.durationSeconds,
});
await runPlan(plan, [asset.file], 'Write metadata');
},
[applyProjectAction, inspectedClipAsset, runPlan]
);
const handleChapterAction = useCallback(
async (request: ChapterExecutionRequest) => {
if (request.type === 'import') {
applyProjectAction({
type: 'chapters/set',
chapters: request.chapters.map((chapter) => ({
id: chapter.id,
startSeconds: chapter.startSeconds,
endSeconds: chapter.endSeconds,
title: chapter.title,
timeBase: chapter.timeBase ?? '1/1000',
})),
updatedAt: timestamp(),
});
setProjectNotice('Imported validated chapter JSON into the project.');
return;
}
if (request.type === 'export') {
downloadBlob(
new Blob([request.json], { type: 'application/json' }),
'av-tools-chapters.json'
);
return;
}
const asset = inspectedClipAsset;
if (!asset?.probe) {
throw new Error('Select an attached, inspected source first.');
}
const extension = extensionFromName(asset.file.name);
const source = {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
};
const plan =
request.type === 'remove'
? buildMetadataEditPlan({
jobId: crypto.randomUUID(),
source,
edits: {},
targetExtension: extension,
targetMuxer: muxerForExtension(extension),
sourceMetadataPolicy: 'copy',
chapterPolicy: 'remove',
expectedDurationSeconds: asset.probe.durationSeconds,
})
: buildChapterMuxPlan({
jobId: crypto.randomUUID(),
source,
chapters: request.chapters,
targetExtension: extension,
targetMuxer: muxerForExtension(extension),
durationSeconds: asset.probe.durationSeconds,
});
await runPlan(
plan,
[asset.file],
request.type === 'remove' ? 'Remove chapters' : 'Replace chapters'
);
},
[applyProjectAction, inspectedClipAsset, runPlan]
);
const handleSubtitleAction = useCallback(
async (request: SubtitleExecutionRequest) => {
const asset = inspectedClipAsset;
if (request.type === 'extract') {
if (!asset) throw new Error('Select an inspected media source first.');
const plan = buildSubtitleExtractionPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
stream: request.stream,
outputFormat: request.outputFormat,
});
await runPlan(plan, [asset.file], 'Extract text subtitle');
return;
}
const attachedFile =
[...subtitleFiles.values()].find(
(file) => file.name === request.file.name
) ?? request.file;
if (request.type === 'convert') {
const plan = buildSubtitleConversionPlan({
jobId: crypto.randomUUID(),
subtitle: {
id: crypto.randomUUID(),
sourceIndex: 0,
fileName: attachedFile.name,
},
outputFormat: request.outputFormat,
offsetSeconds: request.offsetSeconds,
});
await runPlan(plan, [attachedFile], 'Convert text subtitle');
return;
}
if (!asset?.probe) {
throw new Error('Select an attached, inspected video source first.');
}
const mediaSource = {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
};
const subtitleSource = {
id: crypto.randomUUID(),
sourceIndex: 1,
fileName: attachedFile.name,
};
if (request.type === 'soft-mux') {
const targetExtension =
request.targetContainer === 'matroska'
? 'mkv'
: request.targetContainer;
const plan = buildSubtitleMuxPlan({
jobId: crypto.randomUUID(),
source: mediaSource,
subtitle: subtitleSource,
subtitleCodec:
request.subtitleCodec ||
subtitleCodecForFormat(request.inputFormat),
sourceStreams: remuxStreamsFromProbe(asset.probe),
targetContainer: request.targetContainer,
targetExtension,
...(request.language ? { language: request.language } : {}),
...(request.title ? { title: request.title } : {}),
default: request.default,
forced: request.forced,
expectedDurationSeconds: asset.probe.durationSeconds,
});
await runPlan(plan, [asset.file, attachedFile], 'Mux soft subtitle');
return;
}
const preset = resolvePreset(request.presetId);
if (!preset)
throw new Error('The subtitle burn-in preset is unavailable.');
const plan = buildSubtitleBurnPlan({
jobId: crypto.randomUUID(),
source: mediaSource,
subtitle: subtitleSource,
preset,
expectedDurationSeconds: asset.probe.durationSeconds,
});
await runPlan(
plan,
[asset.file, attachedFile],
'Burn subtitle into video'
);
},
[inspectedClipAsset, resolvePreset, runPlan, subtitleFiles]
);
const handleUserPresetAction = useCallback(
async (action: UserPresetAction) => {
const registry = presetSetup.registry;
if (action.type === 'create' || action.type === 'update') {
await registry.save(action.preset);
} else if (action.type === 'clone') {
await registry.clone(action.presetId, action.newId, action.newName);
} else if (action.type === 'rename') {
await registry.rename(action.presetId, action.name);
} else if (action.type === 'delete') {
await registry.delete(action.presetId);
} else if (action.type === 'reset-all') {
if (
!globalThis.confirm(
'Delete every user-created export preset from this browser?'
)
) {
return;
}
await registry.reset();
} else if (action.type === 'import') {
await registry.save(action.preset);
} else {
downloadBlob(
new Blob([action.json], { type: 'application/json' }),
`${action.preset.id}.json`
);
return;
}
setUserPresets(registry.list());
if (!presetSetup.persistent) {
setProjectNotice(
'IndexedDB is unavailable; user presets will last only for this page session.'
);
}
},
[presetSetup]
);
const saveManagedResult = useCallback(
async (output: ExportResult) => {
try {
await saveCollectionResult(resultCollection, output.id);
refreshResults();
} catch (error) {
setOperationError(
error instanceof Error
? error.message
: 'The result could not be saved.'
);
}
},
[refreshResults, resultCollection]
);
const saveManagedZip = useCallback(
async (selected: readonly ExportResult[]) => {
if (selected.length === 0) return;
try {
const saved = await saveResultsZip(selected, {
fileName: `${project.name || 'av-tools'}-results.zip`,
...(latestReport ? { report: latestReport } : {}),
// Compression finishes asynchronously, after transient picker
// activation has expired. A normal download is the honest fallback.
saver: async (blob, fileName) => {
downloadBlob(blob, fileName);
return 'downloaded';
},
});
if (saved.outcome !== 'cancelled') {
for (const result of selected) resultCollection.remove(result.id);
}
refreshResults();
} catch (error) {
setOperationError(
error instanceof Error
? error.message
: 'The ZIP could not be created.'
);
}
},
[latestReport, project.name, refreshResults, resultCollection]
);
const saveManagedReport = useCallback(async () => {
if (!latestReport) return;
try {
await saveExportReport(latestReport);
} catch (error) {
setOperationError(
error instanceof Error
? error.message
: 'The validation report could not be saved.'
);
}
}, [latestReport]);
const activeCapabilities =
engineState.status === 'ready' ? engineState.capabilities : undefined;
const capabilityStatus = (
requirements: {
readonly muxers?: readonly string[];
readonly encoders?: readonly string[];
readonly filters?: readonly string[];
},
unavailableReason?: string
): AdvancedCapabilityStatus => {
if (!activeCapabilities) {
return {
available: false,
reason: 'Inspect a source or initialize the local FFmpeg engine first.',
};
}
const missing = [
...(requirements.muxers ?? [])
.filter((name) => !activeCapabilities.muxers.has(name))
.map((name) => `muxer ${name}`),
...(requirements.encoders ?? [])
.filter((name) => !activeCapabilities.encoders.has(name))
.map((name) => `encoder ${name}`),
...(requirements.filters ?? [])
.filter((name) => !activeCapabilities.filters.has(name))
.map((name) => `filter ${name}`),
];
return missing.length === 0 && !unavailableReason
? { available: true }
: {
available: false,
reason:
unavailableReason ??
`The loaded core is missing ${missing.join(', ')}.`,
};
};
const advancedPresetOptions: readonly AdvancedExportPresetOption[] = [
...BUILT_IN_PRESETS,
...userPresets,
].map((preset) => {
const requirements =
'builtIn' in preset
? preset.requirements
: requirementsForUserPreset(preset);
const availability = activeCapabilities
? presetAvailability({ requirements }, activeCapabilities)
: {
status: 'unavailable' as const,
reasons: ['Initialize the engine to verify this preset.'],
};
const opusBlocked =
preset.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
return {
id: preset.id,
name: preset.name,
fileExtension: preset.fileExtension,
supportsAudio: preset.audio !== undefined,
supportsVideo: preset.video !== undefined,
...(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(' '),
}
: {}),
};
});
const timelinePresetOptions = advancedPresetOptions.filter((option) => {
const preset = resolvePreset(option.id);
return (
option.supportsVideo &&
preset !== undefined &&
['mp4', 'webm', 'matroska'].includes(preset.container)
);
});
const selectedTimelinePreset = resolvePreset(project.output.presetId);
const selectedTimelinePresetOption = timelinePresetOptions.find(
(option) => option.id === project.output.presetId
);
const timelineOutputReason =
project.timeline.length === 0
? 'Add at least one timeline clip.'
: !selectedTimelinePreset?.video
? 'Choose a video export preset.'
: selectedTimelinePresetOption?.disabledReason;
const anyAccuratePreset = advancedPresetOptions.some(
(preset) => !preset.disabledReason
);
const anyAudioPreset = advancedPresetOptions.some(
(preset) => preset.supportsAudio && !preset.disabledReason
);
const anyVideoPreset = advancedPresetOptions.some(
(preset) => preset.supportsVideo && !preset.disabledReason
);
const structuralContext = (() => {
const diagnostics: StructuralCompatibilityDiagnostic[] = [];
const sources: ConcatSource[] = [];
const clips = project.timeline.map((clip, index) => {
const asset = assets.find((entry) => entry.id === clip.assetId);
const projectAsset = project.assets.find(
(entry) => entry.id === clip.assetId
);
const label = `${index + 1}. ${
asset?.file.name ?? projectAsset?.originalName ?? 'Missing source'
}`;
if (!asset?.probe) {
diagnostics.push({
code: 'source-unavailable',
severity: 'error',
message: `${label} is not attached and probed.`,
path: `timeline.${index}.assetId`,
});
return {
id: clip.id,
label,
hasAudio: false,
hasVideo: false,
hasSubtitles: false,
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
};
}
const unsupportedStreams = asset.probe.streams.filter(
(stream) =>
stream.type === 'attachment' ||
stream.type === 'data' ||
stream.type === 'unknown'
);
if (unsupportedStreams.length > 0) {
diagnostics.push({
code: 'unvalidated-stream-type',
severity: 'error',
message: `${label} contains ${unsupportedStreams
.map((stream) => `${stream.type} stream ${stream.index}`)
.join(
', '
)}; fast concat is disabled because those streams cannot yet be validated safely.`,
path: `timeline.${index}.streams`,
});
}
try {
sources.push(concatSourceForClip(clip, asset, index));
} catch (error) {
diagnostics.push({
code: 'source-range',
severity: 'error',
message:
error instanceof Error
? `${label}: ${error.message}`
: `${label} has an invalid source range.`,
path: `timeline.${index}`,
});
}
return {
id: clip.id,
label,
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'),
hasVideo: asset.probe.streams.some(
(stream) =>
stream.type === 'video' && stream.disposition.attached_pic !== true
),
hasSubtitles: asset.probe.streams.some(
(stream) => stream.type === 'subtitle'
),
};
});
if (
project.timeline.length >= 2 &&
sources.length === project.timeline.length
) {
diagnostics.push(...validateConcatCompatibility(sources));
}
const firstAsset = project.timeline[0]
? assets.find((entry) => entry.id === project.timeline[0]?.assetId)
: undefined;
const fastTargetExtension = firstAsset
? extensionFromName(firstAsset.file.name)
: '';
return {
clips,
sources,
diagnostics,
fastTargetExtension,
fastTargetMuxer: fastTargetExtension
? muxerForExtension(fastTargetExtension)
: '',
};
})();
const structuralSelectedClip =
selectedClip && inspectedClipAsset?.probe
? {
id: selectedClip.id,
label: inspectedClipAsset.file.name,
sourceInSeconds: selectedClip.sourceInSeconds,
sourceOutSeconds: selectedClip.sourceOutSeconds,
...(inspectedClipAsset.probe.durationSeconds === undefined
? {}
: {
sourceDurationSeconds: inspectedClipAsset.probe.durationSeconds,
}),
sourceExtension: extensionFromName(inspectedClipAsset.file.name),
hasAudio: inspectedClipAsset.probe.streams.some(
(stream) => stream.type === 'audio'
),
hasVideo: inspectedClipAsset.probe.streams.some(
(stream) =>
stream.type === 'video' &&
stream.disposition.attached_pic !== true
),
}
: undefined;
const structuralHasAnyAudio = structuralContext.clips.some(
(clip) => clip.hasAudio
);
const structuralHasMissingAudio = structuralContext.clips.some(
(clip) => !clip.hasAudio
);
const sourceExtension = inspectedClipAsset
? extensionFromName(inspectedClipAsset.file.name)
: '';
const sourceMuxer = sourceExtension
? muxerForExtension(sourceExtension)
: undefined;
const advancedAvailability: Partial<
Readonly<Record<AdvancedCapabilityKey, AdvancedCapabilityStatus>>
> = {
'split-fast': sourceMuxer
? capabilityStatus({ muxers: [sourceMuxer] })
: { available: false, reason: 'Select a source first.' },
'split-accurate': capabilityStatus(
{},
anyAccuratePreset ? undefined : 'No verified export preset is available.'
),
'waveform-static': capabilityStatus({
muxers: ['image2'],
encoders: ['png'],
filters: ['color', 'aformat', 'showwavespic', 'overlay'],
}),
'waveform-peaks': capabilityStatus({
muxers: ['wav'],
encoders: ['pcm_s16le'],
}),
'loudness-measure': capabilityStatus({
muxers: ['null'],
filters: ['loudnorm'],
}),
'peak-normalize': capabilityStatus(
{ filters: ['volume'] },
anyAudioPreset ? undefined : 'No verified audio preset is available.'
),
'loudness-normalize': capabilityStatus(
{ filters: ['loudnorm'] },
anyAudioPreset ? undefined : 'No verified audio preset is available.'
),
'thumbnail-series': capabilityStatus({
filters: ['split', 'select', 'setpts'],
}),
'contact-sheet': capabilityStatus({
filters: ['fps', 'scale', 'tile'],
}),
'contact-sheet-labels': {
...capabilityStatus({ filters: ['drawtext'] }),
},
'scene-change-analysis': capabilityStatus({
muxers: ['null'],
filters: ['select', 'showinfo'],
}),
'metadata-write': sourceMuxer
? capabilityStatus({ muxers: [sourceMuxer] })
: { available: false, reason: 'Select a source first.' },
'chapter-write': sourceMuxer
? capabilityStatus({ muxers: [sourceMuxer] })
: { available: false, reason: 'Select a source first.' },
'subtitle-extract': capabilityStatus({}),
'subtitle-convert': capabilityStatus({}),
'subtitle-mux': capabilityStatus({}),
'subtitle-burn': capabilityStatus(
{ filters: ['subtitles'] },
anyVideoPreset ? undefined : 'No verified video preset is available.'
),
'preset-storage': { available: true },
};
const structuralAvailability = {
'trim-fast': sourceMuxer
? capabilityStatus({ muxers: [sourceMuxer] })
: { available: false, reason: 'Select an attached source first.' },
'trim-accurate': capabilityStatus(
{},
anyAccuratePreset ? undefined : 'No verified export preset is available.'
),
'concat-fast': structuralContext.fastTargetMuxer
? capabilityStatus({
muxers: [structuralContext.fastTargetMuxer],
})
: {
available: false,
reason: 'Attach and probe the first timeline source.',
},
'concat-normalized': capabilityStatus(
{
filters: [
'trim',
'setpts',
'scale',
'pad',
'setsar',
'fps',
'format',
'concat',
...(structuralHasAnyAudio
? ['atrim', 'asetpts', 'aresample', 'aformat']
: []),
...(structuralHasAnyAudio && structuralHasMissingAudio
? ['anullsrc']
: []),
],
},
anyVideoPreset ? undefined : 'No verified video preset is available.'
),
'concat-crossfade': capabilityStatus({
filters: ['xfade', ...(structuralHasAnyAudio ? ['acrossfade'] : [])],
}),
} as const;
const advancedSource = inspectedClipAsset?.probe
? {
sourceId: inspectedClipAsset.id,
...(inspectedClipAsset.probe.durationSeconds === undefined
? {}
: {
durationSeconds: inspectedClipAsset.probe.durationSeconds,
}),
sourceExtension,
hasAudio: inspectedClipAsset.probe.streams.some(
(stream) => stream.type === 'audio'
),
hasVideo: inspectedClipAsset.probe.streams.some(
(stream) =>
stream.type === 'video' && stream.disposition.attached_pic !== true
),
...(() => {
const index = inspectedClipAsset.probe?.streams.find(
(stream) => stream.type === 'audio'
)?.index;
return index === undefined ? {} : { selectedAudioStreamIndex: index };
})(),
}
: undefined;
const advancedMetadataStreams =
inspectedClipAsset?.probe?.streams.map((stream) => ({
index: stream.index,
type: stream.type,
codec: stream.codecName ?? 'unknown',
...(stream.language ? { language: stream.language } : {}),
...(stream.title ? { title: stream.title } : {}),
disposition: stream.disposition,
})) ?? [];
const advancedChapters = project.chapters.map((chapter) => ({
id: chapter.id,
title: chapter.title,
startSeconds: chapter.startSeconds,
endSeconds: chapter.endSeconds,
timeBase: chapter.timeBase ?? '1/1000',
}));
const advancedTimelineClips = inspectedClipAsset
? project.timeline
.filter((clip) => clip.assetId === inspectedClipAsset.id)
.map((clip, index) => ({
startSeconds: clip.sourceInSeconds,
endSeconds: clip.sourceOutSeconds,
label: `clip-${index + 1}`,
}))
: [];
const advancedSubtitleStreams =
inspectedClipAsset?.probe?.streams.flatMap((stream) =>
stream.type === 'subtitle'
? [
{
index: stream.index,
codec: stream.codecName ?? 'unknown',
...(stream.language ? { language: stream.language } : {}),
...(stream.title ? { title: stream.title } : {}),
default: stream.disposition.default ?? false,
forced: stream.disposition.forced ?? false,
},
]
: []
) ?? [];
const quickBusy =
engineState.status === 'loading' ||
engineState.status === 'recovering' ||
sourceProbeBusy ||
waveformProgress !== undefined ||
scheduledPlanCount >= 8;
const sourceManagementBusy =
sourceProbeBusy || scheduledPlanCount > 0 || waveformProgress !== undefined;
const editWorkspace = (
<>
<nav
className="mobile-edit-drawer-controls"
aria-label="Edit workspace panels"
>
<button
ref={mediaDrawerTrigger}
className="button button--secondary"
type="button"
aria-controls="edit-media-drawer"
aria-expanded={editDrawer === 'media'}
onClick={() => setEditDrawer('media')}
>
<Icon name="video" />
Sources
</button>
<button
ref={inspectorDrawerTrigger}
className="button button--secondary"
type="button"
aria-controls="edit-inspector-drawer"
aria-expanded={editDrawer === 'inspector'}
onClick={() => setEditDrawer('inspector')}
>
<Icon name="edit" />
Inspector
</button>
</nav>
<div className="edit-grid">
<section
id="edit-media-drawer"
className={`panel media-panel mobile-edit-drawer${editDrawer === 'media' ? ' is-open' : ''}`}
aria-labelledby="media-bin-title"
>
<header className="panel__header">
<div>
<p className="eyebrow">Local sources</p>
<h2 id="media-bin-title">Media bin</h2>
</div>
<div className="mobile-edit-drawer__header-actions">
<span>{assets.length}</span>
<button
className="icon-action mobile-edit-drawer__close"
type="button"
aria-label="Close media bin"
onClick={closeEditDrawer}
>
<Icon name="cancel" />
</button>
</div>
</header>
<FileDropZone
onFiles={addFiles}
compact
disabled={sourceManagementBusy}
/>
<MediaBin
assets={assets}
selectedId={selectedAssetId}
onSelect={setSelectedAssetId}
onRemove={removeAsset}
/>
</section>
<section
className="panel preview-panel"
aria-labelledby="preview-title"
>
<header className="panel__header">
<div>
<p className="eyebrow">Browser playback</p>
<h2 id="preview-title">Preview</h2>
</div>
<span className="privacy-badge">
<span />
Object URL
</span>
</header>
<MediaPreview
asset={inspectedClipAsset}
result={previewResult}
waveformPeaks={
inspectedClipAsset
? waveformByAsset[inspectedClipAsset.id]
: undefined
}
onCreateProxy={() => void createPreviewProxy(inspectedClipAsset)}
proxySettings={previewProxySettings}
onProxySettingsChange={setPreviewProxySettings}
proxyBusy={quickBusy}
{...(inspectedClipAsset
? {
currentTimeSeconds:
playheadByAsset[inspectedClipAsset.id] ?? 0,
onCurrentTimeChange: (timeSeconds: number) =>
setPlayheadByAsset((current) => ({
...current,
[inspectedClipAsset.id]: timeSeconds,
})),
}
: {})}
/>
</section>
<Inspector
asset={inspectedClipAsset}
project={project}
clip={selectedClip}
disabled={sourceManagementBusy}
onClipUpdate={(changes) => {
if (selectedClip) updateClip(selectedClip.id, changes);
}}
onMetadataChange={(metadata) =>
applyProjectAction({
type: 'metadata/set',
metadata,
updatedAt: timestamp(),
})
}
onChaptersChange={(chapters) =>
applyProjectAction({
type: 'chapters/set',
chapters,
updatedAt: timestamp(),
})
}
onSubtitlesChange={changeSubtitles}
onSubtitleImport={importSubtitleFile}
onThumbnail={extractThumbnail}
currentPreviewTimeSeconds={
inspectedClipAsset
? (playheadByAsset[inspectedClipAsset.id] ?? 0)
: undefined
}
drawerOpen={editDrawer === 'inspector'}
onDrawerClose={closeEditDrawer}
/>
</div>
<Timeline
project={project}
selectedClipId={selectedClipId}
{...(selectedClip
? {
waveformPeaks: waveformByAsset[selectedClip.assetId],
splitMarkers: splitMarkersByAsset[selectedClip.assetId] ?? [],
}
: {})}
waveformDisabled={sourceManagementBusy}
currentTimeSeconds={
selectedClip
? (playheadByAsset[selectedClip.assetId] ?? 0)
: undefined
}
onSeek={(timeSeconds) => {
if (!selectedClip) return;
setPlayheadByAsset((current) => ({
...current,
[selectedClip.assetId]: timeSeconds,
}));
}}
onSelect={(clipId) => {
setSelectedClipId(clipId);
const clip = project.timeline.find((entry) => entry.id === clipId);
if (clip) setSelectedAssetId(clip.assetId);
}}
onMove={moveClip}
onUpdate={updateClip}
onSelectionChange={(selection) => {
if (!selectedClip) return;
setSplitMarkersByAsset((current) => ({
...current,
[selectedClip.assetId]: selection.markers,
}));
}}
onRemove={(clipId) => {
applyProjectAction({
type: 'clip/remove',
clipId,
updatedAt: timestamp(),
});
if (selectedClipId === clipId) setSelectedClipId(undefined);
}}
/>
<StructuralOperations
{...(structuralSelectedClip
? { selectedClip: structuralSelectedClip }
: {})}
{...(project.timeline.length >= 2
? {
timeline: {
clips: structuralContext.clips,
fastTargetExtension: structuralContext.fastTargetExtension,
fastTargetMuxer: structuralContext.fastTargetMuxer,
fastCompatibilityDiagnostics: structuralContext.diagnostics,
},
}
: {})}
exportPresets={advancedPresetOptions}
availability={structuralAvailability}
busy={quickBusy}
onTrim={handleStructuralTrim}
onConcat={handleStructuralConcat}
/>
<AdvancedOperations
{...(advancedSource ? { source: advancedSource } : {})}
splitMarkers={
inspectedClipAsset
? (splitMarkersByAsset[inspectedClipAsset.id] ?? [])
: []
}
{...(splitBatch ? { splitBatch } : {})}
chapters={advancedChapters}
timelineClips={advancedTimelineClips}
metadataStreams={advancedMetadataStreams}
{...(inspectedClipAsset?.probe
? { metadataFormatTags: inspectedClipAsset.probe.tags }
: {})}
subtitleStreams={advancedSubtitleStreams}
{...(inspectedClipAsset && loudnessByAsset[inspectedClipAsset.id]
? {
loudnessMeasurement: loudnessByAsset[inspectedClipAsset.id],
}
: {})}
exportPresets={advancedPresetOptions}
userPresets={userPresets}
availability={advancedAvailability}
imageFormatAvailability={{
png: capabilityStatus({
muxers: ['image2'],
encoders: ['png'],
}),
jpeg: capabilityStatus({
muxers: ['image2'],
encoders: ['mjpeg'],
}),
webp: capabilityStatus({
muxers: ['webp'],
encoders: ['libwebp'],
}),
}}
subtitleFormatAvailability={{
srt: capabilityStatus({
muxers: ['srt'],
encoders: ['srt'],
}),
vtt: capabilityStatus({
muxers: ['webvtt'],
encoders: ['webvtt'],
}),
ass: capabilityStatus({
muxers: ['ass'],
encoders: ['ass'],
}),
}}
subtitleContainerAvailability={{
matroska: capabilityStatus({ muxers: ['matroska'] }),
mp4: capabilityStatus({
muxers: ['mp4'],
encoders: ['mov_text'],
}),
webm: capabilityStatus({
muxers: ['webm'],
encoders: ['webvtt'],
}),
}}
busy={quickBusy}
onSplit={handleAdvancedSplit}
onAudioAction={handleAdvancedAudio}
onThumbnailSeries={handleThumbnailSeries}
onContactSheet={handleContactSheet}
onMetadataAction={handleMetadataAction}
onChapterAction={handleChapterAction}
onSubtitleAction={handleSubtitleAction}
onUserPresetAction={handleUserPresetAction}
/>
<section className="panel edit-export">
<header>
<p className="eyebrow">Timeline export</p>
<h2>Render the sequence</h2>
<p>
Normalize the sequential clips to these explicit typed settings. The
source files remain local.
</p>
</header>
<div className="edit-export__settings">
<label className="field">
<span>Timeline export preset</span>
<select
value={project.output.presetId}
disabled={sourceManagementBusy}
onChange={(event) => {
const preset = resolvePreset(event.currentTarget.value);
if (!preset?.video) return;
const channels = preset.audio?.channels;
const next: OutputSettings = {
...project.output,
presetId: preset.id,
container: preset.container,
fileName: safeExportFileName(project.output.fileName, {
extension: preset.fileExtension,
fallback: 'timeline',
}),
videoEnabled: true,
audioEnabled: preset.audio !== undefined,
sampleRate:
preset.audio?.sampleRate ??
project.output.sampleRate ??
48_000,
channelLayout:
channels === 1 ? 'mono' : channels === 6 ? '5.1' : 'stereo',
};
applyProjectAction({
type: 'output/set',
output: next,
updatedAt: timestamp(),
});
}}
>
{!timelinePresetOptions.some(
(option) => option.id === project.output.presetId
) ? (
<option value="">Choose a video preset</option>
) : null}
{timelinePresetOptions.map((option) => (
<option
key={option.id}
value={option.id}
disabled={Boolean(option.disabledReason)}
>
{option.name}
{option.disabledReason ? `${option.disabledReason}` : ''}
</option>
))}
</select>
</label>
<label className="field">
<span>Output filename</span>
<input
type="text"
maxLength={120}
value={project.output.fileName}
disabled={sourceManagementBusy}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: {
...project.output,
fileName: event.currentTarget.value,
},
updatedAt: timestamp(),
})
}
onBlur={(event) => {
if (!selectedTimelinePreset) return;
applyProjectAction({
type: 'output/set',
output: {
...project.output,
fileName: safeExportFileName(event.currentTarget.value, {
extension: selectedTimelinePreset.fileExtension,
fallback: 'timeline',
}),
},
updatedAt: timestamp(),
});
}}
/>
</label>
<label className="field">
<span>Canvas width</span>
<input
type="number"
min="2"
max="8192"
step="2"
placeholder="Auto"
value={project.output.width ?? ''}
disabled={sourceManagementBusy}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: outputWithOptionalNumber(
project.output,
'width',
event.currentTarget.value
),
updatedAt: timestamp(),
})
}
/>
</label>
<label className="field">
<span>Canvas height</span>
<input
type="number"
min="2"
max="8192"
step="2"
placeholder="Auto"
value={project.output.height ?? ''}
disabled={sourceManagementBusy}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: outputWithOptionalNumber(
project.output,
'height',
event.currentTarget.value
),
updatedAt: timestamp(),
})
}
/>
</label>
<label className="field">
<span>Output frame rate</span>
<input
type="number"
list="timeline-frame-rates"
min="0.01"
max="240"
step="0.001"
placeholder="Source"
value={project.output.frameRate ?? ''}
disabled={sourceManagementBusy}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: outputWithOptionalNumber(
project.output,
'frameRate',
event.currentTarget.value
),
updatedAt: timestamp(),
})
}
/>
<datalist id="timeline-frame-rates">
<option value="23.976" />
<option value="24" />
<option value="25" />
<option value="29.97" />
<option value="30" />
<option value="50" />
<option value="59.94" />
<option value="60" />
</datalist>
</label>
<label className="field">
<span>Audio sample rate</span>
<select
value={project.output.sampleRate ?? 48_000}
disabled={
sourceManagementBusy ||
!project.output.audioEnabled ||
!selectedTimelinePreset?.audio
}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: {
...project.output,
sampleRate: Number(event.currentTarget.value),
},
updatedAt: timestamp(),
})
}
>
{[16_000, 22_050, 32_000, 44_100, 48_000, 96_000].map(
(sampleRate) => (
<option key={sampleRate} value={sampleRate}>
{sampleRate.toLocaleString('en-US')} Hz
</option>
)
)}
</select>
</label>
<label className="field">
<span>Audio layout</span>
<select
value={project.output.channelLayout ?? 'stereo'}
disabled={
sourceManagementBusy ||
!project.output.audioEnabled ||
!selectedTimelinePreset?.audio
}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: {
...project.output,
channelLayout: event.currentTarget.value as
'mono' | 'stereo' | '5.1',
},
updatedAt: timestamp(),
})
}
>
<option value="mono">Mono</option>
<option value="stereo">Stereo</option>
<option value="5.1">5.1</option>
</select>
</label>
<label className="field">
<span>Clips without audio</span>
<select
value={project.output.missingAudioPolicy ?? 'insert-silence'}
disabled={
sourceManagementBusy ||
!project.output.audioEnabled ||
!selectedTimelinePreset?.audio
}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: {
...project.output,
missingAudioPolicy: event.currentTarget.value as
'insert-silence' | 'drop-all' | 'reject',
},
updatedAt: timestamp(),
})
}
>
<option value="insert-silence">Insert silence</option>
<option value="drop-all">Drop all timeline audio</option>
<option value="reject">Reject mixed-audio timeline</option>
</select>
</label>
<label className="check-field edit-export__audio-toggle">
<input
type="checkbox"
checked={
project.output.audioEnabled &&
selectedTimelinePreset?.audio !== undefined
}
disabled={sourceManagementBusy || !selectedTimelinePreset?.audio}
onChange={(event) =>
applyProjectAction({
type: 'output/set',
output: {
...project.output,
audioEnabled: event.currentTarget.checked,
},
updatedAt: timestamp(),
})
}
/>
<span>Include timeline audio</span>
</label>
</div>
<footer>
<p>
Empty canvas dimensions preserve the first transformed clip up to
1920×1080 without upscaling. Video is required for this sequential
timeline renderer.
</p>
<div>
{timelineOutputReason ? (
<small id="timeline-output-reason">{timelineOutputReason}</small>
) : null}
<button
className="button button--primary button--large"
type="button"
disabled={Boolean(timelineOutputReason) || quickBusy}
aria-describedby={
timelineOutputReason ? 'timeline-output-reason' : undefined
}
onClick={() => void exportTimeline()}
>
<Icon name="sparkles" />
{engineState.status === 'running' || scheduledPlanCount > 0
? 'Add timeline to queue'
: 'Render timeline'}
</button>
</div>
</footer>
</section>
</>
);
return (
<div className="av-app">
<section className="workspace-toolbar">
<div className="segmented" role="tablist" aria-label="Workspace mode">
<button
type="button"
role="tab"
aria-selected={mode === 'quick-convert'}
className={mode === 'quick-convert' ? 'is-active' : ''}
onClick={() => selectWorkspaceMode('quick-convert')}
>
<Icon name="sparkles" />
Quick Convert
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'edit'}
className={mode === 'edit' ? 'is-active' : ''}
onClick={() => selectWorkspaceMode('edit')}
>
<Icon name="edit" />
Edit
</button>
</div>
<div className="project-actions">
<span title={project.name}>{project.name}</span>
<button
className="button button--quiet"
type="button"
disabled={sourceManagementBusy}
title={
sourceManagementBusy
? 'Wait for local media operations to finish before replacing the project.'
: undefined
}
onClick={resetProject}
>
New
</button>
<button
className="button button--quiet"
type="button"
disabled={sourceManagementBusy}
title={
sourceManagementBusy
? 'Wait for local media operations to finish before replacing the project.'
: undefined
}
onClick={() => projectInput.current?.click()}
>
Open project
</button>
<button
className="button button--quiet"
type="button"
onClick={exportProject}
>
Save project
</button>
<input
ref={projectInput}
className="visually-hidden"
type="file"
accept=".json,.avproject"
disabled={sourceManagementBusy}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void importProject(file);
event.currentTarget.value = '';
}}
/>
</div>
</section>
{projectNotice ? (
<p className="project-notice" role="status">
<Icon name="info" />
{projectNotice}
</p>
) : null}
{waveformProgress !== undefined ? (
<section
className="project-notice waveform-progress"
aria-label="Waveform peak aggregation progress"
>
<Icon name="audio" />
<span>
Building bounded waveform peaks locally{' '}
{Math.round(waveformProgress * 100)}%
</span>
<progress value={waveformProgress} max="1" />
<button
className="button button--danger"
type="button"
onClick={() => waveformRuntime.cancelActive()}
>
Cancel waveform
</button>
</section>
) : null}
{mode === 'quick-convert' ? (
<div className="quick-workspace">
<div className="quick-workspace__source">
<FileDropZone onFiles={addFiles} disabled={sourceManagementBusy} />
{assets.length > 0 ? (
<section className="panel quick-media">
<header className="panel__header">
<div>
<p className="eyebrow">Imported locally</p>
<h2>Sources</h2>
</div>
<span>{assets.length}</span>
</header>
<MediaBin
assets={assets}
selectedId={selectedAssetId}
onSelect={setSelectedAssetId}
onRemove={removeAsset}
/>
</section>
) : null}
</div>
<QuickConvert
asset={selectedAsset}
engineState={engineState}
busy={quickBusy}
queueing={
engineState.status === 'running' || scheduledPlanCount > 0
}
presets={[...BUILT_IN_PRESETS, ...userPresets]}
onExport={exportQuick}
/>
<section className="panel quick-preview">
<header className="panel__header">
<div>
<p className="eyebrow">Source or latest result</p>
<h2>Preview</h2>
</div>
</header>
<MediaPreview
asset={selectedAsset}
result={previewResult}
waveformPeaks={
selectedAsset ? waveformByAsset[selectedAsset.id] : undefined
}
onCreateProxy={() => void createPreviewProxy(selectedAsset)}
proxySettings={previewProxySettings}
onProxySettingsChange={setPreviewProxySettings}
proxyBusy={quickBusy}
{...(selectedAsset
? {
currentTimeSeconds: playheadByAsset[selectedAsset.id] ?? 0,
onCurrentTimeChange: (timeSeconds: number) =>
setPlayheadByAsset((current) => ({
...current,
[selectedAsset.id]: timeSeconds,
})),
}
: {})}
/>
</section>
</div>
) : (
editWorkspace
)}
<JobPanel
engineState={engineState}
jobs={scheduledJobs}
results={results}
selectedResultId={previewResultId}
verifications={outputVerifications}
report={latestReport}
logs={logs}
error={operationError}
queuedCount={Math.max(
0,
scheduledPlanCount -
(engineState.status === 'running' ||
engineState.status === 'loading' ||
engineState.status === 'recovering'
? 1
: 0)
)}
onCancel={() => {
const activeJobId = activeScheduledJobIdRef.current;
if (activeJobId) void cancelScheduledJob(activeJobId);
else void engineManager.cancelActive();
}}
onCancelJob={(job) => void cancelScheduledJob(job.id)}
onRetryJob={(job) => retryScheduledJob(job.id)}
onClearJobHistory={clearScheduledJobHistory}
onSave={(output) => void saveManagedResult(output)}
onSaveZip={(selected) => void saveManagedZip(selected)}
onSaveReport={() => void saveManagedReport()}
onPreview={(output) => setPreviewResultId(output.id)}
onRemove={(output) => {
resultCollection.remove(output.id);
refreshResults();
}}
onClearResults={clearResult}
/>
<CapabilityPanel
state={engineState}
preference={enginePreference}
resourcePolicy={resourceContext.policy}
deviceMemoryGiB={resourceContext.deviceMemoryGiB}
onPreferenceChange={(preference) => {
setEnginePreference(preference);
engineManager.setPreference(preference);
}}
onInitialize={() => {
if (engineManager.state.status === 'error') {
engineManager.dispose();
}
void engineManager.initialize().catch(() => undefined);
}}
/>
<StoragePanel projectId={project.id} />
</div>
);
}
export default function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
onContextError={(error) => {
console.warn(
'Toolbox context unavailable; continuing standalone.',
error
);
}}
>
<AvToolsApplication />
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</AppErrorBoundary>
);
}