explainer-video 0.0.0-stage → 0.4.1

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 (153) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -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 +70 -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/craft-video-thumbnail/SKILL.md +168 -0
  24. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  25. package/skills/explainer-video-from-codebase/SKILL.md +698 -0
  26. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  27. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  28. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  29. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  30. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  31. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  32. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  33. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  35. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  37. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  38. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  45. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  59. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  60. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  61. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  62. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  63. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  81. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  88. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  89. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  106. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  107. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +61 -0
  108. package/skills/explainer-video-from-coursework/PIPELINE.md +369 -0
  109. package/skills/explainer-video-from-coursework/ROADMAP.md +727 -0
  110. package/skills/explainer-video-from-coursework/SERIES.md +243 -0
  111. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  112. package/skills/explainer-video-from-coursework/SKILL.md +998 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  118. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  120. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  121. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  122. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  124. package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
  125. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  126. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  127. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  128. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  129. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  130. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  131. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  132. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  133. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  134. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  135. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  136. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  137. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  138. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  139. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  140. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  141. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  142. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  143. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  144. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  145. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  146. package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
  147. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  148. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  149. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  150. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  151. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  152. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  153. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,299 @@
1
+ /* =============================================================================
2
+ timing-report.mjs — render out/<name>/timing.jsonl into four human-readable views.
3
+
4
+ Usage:
5
+ node tools/timing-report.mjs --out out/<name> [--flat]
6
+
7
+ Reads the append-only ledger (one row per attempt) plus the enriched plan for
8
+ titles, then writes:
9
+
10
+ out/<name>/timing.csv — long format, one row per attempt:
11
+ video_title, stage, attempt, seconds, ended_at, note
12
+ out/<name>/timing.md — one heading per video title, a stage -> seconds table
13
+ (attempts summed) with a total.
14
+ out/<name>/timing.svg — static stacked bars (no-JS fallback; opens anywhere).
15
+ out/<name>/timing.html — interactive Plotly chart, self-contained and offline:
16
+ the vendored bundle and src/timing-chart.js are inlined.
17
+
18
+ Grouping — the plan already groups videos by module (the first path segment of a
19
+ video's first source), so a series of 85 videos is really ~17 modules. When the
20
+ ledger spans more than one module the HTML and SVG show one aggregate bar per
21
+ module and (HTML only) drill into a module's videos on click; with a single module
22
+ grouping would be a pointless extra click, so both render every video instead.
23
+ `--flat` forces the per-video chart regardless.
24
+
25
+ All four outputs are derived and regenerable; the ledger is the only source of
26
+ truth. The stage dimension is a *value*, never a column or a hardcoded colour, so
27
+ adding/removing/merging stages changes the legend — not the drawing code.
28
+ ========================================================================== */
29
+
30
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
31
+ import { join, dirname } from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const argv = process.argv.slice(2);
35
+ const flag = (n, d) => { const i = argv.indexOf('--' + n); return i === -1 ? d : argv[i + 1]; };
36
+ const outDir = flag('out');
37
+ if (!outDir) { console.error('usage: node tools/timing-report.mjs --out <seriesDir> [--flat]'); process.exit(1); }
38
+ const forceFlat = argv.includes('--flat');
39
+
40
+ const ledgerPath = join(outDir, 'timing.jsonl');
41
+ const planPath = join(outDir, 'plan.enriched.json');
42
+ const here = dirname(fileURLToPath(import.meta.url));
43
+
44
+ const rows = existsSync(ledgerPath)
45
+ ? readFileSync(ledgerPath, 'utf8').split('\n').filter(Boolean)
46
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
47
+ .filter(Boolean)
48
+ : [];
49
+
50
+ const titles = {};
51
+ const planModule = {};
52
+ if (existsSync(planPath)) {
53
+ try {
54
+ const plan = JSON.parse(readFileSync(planPath, 'utf8'));
55
+ for (const v of (plan.videos || [])) {
56
+ if (v.index == null) continue;
57
+ titles[String(v.index)] = v.title;
58
+ planModule[String(v.index)] = v.group || ((v.sources && v.sources[0]) || '').split('/')[0]; // a code-flow plan groups by flow
59
+ }
60
+ } catch { /* plan unreadable — fall back to the raw key and a single module */ }
61
+ }
62
+ const titleOf = (video) => titles[video] || video;
63
+ const round1 = (n) => String(Math.round(n * 10) / 10);
64
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
65
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
66
+ const fmtDur = (n) => n < 60 ? `${round1(n)}s` : `${Math.floor(n / 60)}m${Math.round(n % 60)}s`;
67
+
68
+ const STAGE_NAMES = { '3': 'script', '4': 'audio', '4b': 'align', '5': 'scene', '6': 'publish', '6b': 'thumbnail', '7': 'render', '8': 'assemble' };
69
+ const PALETTE = ['#4f46e5', '#0ea5e9', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6',
70
+ '#ec4899', '#10b981', '#f97316', '#6366f1', '#a855f7', '#22c55e'];
71
+
72
+ /* Module display names: "09-tool-platforms-mcp" → "Tool Platforms MCP". */
73
+ const ACRONYM = { mcp: 'MCP', llm: 'LLM', llms: 'LLMs', llmops: 'LLMOps', adk: 'ADK', api: 'API', rag: 'RAG', gcp: 'GCP', ui: 'UI' };
74
+ const displayName = (mod) => {
75
+ const s = String(mod || '').replace(/^\d+[-_]/, '');
76
+ if (!s) return '(ungrouped)';
77
+ return s.split(/[-_]/).filter(Boolean)
78
+ .map((w) => ACRONYM[w.toLowerCase()] || (w.charAt(0).toUpperCase() + w.slice(1)))
79
+ .join(' ');
80
+ };
81
+
82
+ const order = [...new Set(rows.map((r) => r.video))];
83
+ order.sort((a, b) => {
84
+ const na = parseInt(a, 10), nb = parseInt(b, 10);
85
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
86
+ return a < b ? -1 : a > b ? 1 : 0;
87
+ });
88
+
89
+ /* Per video: its stages (attempts summed), total, and plan index. */
90
+ const vids = order.map((video) => {
91
+ const byStage = new Map();
92
+ for (const r of rows) {
93
+ if (r.video !== video) continue;
94
+ const e = byStage.get(r.stage) || { s: 0, n: 0 };
95
+ e.s += r.seconds; e.n += 1; byStage.set(r.stage, e);
96
+ }
97
+ const stages = [...byStage.entries()].map(([st, e]) => [st, e.s, e.n]);
98
+ return {
99
+ video, index: parseInt(video, 10),
100
+ title: titleOf(video),
101
+ stages,
102
+ total: stages.reduce((a, [, s]) => a + s, 0),
103
+ };
104
+ });
105
+
106
+ /* Stage order across the whole ledger (first-seen = pipeline order). */
107
+ const stageOrder = [];
108
+ for (const v of vids) for (const [st] of v.stages) if (!stageOrder.includes(st)) stageOrder.push(st);
109
+ const colorOf = new Map(stageOrder.map((st, i) => [st, PALETTE[i % PALETTE.length]]));
110
+ const stageLabel = (st) => STAGE_NAMES[st] ? `${st} · ${STAGE_NAMES[st]}` : st;
111
+
112
+ /* Group by module. A video with no plan entry lands in '(ungrouped)'. */
113
+ const byModule = new Map();
114
+ for (const v of vids) {
115
+ const mod = planModule[v.video] || '';
116
+ if (!byModule.has(mod)) byModule.set(mod, []);
117
+ byModule.get(mod).push(v);
118
+ }
119
+ const modules = [...byModule.entries()]
120
+ .sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))
121
+ .map(([name, videos]) => ({ name, display: displayName(name), videos }));
122
+
123
+ const mode = (forceFlat || modules.length <= 1) ? 'flat' : 'grouped';
124
+ const maxTotal = Math.max(1, ...vids.map((v) => v.total));
125
+
126
+ /* ---- CSV (long format) ---- */
127
+ const csvField = (s) => {
128
+ s = s == null ? '' : String(s);
129
+ return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
130
+ };
131
+ let csv = 'video_title,stage,attempt,seconds,ended_at,note\n';
132
+ for (const r of rows) {
133
+ csv += [titleOf(r.video), r.stage, r.attempt, r.seconds, r.ended_at, r.note ?? '']
134
+ .map(csvField).join(',') + '\n';
135
+ }
136
+ writeFileSync(join(outDir, 'timing.csv'), csv);
137
+
138
+ /* ---- MD (grouped by video) ---- */
139
+ let md = '# Timing audit\n\n';
140
+ if (!rows.length) md += '_No timing rows yet — run stages 3–8 and stamp them._\n';
141
+ for (const v of vids) {
142
+ md += `## ${v.title}\n\n| stage | seconds | attempts |\n| --- | --- | --- |\n`;
143
+ let total = 0;
144
+ for (const [s, e, n] of v.stages) { md += `| ${s} | ${round1(e)} | ${n} |\n`; total += e; }
145
+ md += `| **total** | **${round1(total)}** | |\n\n`;
146
+ }
147
+ writeFileSync(join(outDir, 'timing.md'), md);
148
+
149
+ /* ---- SVG (static stacked bars, no-JS fallback) ---- */
150
+ function stageTotals(videos) {
151
+ const byStage = new Map();
152
+ for (const v of videos) {
153
+ for (const [st, sec, n] of v.stages) {
154
+ const e = byStage.get(st) || { s: 0, n: 0 };
155
+ e.s += sec; e.n += n; byStage.set(st, e);
156
+ }
157
+ }
158
+ const stages = [...byStage.entries()].map(([st, e]) => [st, e.s, e.n]);
159
+ return { stages, total: stages.reduce((a, [, s]) => a + s, 0) };
160
+ }
161
+
162
+ function buildSvg(list, title) {
163
+ const L = 20, LABEL = 360, GAP = 12, CHART = 560, RAXIS = 64, R = 16;
164
+ const WIDTH = L + LABEL + GAP + CHART + RAXIS + R;
165
+ const ROW = 26, ROWGAP = 10;
166
+ const TITLE_H = 30, LEGEND_H = 22, AXIS_H = 26;
167
+ const max = Math.max(1, ...list.map((x) => x.total));
168
+
169
+ let legendLines = 1, lx = 0;
170
+ for (const st of stageOrder) {
171
+ const w = 16 + 6 + stageLabel(st).length * 7.2;
172
+ if (lx + w > WIDTH - L - R && lx > 0) { legendLines++; lx = 0; }
173
+ lx += w + 16;
174
+ }
175
+
176
+ const top = 20;
177
+ const legendTop = top + TITLE_H + 6;
178
+ const chartTop = legendTop + legendLines * LEGEND_H + 14;
179
+ const rowsTop = chartTop + 8;
180
+ const axisTop = rowsTop + list.length * (ROW + ROWGAP);
181
+ const HEIGHT = axisTop + AXIS_H + 16;
182
+ const chartX = L + LABEL + GAP;
183
+
184
+ const p = [];
185
+ p.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" `
186
+ + `viewBox="0 0 ${WIDTH} ${HEIGHT}" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">`);
187
+ p.push(`<rect width="100%" height="100%" fill="#ffffff"/>`);
188
+ p.push(`<text x="${L}" y="${top + 18}" font-size="18" font-weight="600" fill="#111827">${esc(title)}</text>`);
189
+
190
+ if (!list.length) {
191
+ p.push(`<text x="${L}" y="${chartTop + 24}" font-size="14" fill="#6b7280">No timing rows yet.</text>`);
192
+ p.push('</svg>');
193
+ return p.join('\n');
194
+ }
195
+
196
+ lx = L; let ly = legendTop;
197
+ for (const st of stageOrder) {
198
+ const w = 16 + 6 + stageLabel(st).length * 7.2;
199
+ if (lx + w > WIDTH - R && lx > L) { lx = L; ly += LEGEND_H; }
200
+ p.push(`<rect x="${lx}" y="${ly}" width="14" height="14" rx="3" fill="${colorOf.get(st)}"/>`);
201
+ p.push(`<text x="${lx + 20}" y="${ly + 11}" font-size="12" fill="#374151">${esc(stageLabel(st))}</text>`);
202
+ lx += w + 16;
203
+ }
204
+
205
+ const ticks = 5;
206
+ for (let i = 0; i <= ticks; i++) {
207
+ const gx = chartX + CHART * i / ticks;
208
+ p.push(`<line x1="${gx}" y1="${rowsTop - 8}" x2="${gx}" y2="${axisTop - AXIS_H}" stroke="#e5e7eb"/>`);
209
+ const anchor = i === 0 ? 'start' : i === ticks ? 'end' : 'middle';
210
+ p.push(`<text x="${gx}" y="${axisTop}" font-size="11" fill="#6b7280" text-anchor="${anchor}">`
211
+ + `${fmtDur(max * i / ticks)}</text>`);
212
+ }
213
+
214
+ list.forEach((v, i) => {
215
+ const y = rowsTop + i * (ROW + ROWGAP);
216
+ const label = v.title.length > 52 ? v.title.slice(0, 51) + '…' : v.title;
217
+ p.push(`<text x="${L}" y="${y + ROW / 2 + 4}" font-size="12" fill="#111827">${esc(label)}</text>`);
218
+ let bx = chartX;
219
+ for (const [st, sec, attempts] of v.stages) {
220
+ const w = (sec / max) * CHART;
221
+ const tip = `${esc(v.title)} — ${esc(stageLabel(st))}: ${round1(sec)}s${attempts > 1 ? ' (' + attempts + ' attempts)' : ''}`;
222
+ p.push(`<rect x="${bx}" y="${y}" width="${w}" height="${ROW}" rx="2" fill="${colorOf.get(st)}">`
223
+ + `<title>${tip}</title></rect>`);
224
+ bx += w;
225
+ }
226
+ p.push(`<text x="${chartX + (v.total / max) * CHART + 8}" y="${y + ROW / 2 + 4}" `
227
+ + `font-size="11" fill="#374151">${fmtDur(v.total)}</text>`);
228
+ });
229
+
230
+ p.push('</svg>');
231
+ return p.join('\n');
232
+ }
233
+
234
+ const svgList = mode === 'grouped'
235
+ ? modules.map((m) => ({ title: m.display, ...stageTotals(m.videos) }))
236
+ : vids.map((v) => ({ title: v.title, stages: v.stages, total: v.total }));
237
+ const svgTitle = mode === 'grouped' ? 'Time per module, by stage' : 'Time per video, by stage';
238
+ writeFileSync(join(outDir, 'timing.svg'), buildSvg(svgList, svgTitle));
239
+
240
+ /* ---- HTML (interactive Plotly, self-contained + offline) ---- */
241
+ function buildHtml() {
242
+ if (!vids.length) {
243
+ return '<!doctype html><html><head><meta charset="utf-8"></head>'
244
+ + '<body style="font-family:system-ui,sans-serif"><p>No timing rows yet.</p></body></html>';
245
+ }
246
+ const vendor = readFileSync(join(here, '..', 'vendor', 'plotly-basic.min.js'), 'utf8');
247
+ const chartJs = readFileSync(join(here, '..', 'src', 'timing-chart.js'), 'utf8');
248
+
249
+ const htmlModules = modules.map((m) => ({
250
+ name: m.name,
251
+ display: m.display,
252
+ videos: m.videos.map((v) => ({
253
+ index: v.index,
254
+ title: v.title,
255
+ stages: Object.fromEntries(v.stages.map(([st, sec]) => [st, sec])),
256
+ })),
257
+ }));
258
+ // escaping "</" keeps a title containing "</script>" from closing the tag
259
+ const data = JSON.stringify({ mode, stages: stageOrder.map((k) => [k, STAGE_NAMES[k] || '']), modules: htmlModules })
260
+ .replace(/<\//g, '<\\/');
261
+
262
+ const body = mode === 'grouped'
263
+ ? `<h1>Time per module</h1>
264
+ <div id="timing-chart"></div>
265
+ <p class="hint">Click a module bar to break it down by video.</p>
266
+ <div id="timing-detail-wrap" hidden>
267
+ <h2 id="timing-detail-title"></h2>
268
+ <p class="hint"><a href="#" id="all-modules">&uarr; all modules</a></p>
269
+ <div id="timing-detail"></div>
270
+ </div>`
271
+ : `<h1>Time per video, by stage</h1>
272
+ <div id="timing-chart"></div>`;
273
+
274
+ return `<!doctype html>
275
+ <html><head><meta charset="utf-8"><title>timing audit</title>
276
+ <style>
277
+ body { margin:0; padding:0 16px 40px; background:#fff; color:#111827;
278
+ font-family:system-ui,-apple-system,'Segoe UI',sans-serif; }
279
+ #timing-chart, #timing-detail { width:100%; }
280
+ h1 { font-size:17px; font-weight:600; margin:14px 0 0; }
281
+ h2 { font-size:15px; font-weight:600; margin:22px 0 0; }
282
+ .hint { font-size:11px; color:#6b7280; margin:6px 0 0; }
283
+ .hint a { color:#4f46e5; text-decoration:none; }
284
+ .hint a:hover { text-decoration:underline; }
285
+ </style>
286
+ </head><body>
287
+ ${body}
288
+ <script>${vendor}</script>
289
+ <script>window.TIMING_DATA = ${data};</script>
290
+ <script>${chartJs}</script>
291
+ </body></html>`;
292
+ }
293
+ writeFileSync(join(outDir, 'timing.html'), buildHtml());
294
+
295
+ console.log(`[timing] ${rows.length} row(s) · ${order.length} video(s) · ${modules.length} module(s) · ${mode}`);
296
+ console.log(`[out] ${join(outDir, 'timing.csv')}`);
297
+ console.log(`[out] ${join(outDir, 'timing.md')}`);
298
+ console.log(`[out] ${join(outDir, 'timing.svg')}`);
299
+ console.log(`[out] ${join(outDir, 'timing.html')}`);
@@ -0,0 +1,96 @@
1
+ /* =============================================================================
2
+ transcribe-gemini.mjs — word-level timings for a narration WAV.
3
+
4
+ Gemini's TTS models return audio only (verified: the response has no timing
5
+ fields at all). But `gemini-3.5-transcribe` can return per-word offsets when
6
+ asked for them — the flag is `wordTimestamp`, which the API discovery schema
7
+ documents but the prose docs never show in an example:
8
+
9
+ generationConfig.audioTranscriptionConfig.wordTimestamp = true
10
+ -> candidates[0].content.parts[0].audioTranscription.words[]
11
+ { word, startOffset: "3.900s", endOffset: "4.100s" }
12
+
13
+ Two things this module deliberately does NOT do:
14
+ • customVocabulary — the API rejects it together with wordTimestamp.
15
+ • trust the transcript as text. It applies inverse text normalisation
16
+ ("forty" -> "40") and rewrites punctuation, so callers must align the
17
+ returned words back onto their own known text. See align.mjs.
18
+
19
+ Audio is sent inline (base64) — no Files API upload, one request per segment.
20
+ ========================================================================== */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { loadKey, scrub } from './key-source.mjs';
24
+
25
+ const API = 'https://generativelanguage.googleapis.com/v1beta';
26
+ export const MODEL = 'gemini-3.5-transcribe';
27
+
28
+ /** "12.600s" | "1.5s" -> 12.6 | 1.5 (null when absent/unparseable) */
29
+ export function parseOffset(d) {
30
+ if (typeof d === 'number') return d;
31
+ const m = /^(-?\d+(?:\.\d+)?)s$/.exec(String(d ?? '').trim());
32
+ return m ? parseFloat(m[1]) : null;
33
+ }
34
+
35
+ /**
36
+ * Transcribe one WAV, returning word records with float offsets in seconds.
37
+ *
38
+ * @param {{wavPath:string, key?:string, languageCode?:string, retries?:number}} opts
39
+ * @returns {Promise<{text:string, words:Array<{w:string,s:number,e:number}>, model:string}>}
40
+ */
41
+ export async function transcribeWords({ wavPath, key, languageCode = 'en-US', retries = 3 }) {
42
+ const apiKey = key || loadKey().key;
43
+ if (!apiKey) throw new Error('No API key found — see key-source.mjs for the store command.');
44
+
45
+ const b64 = readFileSync(wavPath).toString('base64');
46
+ const body = {
47
+ contents: [{ parts: [{ inlineData: { mimeType: 'audio/wav', data: b64 } }] }],
48
+ generationConfig: {
49
+ audioTranscriptionConfig: { languageCodes: [languageCode], wordTimestamp: true },
50
+ },
51
+ };
52
+
53
+ for (let attempt = 0; ; attempt++) {
54
+ const res = await fetch(`${API}/models/${MODEL}:generateContent`, {
55
+ method: 'POST',
56
+ headers: { 'x-goog-api-key': apiKey, 'Content-Type': 'application/json' },
57
+ body: JSON.stringify(body),
58
+ });
59
+ const text = await res.text();
60
+
61
+ if (res.status === 429 && attempt < retries) {
62
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
63
+ continue;
64
+ }
65
+ if (!res.ok) {
66
+ let msg = text.slice(0, 300);
67
+ try { msg = JSON.parse(text).error?.message || msg; } catch {}
68
+ throw new Error(`transcribe failed ${res.status}: ${scrub(msg)}`);
69
+ }
70
+
71
+ let json;
72
+ try { json = JSON.parse(text); } catch { throw new Error('transcribe returned non-JSON'); }
73
+ const part = json.candidates?.[0]?.content?.parts?.[0] || {};
74
+ const at = part.audioTranscription;
75
+ if (!at) {
76
+ throw new Error(
77
+ 'transcribe returned no audioTranscription block — the model may have refused this audio.\n' +
78
+ ` parts: ${JSON.stringify(Object.keys(part))}`
79
+ );
80
+ }
81
+
82
+ const words = (at.words || []).map(w => ({
83
+ w: String(w.word ?? ''),
84
+ s: parseOffset(w.startOffset),
85
+ e: parseOffset(w.endOffset),
86
+ })).filter(w => w.s != null && w.e != null);
87
+
88
+ if (!words.length) {
89
+ throw new Error(
90
+ 'transcribe returned words[] empty — wordTimestamp was set but no offsets came back.\n' +
91
+ ' Refusing to write a timing-less alignment (silent desync is worse than a hard failure).'
92
+ );
93
+ }
94
+ return { text: String(at.text ?? ''), words, model: MODEL };
95
+ }
96
+ }