feat: publish av-tools 0.2.0

This commit is contained in:
2026-07-27 01:04:21 +02:00
parent fcc537b024
commit 0a4548851c
68 changed files with 4130 additions and 605 deletions

View File

@@ -9,6 +9,10 @@ import {
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 {
@@ -79,7 +83,10 @@ 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';
@@ -152,6 +159,11 @@ import {
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();
@@ -554,6 +566,7 @@ export function AvToolsApplication() {
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>());
@@ -703,7 +716,10 @@ export function AvToolsApplication() {
);
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);
}
@@ -751,15 +767,37 @@ export function AvToolsApplication() {
);
const inspectAsset = useCallback(
async (asset: ImportedMediaAsset, existingAsset: boolean) => {
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(() =>
engineManager.probe(asset.file)
);
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) => ({
@@ -772,8 +810,15 @@ export function AvToolsApplication() {
? { 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,
@@ -823,6 +868,7 @@ export function AvToolsApplication() {
return next;
});
} catch (error) {
if (!sourceInspectionRegistry.current.isCurrent(token)) return;
updateAssetState(asset.id, {
phase: 'error',
error:
@@ -881,6 +927,7 @@ export function AvToolsApplication() {
const pending: Array<{
asset: ImportedMediaAsset;
existingAsset: boolean;
token: SourceInspectionToken;
}> = [];
const claimedIds = new Set(assetsRef.current.map((asset) => asset.id));
@@ -916,7 +963,11 @@ export function AvToolsApplication() {
if (reattach && match) {
asset.id = match.entry.id;
claimedIds.add(match.entry.id);
pending.push({ asset, existingAsset: true });
pending.push({
asset,
existingAsset: true,
token: sourceInspectionRegistry.current.register(asset.id),
});
} else {
setProject((current) =>
projectReducer(current, {
@@ -932,7 +983,11 @@ export function AvToolsApplication() {
updatedAt: timestamp(),
})
);
pending.push({ asset, existingAsset: false });
pending.push({
asset,
existingAsset: false,
token: sourceInspectionRegistry.current.register(asset.id),
});
}
}
@@ -943,7 +998,7 @@ export function AvToolsApplication() {
});
setSelectedAssetId((current) => current ?? pending[0]?.asset.id);
for (const item of pending) {
await inspectAsset(item.asset, item.existingAsset);
await inspectAsset(item.asset, item.existingAsset, item.token);
}
},
[inspectAsset, project.assets, resourceContext.policy]
@@ -953,7 +1008,13 @@ export function AvToolsApplication() {
(id: string) => {
const asset = assets.find((entry) => entry.id === id);
if (asset) URL.revokeObjectURL(asset.objectUrl);
setAssets((current) => current.filter((entry) => entry.id !== id));
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)
@@ -982,6 +1043,7 @@ export function AvToolsApplication() {
assetId: id,
updatedAt: timestamp(),
});
if (cancelActiveInspection) void engineManager.cancelActive();
},
[applyProjectAction, assets, selectedAssetId]
);
@@ -1203,7 +1265,7 @@ export function AvToolsApplication() {
? ('multi-thread' as const)
: ('single-thread' as const),
ffmpegVersion: readyStateAfterRun.capabilities.versionText,
coreVersion: '0.12.10',
coreVersion: FFMPEG_CORE_VERSION,
}
: {}),
},
@@ -2245,6 +2307,9 @@ export function AvToolsApplication() {
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);
@@ -2280,6 +2345,9 @@ export function AvToolsApplication() {
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);
@@ -2556,6 +2624,7 @@ export function AvToolsApplication() {
channelLayout: 'stereo',
missingAudioPolicy: request.missingAudioPolicy,
subtitlePolicy: request.subtitlePolicy,
crossfadeSeconds: request.crossfadeSeconds,
});
})();
await runPlan(
@@ -2950,33 +3019,80 @@ export function AvToolsApplication() {
if (!asset || duration === undefined) {
throw new Error('Select an attached, inspected video source first.');
}
const distribution =
request.distribution.type === 'evenly-spaced'
? { count: request.distribution.count }
: request.distribution.type === 'fixed-interval'
? { intervalSeconds: request.distribution.intervalSeconds }
: { timesSeconds: request.distribution.timesSeconds };
const plan = buildThumbnailSeriesPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
durationSeconds: duration,
...distribution,
size: {
...(request.width ? { width: request.width } : {}),
...(request.height ? { height: request.height } : {}),
},
format: request.format,
quality: request.quality,
});
const execution = await runPlan(
plan,
[asset.file],
'Create thumbnail series'
);
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) =>
@@ -3002,7 +3118,7 @@ export function AvToolsApplication() {
);
}
},
[inspectedClipAsset, project.id, runPlan]
[inspectedClipAsset, project.id, runPlan, runWorkflow]
);
const handleContactSheet = useCallback(
@@ -3012,22 +3128,75 @@ export function AvToolsApplication() {
if (!asset || duration === undefined) {
throw new Error('Select an attached, inspected video source first.');
}
const plan = buildContactSheetPlan({
jobId: crypto.randomUUID(),
source: {
id: asset.id,
sourceIndex: 0,
fileName: asset.file.name,
},
durationSeconds: duration,
...request,
drawtextAvailable: false,
});
const execution = await runPlan(
plan,
[asset.file],
'Create contact sheet'
);
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 {
@@ -3045,7 +3214,7 @@ export function AvToolsApplication() {
);
}
},
[inspectedClipAsset, project.id, runPlan]
[inspectedClipAsset, project.id, runPlan, runWorkflow]
);
const handleMetadataAction = useCallback(
@@ -3413,7 +3582,8 @@ export function AvToolsApplication() {
status: 'unavailable' as const,
reasons: ['Initialize the engine to verify this preset.'],
};
const opusBlocked = preset.audio?.codec === 'libopus';
const opusBlocked =
preset.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
return {
id: preset.id,
name: preset.name,
@@ -3423,7 +3593,7 @@ export function AvToolsApplication() {
...(availability.status === 'unavailable' || opusBlocked
? {
disabledReason: opusBlocked
? 'Opus output remains disabled until the pinned core passes its non-empty ST/MT regression smoke.'
? 'Opus output is disabled until the reviewed core passes the non-empty ST/MT regression encode and re-probe gate.'
: availability.reasons.join(' '),
}
: {}),
@@ -3481,6 +3651,7 @@ export function AvToolsApplication() {
hasAudio: false,
hasVideo: false,
hasSubtitles: false,
durationSeconds: clip.sourceOutSeconds - clip.sourceInSeconds,
};
}
const unsupportedStreams = asset.probe.streams.filter(
@@ -3517,6 +3688,7 @@ export function AvToolsApplication() {
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) =>
@@ -3622,10 +3794,12 @@ export function AvToolsApplication() {
filters: ['fps', 'scale', 'tile'],
}),
'contact-sheet-labels': {
available: false,
reason:
'No reviewed local font is bundled, so labels are omitted instead of fetching a remote font.',
...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.' },
@@ -3678,6 +3852,9 @@ export function AvToolsApplication() {
},
anyVideoPreset ? undefined : 'No verified video preset is available.'
),
'concat-crossfade': capabilityStatus({
filters: ['xfade', ...(structuralHasAnyAudio ? ['acrossfade'] : [])],
}),
} as const;
const advancedSource = inspectedClipAsset?.probe