feat: publish av-tools 0.1.0
This commit is contained in:
393
src/commands/thumbnails.ts
Normal file
393
src/commands/thumbnails.ts
Normal 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 1–500 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 32–4096 pixels');
|
||||
}
|
||||
const spacing = options.spacing ?? 4;
|
||||
if (!Number.isSafeInteger(spacing) || spacing < 0 || spacing > 100) {
|
||||
throw new RangeError('Contact sheet spacing must be 0–100 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;
|
||||
}
|
||||
Reference in New Issue
Block a user