/** * finish-ffmpeg.ts — the FREE, LOCAL, ffmpeg-only finish backend. * * Every other `vclaw video finish` backend is either hosted and paid (Topaz via * apiz, Magnific, Runway) or needs a desktop install (Real-ESRGAN, the Topaz * CLI). This one needs nothing but the ffmpeg that the assemble stage already * depends on, makes ZERO provider calls, and spends ZERO credits — so it is the * right answer to "make the video hi-res" on a machine with no key and no * install. * * THE RECIPE (measured 2026-09-17 on real 720p / 1.6 Mbps club cricket footage; * the stage ORDER is the load-bearing part): * * hqdn3d=1.5:1.5:6:6 → scale (spline) → unsharp * ── denoise BEFORE scaling ── ── resample ── ── restore ── * * - **Denoise first.** Scaling is a resampling filter: it happily interpolates * h264 mosquito noise and blocking artifacts up to 2.25× their old size, * which is what makes a naive `scale=1920:1080` look WORSE than the source on * a flat-panel. A light temporal denoise ahead of the scaler gives the * interpolator clean edges to work from. Run it after the scale instead and * it is smearing detail that was already committed to pixels. * - **spline, not the default bicubic/lanczos.** spline keeps grass/turf texture * without the ringing halo lanczos puts on the boundary rope and the sightscreen. * - **A gentle unsharp last** (amount 0.8, luma only) puts back the micro-contrast * the denoise cost. Chroma amount stays 0.0 — sharpening chroma on 4:2:0 * footage only amplifies the subsampling. * * `nnedi` is deliberately NOT offered: the filter exists but needs a weights * binary this repo does not ship, so advertising it would be a flag that fails * at spawn time. * * Non-square pixels: `width`x`height` from a probe is STORAGE geometry, and a * 1440x1080 SAR 4:3 source DISPLAYS as 1920x1080. `force_original_aspect_ratio` * fits on storage dims, so left alone it computed a fit factor of 1.0 for that * source, padded 240px of black down each side and carried SAR 4:3 through to a * "1080p master" that displays at 2.37:1 — at exit 0. So when the probe reports a * non-square pixel shape the chain normalises to square pixels BEFORE the fit * (`scale=:,setsar=1`) and the downscale refusal is computed * from DISPLAY dimensions. The normalisation sits after the denoise, so the recipe's * denoise-before-any-upscale property survives, and never after the pad, where it * would relabel black bars instead of removing them. * * Streams: `-map 0:v:0 -map 0:a?` keeps EVERY audio track. ffmpeg's default stream * selection picks one video and one audio, which silently dropped the second * language / commentary track of a multi-track source. Subtitles are dropped * deliberately: this is a picture pass, and `mov_text` round-tripping is its own * problem. * * Aspect: `--target` names a BOX, not a stretch. The chain always scales to fit * inside the box (`force_original_aspect_ratio=decrease`, `force_divisible_by=2` * because libx264 + yuv420p refuse odd dimensions) and then pads to it, so a * 2.37:1 source lands letterboxed inside an exact 1920×1080 master rather than * distorted. The pad is a no-op for a source that already matches the box, which * is why the chain is emitted unconditionally: the command a `--dry-run` prints * is byte-identical to the command a real run spawns. * * {@link buildFfmpegUpscaleArgs} is PURE (no probe, no spawn) — the source * dimensions it needs for the downscale refusal are passed in; the runner probes. */ import { VclawError } from './errors.js'; import { runFfmpeg, resolveFfmpegBin, type RunFfmpegResult } from './assemble/ffmpeg.js'; import { spawn } from 'node:child_process'; import { resolve as resolvePath } from 'node:path'; /** Canonical target ids, ordered smallest first. `--target` validates against this. */ export const FFMPEG_UPSCALE_TARGET_IDS = ['1080p', '1440p', '2160p'] as const; export type FfmpegUpscaleTarget = (typeof FFMPEG_UPSCALE_TARGET_IDS)[number]; /** Target id → the exact `[width, height]` box it means. */ export const FFMPEG_UPSCALE_TARGETS: Record = { '1080p': [1920, 1080], '1440p': [2560, 1440], '2160p': [3840, 2160], }; /** * The encoders this backend will drive. `libx264` is the portable default; * `hevc_videotoolbox` is macOS hardware H.265 — roughly 3× faster and smaller at * matched quality, but it is opt-in because it does not exist on every box and a * silent substitution across encoders is exactly the kind of invisible route * change ADR 0001 forbids. */ export const FFMPEG_UPSCALE_ENCODERS = ['libx264', 'hevc_videotoolbox'] as const; export type FfmpegUpscaleEncoder = (typeof FFMPEG_UPSCALE_ENCODERS)[number]; export const FFMPEG_UPSCALE_DEFAULT_TARGET: FfmpegUpscaleTarget = '1080p'; export const FFMPEG_UPSCALE_DEFAULT_ENCODER: FfmpegUpscaleEncoder = 'libx264'; export const FFMPEG_UPSCALE_DEFAULT_SHARPEN = 0.8; export const FFMPEG_UPSCALE_DEFAULT_CRF = 18; /** Light temporal denoise, run BEFORE the scaler. See the module header. */ export const FFMPEG_UPSCALE_DENOISE_FILTER = 'hqdn3d=1.5:1.5:6:6'; /** hevc_videotoolbox quality (0..100, higher is better) — visually matched to crf 18. */ export const FFMPEG_UPSCALE_VIDEOTOOLBOX_QUALITY = 65; /** Knobs shared by the pure builder, the runner and the CLI flag parser. */ export interface FfmpegUpscaleConfig { /** Target box (default 1080p). Ignored when explicit width+height are given. */ target?: FfmpegUpscaleTarget; /** Explicit target width (requires height; wins over `target`). */ width?: number; /** Explicit target height (requires width; wins over `target`). */ height?: number; /** Run the pre-scale denoise (default true). `--no-denoise` turns it off. */ denoise?: boolean; /** unsharp luma amount (default 0.8). 0 drops the unsharp stage entirely. */ sharpen?: number; /** Video encoder (default libx264). */ encoder?: FfmpegUpscaleEncoder; /** libx264 CRF (default 18). Rejected for hevc_videotoolbox, which is -q:v driven. */ crf?: number; } export interface FfmpegUpscaleArgsOptions extends FfmpegUpscaleConfig { input: string; output: string; /** Source width, when known — used ONLY to refuse a downscale. */ sourceWidth?: number; /** Source height, when known — used ONLY to refuse a downscale. */ sourceHeight?: number; /** * Source pixel shape as ffprobe reports it (`'4:3'`, `'1:1'`, `'0:1'`). Anything * but square makes `sourceWidth`x`sourceHeight` storage geometry, which the * chain corrects before fitting. Absent/square → the chain is unchanged. */ sourceSampleAspect?: string; } /** The resolved target box plus the id it came from (absent for explicit w/h). */ export interface ResolvedFfmpegUpscaleSize { width: number; height: number; target?: FfmpegUpscaleTarget; } function assertEvenPositive(value: number, flag: string): void { if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value) || value % 2 !== 0) { throw new VclawError( 'invalid_flag_value', `finish (ffmpeg-upscale): ${flag} must be a positive EVEN integer (yuv420p has no odd dimensions), got: ${value}`, { flag }, ); } } /** * Resolve the target box: explicit `width`+`height` win over `target`, which * defaults to 1080p. PURE. Supplying only one of width/height is an error rather * than a half-applied override. */ export function resolveFfmpegUpscaleSize(config: FfmpegUpscaleConfig): ResolvedFfmpegUpscaleSize { const { width, height } = config; if (width !== undefined || height !== undefined) { if (width === undefined || height === undefined) { throw new VclawError( 'invalid_flag_value', 'finish (ffmpeg-upscale): --width and --height must be given together (one alone cannot define a target box).', { flag: width === undefined ? '--width' : '--height' }, ); } assertEvenPositive(width, '--width'); assertEvenPositive(height, '--height'); return { width, height }; } const target = config.target ?? FFMPEG_UPSCALE_DEFAULT_TARGET; const box = FFMPEG_UPSCALE_TARGETS[target]; if (!box) { throw new VclawError( 'invalid_flag_value', `finish (ffmpeg-upscale): --target must be one of ${FFMPEG_UPSCALE_TARGET_IDS.join(', ')}, got: ${target}`, { flag: '--target' }, ); } return { width: box[0], height: box[1], target }; } /** * The display geometry of a source, given its storage dimensions and the pixel * shape ffprobe reported. PURE. Returns `undefined` when the pixels are already * square (`1:1`, the unspecified `0:1`, absent, or unparseable) — the caller then * leaves the chain exactly as it was. * * Width is rounded to an EVEN number: yuv420p has no odd dimensions, and this * stage sets the geometry every later stage inherits. */ export function resolveSquarePixelSize( sourceWidth: number | undefined, sourceHeight: number | undefined, sampleAspect: string | undefined, ): { width: number; height: number } | undefined { if (!sourceWidth || !sourceHeight || sourceWidth <= 0 || sourceHeight <= 0) return undefined; if (!sampleAspect) return undefined; const m = /^(\d+)\s*[:/]\s*(\d+)$/.exec(sampleAspect.trim()); if (!m) return undefined; const num = Number(m[1]); const den = Number(m[2]); // `0:1` is ffprobe for "unspecified", which means assume square. if (num <= 0 || den <= 0 || num === den) return undefined; const width = Math.max(2, Math.round((sourceWidth * num) / den / 2) * 2); if (width === sourceWidth) return undefined; return { width, height: sourceHeight }; } /** * The dimensions a source actually DISPLAYS at — storage dims corrected for a * non-square pixel shape. PURE. This, not the storage geometry, is what the * downscale refusal has to compare against. */ export function resolveDisplaySize( sourceWidth: number | undefined, sourceHeight: number | undefined, sampleAspect: string | undefined, ): { width: number; height: number } | undefined { if (!sourceWidth || !sourceHeight || sourceWidth <= 0 || sourceHeight <= 0) return undefined; return resolveSquarePixelSize(sourceWidth, sourceHeight, sampleAspect) ?? { width: sourceWidth, height: sourceHeight }; } /** * Build the `-vf` filter chain. PURE. Stage order is the recipe and is not * configurable: denoise → [square-pixel correction] → scale → pad → unsharp. */ export function buildFfmpegUpscaleFilters( width: number, height: number, opts: { denoise?: boolean; sharpen?: number; squarePixel?: { width: number; height: number } } = {}, ): string { const denoise = opts.denoise ?? true; const sharpen = opts.sharpen ?? FFMPEG_UPSCALE_DEFAULT_SHARPEN; const stages: string[] = []; if (denoise) stages.push(FFMPEG_UPSCALE_DENOISE_FILTER); // Square-pixel correction, when the source is anamorphic. It runs AFTER the // denoise (so nothing is stretched before the noise is dealt with) and BEFORE // the fit (so the fit measures the picture as it is actually seen). if (opts.squarePixel) { stages.push(`scale=${opts.squarePixel.width}:${opts.squarePixel.height},setsar=1`); } // force_divisible_by=2: a 2.37:1 source fitted into a 1080p box lands on an odd // inner height (1920×811) that libx264 + yuv420p refuse outright. stages.push(`scale=${width}:${height}:flags=spline:force_original_aspect_ratio=decrease:force_divisible_by=2`); // A no-op when the source already fills the box; present unconditionally so a // dry-run command equals the real one. stages.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`); if (sharpen > 0) stages.push(`unsharp=5:5:${sharpen}:5:5:0.0`); return stages.join(','); } /** The encoder half of the argv. PURE. */ export function buildFfmpegUpscaleEncoderArgs( encoder: FfmpegUpscaleEncoder, crf: number, ): string[] { if (encoder === 'hevc_videotoolbox') { // -tag:v hvc1 is what makes the result playable in QuickTime/Safari; without // it macOS shows a black frame for an otherwise valid HEVC file. return [ '-c:v', 'hevc_videotoolbox', '-q:v', String(FFMPEG_UPSCALE_VIDEOTOOLBOX_QUALITY), '-tag:v', 'hvc1', '-pix_fmt', 'yuv420p', ]; } return ['-c:v', 'libx264', '-preset', 'slow', '-crf', String(crf), '-pix_fmt', 'yuv420p']; } /** * Build the full ffmpeg argv for one upscale. PURE — never probes, never spawns. * `-y` is NOT included: {@link runFfmpeg} prepends it. * * Throws `VclawError('invalid_flag_value')` when the requested box would SHRINK a * known source. "Upscale" that quietly downscales is the worst possible outcome * of a finishing pass, and the operator asked for a hi-res master. */ export function buildFfmpegUpscaleArgs(opts: FfmpegUpscaleArgsOptions): string[] { const { width, height } = resolveFfmpegUpscaleSize(opts); const encoder = opts.encoder ?? FFMPEG_UPSCALE_DEFAULT_ENCODER; const crf = opts.crf ?? FFMPEG_UPSCALE_DEFAULT_CRF; // ffmpeg opens the output with -y: pointed at its own input it truncates the // file to zero and then reads from it. There is no recovering the source. if (resolvePath(opts.input) === resolvePath(opts.output)) { throw new VclawError( 'invalid_flag_value', 'finish (ffmpeg-upscale): --input and --output are the same file. ffmpeg would truncate the source ' + 'before reading it. Write the master to a new path.', { flag: '--output', path: opts.output }, ); } const squarePixel = resolveSquarePixelSize(opts.sourceWidth, opts.sourceHeight, opts.sourceSampleAspect); const display = resolveDisplaySize(opts.sourceWidth, opts.sourceHeight, opts.sourceSampleAspect); if (display) { // Measured against DISPLAY dimensions, not storage: a 1440x1080 SAR 4:3 clip // is a 1920x1080 picture, and comparing its storage width would call a // legitimate no-op a valid upscale (and an anamorphic 4K a small one). // The chain fits the source INSIDE the box, so the effective factor is the // smaller of the two ratios — exactly what `force_original_aspect_ratio=decrease` // computes. Below 1.0 the "upscale" is a downscale. const factor = Math.min(width / display.width, height / display.height); if (factor < 1) { const shape = squarePixel ? ` (${opts.sourceWidth}x${opts.sourceHeight} stored at SAR ${opts.sourceSampleAspect})` : ''; throw new VclawError( 'invalid_flag_value', `finish (ffmpeg-upscale): the source displays at ${display.width}x${display.height}${shape}, which is already ` + `larger than the ${width}x${height} target — this would DOWNSCALE it. Pick a larger --target, or use ` + '`vclaw video make-vertical` / a plain ffmpeg scale if shrinking is what you want.', { flag: '--target', sourceWidth: display.width, sourceHeight: display.height, width, height }, ); } } const filters = buildFfmpegUpscaleFilters(width, height, { ...(opts.denoise !== undefined ? { denoise: opts.denoise } : {}), ...(opts.sharpen !== undefined ? { sharpen: opts.sharpen } : {}), ...(squarePixel ? { squarePixel } : {}), }); return [ '-i', opts.input, '-vf', filters, // Explicit stream selection. ffmpeg's default picks ONE video and ONE audio, // which silently drops a second language or commentary track. `0:a?` keeps // every audio stream and tolerates a silent source. Subtitles are left out // deliberately — this is a picture pass, not a remux. '-map', '0:v:0', '-map', '0:a?', ...buildFfmpegUpscaleEncoderArgs(encoder, crf), // Audio is untouched: this pass changes pixels only, and a re-encode would // cost quality for nothing. Together with -movflags +faststart this assumes // an MP4/MOV output container. '-c:a', 'copy', '-movflags', '+faststart', opts.output, ]; } // ─── Encoder availability ───────────────────────────────────────────────────── const encoderCache = new Map>(); /** * The encoder names ` -encoders` advertises. Returns an EMPTY set when the * binary could not be run at all — callers must treat empty as "unknown", not as * "the encoder is missing", so a machine without ffmpeg still plans. */ export async function probeFfmpegEncoders(bin: string): Promise> { const cached = encoderCache.get(bin); if (cached) return cached; const names = new Set(); try { const out = await new Promise((resolve, reject) => { const child = spawn(bin, ['-hide_banner', '-encoders'], { stdio: ['ignore', 'pipe', 'pipe'] }); let buf = ''; child.stdout.on('data', (c) => { buf += String(c); }); child.stderr.on('data', (c) => { buf += String(c); }); child.on('error', reject); child.on('close', () => resolve(buf)); }); // Rows look like: ` V....D libx264 libx264 H.264 / AVC ...` for (const line of out.split('\n')) { const m = /^\s*[VAS][A-Z0-9.]{5}\s+([A-Za-z0-9_]+)\s/.exec(line); if (m) names.add(m[1]); } } catch { // Unreadable/absent binary → empty set, meaning "could not determine". } encoderCache.set(bin, names); return names; } /** Test seam — clears the per-binary encoder cache. */ export function resetFfmpegEncoderCache(): void { encoderCache.clear(); } // ─── Runner ─────────────────────────────────────────────────────────────────── export interface RunFfmpegUpscaleOptions extends FfmpegUpscaleArgsOptions { /** Build the command without spawning ffmpeg. */ dryRun?: boolean; /** Override the ffmpeg binary (else VCLAW_FFMPEG_BIN, then `ffmpeg`). */ ffmpegBin?: string; /** Injectable ffmpeg runner (default {@link runFfmpeg}) — for offline tests. */ runner?: (args: string[], opts: { dryRun?: boolean; ffmpegBin?: string }) => Promise; /** Injectable encoder probe (default {@link probeFfmpegEncoders}) — for offline tests. */ encoderProbe?: (bin: string) => Promise>; /** Non-fatal notes from the caller (e.g. "planned without a probe"). */ warnings?: string[]; } /** What one ffmpeg upscale did (or would do). `providerCalls`/`spend` are always 0. */ export interface FfmpegUpscaleResult { backend: 'ffmpeg-upscale'; input: string; output: string; /** The target id, when the box came from `--target` rather than explicit w/h. */ target?: FfmpegUpscaleTarget; width: number; height: number; encoder: FfmpegUpscaleEncoder; /** The exact `-vf` chain. */ filters: string; /** The ffmpeg argv (no `-y`, no binary) — the byte-exact contract. */ command: string[]; /** The resolved, copy-pasteable command line including the binary and `-y`. */ commandLine: string; denoise: boolean; sharpen: number; /** Present for libx264 only. */ crf?: number; sourceWidth?: number; sourceHeight?: number; /** Source pixel shape, when probed. Anything but `1:1`/`0:1` triggered a correction. */ sourceSampleAspect?: string; /** The square-pixel geometry the source was corrected to, when it was anamorphic. */ squarePixelCorrection?: { width: number; height: number }; /** Non-fatal notes — e.g. a plan made without a probe. Omitted when empty. */ warnings?: string[]; providerCalls: 0; spend: 0; dryRun: boolean; } /** * Run one local upscale. Free: no upload, no submit, no credits — the only * process it starts is ffmpeg on this machine. * * A non-default encoder is checked against `ffmpeg -encoders` first and REFUSED * when the binary demonstrably lacks it, rather than discovered 20 minutes into * a render. An unprobeable binary is not treated as a refusal (see * {@link probeFfmpegEncoders}). */ export async function runFfmpegUpscale(opts: RunFfmpegUpscaleOptions): Promise { const { width, height, target } = resolveFfmpegUpscaleSize(opts); const encoder = opts.encoder ?? FFMPEG_UPSCALE_DEFAULT_ENCODER; const crf = opts.crf ?? FFMPEG_UPSCALE_DEFAULT_CRF; const denoise = opts.denoise ?? true; const sharpen = opts.sharpen ?? FFMPEG_UPSCALE_DEFAULT_SHARPEN; const bin = resolveFfmpegBin(opts.ffmpegBin); if (encoder !== FFMPEG_UPSCALE_DEFAULT_ENCODER) { const probe = opts.encoderProbe ?? probeFfmpegEncoders; const available = await probe(bin); if (available.size > 0 && !available.has(encoder)) { throw new VclawError( 'invalid_flag_value', `finish (ffmpeg-upscale): this ffmpeg (${bin}) has no "${encoder}" encoder. ` + `hevc_videotoolbox is macOS-only hardware H.265 — drop --encoder to use the portable ` + `${FFMPEG_UPSCALE_DEFAULT_ENCODER} default.`, { flag: '--encoder', encoder, bin }, ); } } const command = buildFfmpegUpscaleArgs(opts); const runner = opts.runner ?? runFfmpeg; const result = await runner(command, { ...(opts.dryRun ? { dryRun: true } : {}), ...(opts.ffmpegBin ? { ffmpegBin: opts.ffmpegBin } : {}), }); const squarePixel = resolveSquarePixelSize(opts.sourceWidth, opts.sourceHeight, opts.sourceSampleAspect); const warnings = opts.warnings ?? []; return { backend: 'ffmpeg-upscale', input: opts.input, output: opts.output, ...(target ? { target } : {}), width, height, encoder, filters: buildFfmpegUpscaleFilters(width, height, { denoise, sharpen, ...(squarePixel ? { squarePixel } : {}), }), command, commandLine: result.command, denoise, sharpen, ...(encoder === 'libx264' ? { crf } : {}), ...(opts.sourceWidth !== undefined ? { sourceWidth: opts.sourceWidth } : {}), ...(opts.sourceHeight !== undefined ? { sourceHeight: opts.sourceHeight } : {}), ...(opts.sourceSampleAspect ? { sourceSampleAspect: opts.sourceSampleAspect } : {}), ...(squarePixel ? { squarePixelCorrection: squarePixel } : {}), ...(warnings.length > 0 ? { warnings } : {}), providerCalls: 0, spend: 0, dryRun: opts.dryRun === true, }; } // ─── CLI flag parsing ───────────────────────────────────────────────────────── /** The ffmpeg-upscale-only flags, for the reject-on-other-backends guard. */ const FFMPEG_ONLY_VALUE_FLAGS = ['--target', '--encoder', '--crf'] as const; const FFMPEG_ONLY_BOOLEAN_FLAGS = ['--no-denoise'] as const; function numberFlag(args: string[], flag: string, min: number, max: number): number | undefined { const index = args.indexOf(flag); if (index < 0) return undefined; const raw = args[index + 1]; const value = Number(raw); if (raw === undefined || !Number.isFinite(value) || value < min || value > max) { throw new VclawError( 'invalid_flag_value', `finish (ffmpeg-upscale): ${flag} takes a number in [${min}, ${max}], got: ${raw ?? '(nothing)'}`, { flag }, ); } return value; } /** * Parse the ffmpeg-upscale flags out of `args`. * * Lives here rather than in the handler for two reasons: `media-production.ts` * is at its module-size ceiling, and this keeps the flag contract next to the * knobs it sets. On a DIFFERENT backend it returns `undefined` — after refusing * any ffmpeg-only flag that was passed, matching how `--target-resolution` and * `--normalize` are rejected off `magnific-precision` instead of silently * ignored. */ export function parseFfmpegUpscaleFlags( args: string[], backend: string, ): FfmpegUpscaleConfig | undefined { if (backend !== 'ffmpeg-upscale') { for (const flag of [...FFMPEG_ONLY_VALUE_FLAGS, ...FFMPEG_ONLY_BOOLEAN_FLAGS]) { if (args.includes(flag)) { throw new VclawError( 'invalid_flag_value', `video finish: ${flag} only applies to --backend ffmpeg-upscale (got --backend ${backend})`, { flag }, ); } } return undefined; } const target = args.includes('--target') ? args[args.indexOf('--target') + 1] : undefined; if (target !== undefined && !(FFMPEG_UPSCALE_TARGET_IDS as readonly string[]).includes(target)) { throw new VclawError( 'invalid_flag_value', `video finish: --target must be one of ${FFMPEG_UPSCALE_TARGET_IDS.join(', ')}, got: ${target}`, { flag: '--target' }, ); } const encoder = args.includes('--encoder') ? args[args.indexOf('--encoder') + 1] : undefined; if (encoder !== undefined && !(FFMPEG_UPSCALE_ENCODERS as readonly string[]).includes(encoder)) { throw new VclawError( 'invalid_flag_value', `video finish: --encoder must be one of ${FFMPEG_UPSCALE_ENCODERS.join(', ')}, got: ${encoder}`, { flag: '--encoder' }, ); } const crf = numberFlag(args, '--crf', 0, 51); if (crf !== undefined && encoder === 'hevc_videotoolbox') { throw new VclawError( 'invalid_flag_value', 'video finish: --crf does not apply to --encoder hevc_videotoolbox (VideoToolbox is quality-driven, -q:v). ' + 'Drop --crf, or drop --encoder to use libx264.', { flag: '--crf' }, ); } // `--sharpen` is a bare boolean on the Topaz backends (it turns the // anti-plastic recipe off). Here it carries the unsharp amount, and the two // spellings are close enough that accepting a bare `--sharpen` as "the // default" would silently hand back a DIFFERENT picture than the operator's // muscle memory expects. Say which one this backend means instead. const sharpenRaw = args.includes('--sharpen') ? args[args.indexOf('--sharpen') + 1] : undefined; let sharpen: number | undefined; if (args.includes('--sharpen')) { if (sharpenRaw === undefined || sharpenRaw.startsWith('--')) { throw new VclawError( 'invalid_flag_value', 'video finish: on --backend ffmpeg-upscale, --sharpen takes a number 0..2 (the unsharp amount); ' + '0 disables the unsharp stage. A bare --sharpen is the Topaz spelling, where it means ' + '"turn the anti-plastic recipe off" — a different thing.', { flag: '--sharpen' }, ); } sharpen = numberFlag(args, '--sharpen', 0, 2); } return { ...(target ? { target: target as FfmpegUpscaleTarget } : {}), ...(encoder ? { encoder: encoder as FfmpegUpscaleEncoder } : {}), ...(crf !== undefined ? { crf } : {}), ...(sharpen !== undefined ? { sharpen } : {}), ...(args.includes('--no-denoise') ? { denoise: false } : {}), }; }