feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit fcc537b024
254 changed files with 63270 additions and 0 deletions

393
src/commands/thumbnails.ts Normal file
View File

@@ -0,0 +1,393 @@
import {
createPlannedInput,
createPlannedOutput,
diagnostic,
normalizeSeconds,
safePathComponent,
type SourceReference,
} from './command-utils';
import {
GENERATED_OUTPUT_LIMIT_EXPLANATION,
MAX_GENERATED_OUTPUT_FILES,
} from '../limits';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';
export type ImageFormat = 'png' | 'jpeg' | 'webp';
export interface ThumbnailSize {
readonly width?: number;
readonly height?: number;
readonly maintainAspect?: boolean;
}
export interface SingleThumbnailOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly timeSeconds: number;
readonly size?: ThumbnailSize;
readonly format: ImageFormat;
readonly quality?: number;
}
export interface ThumbnailSeriesOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly timesSeconds?: readonly number[];
readonly intervalSeconds?: number;
readonly count?: number;
readonly size?: ThumbnailSize;
readonly format: ImageFormat;
readonly quality?: number;
}
export interface ContactSheetOptions {
readonly jobId: string;
readonly source: SourceReference;
readonly durationSeconds: number;
readonly frameCount: number;
readonly rows: number;
readonly columns: number;
readonly cellWidth: number;
readonly spacing?: number;
readonly background?: string;
readonly maintainAspect?: boolean;
readonly timestampLabels?: boolean;
readonly sourceLabel?: boolean;
readonly format: ImageFormat;
readonly quality?: number;
readonly drawtextAvailable?: boolean;
}
export function buildSingleThumbnailPlan(
options: SingleThumbnailOptions
): FFmpegCommandPlan {
if (!Number.isFinite(options.timeSeconds) || options.timeSeconds < 0) {
throw new RangeError('Thumbnail time must be nonnegative');
}
const input = createPlannedInput(options.jobId, options.source);
const extension = extensionForImageFormat(options.format);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'thumbnail',
suffix: `${Math.round(options.timeSeconds * 1000)}ms`,
extension,
role: 'thumbnail',
});
const args = [
'-ss',
normalizeSeconds(options.timeSeconds),
'-i',
input.path,
'-frames:v',
'1',
...imageFilterArguments(options.size),
...imageQualityArguments(options.format, options.quality),
output.path,
];
return freezeCommandPlan({
id: `${options.jobId}:thumbnail`,
operation: 'thumbnail',
inputs: [input],
temporaryFiles: [],
args,
outputs: [output],
requiredCapabilities: imageRequirements(options.format),
diagnostics: [],
});
}
export function buildThumbnailSeriesPlan(
options: ThumbnailSeriesOptions
): FFmpegCommandPlan {
const times = resolveThumbnailTimes(options);
const input = createPlannedInput(options.jobId, options.source);
const extension = extensionForImageFormat(options.format);
const outputs = times.map((time, index) =>
createPlannedOutput(options.jobId, {
id: `thumbnail-${index + 1}`,
baseName: options.source.fileName,
operation: 'thumbnail',
suffix: `${String(index + 1).padStart(3, '0')}-${Math.round(time * 1000)}ms`,
extension,
role: 'thumbnail',
})
);
const size = scaleFilter(options.size);
const splitOutputs = times
.map((_, index) => `[thumb-source-${index}]`)
.join('');
const filtergraph = [
`[0:v]split=${times.length}${splitOutputs}`,
...times.map((time, index) => {
const filters = [
`select='gte(t\\,${normalizeSeconds(time)})'`,
'setpts=N/FRAME_RATE/TB',
...(size ? [size] : []),
];
return `[thumb-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}: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: ['split', 'select', 'setpts', ...(size ? ['scale'] : [])],
},
diagnostics: [
diagnostic(
'single-pass-series',
'info',
'The thumbnail series is decoded in one FFmpeg invocation.'
),
],
});
}
export function resolveThumbnailTimes(
options: Pick<
ThumbnailSeriesOptions,
'durationSeconds' | 'timesSeconds' | 'intervalSeconds' | 'count'
>
): readonly number[] {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Source duration must be positive');
}
let times: number[];
if (options.timesSeconds) {
times = [...options.timesSeconds];
} else if (options.intervalSeconds !== undefined) {
if (
!Number.isFinite(options.intervalSeconds) ||
options.intervalSeconds <= 0
) {
throw new RangeError('Thumbnail interval must be positive');
}
if (
Math.ceil(options.durationSeconds / options.intervalSeconds) >
MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`A thumbnail series can contain at most ${MAX_GENERATED_OUTPUT_FILES} images. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
times = [];
for (
let time = 0;
time < options.durationSeconds;
time += options.intervalSeconds
) {
times.push(time);
}
} else {
const count = options.count ?? 12;
if (
!Number.isSafeInteger(count) ||
count < 1 ||
count > MAX_GENERATED_OUTPUT_FILES
) {
throw new RangeError(
`Thumbnail count must be an integer from 1 to ${MAX_GENERATED_OUTPUT_FILES}. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`
);
}
times = Array.from(
{ length: count },
(_, index) => (options.durationSeconds * (index + 1)) / (count + 1)
);
}
const unique = [...new Set(times)].sort((left, right) => left - right);
if (
unique.length === 0 ||
unique.length > MAX_GENERATED_OUTPUT_FILES ||
unique.some(
(time) =>
!Number.isFinite(time) || time < 0 || time >= options.durationSeconds
)
) {
throw new RangeError('Thumbnail times must be within the source duration');
}
return Object.freeze(unique);
}
export function buildContactSheetPlan(
options: ContactSheetOptions
): FFmpegCommandPlan {
if (
!Number.isFinite(options.durationSeconds) ||
options.durationSeconds <= 0
) {
throw new RangeError('Contact sheet source duration must be positive');
}
if (
!Number.isSafeInteger(options.frameCount) ||
options.frameCount < 1 ||
options.frameCount > 500 ||
!Number.isSafeInteger(options.rows) ||
!Number.isSafeInteger(options.columns) ||
options.rows < 1 ||
options.columns < 1 ||
options.rows * options.columns < options.frameCount ||
options.rows * options.columns > 500
) {
throw new RangeError(
'Contact sheet grid must contain 1500 cells and fit all frames'
);
}
if (
!Number.isSafeInteger(options.cellWidth) ||
options.cellWidth < 32 ||
options.cellWidth > 4096
) {
throw new RangeError('Contact sheet cell width must be 324096 pixels');
}
const spacing = options.spacing ?? 4;
if (!Number.isSafeInteger(spacing) || spacing < 0 || spacing > 100) {
throw new RangeError('Contact sheet spacing must be 0100 pixels');
}
const background = options.background ?? 'black';
if (!/^(?:[a-z]{3,20}|#[0-9a-f]{6})$/iu.test(background)) {
throw new TypeError('Background must be a named colour or #RRGGBB');
}
const input = createPlannedInput(options.jobId, options.source);
const output = createPlannedOutput(options.jobId, {
baseName: options.source.fileName,
operation: 'contact-sheet',
extension: extensionForImageFormat(options.format),
role: 'contact-sheet',
});
const fps = options.frameCount / options.durationSeconds;
const filters = [
`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) {
labelsEnabled = false;
diagnostics.push(
diagnostic(
'labels-unavailable',
'warning',
'Timestamp and filename labels were omitted because drawtext or a safe local font is unavailable.'
)
);
}
if (labelsEnabled) {
const text = options.sourceLabel
? `${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`
);
}
filters.push(
`tile=${options.columns}x${options.rows}:padding=${spacing}:margin=${spacing}:color=${background}`
);
return freezeCommandPlan({
id: `${options.jobId}:contact-sheet`,
operation: 'contact-sheet',
inputs: [input],
temporaryFiles: [],
args: [
'-i',
input.path,
'-vf',
filters.join(','),
'-frames:v',
'1',
...imageQualityArguments(options.format, options.quality),
output.path,
],
outputs: [output],
expectedDurationSeconds: options.durationSeconds,
requiredCapabilities: {
...imageRequirements(options.format),
filters: ['fps', 'scale', 'tile', ...(labelsEnabled ? ['drawtext'] : [])],
},
diagnostics,
});
}
function imageFilterArguments(size?: ThumbnailSize): readonly string[] {
const filter = scaleFilter(size);
return filter ? ['-vf', filter] : [];
}
function scaleFilter(size?: ThumbnailSize): string | undefined {
if (!size || (size.width === undefined && size.height === undefined)) {
return undefined;
}
for (const value of [size.width, size.height]) {
if (
value !== undefined &&
(!Number.isSafeInteger(value) || value < 1 || value > 8192)
) {
throw new RangeError(
'Thumbnail dimensions must be integer values from 1 to 8192'
);
}
}
const dimensions = `scale=${size.width ?? -2}:${size.height ?? -2}`;
return size.maintainAspect === false
? dimensions
: `${dimensions}:force_original_aspect_ratio=decrease`;
}
function imageQualityArguments(
format: ImageFormat,
quality?: number
): readonly string[] {
if (quality === undefined) {
return [];
}
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
throw new RangeError('Image quality must be between 1 and 100');
}
if (format === 'jpeg') {
return ['-q:v', String(Math.max(2, Math.round(31 - quality * 0.29)))];
}
if (format === 'webp') {
return ['-quality', String(Math.round(quality))];
}
return [];
}
function imageRequirements(format: ImageFormat): {
readonly encoders: readonly string[];
readonly muxers: readonly string[];
} {
return format === 'jpeg'
? { encoders: ['mjpeg'], muxers: ['image2'] }
: format === 'png'
? { encoders: ['png'], muxers: ['image2'] }
: { encoders: ['libwebp'], muxers: ['webp'] };
}
function extensionForImageFormat(format: ImageFormat): string {
return format === 'jpeg' ? 'jpg' : format;
}