explainer-video 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -2
  4. package/cordis.patch.yml +7 -0
  5. package/lib/index.js +35 -0
  6. package/package.json +56 -4
  7. package/plugin.json +13 -0
  8. package/skills/coursework-author/.synced-from +5 -0
  9. package/skills/coursework-author/SKILL.md +209 -0
  10. package/skills/craft-codeflow-flows/SKILL.md +116 -0
  11. package/skills/craft-codeflow-mine/SKILL.md +207 -0
  12. package/skills/craft-codeflow-plan/SKILL.md +65 -0
  13. package/skills/craft-codeflow-scene/SKILL.md +168 -0
  14. package/skills/craft-video-align/SKILL.md +109 -0
  15. package/skills/craft-video-assemble/SKILL.md +69 -0
  16. package/skills/craft-video-audio/SKILL.md +59 -0
  17. package/skills/craft-video-enrich/SKILL.md +46 -0
  18. package/skills/craft-video-plan/SKILL.md +57 -0
  19. package/skills/craft-video-publish/SKILL.md +79 -0
  20. package/skills/craft-video-render/SKILL.md +68 -0
  21. package/skills/craft-video-scene/SKILL.md +112 -0
  22. package/skills/craft-video-script/SKILL.md +80 -0
  23. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  24. package/skills/explainer-video-from-codebase/SKILL.md +659 -0
  25. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  26. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  27. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  28. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  29. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  30. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  31. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  32. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  33. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  35. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  37. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  38. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  45. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  59. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  60. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  61. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  62. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  63. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  81. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  88. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  89. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  106. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
  107. package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
  108. package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
  109. package/skills/explainer-video-from-coursework/SERIES.md +232 -0
  110. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  111. package/skills/explainer-video-from-coursework/SKILL.md +858 -0
  112. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  118. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  120. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  121. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  122. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  124. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  125. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  126. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  127. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  128. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  129. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  130. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  131. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  132. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  133. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  134. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  135. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  136. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  137. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  138. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  139. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  140. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  141. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  142. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  143. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  144. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  145. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  146. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  147. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  148. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  149. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  150. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ pacing-check.mjs — does the SCENE give the NARRATION room to breathe?
4
+
5
+ The coverage check stops content being dropped. This stops content being
6
+ RUSHED. A scene can cover every source section and still be unwatchable
7
+ because it gives 41 seconds of narration a 30-second stage.
8
+
9
+ It enforces two independent things:
10
+ 1. BUDGET scene duration must fit the narration (with visual overhead)
11
+ 2. READABILITY line reveal must sit in a human reading band
12
+
13
+ Both are computed with the same dwell model the renderer uses, so the check
14
+ and the render cannot disagree.
15
+
16
+ Usage:
17
+ node tools/pacing-check.mjs --scene out/<name>/<NN>/scenes/<key>.js \
18
+ --timeline out/<name>/<NN>/timeline.json \
19
+ [--segment <key>]
20
+ ========================================================================== */
21
+
22
+ import { readFileSync, existsSync } from 'node:fs';
23
+ import { pathToFileURL } from 'node:url';
24
+ import { resolve, join, dirname } from 'node:path';
25
+ import { WPM as DEFAULT_WPM, EXPANSION as DEFAULT_EXPANSION } from './model.mjs';
26
+
27
+ const argv = process.argv.slice(2);
28
+ const flag = (n, d) => {
29
+ const i = argv.indexOf('--' + n);
30
+ return i === -1 ? d : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true);
31
+ };
32
+
33
+ const WPM = parseFloat(flag('wpm', DEFAULT_WPM));
34
+ const EXPANSION = parseFloat(flag('expansion', DEFAULT_EXPANSION));
35
+ const MIN_RATE = parseFloat(flag('min-rate', '0.45')); // lines/sec — below = sluggish
36
+ const MAX_RATE = parseFloat(flag('max-rate', '1.60')); // lines/sec — above = unreadable
37
+
38
+ const scenePath = flag('scene');
39
+ if (!scenePath || !existsSync(scenePath)) {
40
+ console.error('usage: node tools/pacing-check.mjs --scene <scene-config.js> [--script <script.json>] [--segment <key>]');
41
+ process.exit(1);
42
+ }
43
+
44
+ /* ---- load a browser-style config in node ---- */
45
+ global.window = {};
46
+ await import(pathToFileURL(resolve(scenePath)).href);
47
+ const cfg = global.window.SCENE_CONFIG;
48
+ if (!cfg) { console.error('config did not set window.SCENE_CONFIG'); process.exit(1); }
49
+
50
+ const errors = [];
51
+ const warnings = [];
52
+
53
+ /* ---- dwell model (must mirror scene-code.js) ---- */
54
+ const DWELL_BASE = 0.28, DWELL_PER_CHAR = 0.038;
55
+ function naturalReveal(lines, rev) {
56
+ if (rev.stagger != null) {
57
+ return { total: (lines.length - 1) * rev.stagger, perLine: lines.map(() => rev.stagger) };
58
+ }
59
+ const base = (rev.dwell && rev.dwell.base) != null ? rev.dwell.base : DWELL_BASE;
60
+ const perChar = (rev.dwell && rev.dwell.perChar) != null ? rev.dwell.perChar : DWELL_PER_CHAR;
61
+ const speed = rev.speed || 1;
62
+ const perLine = lines.map(l => (base + l.length * perChar) / speed);
63
+ return { total: perLine.reduce((a, b) => a + b, 0), perLine };
64
+ }
65
+
66
+ /* ---- resolve the measured timeline FIRST: the diagram renderer takes its
67
+ duration from it, and "segment@offset" beats need it resolved ---- */
68
+ const scriptPath = flag('script');
69
+ const timelinePath = flag('timeline');
70
+ const segmentKeyForTotal = flag('segment', cfg.scriptSegment);
71
+
72
+ let SCENES = {};
73
+ let TL_TOTAL = null;
74
+ let WINDOWS = {};
75
+ if (timelinePath && existsSync(timelinePath)) {
76
+ const tl0 = JSON.parse(readFileSync(timelinePath, 'utf8'));
77
+ TL_TOTAL = tl0.total;
78
+ WINDOWS = tl0.windows || {};
79
+ (tl0.segments || []).forEach(sg => { SCENES[sg.key] = { start: sg.start, end: sg.end, dur: sg.duration }; });
80
+ }
81
+ // The code renderer declares `total` in its config. A segment scene takes its
82
+ // duration from that segment's WINDOW in the finished video — not the whole
83
+ // timeline, which would compare one scene against every other scene combined.
84
+ const segWindow = WINDOWS[segmentKeyForTotal];
85
+ const TOTAL = cfg.total != null ? cfg.total : (segWindow ? segWindow.dur : TL_TOTAL);
86
+ if (TOTAL == null) { console.error('no scene total: neither config.total nor timeline.total is set'); process.exit(1); }
87
+
88
+ /* Everything below works in WINDOW-RELATIVE seconds: 0 is the start of this
89
+ segment's window, TOTAL is its end. A config timing like "seg@4.6" is relative
90
+ to the segment's narration, which begins LEAD seconds into the window. Mixing
91
+ absolute and relative in the event set is what used to make the dead-air check
92
+ measure the lead-in and inter-segment gaps instead of in-scene quiet. */
93
+ const leadFor = k => (SCENES[k] && WINDOWS[k]) ? +(SCENES[k].start - WINDOWS[k].start).toFixed(3) : 0;
94
+ const LEAD = leadFor(segmentKeyForTotal);
95
+ const R = v => {
96
+ if (typeof v === 'number') return v;
97
+ if (typeof v === 'string') {
98
+ const m = /^([a-z][a-z0-9-]*)@(-?[0-9.]+)$/.exec(v);
99
+ if (m && SCENES[m[1]]) return +(leadFor(m[1]) + parseFloat(m[2])).toFixed(3);
100
+ }
101
+ return v;
102
+ };
103
+ const asNum = v => (typeof v === 'number' ? v : null);
104
+
105
+ console.log(`\n${scenePath}`);
106
+ console.log('─'.repeat(60));
107
+ console.log(` scene total : ${TOTAL}s${cfg.total == null ? ' (from the measured timeline)' : ' (declared in config)'}`);
108
+
109
+ /* ---- 1. readability of each reveal ---- */
110
+ (cfg.acts || []).filter(a => a.op === 'reveal').forEach(act => {
111
+ const panel = (cfg.panels || []).find(p => p.id === act.panel);
112
+ if (!panel) return;
113
+ const n = naturalReveal(panel.lines, act);
114
+ const rate = panel.lines.length / n.total;
115
+ const end = (act.from || 0) + n.total;
116
+ const flagTxt = rate > MAX_RATE ? ' ✗ too fast to read' : rate < MIN_RATE ? ' ⚠ sluggish' : ' ✓ readable';
117
+ console.log(` reveal ${String(act.panel).padEnd(7)} : ${String(panel.lines.length).padStart(2)} lines, natural ${n.total.toFixed(1)}s → ${rate.toFixed(2)} lines/sec${flagTxt}`);
118
+ console.log(` runs ${(act.from || 0).toFixed(1)}s → ${end.toFixed(1)}s`);
119
+
120
+ if (rate > MAX_RATE) errors.push(`reveal "${act.panel}" is ${rate.toFixed(2)} lines/sec (max ${MAX_RATE}) — viewers cannot read that fast`);
121
+ if (rate < MIN_RATE) warnings.push(`reveal "${act.panel}" is ${rate.toFixed(2)} lines/sec (min ${MIN_RATE}) — may drag`);
122
+ if (end > TOTAL) errors.push(`reveal "${act.panel}" finishes at ${end.toFixed(1)}s, past the ${TOTAL}s scene end`);
123
+ });
124
+
125
+ /* ---- 2. does the scene fit the narration? ---- */
126
+ const segmentKey = flag('segment', cfg.scriptSegment);
127
+ let measured = null;
128
+ if (timelinePath && existsSync(timelinePath)) {
129
+ const tl = JSON.parse(readFileSync(timelinePath, 'utf8'));
130
+ measured = (tl.segments || []).find(s => s.key === segmentKey);
131
+ }
132
+
133
+ if (measured) {
134
+ // MEASURED audio beats an estimate — never tune a scene to a wpm guess when
135
+ // the real waveform exists.
136
+ const need = measured.duration;
137
+ const ratio = TOTAL / need;
138
+ console.log(` narration "${segmentKey}" : MEASURED ${need.toFixed(2)}s (${measured.words} words, ${(measured.words / need * 60).toFixed(0)} wpm actual)`);
139
+ console.log(` scene vs need : ${TOTAL}s / ${need.toFixed(2)}s = ${(ratio * 100).toFixed(0)}%`);
140
+ if (ratio < 1.0) errors.push(`scene is ${TOTAL}s but the measured narration is ${need.toFixed(2)}s — audio would be cut off`);
141
+ else if (ratio > 1.6) warnings.push(`scene is ${TOTAL}s vs ${need.toFixed(2)}s of narration — a lot of dead air`);
142
+ else console.log(' ✓ scene fits the measured audio');
143
+ } else if (scriptPath && existsSync(scriptPath) && segmentKey) {
144
+ const script = JSON.parse(readFileSync(scriptPath, 'utf8'));
145
+ const seg = script.segments.find(s => s.key === segmentKey);
146
+ if (!seg) {
147
+ warnings.push(`segment "${segmentKey}" not found in script — budget check skipped`);
148
+ } else {
149
+ const words = seg.text.split(/\s+/).filter(Boolean).length;
150
+ const narrSec = (words / WPM) * 60;
151
+ const needSec = narrSec * EXPANSION;
152
+ const ratio = TOTAL / needSec;
153
+ console.log(` narration "${segmentKey}" : ${words} words → ${narrSec.toFixed(1)}s ESTIMATED → ${needSec.toFixed(1)}s with overhead`);
154
+ console.log(` scene vs need : ${TOTAL}s / ${needSec.toFixed(1)}s = ${(ratio * 100).toFixed(0)}% (estimate — pass --timeline for measured)`);
155
+ if (ratio < 0.9) {
156
+ errors.push(`scene is ${TOTAL}s but "${segmentKey}" needs ${needSec.toFixed(1)}s — ${((1 - ratio) * 100).toFixed(0)}% too fast`);
157
+ } else if (ratio > 1.6) {
158
+ warnings.push(`scene is ${TOTAL}s vs ${needSec.toFixed(1)}s needed — a lot of dead air`);
159
+ } else {
160
+ console.log(' ✓ scene gives the narration room');
161
+ }
162
+ }
163
+ } else if (!scriptPath && !timelinePath) {
164
+ console.log(' (no --script/--timeline given — budget check skipped)');
165
+ }
166
+
167
+ /* ---- 3. beats: does each beat get the time it declared it needs? ----
168
+ The author declares visual beats, each with a minimum dwell. The narration
169
+ supplies its own MEASURED duration. A beat resolves to
170
+ max(narration, visual_min) — so a beat whose minimum exceeds the time available
171
+ is a SHORTFALL, and a shortfall is a prompt, not a failure: hold the frame, pad
172
+ the audio gap, or extend the narration. A beat that cannot fit inside its
173
+ segment at all is a genuine error. */
174
+ /* Beats may be declared three ways:
175
+ { beats: [{ id, line, min }] } line-anchored — the contract (times resolved)
176
+ [{ id, at, min }] legacy bare array with hand-written seconds
177
+ Line-anchored beats get their `at` from beats.json (written by resolve-beats),
178
+ so a scene can no longer carry a guessed second. A beat that still declares a
179
+ raw `at` without a `line` is reported as a defect, not silently honoured. */
180
+ const declaredBeats = (() => {
181
+ const d = global.window.SCENE_BEATS;
182
+ if (!d) return null;
183
+ if (Array.isArray(d)) return d.slice();
184
+ return Array.isArray(d.beats) ? d.beats.slice() : null;
185
+ })();
186
+
187
+ const beatsPath = (() => {
188
+ const w = flag('work', null);
189
+ if (w) return join(String(w), 'beats.json');
190
+ return timelinePath ? join(dirname(String(timelinePath)), 'beats.json') : null;
191
+ })();
192
+ let resolvedMap = null;
193
+ if (beatsPath && existsSync(beatsPath)) {
194
+ try {
195
+ const doc = JSON.parse(readFileSync(beatsPath, 'utf8'));
196
+ const seg = doc.segments?.[segmentKey];
197
+ if (seg) resolvedMap = new Map(seg.beats.map(b => [b.id, b]));
198
+ } catch { /* unreadable: fall through to the checks below */ }
199
+ }
200
+
201
+ let BEATS = declaredBeats;
202
+ if (BEATS && BEATS.length) {
203
+ BEATS = BEATS.map(b => {
204
+ if (b.line != null) {
205
+ if (!resolvedMap) {
206
+ errors.push(`beat "${b.id}" is line-anchored but beats.json has no entry for "${segmentKey}" — run tools/resolve-beats.mjs`);
207
+ return b;
208
+ }
209
+ const r = resolvedMap.get(b.id);
210
+ if (!r) { errors.push(`beat "${b.id}" (line ${b.line}) is not in beats.json — re-run tools/resolve-beats.mjs`); return b; }
211
+ return { ...b, at: r.at };
212
+ }
213
+ if (b.at != null) {
214
+ warnings.push(`beat "${b.id}" uses a hand-written \`at: ${b.at}\` — bind it to a narration \`line\` instead; a guessed second cannot be validated`);
215
+ }
216
+ return b;
217
+ });
218
+ }
219
+ if (BEATS && BEATS.length && measured) {
220
+ const narr = measured.duration;
221
+ const pad = measured.pad || 0;
222
+ /* a beat can occupy the whole window — narration, the declared `pad` hold, and
223
+ the built-in inter-segment gap */
224
+ /* TOTAL is already WINDOW-RELATIVE (see the note above the resolver), and a
225
+ beat's `at` is window-relative too — so the bound is TOTAL itself. It used to
226
+ subtract LEAD, which quietly removed the last 0.6s of every window; that only
227
+ looked fine while beats were hand-placed nowhere near the end. Line-anchored
228
+ beats legitimately run to the last spoken word, which exposed it. */
229
+ const winEnd = TOTAL;
230
+ BEATS.sort((a, b) => (a.at || 0) - (b.at || 0));
231
+ const last = BEATS[BEATS.length - 1];
232
+ const visualNeed = (last.at || 0) + (last.min || 0);
233
+ console.log(` beats : ${BEATS.length} declared · visual needs ${visualNeed.toFixed(1)}s · narration ${narr.toFixed(2)}s${pad > 0 ? ' + ' + pad + 's hold' : ''} (window ${winEnd.toFixed(2)}s)`);
234
+
235
+ const shortfalls = [];
236
+ BEATS.forEach((b, i) => {
237
+ const at = b.at || 0, min = b.min || 0, end = at + min;
238
+ const next = BEATS[i + 1];
239
+ if (end > winEnd + 0.001) {
240
+ errors.push(`beat "${b.id}" ends at ${end.toFixed(1)}s, past the ${winEnd.toFixed(2)}s window — add \`pad\` to the segment or shorten it`);
241
+ return;
242
+ }
243
+ if (next && end > (next.at || 0) + 0.001) {
244
+ shortfalls.push({ id: b.id, need: min, have: (next.at || 0) - at });
245
+ return;
246
+ }
247
+ if (end > narr + 0.001) {
248
+ if (b.hold) warnings.push(`beat "${b.id}" runs ${(end - narr).toFixed(1)}s past the narration — declared hold`);
249
+ else shortfalls.push({ id: b.id, need: min, have: narr - at });
250
+ }
251
+ });
252
+
253
+ if (shortfalls.length) {
254
+ console.log(` shortfalls : ${shortfalls.length} — hold the frame, pad the audio gap, or extend the narration`);
255
+ shortfalls.forEach(s => console.log(` · "${s.id}" needs ${s.need.toFixed(1)}s, has ${s.have.toFixed(1)}s (+${(s.need - s.have).toFixed(1)}s)`));
256
+ } else {
257
+ console.log(' ✓ every beat has the time it declared it needs');
258
+ }
259
+ } else if (BEATS && BEATS.length) {
260
+ console.log(' beats : declared, but no measured narration — beat check skipped');
261
+ }
262
+
263
+ /* ---- 4. is anything still HAPPENING? ----
264
+ The first two checks passed on a cut with 7.3 seconds of dead air at the end,
265
+ because both only looked at RATES. A scene can be perfectly paced and still
266
+ finish with a static card sitting there. This asks the question directly:
267
+ what is the longest stretch with no visual state change? */
268
+ const MAX_QUIET = parseFloat(flag('max-quiet', '3.5'));
269
+ {
270
+ const events = new Set([0, TOTAL]);
271
+ (cfg.acts || []).forEach(a => {
272
+ if (a.from != null) events.add(a.from);
273
+ if (a.to != null && (a.op === 'show' || a.op === 'layout')) events.add(a.to);
274
+ if (a.op === 'reveal') {
275
+ const p = (cfg.panels || []).find(x => x.id === a.panel);
276
+ if (p) {
277
+ // every line fading in is its own state change — a 20s reveal is not one
278
+ // quiet gap, it is 22 events. Modelling it coarsely reports false dead air.
279
+ let acc = a.from;
280
+ naturalReveal(p.lines, a).perLine.forEach(d => { events.add(+acc.toFixed(3)); acc += d; });
281
+ events.add(+acc.toFixed(3));
282
+ }
283
+ }
284
+ });
285
+ (cfg.notes || []).forEach(n => { events.add(R(n.from)); if (n.to != null) events.add(R(n.to)); });
286
+
287
+ // --- diagram renderer vocabulary: reveal / hide / draw / banners ---
288
+ Object.values(cfg.reveal || {}).forEach(([a, b]) => { [R(a), R(b)].forEach(x => asNum(x) != null && events.add(asNum(x))); });
289
+ Object.values(cfg.hide || {}).forEach(([a, b]) => { [R(a), R(b)].forEach(x => asNum(x) != null && events.add(asNum(x))); });
290
+ Object.values(cfg.draw || {}).forEach(d => { if (d.at) [R(d.at[0]), R(d.at[1])].forEach(x => asNum(x) != null && events.add(asNum(x))); });
291
+ (cfg.banners || []).forEach(b => { const f = asNum(R(b.from)); if (f != null) events.add(f); const u = asNum(R(b.until)); if (u != null) events.add(u); });
292
+
293
+ // declared beats are visual activity: their [at, at+min] spans are state changes,
294
+ // and the span itself counts as "not silent" for the dead-air gap below.
295
+ const beatSpans = (BEATS || []).map(b => {
296
+ const s = LEAD + (b.at || 0), e = LEAD + (b.at || 0) + (b.min || 0);
297
+ if (s <= TOTAL) events.add(+s.toFixed(3));
298
+ if (e <= TOTAL) events.add(+e.toFixed(3));
299
+ return [s, e];
300
+ });
301
+
302
+ const times = [...events].filter(t => t <= TOTAL).sort((a, b) => a - b);
303
+
304
+ /* Dead air is NO VISUAL CHANGE **and** NO NARRATION.
305
+ A note sitting still while the voice explains is normal; a static frame in
306
+ silence is the thing that feels like a hang. So subtract the narration
307
+ windows from each gap and only measure what is genuinely quiet. */
308
+ /* not-silent = narration ∪ the declared beat spans, all window-relative */
309
+ const narrWindows = (measured && segWindow)
310
+ ? [[LEAD, +(LEAD + measured.duration).toFixed(3)]].concat(beatSpans)
311
+ : [];
312
+ const silentPortion = (a, b) => {
313
+ let segs = [[a, b]];
314
+ for (const [s, e] of narrWindows) {
315
+ const next = [];
316
+ for (const [x, y] of segs) {
317
+ if (e <= x || s >= y) { next.push([x, y]); continue; }
318
+ if (s > x) next.push([x, Math.min(s, y)]);
319
+ if (e < y) next.push([Math.max(e, x), y]);
320
+ }
321
+ segs = next;
322
+ }
323
+ return segs.reduce((acc, [x, y]) => acc + Math.max(0, y - x), 0);
324
+ };
325
+
326
+ let maxGap = 0, gapAt = 0;
327
+ for (let i = 1; i < times.length; i++) {
328
+ const g = silentPortion(times[i - 1], times[i]);
329
+ if (g > maxGap) { maxGap = g; gapAt = times[i - 1]; }
330
+ }
331
+ const tail = silentPortion(times[times.length - 2], times[times.length - 1]);
332
+
333
+ console.log(` dead air : longest ${maxGap.toFixed(1)}s with no visual change AND no narration (at ${gapAt.toFixed(1)}s)`);
334
+ console.log(` ending holds a static frame in silence for ${tail.toFixed(1)}s`);
335
+ if (maxGap > MAX_QUIET) {
336
+ errors.push(`dead air: nothing changes on screen and nothing is said for ${maxGap.toFixed(1)}s starting at ${gapAt.toFixed(1)}s (max ${MAX_QUIET}s)`);
337
+ }
338
+ }
339
+
340
+ if (warnings.length) {
341
+ console.log(`\n ⚠ ${warnings.length} warning(s):`);
342
+ warnings.forEach(w => console.log(` - ${w}`));
343
+ }
344
+ if (errors.length) {
345
+ console.log(`\n ✗ ${errors.length} error(s):`);
346
+ errors.forEach(e => console.log(` - ${e}`));
347
+ } else {
348
+ console.log('\n ✓ pacing is sound');
349
+ }
350
+ process.exitCode = errors.length ? 2 : 0;