Setting the file. One moment.
Validate Plan · Music To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Gsap Min
scripts/validate-plan.mjs
scripts/ validate-plan.mjs
JavaScript · 161 lines · 7 KB
14 //
15 // Frame-level checks use the vendored storyboard parser. Group-level checks re-scan
16 // the RAW source (the parser's META_RE consumes indented `- params:`/`- asset:` lines).
17 //
18 // Reads: --storyboard, --audiomap, --hyperframes <root> (for templates/).
19
20 import { existsSync, readFileSync } from "node:fs" ;
21 import { join, resolve } from "node:path" ;
22 import { parseStoryboard } from "./lib/storyboard.mjs" ;
23
24 const argv = process.argv. slice ( 2 );
25 const flag = ( n , d ) => {
26 const i = argv. indexOf ( `--${ n }` );
27 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : d;
28 };
29 const hyperframesDir = resolve ( flag ( "hyperframes" , "." ));
30 const storyboardPath = resolve ( flag ( "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
31 const audiomapPath = resolve ( flag ( "audiomap" , join (hyperframesDir, "audiomap.json" )));
32 const templatesDir = resolve ( flag ( "templates" , join (hyperframesDir, "templates" )));
33
34 const errors = [];
35 const warns = [];
36 const r3 = ( x ) => Math. round (x * 1000 ) / 1000 ;
37
38 if ( ! existsSync (storyboardPath)) {
39 console. error ( `✗ STORYBOARD.md not found at ${ storyboardPath }` );
40 process. exit ( 1 );
41 }
42 const raw = readFileSync (storyboardPath, "utf8" );
43 const manifest = parseStoryboard (raw);
44 const G = manifest.globals.extra ?? {};
45
46 // ---------- audio duration ----------
47 let audioDur = null ;
48 if ( existsSync (audiomapPath)) {
49 try {
50 audioDur = JSON . parse ( readFileSync (audiomapPath, "utf8" ))?.audio?.duration_sec ?? null ;
51 } catch (e) {
52 warns. push ( `audiomap parse failed: ${ e . message }` );
53 }
54 } else {
55 warns. push ( `audiomap not found at ${ audiomapPath } — skipping duration cross-check` );
56 }
57
58 // ---------- frontmatter duration_s ----------
59 const declaredDur = G .duration_s != null ? Number. parseFloat ( G .duration_s) : NaN ;
60 if ( ! Number. isFinite (declaredDur)) errors. push ( `frontmatter \` duration_s \` missing or unparseable` );
61 else if (audioDur != null && Math. abs (declaredDur - audioDur) > 0.05 )
62 errors. push ( `frontmatter duration_s (${ declaredDur }) != audiomap duration (${ audioDur })` );
63
64 // ---------- frames (hard) ----------
65 const frames = manifest.frames;
66 if (frames. length === 0 ) errors. push ( `no frames (no \` ## Frame N — <id> \` headings found)` );
67
68 let sum = 0 ;
69 for ( const f of frames) {
70 const label = `frame ${ f . number ?? f . index }${ f . title ? ` (${ f . title })` : ""}` ;
71 if ( ! f.src) errors. push ( `${ label }: missing \` - src: \` ` );
72 if ( ! Number. isFinite (f.durationSeconds) || f.durationSeconds <= 0 )
73 errors. push ( `${ label }: missing/!positive \` - duration: \` (got ${ JSON . stringify ( f . duration ) })` );
74 else sum += f.durationSeconds;
75 }
76 sum = r3 (sum);
77 const tileTarget = Number. isFinite (declaredDur) ? declaredDur : audioDur;
78 if (tileTarget != null && Math. abs (sum - tileTarget) > 0.1 )
79 errors. push (
80 `frame durations sum to ${ sum }s but the track is ${ tileTarget }s — frames must tile it gap-free` ,
81 );
82
83 // ---------- group checks (warns) — parse RAW text ----------
84 const FRAME_HEAD = / ^ ## \s + (?:frame | scene | section) \b / i ;
85 const GROUP_HEAD = / ^ \s * [-*]\s * \*\* \s * ( \w + ) \s * \*\* \s * [—:-]\s * (template | free_design | asset) \b ( . * ) $ / i ;
86 const templateExistsCache = new Map ();
87 function templateExists ( id ) {
88 if (templateExistsCache. has (id)) return templateExistsCache. get (id);
89 const ok = existsSync ( join (templatesDir, id, "index.html" ));
90 templateExistsCache. set (id, ok);
91 return ok;
92 }
93
94 // walk raw lines → group blocks tagged with their frame label + pacing
95 const blocks = [];
96 let frameLabel = "?" ;
97 let pacing = "" ;
98 let cur = null ;
99 for ( const ln of raw. split ( / \r ? \n / )) {
100 if ( FRAME_HEAD . test (ln)) {
101 frameLabel = ln. replace ( / ^ # + \s + / , "" ). trim ();
102 pacing = "" ;
103 cur = null ;
104 continue ;
105 }
106 const pm = ln. match ( / ^ \s * [-*]\s * pacing \s * : \s * ( [A-Za-z_] + )/ i );
107 if (pm && ! cur) {
108 pacing = pm[ 1 ]. toLowerCase ();
109 continue ;
110 }
111 const h = GROUP_HEAD . exec (ln);
112 if (h) {
113 cur = { frameLabel, pacing, name: h[ 1 ], kind: h[ 2 ]. toLowerCase (), rest: h[ 3 ] ?? "" , lines: [] };
114 blocks. push (cur);
115 continue ;
116 }
117 if (cur) cur.lines. push (ln);
118 }
119
120 // HARD: every frame needs >=1 filled group. A frame with no parseable group head
121 // is still a Step-2 skeleton (`### Groups` = `TBD (Step 3)`/empty) — erroring here
122 // is what stops a skipped Step 3 from reaching the build step.
123 const framesWithGroups = new Set (blocks. map (( b ) => b.frameLabel));
124 for ( const f of frames) {
125 const title = String (f.title ?? "" ). trim ();
126 const lbl = `Frame ${ f . number ?? ""} — ${ f . title ?? f . index }` . replace ( / \s + — \s +$ / , "" );
127 const hasGroup = title !== "" && [ ... framesWithGroups]. some (( s ) => s. includes (title));
128 if ( ! hasGroup) {
129 errors. push (
130 `${ lbl }: no filled groups — still a Step-2 skeleton ( \` ### Groups \` is TBD/empty). ` +
131 `Run Step 3: fill its groups ( \` - **gN** — template|free_design|asset … \` ) before building.` ,
132 );
133 }
134 }
135
136 for ( const b of blocks) {
137 const gid = `${ b . frameLabel } / ${ b . name }` ;
138 const blockText = b.rest + " " + b.lines. join ( " " );
139 if (b.kind === "template" ) {
140 const m = b.rest. match ( /`( [ ^ `] + )`/ ) || b.rest. match ( /: \s * ( [\w-] + )/ );
141 const id = m ? m[ 1 ]. trim () : null ;
142 if ( ! id) warns. push ( `${ gid }: template kind but no template id on the head line` );
143 else if ( ! templateExists (id))
144 warns. push ( `${ gid }: template \` ${ id } \` not found at templates/${ id }/index.html` );
145 }
146 if (b.kind === "asset" && b.pacing === "phrase_flow" && /beat_cut/ . test (blockText))
147 warns. push (
148 `${ gid }: beat_cut asset treatment on a phrase_flow frame — use ken_burns/crossfade instead` ,
149 );
150 }
151
152 // ---------- report ----------
153 for ( const w of warns) console. log ( `⚠ ${ w }` );
154 if (errors. length ) {
155 for ( const e of errors) console. error ( `✗ ${ e }` );
156 console. error ( ` \n validate-plan: ${ errors . length } error(s), ${ warns . length } warning(s)` );
157 process. exit ( 1 );
158 }
159 console. log (
160 `✓ validate-plan: ${ frames . length } frames tile ${ sum }s; ${ blocks . length } groups; ${ warns . length } warning(s)` ,
161 );