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
|
||||
|
||||
76
src/app/source-inspection-registry.ts
Normal file
76
src/app/source-inspection-registry.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface SourceInspectionToken {
|
||||
readonly assetId: string;
|
||||
readonly generation: number;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the exact runtime import that owns queued or active inspection work.
|
||||
*
|
||||
* Project asset IDs can be reused when a missing source is reattached, so an
|
||||
* ID-only check is insufficient: a late result from the old File must not be
|
||||
* applied to the replacement. Tokens are intentionally kept outside React
|
||||
* state so removal invalidates work synchronously.
|
||||
*/
|
||||
export class SourceInspectionRegistry {
|
||||
readonly #currentByAsset = new Map<
|
||||
string,
|
||||
{ readonly generation: number; readonly controller: AbortController }
|
||||
>();
|
||||
#nextGeneration = 0;
|
||||
#activeToken: SourceInspectionToken | undefined;
|
||||
|
||||
register(assetId: string): SourceInspectionToken {
|
||||
const controller = new AbortController();
|
||||
const token = Object.freeze({
|
||||
assetId,
|
||||
generation: ++this.#nextGeneration,
|
||||
signal: controller.signal,
|
||||
});
|
||||
this.#currentByAsset.get(assetId)?.controller.abort();
|
||||
this.#currentByAsset.set(assetId, {
|
||||
generation: token.generation,
|
||||
controller,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
isCurrent(token: SourceInspectionToken): boolean {
|
||||
return (
|
||||
this.#currentByAsset.get(token.assetId)?.generation === token.generation
|
||||
);
|
||||
}
|
||||
|
||||
activate(token: SourceInspectionToken): boolean {
|
||||
if (!this.isCurrent(token)) return false;
|
||||
this.#activeToken = token;
|
||||
return true;
|
||||
}
|
||||
|
||||
finish(token: SourceInspectionToken): void {
|
||||
if (
|
||||
this.#activeToken?.assetId === token.assetId &&
|
||||
this.#activeToken.generation === token.generation
|
||||
) {
|
||||
this.#activeToken = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
remove(assetId: string): boolean {
|
||||
const current = this.#currentByAsset.get(assetId);
|
||||
current?.controller.abort();
|
||||
this.#currentByAsset.delete(assetId);
|
||||
return (
|
||||
this.#activeToken?.assetId === assetId &&
|
||||
this.#activeToken.generation === current?.generation
|
||||
);
|
||||
}
|
||||
|
||||
clear(): boolean {
|
||||
for (const current of this.#currentByAsset.values()) {
|
||||
current.controller.abort();
|
||||
}
|
||||
this.#currentByAsset.clear();
|
||||
return this.#activeToken !== undefined;
|
||||
}
|
||||
}
|
||||
54
src/assets/bundled-font.ts
Normal file
54
src/assets/bundled-font.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const BUNDLED_CONTACT_SHEET_FONT = Object.freeze({
|
||||
family: 'DejaVu Sans',
|
||||
fileName: 'DejaVuSans.ttf',
|
||||
byteLength: 757_076,
|
||||
sha256: '7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954',
|
||||
licenseFile: 'LICENSES/DEJAVU_FONTS.txt',
|
||||
});
|
||||
|
||||
let fontPromise: Promise<File> | undefined;
|
||||
|
||||
/**
|
||||
* Loads the same-origin reviewed font and verifies its release identity before
|
||||
* it is mounted read-only for FFmpeg. A failed check is never silently replaced
|
||||
* with a system or remote font.
|
||||
*/
|
||||
export function loadBundledContactSheetFont(): Promise<File> {
|
||||
fontPromise ??= loadAndVerifyFont().catch((error: unknown) => {
|
||||
fontPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return fontPromise;
|
||||
}
|
||||
|
||||
async function loadAndVerifyFont(): Promise<File> {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.BASE_URL}fonts/${BUNDLED_CONTACT_SHEET_FONT.fileName}`,
|
||||
{
|
||||
cache: 'force-cache',
|
||||
credentials: 'same-origin',
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`The bundled contact-sheet font could not be loaded (${response.status}).`
|
||||
);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength !== BUNDLED_CONTACT_SHEET_FONT.byteLength) {
|
||||
throw new Error('The bundled contact-sheet font has an unexpected size.');
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const sha256 = [...new Uint8Array(digest)]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
if (sha256 !== BUNDLED_CONTACT_SHEET_FONT.sha256) {
|
||||
throw new Error(
|
||||
'The bundled contact-sheet font failed its SHA-256 identity check.'
|
||||
);
|
||||
}
|
||||
return new File([bytes], BUNDLED_CONTACT_SHEET_FONT.fileName, {
|
||||
type: 'font/ttf',
|
||||
lastModified: 0,
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export interface ConcatSource extends SourceReference {
|
||||
|
||||
export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject';
|
||||
const MAX_CONCAT_SOURCES = 100;
|
||||
export const MIN_CROSSFADE_SECONDS = 0.04;
|
||||
export const MAX_CROSSFADE_SECONDS = 10;
|
||||
|
||||
export interface FastConcatOptions {
|
||||
readonly jobId: string;
|
||||
@@ -59,6 +61,11 @@ export interface NormalizedConcatOptions extends FastConcatOptions {
|
||||
readonly channelLayout?: 'mono' | 'stereo' | '5.1';
|
||||
readonly missingAudioPolicy: MissingAudioPolicy;
|
||||
readonly subtitlePolicy?: 'drop-all' | 'reject';
|
||||
/**
|
||||
* Uniform overlap at every edit. Zero/undefined keeps hard cuts. The command
|
||||
* layer bounds both the transition and the available material around edits.
|
||||
*/
|
||||
readonly crossfadeSeconds?: number;
|
||||
}
|
||||
|
||||
export function validateConcatCompatibility(
|
||||
@@ -334,6 +341,8 @@ export function buildNormalizedConcatPlan(
|
||||
}
|
||||
const includeAudio =
|
||||
options.missingAudioPolicy !== 'drop-all' && hasAudio.some(Boolean);
|
||||
const crossfadeSeconds = options.crossfadeSeconds ?? 0;
|
||||
validateCrossfade(crossfadeSeconds, ranges);
|
||||
const inputs = options.sources.map((source) =>
|
||||
createPlannedInput(options.jobId, source)
|
||||
);
|
||||
@@ -360,12 +369,35 @@ export function buildNormalizedConcatPlan(
|
||||
}
|
||||
}
|
||||
});
|
||||
const labels = options.sources
|
||||
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
|
||||
.join('');
|
||||
graphParts.push(
|
||||
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
|
||||
);
|
||||
if (crossfadeSeconds > 0) {
|
||||
let videoLabel = 'v0';
|
||||
let audioLabel = 'a0';
|
||||
let effectiveDuration = ranges[0]?.durationSeconds ?? 0;
|
||||
for (let index = 1; index < options.sources.length; index += 1) {
|
||||
const finalTransition = index === options.sources.length - 1;
|
||||
const nextVideoLabel = finalTransition ? 'vout' : `vxf${index}`;
|
||||
graphParts.push(
|
||||
`[${videoLabel}][v${index}]xfade=transition=fade:duration=${normalizeSeconds(crossfadeSeconds)}:offset=${normalizeSeconds(effectiveDuration - crossfadeSeconds)}[${nextVideoLabel}]`
|
||||
);
|
||||
videoLabel = nextVideoLabel;
|
||||
if (includeAudio) {
|
||||
const nextAudioLabel = finalTransition ? 'aout' : `axf${index}`;
|
||||
graphParts.push(
|
||||
`[${audioLabel}][a${index}]acrossfade=d=${normalizeSeconds(crossfadeSeconds)}:c1=tri:c2=tri[${nextAudioLabel}]`
|
||||
);
|
||||
audioLabel = nextAudioLabel;
|
||||
}
|
||||
effectiveDuration +=
|
||||
(ranges[index]?.durationSeconds ?? 0) - crossfadeSeconds;
|
||||
}
|
||||
} else {
|
||||
const labels = options.sources
|
||||
.map((_, index) => `[v${index}]${includeAudio ? `[a${index}]` : ''}`)
|
||||
.join('');
|
||||
graphParts.push(
|
||||
`${labels}concat=n=${options.sources.length}:v=1:a=${includeAudio ? 1 : 0}[vout]${includeAudio ? '[aout]' : ''}`
|
||||
);
|
||||
}
|
||||
args.push('-filter_complex', graphParts.join(';'), '-map', '[vout]');
|
||||
if (includeAudio) {
|
||||
args.push('-map', '[aout]');
|
||||
@@ -397,10 +429,9 @@ export function buildNormalizedConcatPlan(
|
||||
temporaryFiles: [],
|
||||
args,
|
||||
outputs: [output],
|
||||
expectedDurationSeconds: ranges.reduce(
|
||||
(sum, range) => sum + range.durationSeconds,
|
||||
0
|
||||
),
|
||||
expectedDurationSeconds:
|
||||
ranges.reduce((sum, range) => sum + range.durationSeconds, 0) -
|
||||
crossfadeSeconds * (ranges.length - 1),
|
||||
requiredCapabilities: mergeRequirements(presetRequirements, {
|
||||
muxers: [options.targetMuxer],
|
||||
filters: [
|
||||
@@ -411,7 +442,8 @@ export function buildNormalizedConcatPlan(
|
||||
'setsar',
|
||||
'fps',
|
||||
'format',
|
||||
'concat',
|
||||
...(crossfadeSeconds > 0 ? ['xfade'] : ['concat']),
|
||||
...(crossfadeSeconds > 0 && includeAudio ? ['acrossfade'] : []),
|
||||
...(includeAudio ? ['atrim', 'asetpts', 'aresample', 'aformat'] : []),
|
||||
...(mixedAudioPresence &&
|
||||
options.missingAudioPolicy === 'insert-silence'
|
||||
@@ -423,7 +455,9 @@ export function buildNormalizedConcatPlan(
|
||||
diagnostic(
|
||||
'normalized-concat',
|
||||
'info',
|
||||
'Clips are normalized to common video and audio properties, then re-encoded.'
|
||||
crossfadeSeconds > 0
|
||||
? `Clips are normalized and joined with a ${normalizeSeconds(crossfadeSeconds)} second audio/video crossfade at every edit.`
|
||||
: 'Clips are normalized to common video and audio properties, then re-encoded.'
|
||||
),
|
||||
...(mixedAudioPresence && options.missingAudioPolicy === 'insert-silence'
|
||||
? [
|
||||
@@ -447,6 +481,34 @@ export function buildNormalizedConcatPlan(
|
||||
});
|
||||
}
|
||||
|
||||
function validateCrossfade(
|
||||
crossfadeSeconds: number,
|
||||
ranges: readonly { readonly durationSeconds: number }[]
|
||||
): void {
|
||||
if (!Number.isFinite(crossfadeSeconds) || crossfadeSeconds < 0) {
|
||||
throw new RangeError('Crossfade duration must be finite and nonnegative');
|
||||
}
|
||||
if (crossfadeSeconds === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
crossfadeSeconds < MIN_CROSSFADE_SECONDS ||
|
||||
crossfadeSeconds > MAX_CROSSFADE_SECONDS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Crossfade duration must be ${MIN_CROSSFADE_SECONDS}–${MAX_CROSSFADE_SECONDS} seconds`
|
||||
);
|
||||
}
|
||||
for (const [index, range] of ranges.entries()) {
|
||||
const required = index === 0 || index === ranges.length - 1 ? 1 : 2;
|
||||
if (range.durationSeconds < crossfadeSeconds * required) {
|
||||
throw new RangeError(
|
||||
`Clip ${index + 1} needs at least ${normalizeSeconds(crossfadeSeconds * required)} seconds for the requested crossfade material`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function comparableKeys(
|
||||
kind: ConcatStreamDescription['kind']
|
||||
): readonly (keyof ConcatStreamDescription)[] {
|
||||
|
||||
@@ -14,6 +14,16 @@ import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
|
||||
|
||||
export type ImageFormat = 'png' | 'jpeg' | 'webp';
|
||||
|
||||
export const MAX_SCENE_CHANGE_FRAMES = 100;
|
||||
export const MIN_SCENE_CHANGE_THRESHOLD = 0.01;
|
||||
export const MAX_SCENE_CHANGE_THRESHOLD = 0.95;
|
||||
|
||||
export interface SceneChangeFrame {
|
||||
/** Exact source presentation timestamp in the source video time base. */
|
||||
readonly pts: number;
|
||||
readonly timeSeconds: number;
|
||||
}
|
||||
|
||||
export interface ThumbnailSize {
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
@@ -41,6 +51,21 @@ export interface ThumbnailSeriesOptions {
|
||||
readonly quality?: number;
|
||||
}
|
||||
|
||||
export interface SceneChangeAnalysisOptions {
|
||||
readonly jobId: string;
|
||||
readonly source: SourceReference;
|
||||
readonly durationSeconds: number;
|
||||
readonly threshold: number;
|
||||
readonly maxFrames: number;
|
||||
}
|
||||
|
||||
export interface SceneThumbnailSeriesOptions extends Omit<
|
||||
ThumbnailSeriesOptions,
|
||||
'timesSeconds' | 'intervalSeconds' | 'count'
|
||||
> {
|
||||
readonly frames: readonly SceneChangeFrame[];
|
||||
}
|
||||
|
||||
export interface ContactSheetOptions {
|
||||
readonly jobId: string;
|
||||
readonly source: SourceReference;
|
||||
@@ -57,6 +82,10 @@ export interface ContactSheetOptions {
|
||||
readonly format: ImageFormat;
|
||||
readonly quality?: number;
|
||||
readonly drawtextAvailable?: boolean;
|
||||
/** Reviewed local font mounted as a second, read-only job input. */
|
||||
readonly font?: SourceReference;
|
||||
/** Exact frames returned by a preceding bounded scene-change analysis. */
|
||||
readonly sceneFrames?: readonly SceneChangeFrame[];
|
||||
}
|
||||
|
||||
export function buildSingleThumbnailPlan(
|
||||
@@ -164,6 +193,148 @@ export function buildThumbnailSeriesPlan(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSceneChangeAnalysisPlan(
|
||||
options: SceneChangeAnalysisOptions
|
||||
): FFmpegCommandPlan {
|
||||
validateSceneAnalysisOptions(options);
|
||||
const input = createPlannedInput(options.jobId, options.source);
|
||||
return freezeCommandPlan({
|
||||
id: `${options.jobId}:scene-change-analysis`,
|
||||
operation: 'scene-change-analysis',
|
||||
inputs: [input],
|
||||
temporaryFiles: [],
|
||||
args: [
|
||||
'-i',
|
||||
input.path,
|
||||
'-an',
|
||||
'-vf',
|
||||
`select='gt(scene\\,${normalizedSceneThreshold(options.threshold)})',showinfo`,
|
||||
'-frames:v',
|
||||
String(options.maxFrames),
|
||||
'-f',
|
||||
'null',
|
||||
'-',
|
||||
],
|
||||
outputs: [],
|
||||
expectedDurationSeconds: options.durationSeconds,
|
||||
requiredCapabilities: {
|
||||
muxers: ['null'],
|
||||
filters: ['select', 'showinfo'],
|
||||
},
|
||||
diagnostics: [
|
||||
diagnostic(
|
||||
'bounded-scene-analysis',
|
||||
'info',
|
||||
`Scene analysis stops after ${options.maxFrames} qualifying frames and retains no source media.`
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSceneChangeFrames(
|
||||
logText: string
|
||||
): readonly SceneChangeFrame[] {
|
||||
const frames: SceneChangeFrame[] = [];
|
||||
const seenPts = new Set<number>();
|
||||
const linePattern =
|
||||
/^[^\n]*\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/gimu;
|
||||
for (const match of logText.matchAll(linePattern)) {
|
||||
const pts = Number(match[1]);
|
||||
const timeSeconds = Number(match[2]);
|
||||
if (
|
||||
!Number.isSafeInteger(pts) ||
|
||||
pts < 0 ||
|
||||
!Number.isFinite(timeSeconds) ||
|
||||
timeSeconds < 0 ||
|
||||
seenPts.has(pts)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seenPts.add(pts);
|
||||
frames.push({ pts, timeSeconds });
|
||||
if (frames.length >= MAX_SCENE_CHANGE_FRAMES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Object.freeze(
|
||||
frames
|
||||
.sort((left, right) => left.timeSeconds - right.timeSeconds)
|
||||
.map((frame) => Object.freeze({ ...frame }))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSceneThumbnailSeriesPlan(
|
||||
options: SceneThumbnailSeriesOptions
|
||||
): FFmpegCommandPlan {
|
||||
const frames = validateSceneFrames(
|
||||
options.frames,
|
||||
options.durationSeconds,
|
||||
'Thumbnail series'
|
||||
);
|
||||
const input = createPlannedInput(options.jobId, options.source);
|
||||
const extension = extensionForImageFormat(options.format);
|
||||
const outputs = frames.map((frame, index) =>
|
||||
createPlannedOutput(options.jobId, {
|
||||
id: `thumbnail-${index + 1}`,
|
||||
baseName: options.source.fileName,
|
||||
operation: 'thumbnail',
|
||||
suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(frame.timeSeconds * 1000)}ms`,
|
||||
extension,
|
||||
role: 'thumbnail',
|
||||
})
|
||||
);
|
||||
const size = scaleFilter(options.size);
|
||||
const selected = frames.map((frame) => `eq(pts\\,${frame.pts})`).join('+');
|
||||
const splitOutputs = frames
|
||||
.map((_, index) => `[scene-source-${index}]`)
|
||||
.join('');
|
||||
const filtergraph = [
|
||||
`[0:v]select='${selected}',split=${frames.length}${splitOutputs}`,
|
||||
...frames.map((_, index) => {
|
||||
const filters = [
|
||||
`select='eq(n\\,${index})'`,
|
||||
'setpts=N/FRAME_RATE/TB',
|
||||
...(size ? [size] : []),
|
||||
];
|
||||
return `[scene-source-${index}]${filters.join(',')}[thumb-${index}]`;
|
||||
}),
|
||||
].join(';');
|
||||
const outputArguments = outputs.flatMap((output, index) => [
|
||||
'-map',
|
||||
`[thumb-${index}]`,
|
||||
'-frames:v',
|
||||
'1',
|
||||
...imageQualityArguments(options.format, options.quality),
|
||||
output.path,
|
||||
]);
|
||||
return freezeCommandPlan({
|
||||
id: `${options.jobId}:scene-thumbnail-series`,
|
||||
operation: 'thumbnail-series',
|
||||
inputs: [input],
|
||||
temporaryFiles: [],
|
||||
args: [
|
||||
'-i',
|
||||
input.path,
|
||||
'-filter_complex',
|
||||
filtergraph,
|
||||
...outputArguments,
|
||||
],
|
||||
outputs,
|
||||
expectedDurationSeconds: options.durationSeconds,
|
||||
requiredCapabilities: {
|
||||
...imageRequirements(options.format),
|
||||
filters: ['select', 'split', 'setpts', ...(size ? ['scale'] : [])],
|
||||
},
|
||||
diagnostics: [
|
||||
diagnostic(
|
||||
'scene-change-selection',
|
||||
'info',
|
||||
`${frames.length} frames are selected by exact presentation timestamp from a preceding bounded scene analysis.`
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveThumbnailTimes(
|
||||
options: Pick<
|
||||
ThumbnailSeriesOptions,
|
||||
@@ -271,7 +442,22 @@ export function buildContactSheetPlan(
|
||||
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
|
||||
throw new TypeError('Background must be a named colour or #RRGGBB');
|
||||
}
|
||||
const sceneFrames = options.sceneFrames
|
||||
? validateSceneFrames(
|
||||
options.sceneFrames,
|
||||
options.durationSeconds,
|
||||
'Contact sheet'
|
||||
)
|
||||
: undefined;
|
||||
if (sceneFrames && sceneFrames.length !== options.frameCount) {
|
||||
throw new RangeError(
|
||||
'Contact sheet frame count must match the analyzed scene selection'
|
||||
);
|
||||
}
|
||||
const input = createPlannedInput(options.jobId, options.source);
|
||||
const fontInput = options.font
|
||||
? createPlannedInput(options.jobId, options.font, 'font')
|
||||
: undefined;
|
||||
const output = createPlannedOutput(options.jobId, {
|
||||
baseName: options.source.fileName,
|
||||
operation: 'contact-sheet',
|
||||
@@ -280,14 +466,20 @@ export function buildContactSheetPlan(
|
||||
});
|
||||
const fps = options.frameCount / options.durationSeconds;
|
||||
const filters = [
|
||||
`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`,
|
||||
...(sceneFrames
|
||||
? [
|
||||
`select='${sceneFrames
|
||||
.map((frame) => `eq(pts\\,${frame.pts})`)
|
||||
.join('+')}'`,
|
||||
]
|
||||
: [`fps=${fps.toFixed(8).replace(/0+$/u, '').replace(/\.$/u, '')}`]),
|
||||
options.maintainAspect === false
|
||||
? `scale=${options.cellWidth}:${options.cellWidth}`
|
||||
: `scale=${options.cellWidth}:-2:force_original_aspect_ratio=decrease`,
|
||||
];
|
||||
let labelsEnabled = Boolean(options.timestampLabels || options.sourceLabel);
|
||||
const diagnostics = [];
|
||||
if (labelsEnabled && !options.drawtextAvailable) {
|
||||
if (labelsEnabled && (!options.drawtextAvailable || !fontInput)) {
|
||||
labelsEnabled = false;
|
||||
diagnostics.push(
|
||||
diagnostic(
|
||||
@@ -302,16 +494,19 @@ export function buildContactSheetPlan(
|
||||
? `${safePathComponent(options.source.fileName)} %{pts\\:hms}`
|
||||
: '%{pts\\:hms}';
|
||||
filters.push(
|
||||
`drawtext=text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
|
||||
`drawtext=fontfile='${fontInput?.path ?? ''}':text='${text}':x=8:y=h-th-8:fontcolor=white:box=1:boxcolor=black@0.6`
|
||||
);
|
||||
}
|
||||
if (sceneFrames) {
|
||||
filters.push('setpts=N/FRAME_RATE/TB');
|
||||
}
|
||||
filters.push(
|
||||
`tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}`
|
||||
`tile=${options.columns}x${options.rows}:nb_frames=${options.frameCount}:padding=${spacing}:margin=${spacing}:color=${background}`
|
||||
);
|
||||
return freezeCommandPlan({
|
||||
id: `${options.jobId}:contact-sheet`,
|
||||
operation: 'contact-sheet',
|
||||
inputs: [input],
|
||||
inputs: [input, ...(fontInput ? [fontInput] : [])],
|
||||
temporaryFiles: [],
|
||||
args: [
|
||||
'-i',
|
||||
@@ -327,12 +522,101 @@ export function buildContactSheetPlan(
|
||||
expectedDurationSeconds: options.durationSeconds,
|
||||
requiredCapabilities: {
|
||||
...imageRequirements(options.format),
|
||||
filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])],
|
||||
filters: [
|
||||
sceneFrames ? 'select' : 'fps',
|
||||
'scale',
|
||||
...(sceneFrames ? ['setpts'] : []),
|
||||
'tile',
|
||||
...(labelsEnabled ? ['drawtext'] : []),
|
||||
],
|
||||
},
|
||||
diagnostics,
|
||||
diagnostics: [
|
||||
...diagnostics,
|
||||
...(sceneFrames
|
||||
? [
|
||||
diagnostic(
|
||||
'scene-change-selection',
|
||||
'info',
|
||||
`${sceneFrames.length} analyzed scene changes populate this contact sheet.`
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneAnalysisOptions(
|
||||
options: Pick<
|
||||
SceneChangeAnalysisOptions,
|
||||
'durationSeconds' | 'threshold' | 'maxFrames'
|
||||
>
|
||||
): void {
|
||||
if (
|
||||
!Number.isFinite(options.durationSeconds) ||
|
||||
options.durationSeconds <= 0
|
||||
) {
|
||||
throw new RangeError('Scene analysis source duration must be positive');
|
||||
}
|
||||
normalizedSceneThreshold(options.threshold);
|
||||
if (
|
||||
!Number.isSafeInteger(options.maxFrames) ||
|
||||
options.maxFrames < 1 ||
|
||||
options.maxFrames > MAX_SCENE_CHANGE_FRAMES
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Scene analysis frame limit must be an integer from 1 to ${MAX_SCENE_CHANGE_FRAMES}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedSceneThreshold(value: number): string {
|
||||
if (
|
||||
!Number.isFinite(value) ||
|
||||
value < MIN_SCENE_CHANGE_THRESHOLD ||
|
||||
value > MAX_SCENE_CHANGE_THRESHOLD
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Scene threshold must be ${MIN_SCENE_CHANGE_THRESHOLD}–${MAX_SCENE_CHANGE_THRESHOLD}`
|
||||
);
|
||||
}
|
||||
return String(Number(value.toFixed(3)));
|
||||
}
|
||||
|
||||
function validateSceneFrames(
|
||||
frames: readonly SceneChangeFrame[],
|
||||
durationSeconds: number,
|
||||
label: string
|
||||
): readonly SceneChangeFrame[] {
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
throw new RangeError(`${label} source duration must be positive`);
|
||||
}
|
||||
if (
|
||||
frames.length < 1 ||
|
||||
frames.length > MAX_SCENE_CHANGE_FRAMES ||
|
||||
frames.some(
|
||||
(frame) =>
|
||||
!Number.isSafeInteger(frame.pts) ||
|
||||
frame.pts < 0 ||
|
||||
!Number.isFinite(frame.timeSeconds) ||
|
||||
frame.timeSeconds < 0 ||
|
||||
frame.timeSeconds >= durationSeconds
|
||||
)
|
||||
) {
|
||||
throw new RangeError(
|
||||
`${label} scene frames must be bounded, finite points inside the source`
|
||||
);
|
||||
}
|
||||
const uniquePts = new Set(frames.map((frame) => frame.pts));
|
||||
if (uniquePts.size !== frames.length) {
|
||||
throw new RangeError(`${label} scene frames must have unique timestamps`);
|
||||
}
|
||||
return Object.freeze(
|
||||
[...frames]
|
||||
.sort((left, right) => left.timeSeconds - right.timeSeconds)
|
||||
.map((frame) => Object.freeze({ ...frame }))
|
||||
);
|
||||
}
|
||||
|
||||
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
|
||||
const filter = scaleFilter(size);
|
||||
return filter ? ['-vf', filter] : [];
|
||||
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
GENERATED_OUTPUT_LIMIT_EXPLANATION,
|
||||
MAX_GENERATED_OUTPUT_FILES,
|
||||
} from '../limits';
|
||||
import {
|
||||
MAX_SCENE_CHANGE_FRAMES,
|
||||
MAX_SCENE_CHANGE_THRESHOLD,
|
||||
MIN_SCENE_CHANGE_THRESHOLD,
|
||||
} from '../commands/thumbnails';
|
||||
import {
|
||||
parseUserPresetJson,
|
||||
serializeUserPreset,
|
||||
@@ -58,6 +63,7 @@ export type AdvancedCapabilityKey =
|
||||
| 'thumbnail-series'
|
||||
| 'contact-sheet'
|
||||
| 'contact-sheet-labels'
|
||||
| 'scene-change-analysis'
|
||||
| 'metadata-write'
|
||||
| 'chapter-write'
|
||||
| 'subtitle-extract'
|
||||
@@ -181,6 +187,11 @@ export type AdvancedAudioRequest =
|
||||
export type ThumbnailDistribution =
|
||||
| { readonly type: 'evenly-spaced'; readonly count: number }
|
||||
| { readonly type: 'fixed-interval'; readonly intervalSeconds: number }
|
||||
| {
|
||||
readonly type: 'scene-changes';
|
||||
readonly count: number;
|
||||
readonly threshold: number;
|
||||
}
|
||||
| {
|
||||
readonly type: 'chapter-starts' | 'split-markers';
|
||||
readonly timesSeconds: readonly number[];
|
||||
@@ -206,6 +217,9 @@ export interface ContactSheetRequest {
|
||||
readonly sourceLabel: boolean;
|
||||
readonly format: ImageFormat;
|
||||
readonly quality: number;
|
||||
readonly selection:
|
||||
| { readonly type: 'evenly-spaced' }
|
||||
| { readonly type: 'scene-changes'; readonly threshold: number };
|
||||
}
|
||||
|
||||
export type MetadataExecutionRequest =
|
||||
@@ -556,6 +570,7 @@ export function AdvancedOperations({
|
||||
chapters={chapters}
|
||||
formatAvailability={imageFormatAvailability}
|
||||
labelAvailability={availability['contact-sheet-labels']}
|
||||
sceneAvailability={availability['scene-change-analysis']}
|
||||
onThumbnailSeries={onThumbnailSeries}
|
||||
onContactSheet={onContactSheet}
|
||||
action={actionContext}
|
||||
@@ -1442,6 +1457,7 @@ function ImagePanel({
|
||||
chapters,
|
||||
formatAvailability,
|
||||
labelAvailability,
|
||||
sceneAvailability,
|
||||
onThumbnailSeries,
|
||||
onContactSheet,
|
||||
action,
|
||||
@@ -1453,6 +1469,7 @@ function ImagePanel({
|
||||
Readonly<Record<ImageFormat, AdvancedCapabilityStatus>>
|
||||
>;
|
||||
readonly labelAvailability?: AdvancedCapabilityStatus;
|
||||
readonly sceneAvailability?: AdvancedCapabilityStatus;
|
||||
readonly onThumbnailSeries?: RequestHandler<ThumbnailSeriesRequest>;
|
||||
readonly onContactSheet?: RequestHandler<ContactSheetRequest>;
|
||||
readonly action: ActionContext;
|
||||
@@ -1474,6 +1491,10 @@ function ImagePanel({
|
||||
const [maintainAspect, setMaintainAspect] = useState(true);
|
||||
const [timestampLabels, setTimestampLabels] = useState(false);
|
||||
const [sourceLabel, setSourceLabel] = useState(false);
|
||||
const [sceneThreshold, setSceneThreshold] = useState('0.35');
|
||||
const [sheetSelection, setSheetSelection] = useState<
|
||||
'evenly-spaced' | 'scene-changes'
|
||||
>('evenly-spaced');
|
||||
|
||||
const videoReason = !source
|
||||
? 'Select a source first.'
|
||||
@@ -1521,6 +1542,29 @@ function ImagePanel({
|
||||
type: distribution,
|
||||
intervalSeconds: Number(interval),
|
||||
};
|
||||
} else if (distribution === 'scene-changes') {
|
||||
distributionReason =
|
||||
integerRangeReason(
|
||||
seriesCount,
|
||||
1,
|
||||
MAX_SCENE_CHANGE_FRAMES,
|
||||
'Frame count'
|
||||
) ??
|
||||
numberRangeReason(
|
||||
sceneThreshold,
|
||||
MIN_SCENE_CHANGE_THRESHOLD,
|
||||
MAX_SCENE_CHANGE_THRESHOLD,
|
||||
'Scene threshold'
|
||||
) ??
|
||||
(sceneAvailability?.available
|
||||
? undefined
|
||||
: (sceneAvailability?.reason ??
|
||||
'Scene-change analysis has not been verified.'));
|
||||
resolvedDistribution = {
|
||||
type: distribution,
|
||||
count: Number(seriesCount),
|
||||
threshold: Number(sceneThreshold),
|
||||
};
|
||||
} else if (distribution === 'chapter-starts') {
|
||||
const times = chapters.map((chapter) => chapter.startSeconds);
|
||||
distributionReason =
|
||||
@@ -1556,7 +1600,12 @@ function ImagePanel({
|
||||
'Timestamp and filename labels require verified drawtext and a safe local font.')
|
||||
: undefined;
|
||||
const gridReason =
|
||||
integerRangeReason(sheetFrames, 1, 500, 'Frame count') ??
|
||||
integerRangeReason(
|
||||
sheetFrames,
|
||||
1,
|
||||
sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500,
|
||||
'Frame count'
|
||||
) ??
|
||||
integerRangeReason(sheetRows, 1, 500, 'Rows') ??
|
||||
integerRangeReason(sheetColumns, 1, 500, 'Columns') ??
|
||||
integerRangeReason(cellWidth, 32, 4096, 'Cell width') ??
|
||||
@@ -1571,7 +1620,19 @@ function ImagePanel({
|
||||
gridReason ??
|
||||
numberRangeReason(quality, 1, 100, 'Quality') ??
|
||||
formatReason ??
|
||||
labelReason;
|
||||
labelReason ??
|
||||
(sheetSelection === 'scene-changes'
|
||||
? (numberRangeReason(
|
||||
sceneThreshold,
|
||||
MIN_SCENE_CHANGE_THRESHOLD,
|
||||
MAX_SCENE_CHANGE_THRESHOLD,
|
||||
'Scene threshold'
|
||||
) ??
|
||||
(sceneAvailability?.available
|
||||
? undefined
|
||||
: (sceneAvailability?.reason ??
|
||||
'Scene-change analysis has not been verified.')))
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<div className="advanced-operations__section-grid">
|
||||
@@ -1597,6 +1658,12 @@ function ImagePanel({
|
||||
>
|
||||
<option value="evenly-spaced">Evenly spaced</option>
|
||||
<option value="fixed-interval">Fixed interval</option>
|
||||
<option
|
||||
value="scene-changes"
|
||||
disabled={!sceneAvailability?.available}
|
||||
>
|
||||
Scene changes
|
||||
</option>
|
||||
<option value="chapter-starts" disabled={chapters.length === 0}>
|
||||
Chapter starts
|
||||
</option>
|
||||
@@ -1605,12 +1672,17 @@ function ImagePanel({
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{distribution === 'evenly-spaced' ? (
|
||||
{distribution === 'evenly-spaced' ||
|
||||
distribution === 'scene-changes' ? (
|
||||
<NumberField
|
||||
label="Number of frames"
|
||||
value={seriesCount}
|
||||
min={1}
|
||||
max={MAX_GENERATED_OUTPUT_FILES}
|
||||
max={
|
||||
distribution === 'scene-changes'
|
||||
? MAX_SCENE_CHANGE_FRAMES
|
||||
: MAX_GENERATED_OUTPUT_FILES
|
||||
}
|
||||
onChange={setSeriesCount}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1625,6 +1697,16 @@ function ImagePanel({
|
||||
onChange={setInterval}
|
||||
/>
|
||||
) : null}
|
||||
{distribution === 'scene-changes' ? (
|
||||
<NumberField
|
||||
label="Scene sensitivity threshold"
|
||||
value={sceneThreshold}
|
||||
min={MIN_SCENE_CHANGE_THRESHOLD}
|
||||
max={MAX_SCENE_CHANGE_THRESHOLD}
|
||||
step={0.01}
|
||||
onChange={setSceneThreshold}
|
||||
/>
|
||||
) : null}
|
||||
<ImageSettings
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -1673,11 +1755,32 @@ function ImagePanel({
|
||||
</div>
|
||||
</header>
|
||||
<div className="advanced-form-grid advanced-form-grid--three">
|
||||
<label className="advanced-field">
|
||||
<span>Frame selection</span>
|
||||
<select
|
||||
value={sheetSelection}
|
||||
onChange={(event) =>
|
||||
setSheetSelection(
|
||||
event.currentTarget.value as 'evenly-spaced' | 'scene-changes'
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="evenly-spaced">Evenly spaced</option>
|
||||
<option
|
||||
value="scene-changes"
|
||||
disabled={!sceneAvailability?.available}
|
||||
>
|
||||
Scene changes
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumberField
|
||||
label="Frames"
|
||||
value={sheetFrames}
|
||||
min={1}
|
||||
max={500}
|
||||
max={
|
||||
sheetSelection === 'scene-changes' ? MAX_SCENE_CHANGE_FRAMES : 500
|
||||
}
|
||||
onChange={setSheetFrames}
|
||||
/>
|
||||
<NumberField
|
||||
@@ -1716,6 +1819,16 @@ function ImagePanel({
|
||||
onChange={setBackground}
|
||||
/>
|
||||
</div>
|
||||
{sheetSelection === 'scene-changes' ? (
|
||||
<NumberField
|
||||
label="Contact-sheet scene threshold"
|
||||
value={sceneThreshold}
|
||||
min={MIN_SCENE_CHANGE_THRESHOLD}
|
||||
max={MAX_SCENE_CHANGE_THRESHOLD}
|
||||
step={0.01}
|
||||
onChange={setSceneThreshold}
|
||||
/>
|
||||
) : null}
|
||||
<div className="advanced-check-grid">
|
||||
<CheckField
|
||||
label="Maintain aspect ratio"
|
||||
@@ -1769,6 +1882,13 @@ function ImagePanel({
|
||||
sourceLabel,
|
||||
format,
|
||||
quality: Number(quality),
|
||||
selection:
|
||||
sheetSelection === 'scene-changes'
|
||||
? {
|
||||
type: 'scene-changes',
|
||||
threshold: Number(sceneThreshold),
|
||||
}
|
||||
: { type: 'evenly-spaced' },
|
||||
},
|
||||
'Contact sheet added to the local queue.'
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import applicationLicenseUrl from '../../LICENSE?url&no-inline';
|
||||
import dejavuFontLicenseUrl from '../../LICENSES/DEJAVU_FONTS.txt?url&no-inline';
|
||||
import ffmpegWasmLicenseUrl from '../../LICENSES/ffmpeg.wasm-MIT.txt?url&no-inline';
|
||||
import gplLicenseUrl from '../../LICENSES/GPL-2.0-or-later.txt?url&no-inline';
|
||||
import licenseStatusUrl from '../../LICENSES/README.md?url&no-inline';
|
||||
@@ -7,7 +8,7 @@ import sourceRecordUrl from '../../SOURCE.md?url&no-inline';
|
||||
import thirdPartyNoticesUrl from '../../THIRD_PARTY_NOTICES.md?url&no-inline';
|
||||
import {
|
||||
APP_VERSION,
|
||||
FFMPEG_CORE_VERSION,
|
||||
FFMPEG_CORE_BUILD_ID,
|
||||
FFMPEG_ENGINE_VERSION,
|
||||
FFMPEG_UTIL_VERSION,
|
||||
FFMPEG_WRAPPER_VERSION,
|
||||
@@ -101,7 +102,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>FFmpeg WASM cores</dt>
|
||||
<dd>{FFMPEG_CORE_VERSION} · ST & MT</dd>
|
||||
<dd>{FFMPEG_CORE_BUILD_ID} · ST & MT</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>ffmpeg.wasm utilities</dt>
|
||||
@@ -156,6 +157,16 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
GPL text
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
DejaVu Sans 2.37 — Bitstream Vera and Arev font terms
|
||||
<a
|
||||
href={dejavuFontLicenseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Font licence
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
FFmpeg, linked codecs, Toolbox SDK, React and other runtime
|
||||
packages retain their respective terms.
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { ImportedMediaAsset } from '../app/application-state';
|
||||
import {
|
||||
assessBrowserPlayback,
|
||||
assessGeneratedResultPreview,
|
||||
isAudioMimeType,
|
||||
isVideoMimeType,
|
||||
mimeTypeFromFileName,
|
||||
type MediaProbe,
|
||||
} from '../media';
|
||||
import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform';
|
||||
@@ -47,15 +50,31 @@ export function MediaPreview({
|
||||
const mediaElement = useRef<HTMLMediaElement>(null);
|
||||
const sourceUrl = result?.objectUrl ?? asset?.objectUrl;
|
||||
const playbackFailed = sourceUrl === failedSourceUrl;
|
||||
const fileMimeType = asset?.file.type;
|
||||
const declaredSourceMimeType =
|
||||
fileMimeType &&
|
||||
(isVideoMimeType(fileMimeType) || isAudioMimeType(fileMimeType))
|
||||
? fileMimeType
|
||||
: asset
|
||||
? mimeTypeFromFileName(asset.file.name)
|
||||
: undefined;
|
||||
const hasVideo = result
|
||||
? result.mimeType.startsWith('video/')
|
||||
: asset?.probe?.streams.some(
|
||||
(stream) =>
|
||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||
) === true;
|
||||
: asset?.probe
|
||||
? asset.probe.streams.some(
|
||||
(stream) =>
|
||||
stream.type === 'video' && stream.disposition.attached_pic !== true
|
||||
)
|
||||
: declaredSourceMimeType
|
||||
? isVideoMimeType(declaredSourceMimeType)
|
||||
: false;
|
||||
const hasAudio = result
|
||||
? result.mimeType.startsWith('audio/')
|
||||
: asset?.probe?.streams.some((stream) => stream.type === 'audio') === true;
|
||||
: asset?.probe
|
||||
? asset.probe.streams.some((stream) => stream.type === 'audio')
|
||||
: declaredSourceMimeType
|
||||
? isAudioMimeType(declaredSourceMimeType)
|
||||
: false;
|
||||
const hasImage = result?.mimeType.startsWith('image/') === true;
|
||||
const resultPlayback = useMemo(() => {
|
||||
if (!result) return undefined;
|
||||
@@ -186,7 +205,10 @@ export function MediaPreview({
|
||||
? resultPlayback?.previewable === false
|
||||
? `Download only · ${result.name}`
|
||||
: `Generated result · ${result.name}`
|
||||
: (playback?.reason ?? 'Local source preview')}
|
||||
: (playback?.reason ??
|
||||
(asset?.phase === 'probing'
|
||||
? 'Local source preview · Media inspection continues in the background.'
|
||||
: 'Local source preview'))}
|
||||
</span>
|
||||
{needsProxy && !proxySettings ? (
|
||||
<button type="button" className="text-button" onClick={onCreateProxy}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
presetAvailability,
|
||||
} from '../presets/preset-registry';
|
||||
import { formatBytes, formatDuration } from '../app/application-state';
|
||||
import { FFMPEG_OPUS_REGRESSION_VERIFIED } from '../version';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export type QuickOperation = 'convert' | 'remux';
|
||||
@@ -139,7 +140,8 @@ export function QuickConvert({
|
||||
engineState.capabilities
|
||||
)
|
||||
: undefined;
|
||||
const opusNeedsRegressionTest = preset?.audio?.codec === 'libopus';
|
||||
const opusBlockedByPinnedCore =
|
||||
preset?.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED;
|
||||
const presetNeedsMissingAudio =
|
||||
preset?.kind === 'audio' && effectiveSelection.audio.length === 0;
|
||||
const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) =>
|
||||
@@ -186,7 +188,7 @@ export function QuickConvert({
|
||||
? !remuxMuxerMissing && remuxErrors.length === 0
|
||||
: preset !== undefined &&
|
||||
availability?.status !== 'unavailable' &&
|
||||
!opusNeedsRegressionTest &&
|
||||
!opusBlockedByPinnedCore &&
|
||||
!presetNeedsMissingAudio);
|
||||
|
||||
const presetOption = (entry: ExportPreset) => {
|
||||
@@ -199,8 +201,8 @@ export function QuickConvert({
|
||||
? presetAvailability({ requirements }, engineState.capabilities)
|
||||
: undefined;
|
||||
const reason =
|
||||
entry.audio?.codec === 'libopus'
|
||||
? 'Opus awaits a pinned-core regression test'
|
||||
entry.audio?.codec === 'libopus' && !FFMPEG_OPUS_REGRESSION_VERIFIED
|
||||
? 'the reviewed core has not passed the ST/MT Opus regression encode'
|
||||
: entryAvailability?.status === 'unavailable'
|
||||
? entryAvailability.reasons.join(', ')
|
||||
: undefined;
|
||||
@@ -455,11 +457,11 @@ export function QuickConvert({
|
||||
Select at least one output stream.
|
||||
</p>
|
||||
) : null}
|
||||
{opusNeedsRegressionTest ? (
|
||||
{opusBlockedByPinnedCore ? (
|
||||
<p className="notice notice--warning">
|
||||
<Icon name="warning" />
|
||||
Opus export is held back until the pinned core passes its browser
|
||||
regression test.
|
||||
Opus export is disabled until the reviewed core passes its non-empty
|
||||
encode and re-probe gate in both engine modes.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.structural-field select {
|
||||
.structural-field select,
|
||||
.structural-field input {
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.48rem 0.62rem;
|
||||
@@ -402,6 +403,7 @@
|
||||
}
|
||||
|
||||
.structural-field select:focus-visible,
|
||||
.structural-field input:focus-visible,
|
||||
.structural-operations input:focus-visible,
|
||||
.structural-operations button:focus-visible {
|
||||
outline: 3px solid var(--toolbox-focus);
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useId, useState } from 'react';
|
||||
import './StructuralOperations.css';
|
||||
|
||||
export type StructuralCapabilityKey =
|
||||
'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized';
|
||||
| 'trim-fast'
|
||||
| 'trim-accurate'
|
||||
| 'concat-fast'
|
||||
| 'concat-normalized'
|
||||
| 'concat-crossfade';
|
||||
|
||||
export interface StructuralCapabilityStatus {
|
||||
readonly available: boolean;
|
||||
@@ -35,6 +39,7 @@ export interface StructuralTimelineClip {
|
||||
readonly hasAudio: boolean;
|
||||
readonly hasVideo: boolean;
|
||||
readonly hasSubtitles: boolean;
|
||||
readonly durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface StructuralCompatibilityDiagnostic {
|
||||
@@ -92,6 +97,7 @@ export type StructuralConcatRequest =
|
||||
readonly presetId: string;
|
||||
readonly missingAudioPolicy: MissingAudioPolicy;
|
||||
readonly subtitlePolicy: NormalizedSubtitlePolicy;
|
||||
readonly crossfadeSeconds: number;
|
||||
};
|
||||
|
||||
type RequestHandler<T> = (request: T) => void | Promise<void>;
|
||||
@@ -135,6 +141,7 @@ export function StructuralOperations({
|
||||
const [subtitlePolicy, setSubtitlePolicy] = useState<
|
||||
NormalizedSubtitlePolicy | ''
|
||||
>('');
|
||||
const [crossfadeSeconds, setCrossfadeSeconds] = useState('0');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [status, setStatus] = useState<string>();
|
||||
|
||||
@@ -156,6 +163,7 @@ export function StructuralOperations({
|
||||
preset: concatPreset,
|
||||
missingAudioPolicy,
|
||||
subtitlePolicy,
|
||||
crossfadeSeconds,
|
||||
availability,
|
||||
connected: Boolean(onConcat),
|
||||
busy: busy || pending,
|
||||
@@ -445,6 +453,30 @@ export function StructuralOperations({
|
||||
)}
|
||||
onChange={setSubtitlePolicy}
|
||||
/>
|
||||
<label
|
||||
className="structural-field"
|
||||
htmlFor={`${componentId}-crossfade`}
|
||||
>
|
||||
<span>Crossfade duration</span>
|
||||
<input
|
||||
id={`${componentId}-crossfade`}
|
||||
type="number"
|
||||
aria-label="Crossfade duration"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.04"
|
||||
inputMode="decimal"
|
||||
value={crossfadeSeconds}
|
||||
disabled={busy || pending}
|
||||
onChange={(event) =>
|
||||
setCrossfadeSeconds(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
0 keeps hard cuts. A value from 0.04–10 seconds overlaps video
|
||||
and, when retained, audio at every edit.
|
||||
</small>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -488,6 +520,7 @@ export function StructuralOperations({
|
||||
presetId: concatPreset.id,
|
||||
missingAudioPolicy,
|
||||
subtitlePolicy,
|
||||
crossfadeSeconds: Number(crossfadeSeconds),
|
||||
},
|
||||
'Normalized concat queued.'
|
||||
);
|
||||
@@ -856,6 +889,7 @@ function reasonForConcat({
|
||||
preset,
|
||||
missingAudioPolicy,
|
||||
subtitlePolicy,
|
||||
crossfadeSeconds,
|
||||
availability,
|
||||
connected,
|
||||
busy,
|
||||
@@ -865,6 +899,7 @@ function reasonForConcat({
|
||||
readonly preset: StructuralExportPresetOption | undefined;
|
||||
readonly missingAudioPolicy: MissingAudioPolicy | '';
|
||||
readonly subtitlePolicy: NormalizedSubtitlePolicy | '';
|
||||
readonly crossfadeSeconds: string;
|
||||
readonly availability: StructuralOperationsProps['availability'];
|
||||
readonly connected: boolean;
|
||||
readonly busy: boolean;
|
||||
@@ -924,13 +959,39 @@ function reasonForConcat({
|
||||
) {
|
||||
return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.';
|
||||
}
|
||||
const crossfade = Number(crossfadeSeconds);
|
||||
if (
|
||||
!Number.isFinite(crossfade) ||
|
||||
crossfade < 0 ||
|
||||
(crossfade > 0 && (crossfade < 0.04 || crossfade > 10))
|
||||
) {
|
||||
return 'Crossfade duration must be 0, or a value from 0.04–10 seconds.';
|
||||
}
|
||||
if (crossfade > 0) {
|
||||
for (const [index, clip] of timeline.clips.entries()) {
|
||||
if (clip.durationSeconds === undefined) {
|
||||
continue;
|
||||
}
|
||||
const required =
|
||||
index === 0 || index === timeline.clips.length - 1
|
||||
? crossfade
|
||||
: crossfade * 2;
|
||||
if (clip.durationSeconds < required) {
|
||||
return `Clip ${index + 1} is too short for ${crossfade} second crossfades at its edit boundaries.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!connected) {
|
||||
return 'This action has not been connected to the job runner.';
|
||||
}
|
||||
return capabilityReason(
|
||||
const baseReason = capabilityReason(
|
||||
availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized']
|
||||
);
|
||||
if (baseReason || mode === 'fast' || Number(crossfadeSeconds) === 0) {
|
||||
return baseReason;
|
||||
}
|
||||
return capabilityReason(availability?.['concat-crossfade']);
|
||||
}
|
||||
|
||||
function capabilityReason(
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
type FFmpegFactory = () => Promise<FFmpegAdapter>;
|
||||
|
||||
export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000;
|
||||
export const MIN_PROBE_TIMEOUT_MILLISECONDS = 30_000;
|
||||
export const MAX_PROBE_TIMEOUT_MILLISECONDS = 5 * 60_000;
|
||||
export const PROBE_BUDGET_BYTES_PER_SECOND = 2 * 1024 * 1024;
|
||||
export const MULTITHREAD_CODEC_THREAD_LIMIT = 2;
|
||||
/**
|
||||
* The pinned Emscripten core can retain native heap state across heterogeneous
|
||||
@@ -55,6 +58,19 @@ export function shouldRecycleExecutionCore(
|
||||
);
|
||||
}
|
||||
|
||||
export function probeTimeoutMilliseconds(inputBytes: number): number {
|
||||
if (!Number.isFinite(inputBytes) || inputBytes <= 0) {
|
||||
return MIN_PROBE_TIMEOUT_MILLISECONDS;
|
||||
}
|
||||
const sizeAwareBudget = Math.ceil(
|
||||
(inputBytes / PROBE_BUDGET_BYTES_PER_SECOND) * 1_000
|
||||
);
|
||||
return Math.min(
|
||||
MAX_PROBE_TIMEOUT_MILLISECONDS,
|
||||
Math.max(MIN_PROBE_TIMEOUT_MILLISECONDS, sizeAwareBudget)
|
||||
);
|
||||
}
|
||||
|
||||
async function defaultFFmpegFactory(): Promise<FFmpegAdapter> {
|
||||
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||
return new FFmpeg();
|
||||
@@ -521,8 +537,18 @@ export class EngineManager {
|
||||
};
|
||||
}
|
||||
|
||||
async probe(file: File, timeoutMilliseconds = 30_000): Promise<ProbeResult> {
|
||||
async probe(
|
||||
file: File,
|
||||
timeoutMilliseconds = probeTimeoutMilliseconds(file.size),
|
||||
signal?: AbortSignal
|
||||
): Promise<ProbeResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
await this.initialize();
|
||||
if (signal?.aborted) {
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
const engine = this.#engine;
|
||||
const mode = this.#mode;
|
||||
if (!engine || !mode || this.#state.status !== 'ready') {
|
||||
@@ -537,6 +563,10 @@ export class EngineManager {
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const mounted = await mountJobFiles(engine, id, [file], ['probe.json']);
|
||||
if (signal?.aborted) {
|
||||
await mounted.cleanup();
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.');
|
||||
}
|
||||
const probeOutput = mounted.outputPaths[0];
|
||||
const inputPath = mounted.inputPaths[0];
|
||||
if (!probeOutput || !inputPath) {
|
||||
@@ -565,7 +595,15 @@ export class EngineManager {
|
||||
type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr',
|
||||
message: describeMountedInputAccess(mounted),
|
||||
});
|
||||
const watchdog = setTimeout(() => this.cancelActive(), timeoutMilliseconds);
|
||||
let timedOut = false;
|
||||
const watchdog = setTimeout(() => {
|
||||
timedOut = true;
|
||||
void this.cancelActive();
|
||||
}, timeoutMilliseconds);
|
||||
const cancelOnAbort = () => {
|
||||
void this.cancelActive();
|
||||
};
|
||||
signal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||
try {
|
||||
const exitCode = await engine.ffprobe([
|
||||
'-v',
|
||||
@@ -630,6 +668,13 @@ export class EngineManager {
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.#cancelledJobIds.delete(activeProbeId)) {
|
||||
if (timedOut) {
|
||||
throw new EngineError(
|
||||
'probe-failed',
|
||||
`Media inspection exceeded its ${String(Math.ceil(timeoutMilliseconds / 1_000))}-second safety limit.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw new EngineError('cancelled', 'The media probe was cancelled.', {
|
||||
cause: error,
|
||||
});
|
||||
@@ -641,6 +686,7 @@ export class EngineManager {
|
||||
details: this.#logs.text(),
|
||||
});
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancelOnAbort);
|
||||
clearTimeout(watchdog);
|
||||
this.#activeJobId = null;
|
||||
this.#machineProgressLines = [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EngineMode } from './ffmpeg.types';
|
||||
import { FFMPEG_CORE_VERSION } from '../version';
|
||||
import { FFMPEG_CORE_BUILD_ID } from '../version';
|
||||
|
||||
export function resolveFromAppBase(
|
||||
relativePath: string,
|
||||
@@ -22,8 +22,8 @@ export function getCoreAssetUrls(
|
||||
): CoreAssetUrls {
|
||||
const directory =
|
||||
mode === 'multithread'
|
||||
? `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/mt/`
|
||||
: `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/st/`;
|
||||
? `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/mt/`
|
||||
: `vendor/ffmpeg/${FFMPEG_CORE_BUILD_ID}/st/`;
|
||||
const base = resolver(directory);
|
||||
const urls: CoreAssetUrls = {
|
||||
coreURL: new URL('ffmpeg-core.js', base).href,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.av-tools",
|
||||
"name": "Audio & Video Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Convert and lightly edit audio and video locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -48,10 +48,10 @@
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js"
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm",
|
||||
"./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const APP_VERSION = '0.1.0';
|
||||
export const APP_VERSION = '0.2.0';
|
||||
export const FFMPEG_WRAPPER_VERSION = '0.12.15';
|
||||
export const FFMPEG_UTIL_VERSION = '0.12.2';
|
||||
export const FFMPEG_CORE_VERSION = '0.12.10';
|
||||
export const FFMPEG_CORE_BUILD_ID = '0.12.10-reviewed-stack5m.1';
|
||||
export const FFMPEG_ENGINE_VERSION = 'n5.1.4';
|
||||
export const FFMPEG_OPUS_REGRESSION_VERIFIED = true;
|
||||
|
||||
Reference in New Issue
Block a user