/** * prompt-lint — a PURE, deterministic validator over a filmmaking-prompts * artifact. No I/O, no Date, no Math.random: the CLI handler reads the artifact * (from a project or a `--file`) and hands the parsed object here. * * Per Seedance packet it checks: * - 13-block order (text-driven packets only) via {@link checkSeedanceBlockOrder}; * - word count (warn outside the 280-600 words/packet window); * - brand / proper-name scrub leaks (reuses the prompt-rules scrubbers); * - the single-full-frame grid guard is present whenever a storyboard-grid * reference is attached (else the grid leaks as a moving split-screen); * - SUBJECT LOCK + CAPTURE REALISM + CAMERA CAPTURE present on every video packet; * - no Kelvin / hue° numeric-register tokens in a prose-register packet. * * Over the storyboard-grid panels (when the artifact carries a grid prompt): * - annotation strips (CAM/MOVE/MOOD) must read as 2-6 word uppercase slug * lines, not sentences (advisory); * - framing progression: vary wide -> medium -> close across the panels and * build to a climactic beat with closer framing in the final third * (advisory — the operator's panels are never rewritten). * * Over character descriptions (when the caller passes them): * - the ai-filmmaking identity word budget — 30-60 words is the target, * 61-100 warns, >100 is a hard failure (mirrors the generation-time check). * * Output is machine-readable: `{ packets: [{ sceneIndex, issues[] }], grid?, * characters?, ok }`, where `ok` is true iff no `error`-severity issue was * raised anywhere. The `grid` / `characters` sections are additive: absent * unless the artifact has a grid prompt / descriptions are provided. */ import { checkSeedanceBlockOrder, type FilmmakingPromptsArtifact, type FilmmakingSeedancePacket, type FilmmakingStoryboardPanel, } from './filmmaking-prompts.js'; import { SINGLE_FULL_FRAME_GUARD } from './seedance-blocks.js'; import { brandNeutralize, stripProperNames, type CastDescriptor } from './prompt-rules.js'; import { detectSlop } from './seedance-antislop.js'; import { analyzeReferenceTransfer } from './reference-transfer.js'; import { analyzeAllocation } from './allocation-model.js'; /** * Words-per-packet advisory window. Outside this range raises a warning. * * The ceiling moved 600 → 1000 when the packet reshaped to 13 blocks. The three * new directive blocks (NO ON-SCREEN TEXT, CAPTURE CADENCE, ATMOSPHERE) are * structural — they are on every packet — and measured at +213 words at * `standard` detail and +220 at `rich`. Against the old ceiling that made every * `rich` packet fail the lint on arrival (727 words), which would have read as a * regression rather than the intended reshape. * * 1000 is derived, not picked: the measured worst realistic body is 816 words * (rich detail, four subject locks, phone register, heavy haze + wet), and the * DIRECTOR and BRAND addenda append after that body. The ceiling still does its * original job — past roughly this length the directive blocks start losing * weight against the descriptive body, which is the failure it exists to catch. */ export const PROMPT_LINT_MIN_WORDS = 280; export const PROMPT_LINT_MAX_WORDS = 1000; /** Blocks that must appear on every video (Seedance) packet. */ const REQUIRED_VIDEO_BLOCKS = [ 'SUBJECT LOCK', 'CAPTURE REALISM', 'CAMERA CAPTURE', // The two anti-artifact directive blocks. Absence is the caption-leakage and // interpolation-judder failure, so they are required, not advisory. 'NO ON-SCREEN TEXT', 'CAPTURE CADENCE', ] as const; /** * Canonical tool-emitted blocks the anti-slop linter must never flag: the * toolchain legitimately emits "cinematic shot" and many constraint-slot * "no …" phrases inside its own guards. Stripped before slop scanning. */ const CANONICAL_EMITTED_BLOCKS: string[] = [SINGLE_FULL_FRAME_GUARD]; /** Max slop findings spelled out in the aggregated message before "(+N more)". */ const SLOP_FINDINGS_SHOWN = 6; /** * Hard ceiling on a single prompt's runtime. Past this the model stops holding * a coherent through-line and the packet should be split by camera or by beat * into two prompts instead. */ export const PROMPT_LINT_MAX_DURATION_SECONDS = 15; /** * Directive blocks compete with each other for attention, so past roughly this * many they all dilute and the ones that matter stop landing. */ export const PROMPT_LINT_MAX_DIRECTIVE_BLOCKS = 6; /** Annotation strips (CAM/MOVE/MOOD) should read as 2-6 word slug lines. */ export const ANNOTATION_SLUG_MAX_WORDS = 6; /** ai-filmmaking identity description budget: 30-60 target, >100 is a failure. */ export const CHARACTER_DESCRIPTION_TARGET_MAX_WORDS = 60; export const CHARACTER_DESCRIPTION_FAIL_WORDS = 100; export type PromptLintIssueCode = | 'seedance-block-order' | 'word-count' | 'missing-required-block' | 'grid-guard-missing' | 'numeric-in-prose' | 'proper-name-leak' | 'brand-leak' | 'annotation-slug-format' | 'framing-progression' | 'character-description-budget' | 'slop' | 'reference-transfer' | 'allocation' | 'drifting-landmark' | 'shot-timing-sum' | 'over-max-duration' | 'directive-block-cap' | 'duplicated-fact' | 'lipsync-closure-map-missing' | 'strobe-eats-closures'; export interface PromptLintIssue { code: PromptLintIssueCode; severity: 'warning' | 'error'; message: string; } export interface PromptLintPacketResult { sceneIndex: number; issues: PromptLintIssue[]; } /** Storyboard-grid panel lint (annotation strips + framing progression). */ export interface PromptLintGridResult { issues: PromptLintIssue[]; } /** Per-character identity-description budget lint. */ export interface PromptLintCharacterResult { name: string; issues: PromptLintIssue[]; } export interface PromptLintResult { packets: PromptLintPacketResult[]; /** Present iff the artifact carries a storyboard-grid prompt. */ grid?: PromptLintGridResult; /** Present iff {@link PromptLintOptions.characterDescriptions} was provided. */ characters?: PromptLintCharacterResult[]; ok: boolean; } export interface PromptLintOptions { /** * Cinematography register the packets were rendered in. Default `'prose'` * (the Joey hard default): in prose register, Kelvin (`5200K`) and hue/angle * degree tokens (`40°`) are numeric-register leakage and get flagged. Pass * `'numeric'` to suppress that check when numeric tokens are intentional. */ register?: 'prose' | 'numeric'; /** * Cast name → visual-descriptor map. When provided, a packet whose text still * contains a proper name (i.e. {@link stripProperNames} would change it) is * flagged as a proper-name leak. Omitted → the proper-name check is skipped. */ cast?: CastDescriptor[]; /** * Brand tokens to scrub. When provided, a packet whose text still contains a * brand (i.e. {@link brandNeutralize} would change it) is flagged. Omitted → * the brand check is skipped. */ brands?: string[]; /** * Character name → stored identity description. When provided, each * description is checked against the ai-filmmaking word budget (30-60 target; * 61-100 warns; >100 errors, mirroring the generation-time check). Omitted → * the check is skipped and no `characters` section is emitted. */ characterDescriptions?: Array<{ name: string; description: string }>; } function wordCount(text: string): number { return text.split(/\s+/).filter(Boolean).length; } /** Lint a single packet, returning its ordered issue list. */ /** * Distant landmarks that act as DIRECTIONAL ANCHORS. * * Image-to-video models hold no mental map of a space. A far-off skyline, * mountain or tower is a fixed bearing the viewer can read, so when the camera * cuts or swings during action the model re-invents it at the wrong angle and * the geography visibly breaks — the most conspicuous continuity failure there * is, because the audience can see the world rotate. * * Enclosed spaces have no far horizon to get wrong. A large CENTRAL object is * the opposite of a hazard: it looks the same from every angle, so it gives the * model something consistent to orient around. */ const DRIFTING_LANDMARKS = /\b(?:city\s?skyline|skyline|distant\s+(?:mountains?|city|skyline|tower|spires?|peaks?)|mountain\s+range|on\s+the\s+horizon|far\s+horizon|distant\s+horizon)\b/i; /** Enclosure cues that make a landmark safe: there is no far horizon in frame. */ const ENCLOSURE_CUES = /\b(?:enclosed|walled|courtyard|quad(?:rangle)?|cloisters?|glade|clearing|interior|indoors?|inside|alley|corridor|ringed by|surrounded on all sides)\b/i; /** Movement that forces the camera to re-render the background from a new angle. */ const ACTION_CUES = /\b(?:fight|fights|fighting|battle|chase|chases|combat|brawl|spar|leaps?|charges?|whip pan|orbit|circles?|swings? around|tracking around)\b/i; /** * Timecode ranges in a MOVEMENT / FRAME MAP body, as `[startSeconds, endSeconds]`. * Handles both `0:00-0:03` and `0.0 to 3.0s` shapes, which the emitters both use. */ function parseTimecodeRanges(text: string): Array<[number, number]> { // Deduped: FRAME MAP and MOVEMENT both state the same beat timeline, so the // raw scan sees every range twice and a naive contiguity check reads the // second block's first beat as an overlap of the first block's last. const seen = new Set(); const ranges: Array<[number, number]> = []; for (const m of text.matchAll(/(\d+):([0-5]\d)\s*[-–]\s*(\d+):([0-5]\d)/g)) { const range: [number, number] = [Number(m[1]) * 60 + Number(m[2]), Number(m[3]) * 60 + Number(m[4])]; const key = range.join('-'); if (!seen.has(key)) { seen.add(key); ranges.push(range); } } for (const m of text.matchAll(/(\d+(?:\.\d+)?)\s*to\s*(\d+(?:\.\d+)?)\s*s\b/gi)) { const range: [number, number] = [Number(m[1]), Number(m[2])]; const key = range.join('-'); if (!seen.has(key)) { seen.add(key); ranges.push(range); } } return ranges; } /** * The canonical 13 structural block headers. These are the packet's SKELETON, * not directives competing for attention, so they never count against the cap — * otherwise a well-formed packet fails the check for being well-formed. */ const STRUCTURAL_BLOCK_HEADERS: ReadonlySet = new Set([ 'NO ON-SCREEN TEXT', 'CAPTURE CADENCE', 'SCENE & MOOD', 'FRAME MAP', 'SUBJECT LOCK', 'CROSS-FRAME RULES', 'CROSS-FRAME', 'MOVEMENT', 'ATMOSPHERE', 'LAST FRAME', 'WORLD PLATE', 'SOUND BED', 'CAPTURE REALISM', 'CAMERA CAPTURE', ]); /** * ALL-CAPS directive block headers ON TOP of the structural skeleton — the * `THE STROBE: …` / `THE SINGING …` / `THE PATTERN OF CLOSURES: …` layer that * the cap actually governs. */ function countDirectiveBlocks(text: string): string[] { const headers: string[] = []; for (const line of text.split('\n')) { const m = /^([A-Z][A-Z0-9 ,'’—-]{3,}?):/.exec(line.trim()); if (m && !STRUCTURAL_BLOCK_HEADERS.has(m[1]!.trim())) headers.push(m[1]!.trim()); } return headers; } /** * Split a packet into its labelled blocks so a fact can be attributed to the * block it lives in rather than to a raw offset in the text. */ function splitIntoBlocks(text: string): Array<{ header: string; body: string }> { const blocks: Array<{ header: string; body: string }> = []; let current: { header: string; body: string } | null = null; for (const line of text.split('\n')) { const m = /^([A-Z][A-Z0-9 &,'’—-]{3,}?):/.exec(line.trim()); if (m) { if (current) blocks.push(current); current = { header: m[1]!.trim(), body: line }; } else if (current) { current.body += `\n${line}`; } } if (current) blocks.push(current); return blocks; } /** * Facts that must live in exactly one block. A packet that states its * atmosphere or its cadence twice reads long without reading specific, and the * duplicate dilutes the original rather than reinforcing it. */ const SINGLE_HOME_FACTS: ReadonlyArray<{ label: string; pattern: RegExp }> = [ { label: 'the haze / atmosphere', pattern: /atmospheric haze|volumetric haze|suspended particulate/gi }, { label: 'the shutter cadence', pattern: /180-degree shutter|frame interpolation/gi }, { label: 'the on-screen-text suppression', pattern: /no captions|no on-screen text/gi }, ]; function lintPacket( packet: FilmmakingSeedancePacket, options: PromptLintOptions, ): PromptLintIssue[] { const issues: PromptLintIssue[] = []; const text = packet.promptText; // 1) Block order — only the text-driven packet follows the 13-block contract; // grid variants intentionally use the grid-reference body shape. if (packet.variant === 'text-driven') { const blockIssue = checkSeedanceBlockOrder(text); if (blockIssue) { issues.push({ code: 'seedance-block-order', severity: 'warning', message: blockIssue.message }); } } // 1b) Drifting landmark in an ACTION scene (advisory). // Only fires when both conditions hold — a distant anchor AND movement // that makes the camera re-render the background from a new angle. A // static establishing shot over a skyline is fine and common, so flagging // the landmark alone would be noise. Enclosure cues clear it: if the // space is walled, there is no far horizon to get wrong. const landmark = DRIFTING_LANDMARKS.exec(text); if (landmark && ACTION_CUES.test(text) && !ENCLOSURE_CUES.test(text)) { issues.push({ code: 'drifting-landmark', severity: 'warning', message: `action scene anchored to a distant landmark ("${landmark[0]}") — it will reappear at the wrong angle across cuts. ` + 'Enclose the space (courtyard, walled garden, glade) or put a large object at the CENTRE, ' + 'which reads the same from every angle. Shoot the landmark view as a separate static establishing beat.', }); } // 2) Word count window (advisory). const words = wordCount(text); if (words < PROMPT_LINT_MIN_WORDS || words > PROMPT_LINT_MAX_WORDS) { issues.push({ code: 'word-count', severity: 'warning', message: `packet is ${words} words; target ${PROMPT_LINT_MIN_WORDS}-${PROMPT_LINT_MAX_WORDS} words per packet.`, }); } // 2b) Shot timings must sum to the stated runtime, and the packet must fit // inside one prompt. A gap or overlap between beats is where the model // invents or drops a shot; past the ceiling the through-line collapses // and the packet needs splitting rather than shortening. const ranges = parseTimecodeRanges(text); if (ranges.length > 1) { const sorted = [...ranges].sort((a, b) => a[0] - b[0]); for (let i = 1; i < sorted.length; i += 1) { const prevEnd = sorted[i - 1]![1]; const start = sorted[i]![0]; if (Math.abs(start - prevEnd) > 0.01) { issues.push({ code: 'shot-timing-sum', severity: 'warning', message: `shot timings do not run contiguously: a beat ends at ${prevEnd}s and the next starts at ${start}s ` + `(${start > prevEnd ? 'gap' : 'overlap'}). Per-shot timecodes must tile the full runtime exactly — ` + 'a gap is where the model invents a shot and an overlap is where it drops one.', }); break; } } const total = sorted[sorted.length - 1]![1]; if (total > PROMPT_LINT_MAX_DURATION_SECONDS) { issues.push({ code: 'over-max-duration', severity: 'error', message: `packet runs ${total}s; the ceiling for one prompt is ${PROMPT_LINT_MAX_DURATION_SECONDS}s. ` + 'Split it into two prompts by camera vantage or by beat rather than compressing it.', }); } } // 2c) Directive blocks compete for attention; past the cap they all dilute. const directives = countDirectiveBlocks(text); if (directives.length > PROMPT_LINT_MAX_DIRECTIVE_BLOCKS) { issues.push({ code: 'directive-block-cap', severity: 'warning', message: `${directives.length} directive blocks (${directives.slice(0, 8).join(', ')}…); past about ` + `${PROMPT_LINT_MAX_DIRECTIVE_BLOCKS} they compete and all of them lose weight. ` + 'Fold the lower-priority ones into the descriptive body.', }); } // 2d) Every fact belongs to exactly one BLOCK. Counting raw hits instead // would flag a single well-formed block for using two of the phrases that // legitimately belong to it together (a cadence block naturally names both // the shutter and the interpolation ban). const blocks = splitIntoBlocks(text); for (const fact of SINGLE_HOME_FACTS) { const homes = blocks.filter((b) => new RegExp(fact.pattern.source, 'i').test(b.body)); if (homes.length > 1) { issues.push({ code: 'duplicated-fact', severity: 'warning', message: `${fact.label} is stated in ${homes.length} blocks (${homes.map((h) => h.header).join(', ')}). ` + 'Every fact belongs to exactly one block — the repeat adds length without adding specificity ' + 'and dilutes the original.', }); } } // 2e) A packet with spoken/sung dialogue but no scored closure map is asking // for good singing rather than scoring the mouth mechanics, which is the // diagnosable reason lipsync comes back plausible but unsynced. const hasDialogue = /\bsays:|\bsings\b|"[^"]{6,}"/.test(text); const hasClosureMap = text.includes('THE PATTERN OF CLOSURES'); if (hasDialogue && !hasClosureMap && /\bsings\b|THE SINGING/.test(text)) { issues.push({ code: 'lipsync-closure-map-missing', severity: 'warning', message: 'the packet has a sung line but no scored closure map. What the eye reads as sync is the lip SEAL on ' + 'each B/M/P; without a counted map the mouth flaps plausibly and syncs to nothing. ' + 'Score it with `vclaw video multi-shot --dialogue "…" --lipsync`.', }); } // 2f) Strobe and lipsync fight: hard flash-to-black hides roughly half the // seals, so a scored map behind a full-strength strobe is scored for // frames the viewer never sees. if (hasClosureMap && /THE STROBE|strobe flashes firing/.test(text)) { issues.push({ code: 'strobe-eats-closures', severity: 'warning', message: 'a scored closure map and a full-strength strobe are in the same packet — hard flash-to-black hides ' + 'roughly half the lip seals, so the map is scored for frames nobody sees. Soften the strobe on the ' + 'singer so their face never drops fully to black, and keep the full treatment on background bodies.', }); } // 3) Required video blocks must be present on every text-driven packet. Only // the text-driven variant follows the 13-block contract; the grid-reference // variants intentionally use the grid-reference body shape (which carries // the same identity discipline inline, not as labelled SUBJECT LOCK / etc. // blocks), so applying this check to them would be a false positive. if (packet.variant === 'text-driven') { for (const block of REQUIRED_VIDEO_BLOCKS) { if (!text.includes(block)) { issues.push({ code: 'missing-required-block', severity: 'error', message: `packet is missing the required "${block}" block.`, }); } } } // 4) Grid guard present whenever a storyboard-grid reference is attached. const hasGridRef = packet.references.some((reference) => reference.role === 'storyboard-grid'); if (hasGridRef && !text.includes(SINGLE_FULL_FRAME_GUARD)) { issues.push({ code: 'grid-guard-missing', severity: 'error', message: 'a storyboard-grid reference is attached but the single-full-frame guard is missing; the grid will leak as a moving split-screen.', }); } // 5) No Kelvin / hue° numeric-register tokens in a prose-register packet. if ((options.register ?? 'prose') === 'prose') { const kelvin = text.match(/\b\d+\s?K\b/g) ?? []; const degrees = text.match(/\d+°/g) ?? []; const tokens = [...kelvin, ...degrees]; if (tokens.length > 0) { issues.push({ code: 'numeric-in-prose', severity: 'error', message: `prose-register packet contains numeric-register tokens (${tokens.join(', ')}); use the prose register or pass register=numeric.`, }); } } // 6) Proper-name leak — the scrubbed text must equal the original. if (options.cast && options.cast.length > 0) { if (stripProperNames(text, options.cast) !== text) { issues.push({ code: 'proper-name-leak', severity: 'error', message: 'packet still contains a cast proper name; describe subjects by visual descriptor, never by name.', }); } } // 7) Brand leak — the brand-neutralised text must equal the original (modulo // whitespace collapse, which brandNeutralize always applies). if (options.brands && options.brands.length > 0) { const collapsed = text.replace(/\s{2,}/g, ' ').trim(); if (brandNeutralize(text, options.brands) !== collapsed) { issues.push({ code: 'brand-leak', severity: 'error', message: 'packet still contains a brand token; keep prompts brand-neutral.', }); } } // 8) Anti-slop advisory — empty hype language the model cannot act on. One // aggregated warning per packet; warning severity, so `ok` is unaffected. // Canonical tool-emitted blocks are ignored so the linter never flags its // own wording. const slop = detectSlop(text, { ignore: CANONICAL_EMITTED_BLOCKS }); if (slop.length > 0) { const shown = slop .slice(0, SLOP_FINDINGS_SHOWN) .map((finding) => `"${finding.match}" → ${finding.suggestion}`) .join('; '); const more = slop.length > SLOP_FINDINGS_SHOWN ? ` (+${slop.length - SLOP_FINDINGS_SHOWN} more)` : ''; issues.push({ code: 'slop', severity: 'warning', message: `slop language detected — replace with observable production detail: ${shown}${more}.`, }); } // 9) Reference-transfer contract — warn when a packet's references span ≥2 // bleed-relevant domains without a transfer/ignore clause (advisory). for (const advisory of analyzeReferenceTransfer( packet.references.map((reference) => ({ role: reference.role, label: reference.label })), text, )) { issues.push({ code: advisory.code, severity: advisory.severity, message: advisory.message }); } // 10) Allocation-model — warn when one packet over-allocates the budget (≥3 of // identity-detail / bold-motion / scene-density / readable-text). Advisory. for (const advisory of analyzeAllocation(text)) { issues.push({ code: advisory.code, severity: advisory.severity, message: advisory.message }); } return issues; } // --------------------------------------------------------------------------- // Storyboard-grid panel lint (ai-filmmaking annotation + framing discipline) // --------------------------------------------------------------------------- type FramingClass = 'wide' | 'medium' | 'close'; /** * Classify a CAM annotation string into a shot-size class. Close patterns are * checked first so "MEDIUM CLOSE" reads as close. Angle-only slugs ("LOW * ANGLE. PUSH IN", "DUTCH. SNAP ZOOM") carry no shot size and return null — * unclassifiable panels are simply ignored by the progression checks, so * free-text operator panels never produce false positives. */ export function classifyFraming(cam: string): FramingClass | null { const upper = cam.toUpperCase(); if (/(CLOSE|MACRO)/.test(upper)) return 'close'; if (/WIDE/.test(upper)) return 'wide'; if (/(MEDIUM|MID|OVER SHOULDER|OVER-THE-SHOULDER|OTS|PROFILE)/.test(upper)) return 'medium'; return null; } /** * Advisory lint over the grid panels. Never rewrites the operator's panels — * it only reports: * - annotation-slug-format: CAM/MOVE/MOOD strips longer than * {@link ANNOTATION_SLUG_MAX_WORDS} words or written as lowercase prose * (the strips should read like screenplay slug lines); * - framing-progression: missing wide/medium/close variety across the grid, * or no close framing in the final-third (climax) panels. * All issues are warning severity. Pure and deterministic. */ function lintStoryboardGridPanels(panels: FilmmakingStoryboardPanel[]): PromptLintIssue[] { const issues: PromptLintIssue[] = []; const longStrips: string[] = []; const proseStrips: string[] = []; for (const panel of panels) { const strips = [['CAM', panel.cam], ['MOVE', panel.move], ['MOOD', panel.mood]] as const; for (const [label, value] of strips) { if (wordCount(value) > ANNOTATION_SLUG_MAX_WORDS) { longStrips.push(`panel ${panel.panel} ${label}`); } const lower = (value.match(/[a-z]/g) ?? []).length; const upper = (value.match(/[A-Z]/g) ?? []).length; if (lower > upper) { proseStrips.push(`panel ${panel.panel} ${label}`); } } } if (longStrips.length > 0) { issues.push({ code: 'annotation-slug-format', severity: 'warning', message: `annotation strips exceed ${ANNOTATION_SLUG_MAX_WORDS} words (${longStrips.join(', ')}); strips should read as 2-6 word uppercase slug lines, not sentences.`, }); } if (proseStrips.length > 0) { issues.push({ code: 'annotation-slug-format', severity: 'warning', message: `annotation strips read as lowercase prose (${proseStrips.join(', ')}); strips should read as short uppercase slug lines.`, }); } const classes = panels.map((panel) => classifyFraming(panel.cam)); const classifiable = classes.filter((value): value is FramingClass => value !== null); if (classifiable.length >= 3) { const distinct = new Set(classifiable); if (distinct.size < 3) { const missing = (['wide', 'medium', 'close'] as const).filter((cls) => !distinct.has(cls)); issues.push({ code: 'framing-progression', severity: 'warning', message: `grid framing has no ${missing.join(' or ')} shots; vary the framing wide -> medium -> close across the panels instead of repeating one shot size.`, }); } } // Climax discipline: the final third of the grid should tighten — at least // one close framing among its classifiable panels. const finalThirdStart = Math.floor((panels.length * 2) / 3); const finalThird = classes.slice(finalThirdStart); if (finalThird.some((value) => value !== null) && !finalThird.includes('close')) { issues.push({ code: 'framing-progression', severity: 'warning', message: `no close framing in the climax panels (${finalThirdStart + 1}-${panels.length}); build to a climactic beat with closer framing in the final third.`, }); } return issues; } /** * ai-filmmaking identity-description word budget. Mirrors the generation-time * check in `buildCharacterSheetPrompts`: 30-60 words is the target, 61-100 * warns, >100 is an error ("over 100 is a failure"). */ function lintCharacterDescription(name: string, description: string): PromptLintIssue[] { const words = wordCount(description); if (words > CHARACTER_DESCRIPTION_FAIL_WORDS) { return [{ code: 'character-description-budget', severity: 'error', message: `${name} identity description is ${words} words; ai-filmmaking treats >100 as a failure (target 30-60). Trim scene effects/atmosphere down to identity-locking traits.`, }]; } if (words > CHARACTER_DESCRIPTION_TARGET_MAX_WORDS) { return [{ code: 'character-description-budget', severity: 'warning', message: `${name} identity description is ${words} words; ai-filmmaking target is 30-60 words.`, }]; } return []; } // --------------------------------------------------------------------------- // Video-prompt health checklist // --------------------------------------------------------------------------- export interface HealthCheckResult { criterion: string; pass: boolean; note?: string; } export interface ChecklistSummary { passed: number; total: 9; failures: string[]; } /** * Nine yes/no criteria that constitute the video-prompt health checklist * ("Promptlandia" criteria). Pure, deterministic, no network. * * Criterion definitions: * 1. Explicit subject — who or what is the primary on-screen subject. * 2. Explicit action — what is happening / the main motion or activity. * 3. Explicit scene/setting — where / environmental context. * 4. Camera angle — named viewpoint (wide, close-up, overhead, POV, etc.). * 5. Camera movement — named motion (dolly, pan, tilt, handheld, static, etc.). * 6. Lens/optical effects — focal length, depth-of-field, bokeh, flare, etc. * 7. Concrete style — specific, non-vague aesthetic descriptor. * 8. Temporal/sequence cues — shot timing, duration, cut markers, or ordinal labels. * 9. Audio spec — music, ambience, diegetic sound, or explicit silence. */ export function videoPromptHealthChecklist(prompt: string): HealthCheckResult[] { const lower = prompt.toLowerCase(); // 1) Explicit subject — any named entity, visual descriptor, or subject noun. // Heuristic: look for common subject introducers or concrete nouns. const subjectKeywords = [ 'subject', 'character', 'figure', 'person', 'man', 'woman', 'child', 'face', 'silhouette', 'protagonist', 'hero', 'dancer', 'musician', 'athlete', 'crowd', 'vehicle', 'car', 'product', 'object', 'animal', 'creature', 'robot', ]; const hasSubject = subjectKeywords.some((kw) => lower.includes(kw)); // 2) Explicit action — verb-based motion or activity descriptor. const actionKeywords = [ 'walk', 'run', 'move', 'jump', 'danc', 'sing', 'speak', 'reach', 'turn', 'cross', 'ride', 'drive', 'fly', 'fall', 'climb', 'swim', 'stand', 'sit', 'lean', 'throw', 'catch', 'fight', 'hold', 'carry', 'look', 'gaze', 'explod', 'collid', 'emerg', 'transform', 'dissolv', 'approach', 'depart', 'action', 'motion', 'activit', ]; const hasAction = actionKeywords.some((kw) => lower.includes(kw)); // 3) Explicit scene/setting — location, environment, or spatial context. const settingKeywords = [ 'scene', 'setting', 'location', 'environment', 'background', 'landscape', 'interior', 'exterior', 'indoor', 'outdoor', 'street', 'city', 'forest', 'desert', 'ocean', 'beach', 'mountain', 'room', 'studio', 'stage', 'field', 'corridor', 'courtyard', 'alley', 'roof', 'rooftop', 'plaza', 'market', 'train', 'highway', 'sky', 'space', 'underwater', 'architecture', 'world', ]; const hasSetting = settingKeywords.some((kw) => lower.includes(kw)); // 4) Camera angle — named viewing angle or shot size. const angleKeywords = [ 'wide', 'close-up', 'closeup', 'close up', 'medium shot', 'mid shot', 'overhead', 'bird\'s eye', 'birds eye', "bird's-eye", 'worm\'s eye', 'worms eye', 'eye.level', 'eye level', 'low angle', 'high angle', 'dutch angle', 'dutch tilt', 'over.the.shoulder', 'over the shoulder', 'pov', 'point of view', 'aerial', 'extreme close', 'establishing shot', 'two.shot', 'two shot', 'angle', ]; const hasAngle = angleKeywords.some((kw) => lower.includes(kw)); // 5) Camera movement — named camera motion. const movementKeywords = [ 'dolly', 'pan', 'tilt', 'zoom', 'pull', 'push', 'truck', 'pedestal', 'arc', 'handheld', 'hand.held', 'steadicam', 'tracking shot', 'tracking', 'crane', 'jib', 'gimbal', 'static', 'locked.off', 'locked off', 'orbiting', 'orbit', 'whip.pan', 'whip pan', 'rack focus', 'slow.motion', 'slow motion', 'timelapse', 'time.lapse', 'movement', 'camera move', 'camera motion', ]; const hasMovement = movementKeywords.some((kw) => lower.includes(kw)); // 6) Lens/optical effects — focal length, depth-of-field, bokeh, flare, lens type. const lensKeywords = [ 'bokeh', 'depth of field', 'depth.of.field', 'shallow dof', 'wide angle', 'telephoto', 'anamorphic', 'lens flare', 'lens.flare', 'fisheye', 'macro', 'tilt.shift', 'tilt shift', '35mm', '50mm', '85mm', '24mm', '16mm', '70mm', 'f/', 'aperture', 'focal length', 'prime lens', 'zoom lens', 'optical', 'vignett', 'chromatic aberration', 'distortion', 'film grain', 'grain', ]; const hasLens = lensKeywords.some((kw) => lower.includes(kw)); // 7) Concrete style — named visual style or aesthetic (non-vague). // Keywords like "cinematic" alone are too vague; we look for named styles or // genre descriptors that narrow down the look. const styleKeywords = [ 'noir', 'neon', 'cyberpunk', 'neo.noir', 'gothic', 'baroque', 'minimalist', 'hyperrealistic', 'photorealistic', 'impressionist', 'expressionist', 'documentary', 'commercial', 'editorial', 'fashion', 'horror', 'sci.fi', 'western', 'thriller', 'romance', 'action', 'indie', 'arthouse', 'blockbuster', 'moody', 'gritty', 'dreamy', 'surreal', 'vintage', 'retro', 'futuristic', 'warm tones', 'cool tones', 'muted palette', 'desaturated', 'vibrant', 'high.contrast', 'high contrast', 'low.key', 'high.key', 'golden hour', 'blue hour', 'magic hour', 'neon.lit', 'bleach bypass', 'technicolor', 'grindhouse', 'kodachrome', 'natural light', 'lifelike', 'anti.plastic', 'cinematic grounded', 'grounded realism', 'capture realism', ]; const hasStyle = styleKeywords.some((kw) => lower.includes(kw)); const styleNote = !hasStyle ? 'No concrete style keyword found; avoid vague terms like "cinematic" alone — name a genre, look, or aesthetic.' : undefined; // 8) Temporal/sequence cues — shot timing, duration markers, or ordinal labels. // Looks for timecode patterns (0:00, 00:00), "shot N", "scene N", duration words. const hasTimecode = /\d:\d{2}/.test(prompt) || /\d{2}:\d{2}/.test(prompt); const temporalKeywords = [ 'shot 1', 'shot 2', 'shot 3', 'shot 4', 'shot 5', 'shot 6', 'shot 7', 'shot 8', 'shot 9', 'first shot', 'second shot', 'opening shot', 'final shot', 'last frame', 'cut to', 'transition', 'sequence', 'beat', 'moment', 'duration', 'seconds', 'frame map', 'timeline', 'scene 1', 'scene 2', 'beginning', 'middle', 'end', 'intro', 'outro', 'act 1', 'act 2', 'act 3', 'open on', 'opens on', ]; const hasTemporal = hasTimecode || temporalKeywords.some((kw) => lower.includes(kw)); // 9) Audio spec — any music, ambient sound, diegetic audio, or explicit silence. const audioKeywords = [ 'sound', 'audio', 'music', 'score', 'soundtrack', 'ambien', 'ambient', 'diegetic', 'non.diegetic', 'silence', 'quiet', 'no music', 'sound bed', 'sfx', 'sound effect', 'voice', 'dialogue', 'narration', 'foley', 'beat', 'bass', 'melody', 'rhythm', 'tempo', 'audio spec', 'natural sound', ]; const hasAudio = audioKeywords.some((kw) => lower.includes(kw)); return [ { criterion: 'Explicit subject', pass: hasSubject, note: !hasSubject ? 'No clear subject found; name or describe the primary on-screen entity.' : undefined, }, { criterion: 'Explicit action', pass: hasAction, note: !hasAction ? 'No action verb found; describe what is happening or the main motion.' : undefined, }, { criterion: 'Explicit scene/setting', pass: hasSetting, note: !hasSetting ? 'No setting/location found; specify where the scene takes place.' : undefined, }, { criterion: 'Camera angle', pass: hasAngle, note: !hasAngle ? 'No camera angle found; name the shot size or viewpoint (wide, close-up, overhead, etc.).' : undefined, }, { criterion: 'Camera movement', pass: hasMovement, note: !hasMovement ? 'No camera movement found; specify motion (dolly, pan, static, handheld, etc.).' : undefined, }, { criterion: 'Lens/optical effects', pass: hasLens, note: !hasLens ? 'No lens or optical descriptor found; add focal length, bokeh, grain, or depth-of-field detail.' : undefined, }, { criterion: 'Concrete style (not vague)', pass: hasStyle, note: styleNote, }, { criterion: 'Temporal/sequence cues', pass: hasTemporal, note: !hasTemporal ? 'No temporal cues found; add timecodes, shot labels (Shot 1…), or duration beats.' : undefined, }, { criterion: 'Audio spec', pass: hasAudio, note: !hasAudio ? 'No audio specification found; add music, ambience, diegetic sound, or explicit silence.' : undefined, }, ]; } /** * Summarise the output of {@link videoPromptHealthChecklist}. * The `total` field is always 9 (the fixed number of criteria). */ export function summarizeChecklist(results: HealthCheckResult[]): ChecklistSummary { const passed = results.filter((r) => r.pass).length; const failures = results.filter((r) => !r.pass).map((r) => r.criterion); return { passed, total: 9, failures }; } // --------------------------------------------------------------------------- /** * Lint a filmmaking-prompts artifact: every Seedance packet, the storyboard-grid * panels (when a grid prompt is present), and the provided character identity * descriptions (when given). Pure: depends only on its arguments. */ export function lintFilmmakingPrompts( artifact: FilmmakingPromptsArtifact, options: PromptLintOptions = {}, ): PromptLintResult { const packets = artifact.seedancePackets.map((packet) => ({ sceneIndex: packet.sceneIndex, issues: lintPacket(packet, options), })); const grid = artifact.storyboardGridPrompt ? { issues: lintStoryboardGridPanels(artifact.storyboardGridPrompt.panels) } : undefined; const characters = options.characterDescriptions?.map((entry) => ({ name: entry.name, issues: lintCharacterDescription(entry.name, entry.description), })); const allIssues = [ ...packets.flatMap((packet) => packet.issues), ...(grid?.issues ?? []), ...(characters?.flatMap((entry) => entry.issues) ?? []), ]; const ok = allIssues.every((issue) => issue.severity !== 'error'); return { packets, ...(grid ? { grid } : {}), ...(characters ? { characters } : {}), ok, }; }