/** * Motion pack — one per video: coverage map + action-only blocks. This module * is the anti-drift gate: lint (style words stay OUT of actions), stats * (counts + cost tiers for the review gate), and prompt assembly (style lock + * SHOT + AUDIO + AVOID stacked by code, never by hand). * * Pure/deterministic — the CLI handler does the I/O. */ import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { resolveProjectWorkspace } from '../workspace.js'; import { resolveStyleFamily } from './style-families.js'; import { writeTextFileAtomic } from '../atomic-write.js'; import type { MographLintIssue, MographLintResult, MographPriority, MotionPackArtifact, MotionPackBlock, MotionSheetArtifact, } from './types.js'; export const ACTION_TARGET_WORDS = 90; export const ACTION_FAIL_WORDS = 120; export const TEXT_STRING_MAX_WORDS = 4; export const TEXT_STRINGS_MAX = 2; /** * A sheet on the `film` style-lock budget is refused once the pack has more * blocks than this: the 320-word lock is paid once per block, and the cost * argument that sizes the fleet budget at 120 words is a function of block * count, so the gate keys on block count, not on the word count. */ export const FILM_BUDGET_MAX_BLOCKS = 4; /** `*word*` accent markup inside a copyList entry. */ const ACCENT_MARK = /\*([^*]+)\*/g; const BLOCK_ID_PATTERN = /^B\d{3}[a-z]?$/; const HEX_CODE_PATTERN = /#[0-9a-fA-F]{3,8}\b/; /** * Style vocabulary that belongs in the sheet, not in choreography. An action * that names a color, material, or type family is fighting the reference * image — the #1 drift source. Word-list matching is heuristic, so these lint * as warnings; hex codes are unambiguous and lint as errors. */ const STYLE_WORDS = [ 'cream', 'beige', 'cobalt', 'crimson', 'teal', 'magenta', 'violet', 'turquoise', 'glassy', 'glass', 'chrome', 'metallic', 'velvet', 'pastel', 'neon', 'serif', 'grotesk', 'helvetica', 'futura', 'monospace', 'cinematic', 'premium', 'photorealistic', 'hyperrealistic', 'stunning', 'beautiful', ]; const MUSIC_WORDS = /\b(music|soundtrack|melody|song|lyrics|voice[- ]?over|narration)\b/i; const CAMERA_VERBS = /\b(push[- ]?in|pull[- ]?back|drift|whip|orbit|pan|zoom|dolly|crane|handheld)\b/gi; /** * A negation clause ("no text at all", "carry no text", "with nothing on them") * — on the generator these bleed onto the nearest quoted string and melt the * headline (pilot take 3). Declare text state POSITIVELY instead ("blank tile"). */ const TRAILING_NEGATION = /\b(no\s+text\b|carr(?:y|ies|ying)\s+no\b|nothing\s+(?:on|written)|without\s+(?:any\s+)?(?:text|words|labels?))/i; /** * "chip" renders as a poker / SIM / credit chip on the generator; "tile" is the * safe component noun. (Advisory, action text only — "chip" stays a legal * component name in the style-family register.) */ const CHIP_VOCAB = /\bchips?\b/i; export function packPathFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'motion-pack.json'); } export async function readMotionPack(root: string, slug: string): Promise { const path = packPathFor(root, slug); if (!existsSync(path)) return null; return JSON.parse(await readFile(path, 'utf-8')) as MotionPackArtifact; } export async function writeMotionPack(root: string, slug: string, pack: MotionPackArtifact): Promise { const path = packPathFor(root, slug); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, JSON.stringify(pack, null, 2) + '\n'); return path; } /** Parse "mm:ss" or "h:mm:ss" (also plain seconds) into seconds. */ export function parseTimecode(value: string): number { const trimmed = value.trim(); if (/^\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); const parts = trimmed.split(':').map((p) => Number(p)); if (parts.some((p) => Number.isNaN(p) || p < 0)) { throw new Error(`invalid timecode "${value}" — expected mm:ss or h:mm:ss`); } if (parts.length === 2) return parts[0] * 60 + parts[1]; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; throw new Error(`invalid timecode "${value}" — expected mm:ss or h:mm:ss`); } export function formatTimecode(totalSec: number): string { const sec = Math.max(0, Math.round(totalSec)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const mm = String(m).padStart(2, '0'); const ss = String(s).padStart(2, '0'); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } function countWords(text: string): number { return text.trim().split(/\s+/).filter(Boolean).length; } function quotedStrings(action: string): string[] { const matches = action.match(/"([^"]+)"/g) ?? []; return matches.map((m) => m.slice(1, -1)); } /** Terminal verbs every block ends on by design — not a variety signal. */ const SETTLE_VERB = /settle|hold/i; /** * Match a family verb (or component) inside action prose, on WHOLE words. * * Two things this has to get right, both learned the hard way: * * 1. The register writes verbs in the third person ("tapes down"), and action * prose uses whatever reads best — the two delivered films say "tape down", * "stagger in" and "bounce once". Matching the register's spelling literally * found no verb at all in three of five real action lines, so the variety * gate went silent on the exact repeat it exists to catch. Reduce to the root * and accept the inflections that actually occur. * 2. A LEADING \b alone does not stop a prefix matching a longer word: * /\bpop/ matches "population". The trailing \b is what does it — with the * suffix set closed, "pop"+"ulation" has no way through. */ const VERB_SUFFIXES = ['', 'e', 'es', 'ed', 'ing', 's']; function wordFormPattern(phrase: string): RegExp { const head = phrase.split(' ')[0].toLowerCase(); const stem = head.replace(/(?:es|s)$/, ''); // tapes -> tape, pops -> pop const root = stem.replace(/e$/, ''); // tape -> tap, settle -> settl const esc = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const suffixes = [...VERB_SUFFIXES]; // Doubled-consonant progressives ("pops up" -> "popping up"). Only for a // vowel+single-consonant root, which is where English doubles. const doubled = /[aeiou][^aeiou]$/.test(root) ? root.slice(-1) : ''; if (doubled) suffixes.push(`${doubled}ed`, `${doubled}ing`); return new RegExp(`\\b${esc}(?:${suffixes.join('|')})\\b`, 'i'); } /** * Components that CARRY COPY. An unspecified one is not empty on the generator — * it is a label the model invents, and the nearest vocabulary it has is the * style lock riding above every prompt. */ const TEXT_SLOT_COMPONENT = /\b(label bars?|caption strips?|label chips?|stat tiles?|stat chips?)\b/gi; const COUNT_WORD = /\b(one|two|three|four|five|six|seven|eight|nine|[2-9]|1[0-2])\b/i; /** * Declaring a spare slot blank SATISFIES the check. Note `action-trailing-negation` * separately discourages a negation sitting next to a quoted string, so the * house phrasing is the positive one ("blank label bar") — both spellings are * accepted here because refusing the negation twice would be double jeopardy. */ const BLANK_QUALIFIER = /\b(blank|plain|unlabell?ed|empty|no text|carr(?:y|ies|ying)\s+no)\b/i; const COUNT_VALUES: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, }; /** * How many copy-bearing components the action builds WITHOUT giving them words. * Pure, and 0 whenever the action declares its spares blank. * * Counts per sentence so a count word only applies to the component it governs: * "Three image cards with label bars" is three slots, while a later "A caption * strip" in the next sentence is one. */ export function countUnspecifiedTextSlots(action: string, quotedCount: number): number { if (BLANK_QUALIFIER.test(action)) return 0; let slots = 0; for (const sentence of action.split(/(?<=[.;])\s+/)) { const matches = sentence.match(TEXT_SLOT_COMPONENT); if (!matches) continue; for (const m of matches) { const plural = /s$/i.test(m.trim()); if (!plural) { slots += 1; continue; } // A plural slot is at least two; a count word in the same sentence pins it. const c = sentence.match(COUNT_WORD)?.[1]?.toLowerCase(); slots += (c ? COUNT_VALUES[c] ?? Number(c) : 0) || 2; } } return Math.max(0, slots - quotedCount); } function isV2v(mode: MotionPackBlock['mode']): boolean { return mode.startsWith('v2v'); } /** * Lint a motion pack (optionally against its sheet). Errors block rendering; * warnings are advisory. The rules encode the drift lessons: style lives in * the sheet, actions are choreography, audio is sound design, text is quoted. */ export function lintMotionPack( pack: MotionPackArtifact, sheet?: MotionSheetArtifact | null, ): MographLintResult { const issues: MographLintIssue[] = []; const err = (code: string, message: string, blockId?: string) => issues.push({ code, severity: 'error', message, ...(blockId ? { blockId } : {}) }); const warn = (code: string, message: string, blockId?: string) => issues.push({ code, severity: 'warning', message, ...(blockId ? { blockId } : {}) }); if (pack.schemaVersion !== 1) err('pack-schema-version', 'schemaVersion must be 1'); if (sheet && sheet.sheetId !== pack.sheetId) { err('pack-sheet-mismatch', `pack references sheet "${pack.sheetId}" but the project sheet is "${sheet.sheetId}"`); } if (!sheet) { warn('pack-sheet-unresolved', 'no motion sheet supplied — style lock and negative cannot be verified'); } if (pack.blocks.length === 0) { warn('pack-no-blocks', 'pack has no blocks yet (coverage-only skeleton) — nothing can render'); } if (sheet?.budget === 'film' && pack.blocks.length > FILM_BUDGET_MAX_BLOCKS) { err( 'sheet-film-budget-too-many-blocks', `the sheet is on the film style-lock budget but the pack has ${pack.blocks.length} blocks (max ${FILM_BUDGET_MAX_BLOCKS}) — ` + 'a film lock is paid once per block; past a short film, trim the lock to the fleet budget instead', ); } // ---- copy list: every on-screen string, once, as a contract --------------- // Only when the pack declares one. Without it, text stays per-block and this // whole section is a no-op, so an existing pack lints byte-identically. const copyList = pack.copyList; const stripAccent = (s: string) => s.replace(ACCENT_MARK, '$1').trim(); const listed = copyList ? new Set(copyList.map(stripAccent)) : null; const rendered = new Set(); if (copyList) { for (const entry of copyList) { const accents = [...entry.matchAll(ACCENT_MARK)]; if (accents.length > 1) { warn( 'copy-list-accent-word-count', `"${stripAccent(entry)}" marks ${accents.length} accent words — one accent word per headline`, ); } } } const seen = new Set(); for (const block of pack.blocks) { const id = block.id; if (!BLOCK_ID_PATTERN.test(id)) err('block-id-invalid', `block id "${id}" must match B### (optional trailing letter)`, id); if (seen.has(id)) err('block-id-duplicate', `block id "${id}" appears more than once`, id); seen.add(id); if (!(block.t1Sec > block.t0Sec)) { err('block-times-invalid', `[${formatTimecode(block.t0Sec)}–${formatTimecode(block.t1Sec)}] runs backwards or is empty`, id); } if (isV2v(block.mode) && !block.videoSource?.trim()) { err('v2v-video-source-missing', `mode ${block.mode} requires a videoSource path`, id); } if (!isV2v(block.mode) && block.videoSource) { warn('video-source-unused', `videoSource is set but mode ${block.mode} never uses it`, id); } const action = block.action ?? ''; if (!action.trim()) { err('action-missing', 'block has no action text', id); continue; } const words = countWords(action); if (words > ACTION_FAIL_WORDS) { err('action-over-budget', `action is ${words} words (hard cap ${ACTION_FAIL_WORDS}) — long actions re-describe style, stack ideas, or micro-manage`, id); } else if (words > ACTION_TARGET_WORDS) { warn('action-over-target', `action is ${words} words (target ≤${ACTION_TARGET_WORDS})`, id); } if (HEX_CODE_PATTERN.test(action)) { err('hex-in-action', 'hex color code in the action — style lives in the sheet, never in choreography', id); } const lowered = ` ${action.toLowerCase()} `; const leaked = STYLE_WORDS.filter((w) => lowered.includes(` ${w} `) || lowered.includes(` ${w},`) || lowered.includes(` ${w}.`)); if (leaked.length > 0) { warn('style-word-in-action', `style vocabulary in the action (${leaked.join(', ')}) — the sheet already answers how things look`, id); } const texts = quotedStrings(action); for (const t of texts) { if (countWords(t) > TEXT_STRING_MAX_WORDS) { err('text-string-too-long', `on-screen string "${t}" is over ${TEXT_STRING_MAX_WORDS} words — long strings render as gibberish`, id); } if (listed) { if (listed.has(t.trim())) rendered.add(t.trim()); else err('copy-list-string-unlisted', `on-screen string "${t}" is not in the pack copyList — the list is the contract; add it there or take it out of the block`, id); } } if (texts.length > TEXT_STRINGS_MAX) { warn('text-strings-too-many', `${texts.length} quoted strings in one clip (target ≤${TEXT_STRINGS_MAX})`, id); } if (texts.length === 0 && !/no text in frame/i.test(action)) { warn('text-policy-unstated', 'no quoted text and no "no text in frame" — silence invites invented labels', id); } const unspecified = countUnspecifiedTextSlots(action, texts.length); if (unspecified > 0) { warn( 'action-text-slots-unspecified', `the action builds ${unspecified} more copy-bearing component(s) than it gives words for — the model fills the empty ones from the nearest vocabulary it has, which is the STYLE LOCK. A pack asking for three label bars with one quoted string rendered two cards labelled "QUIET MONO MONO", straight out of the lock's "quiet mono labels". Quote every slot, or declare the spares positively ("blank label bar").`, id, ); } if (texts.length > 0 && TRAILING_NEGATION.test(action)) { warn( 'action-trailing-negation', 'a negation clause ("no text…", "carry no text") sits near a quoted string — on the generator it bleeds onto the nearest text and melts it. Declare the state positively instead ("blank tile").', id, ); } if (CHIP_VOCAB.test(action)) { warn('action-chip-vocabulary', '"chip" renders as a poker/credit chip on the generator — use "tile" for a small card component.', id); } if (block.vo && block.vo.trim().length > 20 && action.includes(block.vo.trim())) { warn('vo-leaked-into-action', 'the VO line appears inside the action — the model will try to narrate it', id); } const sfxLine = (block.sfx ?? []).join(', '); if (sfxLine && MUSIC_WORDS.test(sfxLine)) { err('sfx-names-music', 'SFX cues must be sound design — never music, soundtrack, or voice', id); } const cameraMoves = action.match(CAMERA_VERBS) ?? []; const distinctMoves = new Set(cameraMoves.map((m) => m.toLowerCase())); if (distinctMoves.size > 1) { warn('camera-multi-move', `multiple camera behaviors (${[...distinctMoves].join(', ')}) — one per clip, stated once`, id); } if (block.priority === 'P3' && !block.loop && !isV2v(block.mode)) { warn('p3-not-loopable', 'P3 texture clips multiply their value when loopable — consider loop: true', id); } } // ---- variety: a film that repeats one move reads as static --------------- // The families each define motionVerbs and components, and nothing looked at // them: a pack could "pop up ... settle" eleven times and pass every rule // above. That is the failure a bigger style library does NOT fix — the board // can be perfect and the film still feel like one slide shown repeatedly. const family = sheet ? resolveStyleFamily(sheet.family) : null; if (family && pack.blocks.length > 1) { // Compile one pattern per verb (see wordFormPattern: root + inflections, // bounded both ends) rather than rebuilding a regex per block-verb pair. const verbPatterns = family.motionVerbs.map((v: string) => [v, wordFormPattern(v)] as const); const verbsUsed = pack.blocks.map((b: MotionPackBlock) => { const a = (b.action ?? '').toLowerCase(); // the family verbs that actually appear in this action, in any form return verbPatterns.filter(([, re]) => re.test(a)).map(([v]) => v); }); // Entrance verbs only — every block ends on a settle by design, so a shared // settle is not a repeat. const entrances = verbsUsed.map((vs: string[]) => vs.filter((v: string) => !SETTLE_VERB.test(v))); pack.blocks.forEach((block: MotionPackBlock, i: number) => { // No family verb in this action, so the repeat check cannot evaluate it. // Silently skipping taught the author nothing — they assume it passed. // Checked for EVERY block including the first: the old loop started at 1, // which quietly exempted B001 from the one warning meant to catch this. if (entrances[i].length === 0) { warn( 'block-verb-unrecognised', `${block.id} uses no motion verb from the "${family.id}" family, so the ` + `variety check could not run on it. Family verbs: ${family.motionVerbs.join(', ')}.`, block.id, ); return; } if (i === 0) return; const prev = entrances[i - 1]; const cur = entrances[i]; const shared = cur.filter((v: string) => prev.includes(v)); // Every motion this block has was already used by the one before it, so // there is nothing new on screen. prev may carry MORE verbs than these — // the defect is this block adding no new movement, not prev being narrow. if (prev.length > 0 && shared.length === cur.length) { err( 'block-verb-repeated', `${block.id} adds no motion that ${pack.blocks[i - 1].id} did not already use ` + `(${shared.join(', ')}) — consecutive blocks must move differently. The ` + `"${family.id}" family offers: ${family.motionVerbs.join(', ')}.`, block.id, ); } }); // Word-boundary match here too. `.includes('arrow')` also counted // "arrowhead"/"narrow", inflating coverage and hiding the warning. const compPatterns = family.components.map((c: string) => [c, wordFormPattern(c)] as const); const compsUsed = new Set( compPatterns .filter(([, re]) => pack.blocks.some((b: MotionPackBlock) => re.test((b.action ?? '').toLowerCase()))) .map(([c]) => c), ); const need = Math.min(4, family.components.length); if (compsUsed.size < need) { warn( 'family-components-underused', `the film uses ${compsUsed.size} of the "${family.id}" family's ${family.components.length} components ` + `(target ≥${need}) — unused: ${family.components.filter((c: string) => !compsUsed.has(c)).join(' | ')}`, ); } } if (listed) { for (const entry of listed) { if (!rendered.has(entry)) { warn('copy-list-entry-unused', `copyList entry "${entry}" is rendered by no block — a string the film promises and never shows`); } } } // Coverage sanity: rows should tile the timeline without overlaps. const coverage = pack.coverage ?? []; const sorted = [...coverage].sort((a, b) => a.t0Sec - b.t0Sec); for (let i = 1; i < sorted.length; i++) { if (sorted[i].t0Sec < sorted[i - 1].t1Sec) { warn('coverage-overlap', `coverage rows "${sorted[i - 1].segment}" and "${sorted[i].segment}" overlap`); } else if (sorted[i].t0Sec > sorted[i - 1].t1Sec) { warn('coverage-gap', `coverage gap between "${sorted[i - 1].segment}" and "${sorted[i].segment}" — every second needs a decision`); } } const errors = issues.filter((i) => i.severity === 'error'); const warnings = issues.filter((i) => i.severity === 'warning'); return { ok: errors.length === 0, errors, warnings }; } export interface MographPackStats { blockCount: number; byPriority: Record; byMode: Record; v2vCount: number; loopCount: number; /** Clip counts per render scope. */ scopes: { p1: number; p1p2: number; all: number }; /** Estimated USD per scope; null when the per-clip cost is unknown. */ estCostUsd: { p1: number | null; p1p2: number | null; all: number | null }; } export function motionPackStats(pack: MotionPackArtifact, costPerClipUsd: number | null = null): MographPackStats { const byPriority: Record = { P1: 0, P2: 0, P3: 0 }; const byMode: Record = {}; let v2vCount = 0; let loopCount = 0; for (const b of pack.blocks) { byPriority[b.priority] += 1; byMode[b.mode] = (byMode[b.mode] ?? 0) + 1; if (isV2v(b.mode)) v2vCount += 1; if (b.loop) loopCount += 1; } const p1 = byPriority.P1; const p1p2 = byPriority.P1 + byPriority.P2; const all = pack.blocks.length; const cost = (n: number) => (costPerClipUsd === null ? null : Math.round(n * costPerClipUsd * 100) / 100); return { blockCount: all, byPriority, byMode, v2vCount, loopCount, scopes: { p1, p1p2, all }, estCostUsd: { p1: cost(p1), p1p2: cost(p1p2), all: cost(all) }, }; } /** Vertical safe zones, stated positively and stacked by code on 9:16 packs. */ const SAFE_ZONES_LINE = 'SAFE ZONES: the top 12% and bottom 15% of the frame stay free of type and critical detail; ' + 'headlines stack in two or three short lines and tiles sit in a single column inside the middle of the frame.'; export interface AssembleBlockPromptOptions { /** The pack aspect; `9:16` appends the SAFE ZONES line. Omitted = no line (legacy). */ aspect?: string; } /** * Assemble the exact prompt a block submits: style lock + (HERO) + SHOT + * AUDIO + (LOOP) + (SAFE ZONES) + AVOID. Code stacks these — hand-assembly * (and hand-paraphrase of the style lock) is how packs drift. The HERO line * is how a split film restates its subject and identity anchor above every * segment without the author re-typing it into the action. */ export function assembleBlockPrompt( block: MotionPackBlock, sheet: MotionSheetArtifact, options: AssembleBlockPromptOptions = {}, ): string { const sfxLine = (block.sfx ?? []).join(', ') || 'room tone only'; const parts = [sheet.styleLock.trim()]; if (sheet.hero?.descriptor?.trim()) { const anchor = sheet.hero.anchor?.trim(); parts.push( `HERO: ${sheet.hero.descriptor.trim()}${anchor ? ` Identity anchor: ${anchor} — it reads in every frame the hero appears in.` : ''}`, ); } parts.push(`SHOT:\n${block.action.trim()}`, `AUDIO: ${sfxLine} — sound design only, no music, no narration.`); if (block.loop) { parts.push('LOOP: the final frame matches the opening frame so the clip loops seamlessly.'); } if (options.aspect === '9:16') parts.push(SAFE_ZONES_LINE); parts.push(`AVOID: ${sheet.negative.trim()}`); return parts.join('\n\n'); } /** Reference images for a block: sheet ref first, then pack-wide logos, then per-block refs. */ export function blockReferencePaths(block: MotionPackBlock, pack: MotionPackArtifact, sheet: MotionSheetArtifact): string[] { const refs: string[] = []; if (sheet.refImage?.path) refs.push(sheet.refImage.path); for (const logo of pack.logoRefs ?? []) refs.push(logo); for (const r of block.refs ?? []) refs.push(r); return [...new Set(refs)]; } export function filterBlocks( pack: MotionPackArtifact, opts: { priority?: MographPriority; blockIds?: string[] } = {}, ): MotionPackBlock[] { let blocks = pack.blocks; if (opts.blockIds?.length) { const wanted = new Set(opts.blockIds); blocks = blocks.filter((b) => wanted.has(b.id)); } else if (opts.priority) { // Priority scope is cumulative: P2 means P1+P2 (render the edit's spine plus support). const rank: Record = { P1: 1, P2: 2, P3: 3 }; blocks = blocks.filter((b) => rank[b.priority] <= rank[opts.priority!]); } return blocks; }