4762 lines
157 KiB
TypeScript
4762 lines
157 KiB
TypeScript
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>
|
||
);
|
||
}
|