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

View 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;
}
}

View 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,
});
}

View File

@@ -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)[] {

View File

@@ -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] : [];

View File

@@ -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.'
)

View File

@@ -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 &amp; MT</dd>
<dd>{FFMPEG_CORE_BUILD_ID} · ST &amp; 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.

View File

@@ -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}>

View File

@@ -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}

View File

@@ -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);

View File

@@ -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.0410 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.0410 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(

View File

@@ -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 = [];

View File

@@ -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,

View File

@@ -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"
]
}

View File

@@ -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;