import type { ReframeAspectRatio, ReframeCaptionOptions, ReframeCaptionProfile, ReframeCrop, ReframeCropWindow, ReframeHoldPolicy, ReframePlan, ReframeShot, ReframeSourceContext, ReframeSubject, ReframeSubjectKind, ReframeTarget, } from './types.js'; const TIMELINE_EPSILON_SECONDS = 0.001; const DEFAULT_CENTER_TOLERANCE_PERCENT = 12; function record(value: unknown, label: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${label} must be an object`); } return value as Record; } function text(value: unknown, label: string): string { if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`); return value.trim(); } function finite(value: unknown, label: string, minimum = 0): number { if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum) { throw new Error(`${label} must be a finite number >= ${minimum}`); } return value; } function booleanValue(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; } function enumValue(value: unknown, allowed: readonly T[], label: string, fallback?: T): T { if (value === undefined && fallback !== undefined) return fallback; if (typeof value !== 'string' || !allowed.includes(value as T)) { throw new Error(`${label} must be one of: ${allowed.join(', ')}`); } return value as T; } function normalizeTarget(value: unknown): ReframeTarget { const input = record(value, 'target'); const aspectRatio = enumValue(input.aspectRatio, ['9:16', '1:1'], 'target.aspectRatio'); const width = finite(input.width, 'target.width', 1); const height = finite(input.height, 'target.height', 1); const expected = aspectRatio === '9:16' ? 9 / 16 : 1; if (Math.abs(width / height - expected) > 0.002) { throw new Error(`target dimensions do not match ${aspectRatio}`); } return { aspectRatio, width, height }; } function normalizeSubject(value: unknown, index: number): ReframeSubject { const input = record(value, `shots[${index}].subject`); const anchorInput = input.anchor === undefined ? undefined : record(input.anchor, `shots[${index}].subject.anchor`); if (anchorInput && ((anchorInput.endX === undefined) !== (anchorInput.endY === undefined))) { throw new Error(`shots[${index}].subject.anchor endX and endY must be supplied together`); } const anchor = anchorInput ? { x: finite(anchorInput.x, `shots[${index}].subject.anchor.x`), y: finite(anchorInput.y, `shots[${index}].subject.anchor.y`), verified: booleanValue(anchorInput.verified, true), ...(anchorInput.endX === undefined ? {} : { endX: finite(anchorInput.endX, `shots[${index}].subject.anchor.endX`), endY: finite(anchorInput.endY, `shots[${index}].subject.anchor.endY`), }), ...(anchorInput.width === undefined ? {} : { width: finite(anchorInput.width, `shots[${index}].subject.anchor.width`, 1) }), ...(anchorInput.height === undefined ? {} : { height: finite(anchorInput.height, `shots[${index}].subject.anchor.height`, 1) }), } : undefined; return { kind: enumValue(input.kind, ['person', 'vehicle', 'object', 'group', 'none'], `shots[${index}].subject.kind`), label: text(input.label, `shots[${index}].subject.label`), ...(anchor ? { anchor } : {}), }; } function normalizeCrop(value: unknown, index: number): ReframeCrop { const input = record(value, `shots[${index}].crop`); const mode = enumValue(input.mode, ['fixed', 'linear', 'contain'] as const, `shots[${index}].crop.mode`); if (mode === 'fixed') { return { mode, x: finite(input.x, `shots[${index}].crop.x`), y: finite(input.y, `shots[${index}].crop.y`), }; } if (mode === 'linear') { return { mode, startX: finite(input.startX, `shots[${index}].crop.startX`), endX: finite(input.endX, `shots[${index}].crop.endX`), startY: finite(input.startY, `shots[${index}].crop.startY`), endY: finite(input.endY, `shots[${index}].crop.endY`), }; } return { mode: 'contain' }; } function normalizeCaptions(value: unknown): ReframeCaptionOptions { const input = value === undefined ? {} : record(value, 'captions'); const sourcePath = input.sourcePath === undefined ? undefined : text(input.sourcePath, 'captions.sourcePath'); const profile = enumValue( input.profile, ['social-large', 'social-standard'], 'captions.profile', 'social-large', ); return { ...(sourcePath ? { sourcePath } : {}), profile, burnIn: booleanValue(input.burnIn, false), allowBurnedCaptionSource: booleanValue(input.allowBurnedCaptionSource, false), }; } export function calculateCropWindow( sourceWidth: number, sourceHeight: number, target: ReframeTarget, ): ReframeCropWindow { const targetRatio = target.width / target.height; const sourceRatio = sourceWidth / sourceHeight; if (sourceRatio >= targetRatio) { return { width: sourceHeight * targetRatio, height: sourceHeight }; } return { width: sourceWidth, height: sourceWidth / targetRatio }; } function validateCropBounds(shot: ReframeShot, index: number, target: ReframeTarget, context: ReframeSourceContext): void { if (shot.crop.mode === 'contain') return; const window = calculateCropWindow(context.sourceWidth, context.sourceHeight, target); const points = shot.crop.mode === 'fixed' ? [[shot.crop.x, shot.crop.y]] : [[shot.crop.startX, shot.crop.startY], [shot.crop.endX, shot.crop.endY]]; for (const [x, y] of points) { if (x === undefined || y === undefined || x + window.width > context.sourceWidth + TIMELINE_EPSILON_SECONDS || y + window.height > context.sourceHeight + TIMELINE_EPSILON_SECONDS) { throw new Error(`shots[${index}] crop is outside source bounds`); } } } function normalizeShot(value: unknown, index: number, holdPolicy: ReframeHoldPolicy): ReframeShot { const input = record(value, `shots[${index}]`); const startSeconds = finite(input.startSeconds, `shots[${index}].startSeconds`); const endSeconds = finite(input.endSeconds, `shots[${index}].endSeconds`); if (endSeconds <= startSeconds) throw new Error(`shots[${index}] endSeconds must be greater than startSeconds`); if ((input.sourceStartSeconds === undefined) !== (input.sourceEndSeconds === undefined)) { throw new Error(`shots[${index}] sourceStartSeconds and sourceEndSeconds must be supplied together`); } const sourceStartSeconds = input.sourceStartSeconds === undefined ? undefined : finite(input.sourceStartSeconds, `shots[${index}].sourceStartSeconds`); const sourceEndSeconds = input.sourceEndSeconds === undefined ? undefined : finite(input.sourceEndSeconds, `shots[${index}].sourceEndSeconds`); if (sourceStartSeconds !== undefined && sourceEndSeconds !== undefined && sourceEndSeconds <= sourceStartSeconds) { throw new Error(`shots[${index}] source window end must be greater than its start`); } const allowHold = booleanValue(input.allowHold, holdPolicy === 'allow'); const holdSeconds = input.holdSeconds === undefined ? undefined : finite(input.holdSeconds, `shots[${index}].holdSeconds`); if (holdSeconds !== undefined && holdSeconds > 0 && !allowHold) { throw new Error(`shots[${index}] holdSeconds requires allowHold: true`); } const note = input.note === undefined ? undefined : text(input.note, `shots[${index}].note`); if (holdSeconds !== undefined && holdSeconds > 0 && !note) { throw new Error(`shots[${index}] approved hold requires a note explaining the reason`); } return { startSeconds, endSeconds, ...(sourceStartSeconds !== undefined ? { sourceStartSeconds, sourceEndSeconds } : {}), subject: normalizeSubject(input.subject, index), crop: normalizeCrop(input.crop, index), centerTolerancePercent: input.centerTolerancePercent === undefined ? DEFAULT_CENTER_TOLERANCE_PERCENT : finite(input.centerTolerancePercent, `shots[${index}].centerTolerancePercent`), allowTightCrop: booleanValue(input.allowTightCrop, false), allowHold, ...(holdSeconds !== undefined ? { holdSeconds } : {}), ...(note ? { note } : {}), }; } function validateTimeline(shots: ReframeShot[], sourceDurationSeconds: number): void { if (shots.length === 0) throw new Error('shots must contain at least one shot'); if (Math.abs((shots[0]?.startSeconds ?? 0)) > TIMELINE_EPSILON_SECONDS) { throw new Error('shot plan has a gap before the first shot'); } for (let index = 1; index < shots.length; index += 1) { const previous = shots[index - 1]; const current = shots[index]; if (!previous || !current) continue; const delta = current.startSeconds - previous.endSeconds; if (delta > TIMELINE_EPSILON_SECONDS) throw new Error(`shot plan has a gap before shots[${index}]`); if (delta < -TIMELINE_EPSILON_SECONDS) throw new Error(`shot plan has an overlap at shots[${index}]`); } const lastEnd = shots[shots.length - 1]?.endSeconds ?? 0; if (sourceDurationSeconds - lastEnd > TIMELINE_EPSILON_SECONDS) throw new Error('shot plan has a gap after the final shot'); if (lastEnd - sourceDurationSeconds > TIMELINE_EPSILON_SECONDS) throw new Error('shot plan extends beyond source duration'); } export function normalizeReframePlan(value: unknown, context: ReframeSourceContext): ReframePlan { const input = record(value, 'reframe plan'); if (input.schemaVersion !== 1) throw new Error('reframe plan schemaVersion must be 1'); const defaultHoldPolicy = enumValue( input.defaultHoldPolicy, ['forbid', 'allow'], 'defaultHoldPolicy', 'forbid', ); if (!Array.isArray(input.shots)) throw new Error('shots must be an array'); const target = normalizeTarget(input.target); const shots = input.shots.map((shot, index) => normalizeShot(shot, index, defaultHoldPolicy)); validateTimeline(shots, context.sourceDurationSeconds); shots.forEach((shot, index) => validateCropBounds(shot, index, target, context)); shots.forEach((shot, index) => { const sourceStart = shot.sourceStartSeconds ?? shot.startSeconds; const sourceEnd = shot.sourceEndSeconds ?? shot.endSeconds; if (sourceStart < 0 || sourceEnd > context.sourceDurationSeconds + TIMELINE_EPSILON_SECONDS) { throw new Error(`shots[${index}] source window is outside source duration`); } }); return { schemaVersion: 1, sourcePath: text(input.sourcePath, 'sourcePath'), target, defaultHoldPolicy, shots, captions: normalizeCaptions(input.captions), }; }