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

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