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 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; 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 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; 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; /** 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( 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 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(); 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, '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 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', extension: extensionForImageFormat(options.format), role: 'contact-sheet', }); const fps = options.frameCount / options.durationSeconds; const filters = [ ...(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 || !fontInput)) { 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=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}:nb_frames=${options.frameCount}:padding=${spacing}:margin=${spacing}:color=${background}` ); return freezeCommandPlan({ id: `${options.jobId}:contact-sheet`, operation: 'contact-sheet', inputs: [input, ...(fontInput ? [fontInput] : [])], 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: [ sceneFrames ? 'select' : 'fps', 'scale', ...(sceneFrames ? ['setpts'] : []), 'tile', ...(labelsEnabled ? ['drawtext'] : []), ], }, 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] : []; } 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; }