import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { calculateCropWindow } from './plan.js'; import type { ReframeCaptionProfile, ReframePlan, ReframeSourceContext, ReframeTarget, } from './types.js'; const execFileAsync = promisify(execFile); const DEFAULT_SCENE_THRESHOLD = 0.28; const MIN_SHOT_SECONDS = 0.2; function roundedSeconds(value: number): number { return Math.round(value * 1000) / 1000; } export function parseSceneBoundaryTimes(stderr: string, durationSeconds: number): number[] { const detected = [...stderr.matchAll(/pts_time:([0-9]+(?:\.[0-9]+)?)/g)] .map((match) => Number(match[1])) .filter((value) => Number.isFinite(value) && value > MIN_SHOT_SECONDS && value < durationSeconds - MIN_SHOT_SECONDS) .sort((left, right) => left - right); const boundaries = [0]; for (const value of detected) { const rounded = roundedSeconds(value); const previous = boundaries[boundaries.length - 1] ?? 0; if (rounded - previous >= MIN_SHOT_SECONDS) boundaries.push(rounded); } const previous = boundaries[boundaries.length - 1] ?? 0; if (durationSeconds - previous < MIN_SHOT_SECONDS && boundaries.length > 1) boundaries.pop(); boundaries.push(roundedSeconds(durationSeconds)); return boundaries; } export async function detectSceneBoundaries(input: { sourcePath: string; durationSeconds: number; threshold?: number; ffmpegPath?: string; }): Promise { const threshold = input.threshold ?? DEFAULT_SCENE_THRESHOLD; const { stderr } = await execFileAsync(input.ffmpegPath ?? 'ffmpeg', [ '-hide_banner', '-i', input.sourcePath, '-filter:v', `select=gt(scene\\,${threshold}),showinfo`, '-an', '-f', 'null', '-', ], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); return parseSceneBoundaryTimes(stderr, input.durationSeconds); } export function buildSuggestedReframePlan(input: { sourcePath: string; context: ReframeSourceContext; boundaries: number[]; captionsPath?: string; captionProfile?: ReframeCaptionProfile; target?: ReframeTarget; }): ReframePlan { const target = input.target ?? { aspectRatio: '9:16', width: 1080, height: 1920 }; const cropWindow = calculateCropWindow(input.context.sourceWidth, input.context.sourceHeight, target); const cropX = Math.max(0, Math.min( input.context.sourceWidth - cropWindow.width, input.context.sourceWidth / 2 - cropWindow.width / 2, )); const cropY = Math.max(0, Math.min( input.context.sourceHeight - cropWindow.height, input.context.sourceHeight / 2 - cropWindow.height / 2, )); return { schemaVersion: 1, sourcePath: input.sourcePath, target, defaultHoldPolicy: 'forbid', shots: input.boundaries.slice(0, -1).map((startSeconds, index) => ({ startSeconds, endSeconds: input.boundaries[index + 1] ?? input.context.sourceDurationSeconds, subject: { kind: 'group', label: `VERIFY subject for shot ${index + 1}`, anchor: { x: input.context.sourceWidth / 2, y: input.context.sourceHeight / 2, verified: false, }, }, crop: { mode: 'fixed', x: cropX, y: cropY }, centerTolerancePercent: 12, allowTightCrop: false, allowHold: false, note: 'Automatic scene boundary and centre-anchor suggestion. Set anchor coordinates and verified=true after visual review.', })), captions: { ...(input.captionsPath ? { sourcePath: input.captionsPath } : {}), profile: input.captionProfile ?? 'social-large', burnIn: Boolean(input.captionsPath), allowBurnedCaptionSource: false, }, }; }