#!/usr/bin/env node // Validate ./narrator_scripts.json against the Phase 2 canonical schema. // (Split out of the former merged validate.mjs — narrator and section share no // logic, so they live as independent, independently-invocable scripts.) // // Usage: node validate-narrator.mjs [path] (default: ./narrator_scripts.json) // Run after Phase 2 dispatch returns, before Phase 3 begins. // // Exit 0 = pass; non-zero = fail (errors on stderr). import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const REQUIRED_TOP = ["project", "narrativeArchetype", "emotionalArc", "scenes"]; const REQUIRED_SCENE = [ "sceneNumber", "sceneName", "narrativeIntent", "transition", "assetCandidates", "script", "estimatedDuration", ]; const REQUIRED_INTENT = ["type", "narrativeRole", "keyMessage", "persuasion", "emotionalBeat"]; // transition.intent — a soft hint for downstream phases (decoupled model: it no // longer drives continuity). `morph` hints that a continue-group's worker should // morph a shared element across the seam; cut/slide/dissolve/zoom hint the Tier-B // between-scene transition at a `break`. Only enum membership is enforced. const VALID_TX_INTENTS = new Set(["morph", "cut", "slide", "dissolve", "zoom"]); const VALID_INTENT_TYPES = new Set([ "hook", "pain_point", "product_intro", "feature_showcase", "benefit_highlight", "social_proof", "branding", "cta", ]); // Common drift modes the story-design SKILL.md explicitly warns about. const FORBIDDEN_LEGACY = { scene_id: "sceneNumber", scene_name: "sceneName", narration: "script", voicePath: "(out of scope; remove)", voiceDuration: "(out of scope; remove)", }; // Per-scene script budget — empirically measured against ElevenLabs Rachel (Phase 3 default) // for technical narration: 2.2 words/second (= ~130 wpm). Story-design agents historically // overestimate speech rate (writing ~5 wps estimates that translate to 2-2.5× longer // actual TTS), which inflates scene durations and pushes the multi-phase choreography's // idle tail past the user-readable budget. We enforce a realistic floor here so visual-design // and prep plan against truth, not against the agent's guess. const TTS_WPS = 2.2; const SCENE_TARGET_S = 9; // soft target — visible warning above this const SCENE_HARDCAP_S = 12; // hard cap — fatal error above this (= ~26 words) const SCENE_EXCEPTION_BUDGET = 2; // allow this many scenes to exceed soft target (the main feature_showcase, etc.) function scriptWordCount(script) { if (typeof script !== "string") return 0; const stripped = script.replace(/<[^>]+>/g, " ").trim(); if (!stripped) return 0; return stripped.split(/\s+/).length; } function parseSecondsToken(token) { // Accepts: 6 | "6" | "6s" | "5-6s" | "5 - 6 s" — picks upper bound. if (typeof token === "number" && Number.isFinite(token)) return token; if (typeof token !== "string") return NaN; const range = token.match(/(\d+(?:\.\d+)?)\s*[-–]\s*(\d+(?:\.\d+)?)\s*s?\s*$/); if (range) return parseFloat(range[2]); const single = token.match(/(\d+(?:\.\d+)?)\s*s?\s*$/); return single ? parseFloat(single[1]) : NaN; } function validate(filePath) { const errors = []; let raw; try { raw = readFileSync(filePath, "utf8"); } catch { return [`File not found: ${filePath}`]; } let data; try { data = JSON.parse(raw); } catch (e) { return [`Invalid JSON: ${e.message}`]; } for (const key of REQUIRED_TOP) { if (!(key in data)) errors.push(`Missing top-level field "${key}"`); } for (const [legacy, correct] of Object.entries(FORBIDDEN_LEGACY)) { if (legacy in data) { errors.push(`Top-level forbidden field "${legacy}" — use "${correct}" instead`); } } if (Array.isArray(data.scenes)) { data.scenes.forEach((scene, i) => { const ctx = `scenes[${i}]`; for (const key of REQUIRED_SCENE) { if (!(key in scene)) errors.push(`${ctx}: missing "${key}"`); } for (const [legacy, correct] of Object.entries(FORBIDDEN_LEGACY)) { if (legacy in scene) { errors.push(`${ctx}: forbidden "${legacy}" — use "${correct}"`); } } if (scene.narrativeIntent && typeof scene.narrativeIntent === "object") { for (const key of REQUIRED_INTENT) { if (!(key in scene.narrativeIntent)) { errors.push(`${ctx}.narrativeIntent: missing "${key}"`); } } const t = scene.narrativeIntent.type; if (t && !VALID_INTENT_TYPES.has(t)) { errors.push( `${ctx}.narrativeIntent.type: "${t}" not in allowed set [${[...VALID_INTENT_TYPES].join(", ")}]`, ); } } else if ("narrativeIntent" in scene) { errors.push( `${ctx}.narrativeIntent must be an object with [${REQUIRED_INTENT.join(", ")}]`, ); } // Flattened-intent-fields trap (intent fields living on scene root instead of nested). for (const intentKey of REQUIRED_INTENT) { if ( intentKey in scene && (!scene.narrativeIntent || !(intentKey in scene.narrativeIntent)) ) { errors.push( `${ctx}: "${intentKey}" flat on the scene — must live inside "narrativeIntent"`, ); } } // assetCandidates shape — must be an array of {path, description}. // Empty array is allowed (text-only scenes). if ("assetCandidates" in scene) { if (!Array.isArray(scene.assetCandidates)) { errors.push(`${ctx}.assetCandidates must be an array (use [] for text-only scenes)`); } else { scene.assetCandidates.forEach((cand, j) => { const cctx = `${ctx}.assetCandidates[${j}]`; if (!cand || typeof cand !== "object") { errors.push(`${cctx}: must be an object with {path, description}`); return; } if (typeof cand.path !== "string" || !cand.path) { errors.push(`${cctx}: missing or empty "path"`); } else if (!cand.path.startsWith("public/")) { errors.push(`${cctx}.path: must start with "public/" (got "${cand.path}")`); } if (typeof cand.description !== "string" || !cand.description) { errors.push(`${cctx}: missing or empty "description"`); } }); } } // transition (decoupled model): `continuity` drives WORKER GROUPING only — // "continue" = authored by the same worker as the previous scene (a continuous // run of up to 3 scenes; one worker owns all their DOMs and authors the visual // continuity / optional shared-element morph itself). "break" = a new worker + // a Tier-B transition between. `intent` and `sharedMotif` are soft hints for the // visual-design / scene phases — NOT a rigid biconditional. (Tier-A shared-element // bridges were removed when continue and morph were decoupled.) const tx = scene.transition; if (tx && typeof tx === "object") { const intent = tx.intent; const cont = typeof tx.continuity === "string" ? tx.continuity.toLowerCase() : tx.continuity; if (intent != null && !VALID_TX_INTENTS.has(intent)) { errors.push( `${ctx}.transition.intent: "${intent}" not in [${[...VALID_TX_INTENTS].join(", ")}]`, ); } if (cont != null && cont !== "break" && cont !== "continue") { errors.push( `${ctx}.transition.continuity: must be "break" or "continue" (got "${tx.continuity}")`, ); } if (i === 0 && cont != null && cont !== "break") { errors.push(`${ctx}.transition.continuity: scene 1 must be "break"`); } } else if ("transition" in scene) { errors.push(`${ctx}.transition must be an object {continuity, intent, ...}`); } // captions field is no longer consumed (LV2 captions are agent-authored // in Phase 4a.5 from whisper word JSON). Surface a stderr warning if it's // still present so story-design templates get updated, but don't fail // validation — old narrator_scripts.json files should still pass. if ("captions" in scene) { console.warn( `! ${ctx}.captions: field is deprecated and ignored by Phase 4a.5 captions agent — remove from narrator_scripts.json`, ); } // Per-scene script-length budget — see TTS_WPS / SCENE_TARGET_S above. const wc = scriptWordCount(scene.script); const predictedS = wc / TTS_WPS; if (predictedS > SCENE_HARDCAP_S) { errors.push( `${ctx}.script: ${wc} words predicts ${predictedS.toFixed(1)}s at the realistic ${TTS_WPS} wps TTS rate — exceeds the ${SCENE_HARDCAP_S}s/scene hard cap (≤ ${Math.floor(SCENE_HARDCAP_S * TTS_WPS)} words). Trim clauses; this scene's idle phase will dominate the visual.`, ); } const statedS = parseSecondsToken(scene.estimatedDuration); if (Number.isFinite(statedS) && statedS > 0 && statedS < predictedS * 0.7) { console.warn( `! ${ctx}.estimatedDuration: "${scene.estimatedDuration}" understates a ${wc}-word script — realistic TTS at ${TTS_WPS} wps ≈ ${predictedS.toFixed(1)}s. Update so downstream (audio_meta, visual-design phases) plans against truth, not a fast-talking guess.`, ); } // Annotate for the per-film summary outside the loop. scene.__predictedS = predictedS; scene.__wc = wc; }); // Per-film scene-budget summary — flag if too many scenes exceed the soft target. // (Hard cap was already errored above; this catches "every scene is 10-11s" drift.) const overSoft = data.scenes.filter((s) => s.__predictedS > SCENE_TARGET_S); if (overSoft.length > SCENE_EXCEPTION_BUDGET) { console.warn( `! script budget: ${overSoft.length} of ${data.scenes.length} scene(s) exceed the ${SCENE_TARGET_S}s soft target (≤ ${Math.floor(SCENE_TARGET_S * TTS_WPS)} words) — only ${SCENE_EXCEPTION_BUDGET} allowed (typically the main feature_showcase / a complex causal-chain scene). Trim the rest:\n` + overSoft .map( (s) => ` - scene ${s.sceneNumber} (${s.sceneName}): ${s.__wc} words → ~${s.__predictedS.toFixed(1)}s`, ) .join("\n"), ); } // Clean up scratch fields so the schema check below stays clean for downstream re-reads. data.scenes.forEach((s) => { delete s.__predictedS; delete s.__wc; }); // UI demo requirement from story-design SKILL.md. const types = data.scenes.flatMap((s) => s.narrativeIntent?.type ? [s.narrativeIntent.type] : [], ); if (!types.some((t) => t === "feature_showcase" || t === "product_intro")) { errors.push( "No scene with type=feature_showcase or product_intro — story-design requires at least one UI-demo scene", ); } } else if ("scenes" in data) { errors.push("scenes must be an array"); } return errors; } const target = resolve(process.argv[2] || "./narrator_scripts.json"); const errs = validate(target); if (errs.length) { console.error(`✗ ${target}: ${errs.length} schema error(s)`); for (const e of errs) console.error(` - ${e}`); process.exit(1); } else { console.log(`✓ ${target}: schema OK`); }