import { calculateCropWindow } from './plan.js'; import type { ReframePlan, ReframeShot, ReframeSourceContext } from './types.js'; export type ReframeQcCode = | 'subject-off-center' | 'subject-clipped' | 'subject-anchor-missing' | 'subject-anchor-unverified'; export interface ReframeQcFinding { code: ReframeQcCode; severity: 'error' | 'warning'; shotIndex: number; message: string; observed?: number; limit?: number; } export interface ReframePlanQcReport { ok: boolean; findings: ReframeQcFinding[]; } export function approvedReframeFreezeSeconds(plan: ReframePlan): number { return plan.shots.reduce( (total, shot) => total + (shot.allowHold ? (shot.holdSeconds ?? 0) : 0), 0, ); } function cropOrigins(shot: ReframeShot): Array<{ x: number; y: number }> { if (shot.crop.mode === 'fixed') return [{ x: shot.crop.x, y: shot.crop.y }]; if (shot.crop.mode === 'linear') { return [ { x: shot.crop.startX, y: shot.crop.startY }, { x: shot.crop.endX, y: shot.crop.endY }, ]; } return []; } function clippedFraction( subjectStart: number, subjectSize: number, cropStart: number, cropSize: number, ): number { const overlap = Math.max(0, Math.min(subjectStart + subjectSize, cropStart + cropSize) - Math.max(subjectStart, cropStart)); return 1 - Math.min(1, overlap / subjectSize); } export function runReframePlanQc(plan: ReframePlan, context: ReframeSourceContext): ReframePlanQcReport { const findings: ReframeQcFinding[] = []; const window = calculateCropWindow(context.sourceWidth, context.sourceHeight, plan.target); plan.shots.forEach((shot, shotIndex) => { if (shot.crop.mode === 'contain' || shot.subject.kind === 'none') return; const anchor = shot.subject.anchor; if (!anchor) { findings.push({ code: 'subject-anchor-missing', severity: 'error', shotIndex, message: `Shot ${shotIndex} (${shot.subject.label}) has no subject anchor; centring cannot be proven.`, }); return; } if (!anchor.verified) { findings.push({ code: 'subject-anchor-unverified', severity: 'error', shotIndex, message: `Shot ${shotIndex} (${shot.subject.label}) uses an automatic anchor suggestion that has not been verified.`, }); return; } const origins = cropOrigins(shot); const anchors = origins.map((_, index) => ({ x: index === origins.length - 1 ? (anchor.endX ?? anchor.x) : anchor.x, y: index === origins.length - 1 ? (anchor.endY ?? anchor.y) : anchor.y, })); for (let index = 0; index < origins.length; index += 1) { const origin = origins[index]; const point = anchors[index]; if (!origin || !point) continue; const cropCenterX = origin.x + window.width / 2; const allowedOffset = window.width * (shot.centerTolerancePercent / 100); const centerOffset = Math.abs(point.x - cropCenterX); if (centerOffset > allowedOffset) { findings.push({ code: 'subject-off-center', severity: 'error', shotIndex, message: `Shot ${shotIndex} subject is ${centerOffset.toFixed(1)}px from crop centre; allowed ${allowedOffset.toFixed(1)}px.`, observed: centerOffset, limit: allowedOffset, }); break; } } if (!shot.allowTightCrop && anchor.width && anchor.height) { for (let index = 0; index < origins.length; index += 1) { const origin = origins[index]; const point = anchors[index]; if (!origin || !point) continue; const horizontal = clippedFraction(point.x - anchor.width / 2, anchor.width, origin.x, window.width); const vertical = clippedFraction(point.y - anchor.height / 2, anchor.height, origin.y, window.height); const clipped = Math.max(horizontal, vertical); if (clipped > 0.1) { findings.push({ code: 'subject-clipped', severity: 'error', shotIndex, message: `Shot ${shotIndex} clips ${(clipped * 100).toFixed(1)}% of the declared subject box; allowed 10%.`, observed: clipped, limit: 0.1, }); break; } } } }); return { ok: !findings.some((finding) => finding.severity === 'error'), findings }; }