/** * Motion sheet — the single style authority for a project's motion-graphics * lane. One reference image + a ≤120-word style lock replace ~1,000 words of * per-clip style prose: the image carries what words describe badly (texture, * weight, spacing), the text carries what images cannot enforce (rules, * negatives, audio policy). * * This module is deterministic: validate/normalize/persist + the master-image * prompt composer. Creative authoring (the interview) lives in the mograph * skill; the artifact is the contract. */ import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { resolveProjectWorkspace } from '../workspace.js'; import { writeTextFileAtomic } from '../atomic-write.js'; import type { MographLintIssue, MotionSheetArtifact } from './types.js'; import { resolveStyleFamily, listStyleFamilyIds, type MographStyleFamily } from './style-families.js'; /** Style lock rides above EVERY action block — every word is paid for N times. */ export const STYLE_LOCK_MAX_WORDS = 120; /** The `film` budget: a 2–4 block launch/product film whose lock carries the hero too. */ export const STYLE_LOCK_FILM_MAX_WORDS = 320; /** Below this, a lock is naming the family rather than describing it. */ export const STYLE_LOCK_MIN_DESCRIPTIVE_WORDS = 35; export const NEGATIVE_MAX_WORDS = 80; /** * Clips sit under the operator's own voice-over: generated audio is sound * design only. Every sheet negative must END with this tail — belt and * suspenders, because video models treat a music bed as the default. */ export const AUDIO_BAN_TAIL = 'no music, no soundtrack, no voice-over, no narration, no lyrics'; /** * The sheet is a language, not a layout: without an explicit guard the model * reproduces the reference board's grid as the clip composition. */ const LAYOUT_GUARD_PATTERN = /do\s+not\s+copy\s+the\s+sheet'?s\s+layout/i; /** * When the style board IS attached as a reference (Seedance-family routes), its * sample copy bleeds into clips as on-screen text unless the style lock also * bans transferring the board's words. Advisory: the Omni default is now * reference-free (no board attached), so this only bites routes that keep the * ref — hence a warning, not an error like the layout guard. */ const SAMPLE_WORDS_GUARD_PATTERN = /(sample\s+words|its\s+words|board'?s\s+words|words?\s+comes?\s+from\s+the\s+shot)/i; /** * A hex code in the style lock is RENDERED as on-screen text by the video model * — it reads the string as copy to set. Global so the lint reports every one. */ const HEX_CODE_PATTERN_G = /#[0-9a-fA-F]{3,8}\b/g; /** * Global twins of the two guard patterns, used ONLY by the descriptive-word * strip below. They must stay separate from the /i originals: those are used * with .test(), and a /g regex carries lastIndex between calls, so .test() * would alternate true/false across invocations on different strings. * Non-global .replace() strips only the FIRST match, which let a lock that * repeats its guard clauses bank that boilerplate as "description". */ const LAYOUT_GUARD_PATTERN_G = new RegExp(LAYOUT_GUARD_PATTERN.source, 'gi'); const SAMPLE_WORDS_GUARD_PATTERN_G = new RegExp(SAMPLE_WORDS_GUARD_PATTERN.source, 'gi'); export function countWords(text: string): number { const words = text.trim().split(/\s+/).filter(Boolean); return words.length === 1 && words[0] === '' ? 0 : words.length; } export function motionSheetPathFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'motion-sheet.json'); } export async function readMotionSheet(root: string, slug: string): Promise { const path = motionSheetPathFor(root, slug); if (!existsSync(path)) return null; return JSON.parse(await readFile(path, 'utf-8')) as MotionSheetArtifact; } export async function writeMotionSheet( root: string, slug: string, artifact: MotionSheetArtifact, ): Promise { const path = motionSheetPathFor(root, slug); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, JSON.stringify(artifact, null, 2) + '\n'); return path; } /** * Structural + budget validation for a motion sheet. Returns issues; empty * array = valid. Pure — the handler does the I/O and exit codes. */ export function validateMotionSheet(input: Partial): MographLintIssue[] { const issues: MographLintIssue[] = []; const err = (code: string, message: string) => issues.push({ code, severity: 'error', message }); const warn = (code: string, message: string) => issues.push({ code, severity: 'warning', message }); if (input.schemaVersion !== 1) err('sheet-schema-version', 'schemaVersion must be 1'); if (input.budget !== undefined && input.budget !== 'fleet' && input.budget !== 'film') { err('sheet-budget-unknown', `budget "${String(input.budget)}" must be "fleet" or "film"`); } if (input.hero) { if (!input.hero.descriptor?.trim()) err('hero-descriptor-missing', 'hero.descriptor is required when a hero is declared'); if (!input.hero.anchor?.trim()) { err( 'hero-anchor-missing', 'hero.anchor is required — the ONE design feature the generator keeps in every frame (a feature, never a material or a colour)', ); } } if (!input.projectSlug) err('sheet-project-slug-missing', 'projectSlug is required'); if (!input.sheetId) err('sheet-id-missing', 'sheetId is required'); else if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(input.sheetId)) { err('sheet-id-invalid', `sheetId "${input.sheetId}" must be alphanumeric-with-dashes`); } if (!input.family) err('sheet-family-missing', 'family is required'); else if (!resolveStyleFamily(input.family)) { err( 'sheet-family-unknown', `family "${input.family}" is not in the register (${listStyleFamilyIds().join(', ')})`, ); } if (!input.styleLock?.trim()) { err('style-lock-missing', 'styleLock is required — it is the text half of the style authority'); } else { const words = countWords(input.styleLock); const maxWords = input.budget === 'film' ? STYLE_LOCK_FILM_MAX_WORDS : STYLE_LOCK_MAX_WORDS; if (words > maxWords) { err('style-lock-over-budget', `styleLock is ${words} words (max ${maxWords}${input.budget === 'film' ? ' on the film budget' : ''}) — trim it; every word rides on every clip`); } if (!LAYOUT_GUARD_PATTERN.test(input.styleLock)) { err( 'style-lock-layout-guard-missing', 'styleLock must contain a "do NOT copy the sheet\'s layout" clause — without it clips reproduce the reference board grid', ); } if (!SAMPLE_WORDS_GUARD_PATTERN.test(input.styleLock)) { warn( 'style-lock-sample-words-guard-missing', 'styleLock has no sample-words guard — if the board is attached as a reference (Seedance-family routes) its sample copy can bleed into clips as on-screen text. Add a clause like "do NOT copy the sheet\'s sample words; every on-screen word comes from the SHOT text." (Not needed on the Omni ref-free default.)', ); } // The lock must DESCRIBE the style, not merely point at the board. On the // default route (veo-useapi) image refs are stripped, so this prose is the // ONLY style instruction the model ever sees — a lock that says "use the // attached style sheet for the flat-vector family" instructs nothing, and a // flat-vector clip came back a 3D render with a studio backdrop and confetti // because of exactly that. Every rule above passed on that lock. const describes = countWords( input.styleLock.replace(LAYOUT_GUARD_PATTERN_G, ' ').replace(SAMPLE_WORDS_GUARD_PATTERN_G, ' '), ); // A hex code in the lock gets RENDERED as on-screen text. Confirmed on three // delivered clips: "#F5B72E", "#1B2A4A" and a hallucinated "CERT #FF658" // appeared as labels in frame. The board prompt (buildMasterImagePrompt) is // where hexes belong — it draws a labelled swatch strip on purpose — but the // lock rides above every action block, and the model treats a hex there as // copy to set. Name the colours instead; the board carries the exact values. const hexes = input.styleLock.match(HEX_CODE_PATTERN_G); if (hexes) { err( 'style-lock-hex-code', `styleLock contains ${hexes.length} hex code(s) (${[...new Set(hexes)].slice(0, 4).join(', ')}) — ` + 'they render as on-screen text. Describe the colours by name ("saturated yellow field, near-black ' + 'keylines"); the reference board is what pins the exact values.', ); } if (describes < STYLE_LOCK_MIN_DESCRIPTIVE_WORDS) { err( 'style-lock-not-descriptive', `styleLock carries only ~${describes} words of actual description (min ${STYLE_LOCK_MIN_DESCRIPTIVE_WORDS}) — ` + 'naming the family or pointing at the attached board is not a style instruction. On ref-free routes ' + 'this prose is the only thing the model sees: state the surface, the palette, the type treatment and ' + 'the finish (see the family register for the vocabulary).', ); } } if (!input.negative?.trim()) { err('negative-missing', 'negative is required'); } else { const normalized = input.negative.replace(/\s+/g, ' ').trim().toLowerCase(); if (!normalized.endsWith(AUDIO_BAN_TAIL)) { err('negative-audio-bans-missing', `negative must end with "${AUDIO_BAN_TAIL}"`); } const words = countWords(input.negative); if (words > NEGATIVE_MAX_WORDS) { warn('negative-over-budget', `negative is ${words} words (target ≤${NEGATIVE_MAX_WORDS})`); } } if (input.stage && input.stage.mode === 'locked' && !input.stage.description?.trim()) { err('stage-description-missing', 'a locked stage needs a description — it is the persistent world every clip lives on'); } if (input.refImage && input.refImageHistory?.some((h) => h.path === input.refImage?.path && h.version !== input.refImage?.version)) { warn('ref-image-version-drift', 'refImage path appears in history under a different version — versions must never overwrite'); } return issues; } /** * Compose the master-image prompt that renders the sheet's reference board. * Deterministic template over the family + sheet fields; the image is a * designed art-direction board (type specimen, palette strip, component zoo, * mini-scenes, motion thumbnails), rendered via the existing `gen-image` * surface at the DELIVERY aspect (providers inherit aspect from the attached * reference). */ export function buildMasterImagePrompt( sheet: Pick, family: MographStyleFamily, opts: { aspect?: string; projectLabel?: string } = {}, ): string { const aspect = opts.aspect ?? '16:9'; const label = opts.projectLabel ?? sheet.sheetId; const paletteLine = (sheet.palette ?? []).map((p) => `${p.name} ${p.hex}${p.role ? ` (${p.role})` : ''}`).join(', '); const typeLines = (sheet.typeRoles ?? []) .map((t) => `${t.role} sample in ${t.treatment}`) .join('; '); const components = family.components.join(' / '); const verbs = family.motionVerbs.join(' · '); const sections = [ `MASTER STYLE SHEET — ${label} visual system, one ${aspect} reference board. A single polished art-direction sheet defining the visual language for a video series: editorial grid, consistent margins, section labels. Visible typography is intentional — this is a style guide.`, `SURFACE & MOOD: ${family.surface}. ${sheet.canonicalDescription ? sheet.canonicalDescription.trim() + ' ' : ''}Energy: ${sheet.energy ?? 'balanced'}.`, `TYPE SPECIMEN PANEL: ${typeLines || family.typeVoice}. Letterforms aligned and sharp — no warped or melting text.`, paletteLine ? `PALETTE STRIP: labeled swatches — ${paletteLine}.` : '', `COMPONENT ZOO PANEL: ${components}. Every component shares one construction logic.`, `MINI-SCENE PANEL: three small example frames composing those components — an entrance, a data moment, a settle. Same lighting and finish in all three.`, `MOTION THUMBNAILS: four tiny storyboard frames with arrows only, showing the family's verbs: ${verbs}.`, sheet.logoRefs?.length ? `LOGO: use the attached ${sheet.logoRefs.map((l) => l.brand).join(', ')} mark(s) exactly — flat, unmodified, correct proportions, placed once in the header with generous clear space. Never restyle a logo.` : '', sheet.stage?.mode === 'locked' && sheet.stage.description ? `THE STAGE: one panel shows the persistent background world every clip lives on — ${sheet.stage.description}. Empty, pre-lit, ready for elements to enter.` : '', `FINISH: consistent lighting and texture across all panels. No watermarks, no placeholder gibberish, no unrelated logos — every visible word is one of the samples above.`, ].filter(Boolean); return sections.join('\n\n'); }