/** * Scene headcount lint — pure/deterministic, no I/O. * * Production-learned: a video model treats an unbounded plural as licence to * invent people. The phrase that broke a 37-shot film was * * "@A and @B, with four more women in silk sarees behind them, walk abreast" * * which has no identity, no count and no position for the "four more". The * renders came back with duplicated leads, invented cast, and figures walking * in from frame edges mid-clip — eight clips discarded. * * The standing render rules already appended "no duplicates or extra figures" * to every prompt and the model IGNORED it. Negative prohibitions do not bind; * a positive count does. What fixed it: * * "exactly two women are in frame, @A and @B ... the corridor beyond them is * empty. Medium, 35mm" * * — an exact count, the background described as empty rather than forbidden, * and a longer lens so there is no vacant frame left to populate. */ export type SceneHeadcountCode = | 'unbounded-crowd' | 'missing-explicit-count' | 'wide-multi-subject'; export interface SceneHeadcountFinding { sceneIndex: number; code: SceneHeadcountCode; severity: 'error' | 'warning'; message: string; /** The matched text, so the operator can see exactly what tripped the rule. */ evidence?: string; } export interface SceneHeadcountResult { ok: boolean; errorCount: number; warningCount: number; findings: SceneHeadcountFinding[]; } export interface SceneHeadcountInput { sceneIndex: number; description: string; /** Storyboard cast field. Often absent — `--scene` authoring never sets it. */ characters?: string[]; /** Character names this scene references by @tag (caller resolves these). */ taggedCharacters?: string[]; } /** * Phrases that summon an unspecified number of additional people. Each is a * real pattern an operator reaches for when describing an ensemble. */ const UNBOUNDED_CROWD_PATTERNS: Array<{ re: RegExp; hint: string }> = [ { re: /\b(?:\d+|one|two|three|four|five|six|seven|eight|nine|ten|a few|several|some|more|other)\s+more\s+(?:women|men|people|persons|figures|dancers|extras|others|girls|boys|children|kids)\b/i, hint: 'name every person in frame and state an exact count', }, { re: /\b(?:\d+|a few|several|some|two|three|four|five|the rest)\s+of\s+the\s+others\b/i, hint: 'name the specific characters instead of "the others"', }, { re: /\bothers?\s+(?:behind|beside|around|flanking|following|with)\b/i, hint: 'name them, or describe the background as empty', }, { re: /\ba\s+(?:crowd|throng|cluster|sea|group)\s+of\s+(?:women|men|people|figures|dancers|extras)\b/i, hint: 'a crowd cannot be identity-locked; frame fewer, named subjects', }, { re: /\bbackground\s+(?:dancers|figures|people|extras|characters|crowd)\b/i, hint: 'background people get invented and duplicated; state an empty background', }, { re: /\b(?:and|plus|joined by)\s+others\b/i, hint: 'name them, or state an exact count', }, ]; /** An explicit, bounded count of the people in frame. */ const EXPLICIT_COUNT_RE = /\bexactly\s+(?:one|two|three|four|five|six|seven|\d+)\b|\b(?:only|just)\s+(?:one|two|three|four|five|six|seven|\d+)\s+(?:woman|women|man|men|person|people|figure|figures)\b|\bboth\s+(?:of\s+them|women|men|figures)\b/i; /** Focal length in millimetres, when the shot line states one. */ const LENS_MM_RE = /\b(\d{1,3})\s*mm\b/i; /** At/below this focal length a multi-subject frame has room the model fills. */ const WIDE_LENS_MM = 24; function subjectCount(scene: SceneHeadcountInput): number { const named = new Set(); for (const name of scene.characters ?? []) { if (name.trim()) named.add(name.trim().toLowerCase()); } for (const name of scene.taggedCharacters ?? []) { if (name.trim()) named.add(name.trim().toLowerCase()); } return named.size; } /** * Lint scenes for headcount hazards. Errors are unambiguous defects (an * unbounded plural); warnings are prompts that will probably survive but carry * the risk that produced the original failure. */ export function lintSceneHeadcount(scenes: SceneHeadcountInput[]): SceneHeadcountResult { const findings: SceneHeadcountFinding[] = []; for (const scene of scenes) { const text = scene.description ?? ''; const subjects = subjectCount(scene); for (const { re, hint } of UNBOUNDED_CROWD_PATTERNS) { const match = re.exec(text); if (!match) continue; findings.push({ sceneIndex: scene.sceneIndex, code: 'unbounded-crowd', severity: 'error', message: `scene ${scene.sceneIndex}: "${match[0]}" summons an unspecified number of people — ` + `the model invents and duplicates them, and walks them in from frame edges. ${hint}.`, evidence: match[0], }); break; // one crowd finding per scene is enough to act on } if (subjects >= 2 && !EXPLICIT_COUNT_RE.test(text)) { findings.push({ sceneIndex: scene.sceneIndex, code: 'missing-explicit-count', severity: 'warning', message: `scene ${scene.sceneIndex}: names ${subjects} subjects but states no exact count. ` + 'Lead with "exactly N ... are in frame" — a positive count binds where ' + '"no extra figures" is ignored.', }); } const lens = LENS_MM_RE.exec(text); const lensMm = lens ? Number(lens[1]) : null; if (subjects >= 2 && lensMm !== null && lensMm <= WIDE_LENS_MM) { findings.push({ sceneIndex: scene.sceneIndex, code: 'wide-multi-subject', severity: 'warning', message: `scene ${scene.sceneIndex}: ${lensMm}mm on a ${subjects}-subject frame leaves vacant ` + 'space the model populates. Tighten toward 35mm, or state that the background is empty.', evidence: lens?.[0], }); } } const errorCount = findings.filter((f) => f.severity === 'error').length; const warningCount = findings.length - errorCount; return { ok: errorCount === 0, errorCount, warningCount, findings }; }