feat: publish av-tools 0.2.0
This commit is contained in:
295
src/App.tsx
295
src/App.tsx
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user