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,131 @@
1
+ /* =============================================================================
2
+ frame-dedup.mjs — capture each DISTINCT frame once; hardlink the repeats.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ renderAt(t) is a pure function of time, so consecutive output frames are very
7
+ often byte-identical (measured on this pipeline: 82.1% on a declarative diagram
8
+ scene, 90.3% on an svg scene, 99.7% on a held card). Chromium cannot know that,
9
+ so the old capture loop asked for a full 1920x1080 PNG screenshot — roughly 63 ms,
10
+ ~94% of render wall time — for every one of the 30 frames per second, and threw
11
+ away the ones that were mere re-photographs.
12
+
13
+ The loop here asks the PAGE instead: it hashes the serialized #stage SVG after each
14
+ renderAt(t) (~3.7 ms) and, when the markup is unchanged, hardlinks the previous
15
+ frame to this frame number — same inode, zero extra bytes — so the existing ffmpeg
16
+ encode path runs unchanged.
17
+
18
+ THE ONE WAY THIS CAN BE WRONG
19
+ -----------------------------
20
+ The probe compares MARKUP. Anything that moves pixels without changing the SVG
21
+ markup — a CSS @keyframes animation, a <canvas>, WebGL — would read as "unchanged"
22
+ and freeze. Two independent guards make that impossible to ship silently:
23
+
24
+ 1. GATE before frame 1, if `document.getAnimations().length > 0` the segment
25
+ takes the plain capture path (cheap, decisive, no sampling involved).
26
+ 2. AUDIT every AUDIT_EVERY frames, take a REAL screenshot and diff it against
27
+ the frame that was written. On disagreement: write the truthful frame,
28
+ REWIND and re-capture every frame since the last audit, then disable
29
+ dedup for the remainder of the segment.
30
+
31
+ So the fallback is the old behaviour, and it self-triggers. Worst case we pay the
32
+ original price and stay correct.
33
+
34
+ Measured: 2 real videos (72 scenes, ~54,000 frames) re-rendered to SSIM 1.000000
35
+ against renders that photographed every frame, at 3.28x and 4.32x the speed.
36
+ ========================================================================== */
37
+
38
+ import { writeFileSync, linkSync, copyFileSync, existsSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+
41
+ /* FNV-1a over the serialized stage + its length. Cheap, deterministic, and it covers
42
+ every attribute scene-core mutates (opacity, transform, stroke, fill, text, count). */
43
+ const HASH_EXPR = `(() => {
44
+ const el = document.getElementById('stage') || document.body;
45
+ const s = el.outerHTML;
46
+ let h = 2166136261;
47
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
48
+ return (h >>> 0).toString(16) + ':' + s.length;
49
+ })()`;
50
+
51
+ const ANIM_EXPR = '(document.getAnimations ? document.getAnimations().length : 0)';
52
+ const AUDIT_EVERY = 30; // ~1 s of video; 1 extra screenshot per 30 frames when active
53
+
54
+ const framePath = (dir, i) => join(dir, `frame_${String(i).padStart(5, '0')}.png`);
55
+
56
+ /**
57
+ * Render [tStart, tEnd) at `fps`, writing frame_%05d.png, capturing only distinct states.
58
+ * Returns stats; throws on a missing page (the caller already validated VIDEO_META).
59
+ */
60
+ export async function runFrames({ dir, fps, tStart, tEnd, evaluate, screenshot, log = console.log }) {
61
+ const total = Math.max(1, Math.round((tEnd - tStart) * fps));
62
+ let screenshots = 0, reused = 0, audits = 0, mismatches = 0;
63
+ let prevHash = null, lastAudit = 0;
64
+ let off = false, offAt = null, offWhy = null;
65
+ const hashes = [];
66
+
67
+ const disable = (why, at) => {
68
+ if (off) return;
69
+ off = true; offAt = at; offWhy = why;
70
+ log(`[dedup] dedup off from frame ${at} (${why}) — plain capture for the rest of this segment`);
71
+ };
72
+
73
+ const t0 = Date.now();
74
+ for (let i = 0; i < total; i++) {
75
+ const t = tStart + i / fps;
76
+ await evaluate(`window.renderAt(${t})`);
77
+
78
+ /* GATE — after frame 0 draws, because a scene may inject its CSS on first render. */
79
+ if (i === 0) {
80
+ const anims = await evaluate(ANIM_EXPR);
81
+ if (anims > 0) disable(`scene declares ${anims} CSS/web animation(s)`, 0);
82
+ }
83
+
84
+ const hash = await evaluate(HASH_EXPR);
85
+ hashes.push(hash);
86
+
87
+ const canReuse = !off && prevHash !== null && hash === prevHash && existsSync(framePath(dir, i - 1));
88
+ if (canReuse) {
89
+ try { linkSync(framePath(dir, i - 1), framePath(dir, i)); }
90
+ catch { copyFileSync(framePath(dir, i - 1), framePath(dir, i)); } // different filesystem
91
+ reused++;
92
+ } else {
93
+ writeFileSync(framePath(dir, i), await screenshot());
94
+ screenshots++;
95
+ }
96
+ prevHash = hash;
97
+
98
+ /* AUDIT — only meaningful while dedup is live. Once it is off every frame is a real
99
+ capture, so a disagreement could only mean the page animates on its own clock
100
+ between the capture and the audit; re-capturing then would burn time for nothing. */
101
+ if (!off && i > 0 && i % AUDIT_EVERY === 0) {
102
+ audits++;
103
+ const real = await screenshot();
104
+ const { readFileSync } = await import('node:fs');
105
+ if (!real.equals(readFileSync(framePath(dir, i)))) {
106
+ mismatches++;
107
+ log(`[dedup] audit mismatch at frame ${i} (t=${t.toFixed(2)}s): probe said unchanged, pixels differ`);
108
+ writeFileSync(framePath(dir, i), real);
109
+ const from = lastAudit + 1;
110
+ for (let j = from; j < i; j++) {
111
+ await evaluate(`window.renderAt(${tStart + j / fps})`);
112
+ writeFileSync(framePath(dir, j), await screenshot());
113
+ screenshots++; reused--;
114
+ }
115
+ if (i > from) log(`[dedup] re-captured frames ${from}..${i - 1} truthfully`);
116
+ disable('audit caught a change the markup probe missed', i);
117
+ }
118
+ lastAudit = i;
119
+ }
120
+
121
+ if (i % 120 === 0 || i === total - 1) {
122
+ const el = (Date.now() - t0) / 1000;
123
+ const rate = (i + 1) / Math.max(el, 0.001);
124
+ process.stdout.write(`\r[render] ${i + 1}/${total} ${rate.toFixed(1)} fps ` +
125
+ `shots=${screenshots} reused=${reused} eta ${((total - i - 1) / Math.max(rate, 0.001)).toFixed(0)}s `);
126
+ }
127
+ }
128
+ process.stdout.write('\n');
129
+ return { total, screenshots, reused, audits, mismatches, distinct: new Set(hashes).size,
130
+ disabledAt: offAt, disabledWhy: offWhy, captureSec: (Date.now() - t0) / 1000 };
131
+ }
@@ -0,0 +1,151 @@
1
+ // Resolve a Google API key from the OS's native secret store — never from a
2
+ // plaintext file and never from the environment. The secret stays protected
3
+ // from the shell, the harness, and anyone reading /proc, `ps`, or `env`.
4
+ //
5
+ // The store, per platform:
6
+ // macOS Keychain security find-generic-password -s gemini-tts -w
7
+ // Linux Secret Service secret-tool lookup service gemini-tts (libsecret)
8
+ // Windows Credential Manager read via CredRead (advapi32) — the "generic credential"
9
+ // whose Internet/network address is "gemini-tts"
10
+ //
11
+ // Resolution order (first hit wins):
12
+ // 1. --key <value> explicit CLI arg — transient; visible in the
13
+ // process list, so one-off runs only
14
+ // 2. the platform secret store
15
+ //
16
+ // Nothing here ever logs, echoes or returns the key to stdout.
17
+ // Error text is scrubbed before it is printed.
18
+ //
19
+ // NOTE: the Linux and Windows branches are written from documented behaviour
20
+ // and have not been run on those platforms (ROADMAP §2.1 — unverified off macOS).
21
+ // Windows retrieval: https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw
22
+
23
+ import { execFileSync } from 'node:child_process';
24
+
25
+ const SERVICE = 'gemini-tts';
26
+
27
+ // Windows CredMan read. CRED_TYPE_GENERIC = 1; the blob is UTF-16LE. The target
28
+ // name is the "Internet or network address" typed in Credential Manager's GUI.
29
+ const CRED_READ = `
30
+ Add-Type @'
31
+ using System;
32
+ using System.Runtime.InteropServices;
33
+ public static class Cred {
34
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
35
+ private struct CREDENTIAL {
36
+ public int Flags; public int Type; public IntPtr TargetName; public IntPtr Comment;
37
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
38
+ public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist;
39
+ public int AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;
40
+ }
41
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
42
+ private static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
43
+ [DllImport("advapi32.dll", SetLastError = true)]
44
+ private static extern void CredFree(IntPtr credential);
45
+ public static string Get(string target) {
46
+ IntPtr p;
47
+ if (!CredRead(target, 1, 0, out p)) return null; // 1 = CRED_TYPE_GENERIC
48
+ try {
49
+ var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));
50
+ if (c.CredentialBlobSize == 0) return "";
51
+ var b = new byte[c.CredentialBlobSize];
52
+ Marshal.Copy(c.CredentialBlob, b, 0, b.Length);
53
+ return System.Text.Encoding.Unicode.GetString(b);
54
+ } finally { CredFree(p); }
55
+ }
56
+ }
57
+ '@
58
+ $key = [Cred]::Get('${SERVICE}')
59
+ if ($null -eq $key) { exit 44 }
60
+ [Console]::Write($key)
61
+ `;
62
+
63
+ function run(cmd, args) {
64
+ return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
65
+ }
66
+
67
+ /** The secret-store backend for this platform, or null if there is none. */
68
+ function backend() {
69
+ switch (process.platform) {
70
+ case 'darwin':
71
+ return {
72
+ name: 'Keychain',
73
+ retrieve: () => run('security', ['find-generic-password', '-s', SERVICE, '-w']),
74
+ storeHint: `security add-generic-password -U -s ${SERVICE} -a "$USER" -w (prompts for the key)`,
75
+ };
76
+ case 'linux':
77
+ return {
78
+ name: 'Secret Service (secret-tool)',
79
+ retrieve: () => run('secret-tool', ['lookup', 'service', SERVICE]),
80
+ storeHint: `secret-tool store --label="Gemini TTS" service ${SERVICE} (reads the key from stdin — paste it, then Ctrl-D)`,
81
+ };
82
+ case 'win32':
83
+ return {
84
+ name: 'Credential Manager',
85
+ retrieve: () => run('powershell.exe', ['-NoProfile', '-Command', CRED_READ]),
86
+ storeHint: `Control Panel → User Accounts → Credential Manager → Windows Credentials → "Add a generic credential" — Internet or network address: ${SERVICE}, Password: your API key`,
87
+ };
88
+ default:
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * @returns {{status:'ok'|'empty'|'missing'|'unsupported', key?:string}}
95
+ */
96
+ export function fromSecretStore() {
97
+ const b = backend();
98
+ if (!b) return { status: 'unsupported' };
99
+ let out;
100
+ try {
101
+ out = b.retrieve();
102
+ } catch {
103
+ return { status: 'missing' }; // macOS exits 44 when the item is absent
104
+ }
105
+ const key = out.trim();
106
+ // An entry that exists but was stored empty has a zero-length password.
107
+ return key ? { status: 'ok', key } : { status: 'empty' };
108
+ }
109
+
110
+ /**
111
+ * @returns {{key: string|null, source: string, store: string}}
112
+ */
113
+ export function loadKey(explicit) {
114
+ const st = fromSecretStore();
115
+
116
+ if (explicit && typeof explicit === 'string' && explicit !== 'true') {
117
+ return { key: explicit, source: 'cli --key', store: st.status };
118
+ }
119
+ if (st.status === 'ok') {
120
+ return { key: st.key, source: `secret store (${backend().name})`, store: st.status };
121
+ }
122
+ return { key: null, source: 'none', store: st.status };
123
+ }
124
+
125
+ /** Remove anything that looks like a key from a string before printing it. */
126
+ export function scrub(text) {
127
+ if (!text) return text;
128
+ return String(text)
129
+ .replace(/AIza[0-9A-Za-z_\-]{10,}/g, 'AIza***REDACTED***')
130
+ .replace(/([?&]key=)[^&\s"']+/gi, '$1***REDACTED***')
131
+ .replace(/(x-goog-api-key["':\s]+)[0-9A-Za-z_\-]{10,}/gi, '$1***REDACTED***');
132
+ }
133
+
134
+ /** Human-readable description of where a key came from, with no secret in it. */
135
+ export function describe(src, key) {
136
+ if (!key) return 'not found';
137
+ return `${src}, ${key.length} chars`;
138
+ }
139
+
140
+ /** User-facing "here is how to store your key", per platform and status. */
141
+ export function storeHint(status) {
142
+ const b = backend();
143
+ if (!b) return '';
144
+ if (status === 'empty') {
145
+ return `\n A ${b.name} credential exists but is empty — store it again:\n ${b.storeHint}\n`;
146
+ }
147
+ if (status === 'missing') {
148
+ return `\n Store the key in ${b.name}:\n ${b.storeHint}\n`;
149
+ }
150
+ return '';
151
+ }
@@ -0,0 +1,21 @@
1
+ /* =============================================================================
2
+ model.mjs — the ONE place the duration/estimate constants live.
3
+
4
+ These feed ONLY the estimation layer: planner packing, script-check budget,
5
+ and pacing-check's fallback. The audio/video stages never read them — they
6
+ measure real durations with ffprobe (build-audio → timeline.json → render).
7
+
8
+ Both numbers are MEASURED, not guessed (video 6, the one fully-produced run):
9
+ WPM = 153 Gemini Aoede narration pace: 508 words → 199.0s of audio
10
+ EXPANSION = 1.10 picture-over-narration overhead: 205.7s picture / 199.0s
11
+ narration = 1.03, plus a small buffer for code-heavy
12
+ videos we have not measured yet.
13
+
14
+ If a future run measures a different pace, change it HERE, not in the tools.
15
+
16
+ MAX_PAUSES = 3 pause prompts per code video (design §5.5): script-check and the
17
+ codebase skill's excerpt-check enforce the same cap; a fourth forces a split.
18
+ ========================================================================== */
19
+ export const WPM = 153;
20
+ export const EXPANSION = 1.10;
21
+ export const MAX_PAUSES = 3;
@@ -0,0 +1,38 @@
1
+ /* =============================================================================
2
+ naming.mjs — one stable, order-preserving folder name per finished video.
3
+
4
+ The finished series lands as one folder per video:
5
+
6
+ <destination>/<NN>-<slug>/video.mp4
7
+ <destination>/<NN>-<slug>/description.md
8
+ <destination>/<NN>-<slug>/captions.srt
9
+
10
+ `NN` is the plan's `videoIndex` — the consumption order, stable and monotonic —
11
+ and `slug` is a filesystem-safe form of the video title. Because each video owns
12
+ its own folder, the fixed sidecar filenames (`description.md`, `captions.srt`)
13
+ can never collide across videos, and listing the destination sorts it into
14
+ watch order. `publish.mjs` and `assemble.mjs` both import this so the two agree
15
+ on the folder, regardless of the ad-hoc `--name` the agent may pick.
16
+ ========================================================================== */
17
+
18
+ export function slugify(s) {
19
+ return String(s || '')
20
+ .toLowerCase()
21
+ .normalize('NFKD') // decompose, then strip combining accents
22
+ .replace(/[\u0300-\u036f]/g, '')
23
+ .replace(/[^a-z0-9]+/g, '-') // any run of non-alphanumerics → one dash
24
+ .replace(/^-+|-+$/g, '') // no leading/trailing dashes
25
+ .slice(0, 64); // keep folders and paths short
26
+ }
27
+
28
+ /* Derive the per-video folder from the script. Falls back to `fallback` (the work
29
+ dir's `<NN>` basename) when the script is missing or carries no index/title. */
30
+ export function videoDir(script, fallback) {
31
+ const hasIdx = script && script.videoIndex != null;
32
+ const idx = hasIdx ? String(script.videoIndex) : '';
33
+ const slug = slugify(script && script.title);
34
+ if (idx && slug) return `${idx}-${slug}`;
35
+ if (idx) return idx;
36
+ if (slug) return slug;
37
+ return fallback || 'video';
38
+ }
@@ -0,0 +1,51 @@
1
+ /* onset-audit.mjs — the earliest visual event in each scene, in seconds after the
2
+ narration starts. A scene whose first visual lands more than ~2.5s in opens on a
3
+ near-empty stage (header + badge only), which reads as a dead cut. */
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { resolve } from 'node:path';
7
+
8
+ const off = v => {
9
+ if (typeof v === 'number') return v;
10
+ if (typeof v === 'string') {
11
+ const m = /^[a-z][a-z0-9-]*@(-?[0-9.]+)$/.exec(v);
12
+ if (m) return parseFloat(m[1]);
13
+ }
14
+ return null;
15
+ };
16
+ const push = (arr, v) => { const o = off(v); if (o != null) arr.push(o); };
17
+
18
+ const roots = process.argv.slice(2);
19
+ const rows = [];
20
+ for (const root of roots) {
21
+ const dir = resolve(root, 'scenes');
22
+ let files;
23
+ try { files = readdirSync(dir).filter(f => f.endsWith('.js')); } catch { continue; }
24
+ for (const f of files) {
25
+ global.window = {};
26
+ await import(pathToFileURL(resolve(dir, f)).href);
27
+ const cfg = global.window.SCENE_CONFIG;
28
+ if (!cfg) { rows.push({ scene: f, onset: null, note: 'no SCENE_CONFIG' }); continue; }
29
+ const onsets = [];
30
+ Object.values(cfg.reveal || {}).forEach(a => push(onsets, a[0]));
31
+ Object.values(cfg.draw || {}).forEach(d => push(onsets, d.at && d.at[0]));
32
+ Object.values(cfg.hide || {}).forEach(a => push(onsets, a[0]));
33
+ (cfg.banners || []).forEach(b => push(onsets, b.from));
34
+ (cfg.flows || []).forEach(fl => push(onsets, fl.from));
35
+ const OV = cfg.overlays || {};
36
+ if (OV.cards) push(onsets, OV.cards.inAt);
37
+ if (OV.chips) push(onsets, OV.chips.at);
38
+ if (OV.title) push(onsets, OV.title.from);
39
+ if (OV.finalLine) push(onsets, OV.finalLine.at);
40
+ const finite = onsets.filter(n => Number.isFinite(n));
41
+ rows.push({ scene: f, onset: finite.length ? Math.min(...finite) : null });
42
+ }
43
+ }
44
+ rows.sort((a, b) => (b.onset ?? -1) - (a.onset ?? -1));
45
+ const bad = rows.filter(r => (r.onset ?? 99) > 2.5);
46
+ console.log('scene first visual (s after narration start)');
47
+ console.log('─'.repeat(72));
48
+ rows.forEach(r => console.log(` ${r.scene.padEnd(30)} ${r.onset == null ? 'NONE' : r.onset.toFixed(1)}`));
49
+ console.log('─'.repeat(72));
50
+ console.log(`${rows.length} scenes · ${bad.length} open later than 2.5s`);
51
+ bad.forEach(r => console.log(` ✗ ${r.scene} first visual at ${r.onset.toFixed(1)}s`));