feat: publish av-tools 0.2.0
This commit is contained in:
@@ -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] : [];
|
||||
|
||||
Reference in New Issue
Block a user