@kaydash9999/banker-plugins 0.15.1 → 0.16.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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +60 -0
  4. package/README.md +31 -9
  5. package/codex/manifest.json +6 -3
  6. package/codex/transform-matrix.md +2 -0
  7. package/commands/progress.md +10 -0
  8. package/commands/setup.md +1 -1
  9. package/hooks/graceful-pause.mjs +31 -71
  10. package/hooks/hooks.json +11 -1
  11. package/hooks/progress-fallback.mjs +10 -0
  12. package/hooks/progress.mjs +427 -0
  13. package/hooks/register.mjs +82 -5
  14. package/package.json +1 -1
  15. package/skills/3d-intro-build/SKILL.md +235 -122
  16. package/skills/3d-intro-build/references/NOTICE.md +2 -1
  17. package/skills/3d-intro-build/references/assemble.mjs +164 -108
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
  19. package/skills/3d-intro-build/references/curate.mjs +573 -267
  20. package/skills/3d-intro-build/references/intro-fixes.css +61 -0
  21. package/skills/3d-intro-build/references/panel-glass.css +55 -0
  22. package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
  23. package/skills/3d-intro-build/references/prompts.md +32 -9
  24. package/skills/3d-intro-build/references/serve.mjs +111 -103
  25. package/skills/3d-intro-build/references/step-nav.css +59 -0
  26. package/skills/3d-intro-build/references/step-nav.js +503 -0
  27. package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
  28. package/skills/3d-intro-build/references/verify.md +45 -0
  29. package/skills/3d-intro-build/references/video-pool.mjs +780 -0
  30. package/skills/3d-intro-setup/SKILL.md +49 -12
  31. package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
  32. package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
  33. package/skills/lineage/SKILL.md +12 -8
  34. package/skills/lineage/lineage.py +190 -59
  35. package/skills/motion-graphic-make/SKILL.md +73 -28
  36. package/skills/motion-graphic-make/references/curate.mjs +607 -0
  37. package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
  38. package/skills/motion-graphic-make/references/serve.mjs +131 -0
  39. package/skills/omc-patch/SKILL.md +143 -0
  40. package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
  41. package/skills/remains/SKILL.md +125 -0
  42. package/skills/remains/scripts/boxes.mjs +448 -0
  43. package/skills/trouble-shooting/SKILL.md +50 -0
@@ -2,32 +2,45 @@
2
2
  /*
3
3
  * First-party assembler for the 3d-intro-build skill.
4
4
  *
5
- * Takes a project directory that already holds the generated stills + forward-chained clips
6
- * (+ an intro.json manifest describing them) and produces a self-contained preview site:
5
+ * Takes a project directory that already holds the generated stills + clips (+ an intro.json
6
+ * manifest describing them) and produces the intro page:
7
7
  * <outDir>/
8
- * index.html (written from index-template.html, wired to the manifest)
8
+ * index.html (head of index-template.html; no inline script or style)
9
9
  * scrub-engine.js (copied verbatim from references/)
10
+ * scrub-engine.css (the CSS the engine injects, taken from scrub-engine.js unchanged)
11
+ * scrub-engine.LICENSE.txt (scroll-world's MIT notice, which ships with the engine files)
12
+ * theme.css (the template's --sw-* tokens, set from the manifest theme)
13
+ * intro-fixes.css (layout, button text and overflow fixes for the engine, every page)
14
+ * intro.js (the mountScrollWorld config and call)
15
+ * panel-glass.css (only with "panel": "glass")
16
+ * step-nav.js/.css (only with "stepNav": scene-by-scene navigation)
10
17
  * assets/<id>.<ext> (scene posters/stills)
11
- * assets/vid/<id>.mp4 (per-scene dive clips)
12
- * assets/vid/connN.mp4 (optional connectors)
13
- * Serve <outDir> with serve.mjs to preview.
18
+ * assets/vid/<id>.mp4 (per-scene clips: dives, or the short holds of the hold-and-flight mode)
19
+ * assets/vid/connN.mp4 (optional connectors: the flights between scenes)
20
+ * Nothing inline, so the page runs under a strict Content-Security-Policy (no 'unsafe-inline';
21
+ * media-src needs blob:). The engine still tries to inject its <style>: a site with a CSP adds the
22
+ * reported `styleHash` to style-src to keep that refusal out of the console (scrub-engine.css
23
+ * carries the same rules either way). Serve <outDir> with serve.mjs (--csp to check under a policy).
14
24
  *
15
- * Runtime: Node >=18 builtins ONLY (node:fs / node:path / node:url). No external deps,
16
- * no shell — cross-platform (path.join / path.sep; asset URLs are always posix '/').
25
+ * Runtime: Node >=18 builtins ONLY (node:fs / node:path / node:url / node:crypto). No external
26
+ * deps, no shell; cross-platform (path.join / path.sep; asset URLs are always posix '/').
17
27
  *
18
28
  * The manifest (intro.json) mirrors the mountScrollWorld config, but its `still` / `clip` /
19
29
  * `connectors` values are paths to SOURCE files relative to projectDir; this assembler copies
20
30
  * them into assets/ and rewrites the config to the copied relative URLs. Shape:
21
31
  *
22
32
  * {
23
- * "pageTitle": "BRAND — the world of SUBJECT", // optional (else derived)
33
+ * "pageTitle": "BRAND - the world of SUBJECT", // optional (else derived)
24
34
  * "pageDescription": "Scroll to fly through ...", // optional (else derived)
35
+ * "lang": "ko", // optional <html lang> (template: en)
25
36
  * "brand": { "name": "BRAND", "href": "#top" }, // optional
26
37
  * "cta": { "label": "Order now", "href": "#finale" }, // optional top-bar CTA
27
38
  * "hint": "scroll to fly in", // optional
28
39
  * "theme": { "bg":"#F5EDE0","ink":"#241d2b","inkSoft":"#6a6072","accent":"#9B7EBD" }, // optional
29
40
  * "diveScroll": 1.3, "connScroll": 0.9, "crossfade": 0.12, // optional
30
41
  * "nav": true, "atmosphere": true, // optional (only false is emitted)
42
+ * "panel": "glass", // optional frosted-glass copy panel
43
+ * "stepNav": { "end": "#main", ... }, // optional, see step-nav.js for the options
31
44
  * "sections": [ // required, >= 1, in order
32
45
  * { "id":"sceneA", "label":"Scene A",
33
46
  * "still":"still-1.png", "clip":"dive-1.mp4", // required (relative to projectDir)
@@ -47,6 +60,7 @@
47
60
  * outDir output site dir (default: <projectDir>/site)
48
61
  * --manifest manifest path (default: <projectDir>/intro.json)
49
62
  */
63
+ import crypto from 'node:crypto';
50
64
  import fs from 'node:fs';
51
65
  import path from 'node:path';
52
66
  import { fileURLToPath } from 'node:url';
@@ -73,6 +87,53 @@ const extOf = (p) => path.extname(String(p)).toLowerCase();
73
87
 
74
88
  // ---- config build -----------------------------------------------------------
75
89
 
90
+ // The copy and pacing fields a section carries over to the engine as they are.
91
+ const SECTION_FIELDS = ['accent', 'eyebrow', 'title', 'body', 'scroll', 'linger', 'cta'];
92
+ // Where each media field of a section is copied (the source extension is kept).
93
+ const MEDIA = [
94
+ ['still', (id, src) => `assets/${id}${extOf(src)}`],
95
+ ['stillMobile', (id, src) => `assets/${id}-m${extOf(src)}`],
96
+ ['clip', (id, src) => `assets/vid/${id}${extOf(src) || '.mp4'}`],
97
+ ['clipMobile', (id, src) => `assets/vid/${id}-m${extOf(src) || '.mp4'}`],
98
+ ];
99
+
100
+ function requireFields(s, i) {
101
+ if (!s || !s.id) throw new Error(`assemble: section[${i}] missing "id"`);
102
+ for (const k of ['still', 'clip']) if (!s[k]) throw new Error(`assemble: section[${i}] (${s.id}) missing "${k}"`);
103
+ }
104
+
105
+ // One section of the engine config, its still and clips copied into assets/.
106
+ function sectionOut(s, i, copy) {
107
+ requireFields(s, i);
108
+ const out = { id: s.id, label: s.label || s.id };
109
+ for (const [k, dest] of MEDIA) if (s[k]) out[k] = copy(s[k], dest(s.id, s[k]));
110
+ for (const k of SECTION_FIELDS) if (s[k] != null && s[k] !== '') out[k] = s[k];
111
+ if (Array.isArray(s.tags) && s.tags.length) out.tags = s.tags;
112
+ return out;
113
+ }
114
+
115
+ // Connectors: one per gap between sections, in order; a null entry means "crossfade directly".
116
+ function connectorsOut(list, n, suffix, copy) {
117
+ const src = Array.isArray(list) ? list : [];
118
+ return Array.from({ length: Math.max(0, n - 1) }, (_, i) => (src[i]
119
+ ? copy(src[i], `assets/vid/conn${i + 1}${suffix}${extOf(src[i]) || '.mp4'}`) : null));
120
+ }
121
+
122
+ // The config in the shape mountScrollWorld reads (see scrub-engine.js).
123
+ function engineConfig(m, sections, connectors, connectorsMobile) {
124
+ const config = {};
125
+ for (const k of ['brand', 'cta']) if (m[k]) config[k] = m[k];
126
+ config.hint = m.hint || 'scroll to fly in';
127
+ config.diveScroll = m.diveScroll ?? 1.3;
128
+ config.connScroll = m.connScroll ?? 0.9;
129
+ if (m.crossfade != null) config.crossfade = m.crossfade;
130
+ for (const k of ['nav', 'atmosphere']) if (m[k] === false) config[k] = false;
131
+ config.sections = sections;
132
+ config.connectors = connectors;
133
+ if (connectorsMobile.some(Boolean)) config.connectorsMobile = connectorsMobile;
134
+ return config;
135
+ }
136
+
76
137
  /**
77
138
  * Build the exact mountScrollWorld config object from a manifest, copying every referenced
78
139
  * asset into outDir/assets. Returns { config, copied:[destPosix,...] }.
@@ -80,120 +141,118 @@ const extOf = (p) => path.extname(String(p)).toLowerCase();
80
141
  function buildConfig(manifest, projectDir, outDir) {
81
142
  const sections = Array.isArray(manifest.sections) ? manifest.sections : [];
82
143
  if (sections.length === 0) throw new Error('assemble: manifest.sections must have at least one section');
83
-
84
144
  const copied = [];
85
- const track = (destPosix) => { copied.push(destPosix); return destPosix; };
86
-
87
- const sectionsOut = sections.map((s, i) => {
88
- if (!s || !s.id) throw new Error(`assemble: section[${i}] missing "id"`);
89
- if (!s.still) throw new Error(`assemble: section[${i}] (${s.id}) missing "still"`);
90
- if (!s.clip) throw new Error(`assemble: section[${i}] (${s.id}) missing "clip"`);
91
-
92
- const out = { id: s.id, label: s.label || s.id };
93
-
94
- // poster still (keep source extension so png/webp/jpg all work)
95
- out.still = track(copyAsset(projectDir, outDir, s.still, `assets/${s.id}${extOf(s.still)}`));
96
- if (s.stillMobile) {
97
- out.stillMobile = track(copyAsset(projectDir, outDir, s.stillMobile, `assets/${s.id}-m${extOf(s.stillMobile)}`));
98
- }
99
-
100
- // dive clip
101
- out.clip = track(copyAsset(projectDir, outDir, s.clip, `assets/vid/${s.id}${extOf(s.clip) || '.mp4'}`));
102
- if (s.clipMobile) {
103
- out.clipMobile = track(copyAsset(projectDir, outDir, s.clipMobile, `assets/vid/${s.id}-m${extOf(s.clipMobile) || '.mp4'}`));
104
- }
105
-
106
- if (s.accent) out.accent = s.accent;
107
- if (s.eyebrow) out.eyebrow = s.eyebrow;
108
- if (s.title) out.title = s.title;
109
- if (s.body) out.body = s.body;
110
- if (Array.isArray(s.tags) && s.tags.length) out.tags = s.tags;
111
- if (s.scroll != null) out.scroll = s.scroll;
112
- if (s.linger != null) out.linger = s.linger;
113
- if (s.cta) out.cta = s.cta; // last section only (engine renders it wherever present)
114
- return out;
115
- });
116
-
117
- // connectors: length === sections.length - 1; null entries mean "crossfade directly".
118
- const srcConn = Array.isArray(manifest.connectors) ? manifest.connectors : [];
119
- const srcConnM = Array.isArray(manifest.connectorsMobile) ? manifest.connectorsMobile : [];
120
- const connectorsOut = [];
121
- const connectorsMobileOut = [];
122
- for (let i = 0; i < sectionsOut.length - 1; i++) {
123
- const c = srcConn[i];
124
- connectorsOut.push(c ? track(copyAsset(projectDir, outDir, c, `assets/vid/conn${i + 1}${extOf(c) || '.mp4'}`)) : null);
125
- const cm = srcConnM[i];
126
- connectorsMobileOut.push(cm ? track(copyAsset(projectDir, outDir, cm, `assets/vid/conn${i + 1}-m${extOf(cm) || '.mp4'}`)) : null);
127
- }
128
-
129
- // Assemble in the shape mountScrollWorld reads (see scrub-engine.js).
130
- const config = {};
131
- if (manifest.brand) config.brand = manifest.brand;
132
- if (manifest.cta) config.cta = manifest.cta;
133
- config.hint = manifest.hint || 'scroll to fly in';
134
- config.diveScroll = manifest.diveScroll != null ? manifest.diveScroll : 1.3;
135
- config.connScroll = manifest.connScroll != null ? manifest.connScroll : 0.9;
136
- if (manifest.crossfade != null) config.crossfade = manifest.crossfade;
137
- if (manifest.nav === false) config.nav = false;
138
- if (manifest.atmosphere === false) config.atmosphere = false;
139
- config.sections = sectionsOut;
140
- config.connectors = connectorsOut;
141
- if (connectorsMobileOut.some(Boolean)) config.connectorsMobile = connectorsMobileOut;
142
-
143
- return { config, copied };
145
+ const copy = (src, dest) => { copied.push(copyAsset(projectDir, outDir, src, dest)); return dest; };
146
+ const sectionsOut = sections.map((s, i) => sectionOut(s, i, copy));
147
+ const connectors = connectorsOut(manifest.connectors, sectionsOut.length, '', copy);
148
+ const connectorsMobile = connectorsOut(manifest.connectorsMobile, sectionsOut.length, '-m', copy);
149
+ return { config: engineConfig(manifest, sectionsOut, connectors, connectorsMobile), copied };
144
150
  }
145
151
 
146
- // ---- index.html generation --------------------------------------------------
152
+ // ---- page generation -------------------------------------------------------
153
+
154
+ const PANELS = { glass: 'panel-glass.css' };
155
+
156
+ /**
157
+ * The CSS the engine injects into <head>, exactly: the `css` template literal wrapped in @layer sw.
158
+ * A template literal turns CRLF and CR into LF, so a checkout with CRLF line ends (git on Windows)
159
+ * is read the same way and gives the same hash.
160
+ */
161
+ export function engineCss(rawSource) {
162
+ const engineSource = String(rawSource).replace(/\r\n?/g, '\n');
163
+ const open = engineSource.indexOf('const css = `');
164
+ const close = open === -1 ? -1 : engineSource.indexOf('`;', open + 13);
165
+ if (close === -1) throw new Error('assemble: scrub-engine.js no longer holds `const css = `...``');
166
+ const css = engineSource.slice(open + 13, close);
167
+ if (/\\|\$\{/.test(css)) throw new Error('assemble: the engine CSS has escapes or substitutions; copy it by hand');
168
+ return '@layer sw {\n' + css + '\n}';
169
+ }
147
170
 
148
- // Replace the value of a --sw-* custom property in the template's :root block, keeping comments.
149
- function setToken(html, name, value) {
150
- if (!value) return html;
171
+ // Replace the value of a --sw-* custom property, keeping comments.
172
+ function setToken(css, name, value) {
173
+ if (!value) return css;
151
174
  const re = new RegExp(`(--${name}\\s*:\\s*)[^;]+;`);
152
- return html.replace(re, `$1${value};`);
175
+ return css.replace(re, `$1${value};`);
176
+ }
177
+
178
+ // The template's inline theme block, with the manifest's colours, as a stylesheet.
179
+ function themeCss(styleInner, theme = {}) {
180
+ let css = setToken(styleInner, 'sw-bg', theme.bg);
181
+ css = setToken(css, 'sw-ink', theme.ink);
182
+ css = setToken(css, 'sw-ink-soft', theme.inkSoft);
183
+ css = setToken(css, 'sw-accent', theme.accent);
184
+ return css.replace(/^\n+/, '').replace(/\s*$/, '\n');
185
+ }
186
+
187
+ function stylesheets(manifest) {
188
+ return ['scrub-engine.css', 'theme.css', 'intro-fixes.css', manifest.panel ? PANELS[manifest.panel] : null,
189
+ manifest.stepNav ? 'step-nav.css' : null].filter(Boolean);
153
190
  }
154
191
 
155
- /** Render index.html from index-template.html for the given config + manifest metadata. */
156
- function renderIndexHtml(template, config, manifest) {
192
+ /** index.html from the template's head: title, description and lang set, its inline style replaced by links. */
193
+ function renderIndexHtml(template, manifest) {
157
194
  const brandName = manifest.brand?.name || 'BRAND';
158
195
  const subject = manifest.subject || 'SUBJECT';
159
- const title = manifest.pageTitle || `${brandName} — the world of ${subject}`;
196
+ const title = manifest.pageTitle || `${brandName} - the world of ${subject}`;
160
197
  const desc = manifest.pageDescription || `Scroll to fly through the world of ${brandName.replace(/\.+$/, '')}.`;
161
-
162
- // Everything up to the engine <script> tag is the head+body-open; regenerate the tail.
163
198
  const marker = '<script src="scrub-engine.js">';
164
199
  const idx = template.indexOf(marker);
165
200
  if (idx === -1) throw new Error('assemble: index-template.html missing the scrub-engine.js script tag');
166
201
  let head = template.slice(0, idx).replace(/\s*$/, '\n');
167
-
168
202
  head = head.replace(/<title>[^<]*<\/title>/, `<title>${escHtml(title)}</title>`);
169
203
  head = head.replace(/(<meta name="description" content=")[^"]*(")/, `$1${escAttr(desc)}$2`);
170
- const t = manifest.theme || {};
171
- head = setToken(head, 'sw-bg', t.bg);
172
- head = setToken(head, 'sw-ink', t.ink);
173
- head = setToken(head, 'sw-ink-soft', t.inkSoft);
174
- head = setToken(head, 'sw-accent', t.accent);
175
-
176
- const configJson = JSON.stringify(config, null, 2).replace(/\n/g, '\n ');
177
- const tail =
178
- ` <script src="scrub-engine.js"></script>
179
- <script>
180
- mountScrollWorld(document.getElementById('world'), ${configJson});
181
- </script>
182
- </body>
183
- </html>
204
+ if (manifest.lang) head = head.replace(/<html lang="[^"]*">/, `<html lang="${escAttr(manifest.lang)}">`);
205
+ const links = stylesheets(manifest).map((f) => ` <link rel="stylesheet" href="${f}" />`).join('\n');
206
+ head = head.replace(/[ \t]*<style>[\s\S]*?<\/style>\n?/, `${links}\n`);
207
+ const scripts = ['scrub-engine.js', manifest.stepNav ? 'step-nav.js' : null, 'intro.js'].filter(Boolean)
208
+ .map((f) => ` <script src="${f}"></script>`).join('\n');
209
+ return `${head}${scripts}\n</body>\n</html>\n`;
210
+ }
211
+
212
+ // The config and the mount, out of the page so no inline script is needed.
213
+ function introJs(config, nav) {
214
+ const json = (v) => JSON.stringify(v, null, 2).replace(/\n/g, '\n ');
215
+ const lines = nav
216
+ ? [`var nav = ${json(nav === true ? {} : nav)};`, 'if (window.IntroStepNav) window.IntroStepNav.prefetch(config, nav);',
217
+ 'mountScrollWorld(world, config);', 'if (window.IntroStepNav) window.IntroStepNav.attach(world, config, nav);']
218
+ : ['mountScrollWorld(world, config);'];
219
+ return `// Generated by assemble.mjs from intro.json. Kept out of index.html so the page runs under a
220
+ // Content-Security-Policy without 'unsafe-inline'.
221
+ (function () {
222
+ var world = document.getElementById('world');
223
+ var config = ${json(config)};
224
+ ${lines.map((l) => ` ${l}`).join('\n')}
225
+ })();
184
226
  `;
185
- return head + tail;
186
227
  }
187
228
 
188
229
  const escHtml = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
189
230
  const escAttr = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
190
231
 
232
+ // Heads the copied engine rules: they are scroll-world's (MIT), whose notice ships beside them.
233
+ const ENGINE_NOTICE = '/* scroll-world (https://github.com/oso95/scroll-world), MIT License, (c) 2026 cyw.\n'
234
+ + ' The rules scrub-engine.js injects, copied unchanged. Notice: scrub-engine.LICENSE.txt */\n';
235
+
236
+ function writeStatic(out, manifest, template) {
237
+ const engineSource = fs.readFileSync(path.join(HERE, 'scrub-engine.js'), 'utf8');
238
+ fs.copyFileSync(path.join(HERE, 'scrub-engine.js'), path.join(out, 'scrub-engine.js'));
239
+ fs.copyFileSync(path.join(HERE, 'LICENSE'), path.join(out, 'scrub-engine.LICENSE.txt'));
240
+ const css = engineCss(engineSource);
241
+ fs.writeFileSync(path.join(out, 'scrub-engine.css'), ENGINE_NOTICE + css);
242
+ const style = /<style>([\s\S]*?)<\/style>/.exec(template);
243
+ fs.writeFileSync(path.join(out, 'theme.css'), themeCss(style ? style[1] : '', manifest.theme));
244
+ const copies = ['intro-fixes.css', manifest.panel ? PANELS[manifest.panel] : null,
245
+ manifest.stepNav ? 'step-nav.css' : null, manifest.stepNav ? 'step-nav.js' : null].filter(Boolean);
246
+ for (const f of copies) fs.copyFileSync(path.join(HERE, f), path.join(out, f));
247
+ return `'sha256-${crypto.createHash('sha256').update(css).digest('base64')}'`;
248
+ }
249
+
191
250
  // ---- public API -------------------------------------------------------------
192
251
 
193
252
  /**
194
- * Assemble a preview site. Copies scrub-engine.js + all referenced assets into outDir and
195
- * writes index.html wired to the exact mountScrollWorld config.
196
- * @returns {{ outDir:string, index:string, engine:string, assets:string[] }}
253
+ * Assemble the intro page. Copies the engine, its fixes and all referenced assets into outDir and
254
+ * writes index.html, intro.js and the stylesheets (see the header).
255
+ * @returns {{ outDir:string, index:string, engine:string, assets:string[], styleHash:string }}
197
256
  */
198
257
  export function assemble({ projectDir, outDir, manifestPath } = {}) {
199
258
  if (!projectDir) throw new Error('assemble: projectDir is required');
@@ -205,21 +264,17 @@ export function assemble({ projectDir, outDir, manifestPath } = {}) {
205
264
  'Write an intro.json in the project dir (see the header of assemble.mjs for its shape).');
206
265
  }
207
266
  const manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
267
+ if (manifest.panel && !PANELS[manifest.panel]) throw new Error(`assemble: unknown panel "${manifest.panel}" (known: ${Object.keys(PANELS).join(', ')})`);
208
268
 
209
269
  fs.mkdirSync(out, { recursive: true });
210
270
  const { config, copied } = buildConfig(manifest, proj, out);
211
-
212
- // Vendored engine sits next to this script; copy it beside index.html.
213
- const engineSrc = path.join(HERE, 'scrub-engine.js');
214
- if (!fs.existsSync(engineSrc)) throw new Error(`assemble: scrub-engine.js not found at ${engineSrc}`);
215
- fs.copyFileSync(engineSrc, path.join(out, 'scrub-engine.js'));
216
-
217
271
  const template = fs.readFileSync(path.join(HERE, 'index-template.html'), 'utf8');
218
- const html = renderIndexHtml(template, config, manifest);
272
+ const styleHash = writeStatic(out, manifest, template);
273
+ fs.writeFileSync(path.join(out, 'intro.js'), introJs(config, manifest.stepNav));
219
274
  const indexPath = path.join(out, 'index.html');
220
- fs.writeFileSync(indexPath, html);
275
+ fs.writeFileSync(indexPath, renderIndexHtml(template, manifest));
221
276
 
222
- return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied };
277
+ return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied, styleHash };
223
278
  }
224
279
 
225
280
  // ---- CLI --------------------------------------------------------------------
@@ -244,7 +299,8 @@ if (invokedDirectly) {
244
299
  console.log(`assembled ${r.assets.length} asset(s) -> ${r.outDir}`);
245
300
  console.log(`index: ${r.index}`);
246
301
  console.log(`engine: ${r.engine}`);
247
- console.log(`\npreview: node ${path.join(HERE, 'serve.mjs')} ${r.outDir}`);
302
+ console.log(`CSP: the engine injects one <style>; to allow it add ${r.styleHash} to style-src (scrub-engine.css carries the same rules)`);
303
+ console.log(`\npreview: node ${path.join(HERE, 'serve.mjs')} ${r.outDir} [--csp strict]`);
248
304
  } catch (e) {
249
305
  console.error(String(e.message || e));
250
306
  process.exit(1);
@@ -101,17 +101,31 @@ export function parseConsoleSample(text) {
101
101
  return out;
102
102
  }
103
103
 
104
- /** Parse a KEY=VALUE .env body into a plain object (strips surrounding quotes, skips comments). */
104
+ /**
105
+ * Parse a KEY=VALUE .env body into a plain object, skipping comment lines. A quoted value keeps
106
+ * everything inside its quotes. In an unquoted value, a # that follows whitespace starts a comment
107
+ * (also right after `=`), so a note after a value never becomes part of an endpoint or a key.
108
+ */
105
109
  export function parseEnvFile(text) {
106
110
  const env = {};
107
111
  for (const line of String(text || '').split(/\r?\n/)) {
108
112
  if (line.trimStart().startsWith('#')) continue;
109
- const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
110
- if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
113
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=([^\n]*)$/);
114
+ if (m) env[m[1]] = envValue(m[2]);
111
115
  }
112
116
  return env;
113
117
  }
114
118
 
119
+ // `raw` is everything after `=`, so the whitespace before a leading # is still there. A stray
120
+ // trailing \r (a \r\r\n line end) is whitespace too.
121
+ function envValue(raw) {
122
+ const v = raw.trim();
123
+ const q = v[0];
124
+ const end = q === '"' || q === "'" ? v.indexOf(q, 1) : -1;
125
+ if (end > 0) return v.slice(1, end);
126
+ return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
127
+ }
128
+
115
129
  /**
116
130
  * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
117
131
  * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found).
@@ -135,7 +149,7 @@ export function persistCreds(creds, { target } = {}) {
135
149
  const lines = [];
136
150
  for (const [k, v] of Object.entries(creds || {})) {
137
151
  if (k.startsWith('_') || v == null) continue;
138
- lines.push(`${k}=${String(v)}`);
152
+ lines.push(`${k}=${envLiteral(String(v))}`);
139
153
  }
140
154
  const body = lines.length ? `${lines.join('\n')}\n` : '';
141
155
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -144,6 +158,12 @@ export function persistCreds(creds, { target } = {}) {
144
158
  return target;
145
159
  }
146
160
 
161
+ // Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
162
+ function envLiteral(v) {
163
+ if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
164
+ return v.includes('"') ? `'${v}'` : `"${v}"`;
165
+ }
166
+
147
167
  /** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
148
168
  export function redact(s) {
149
169
  if (!s) return '(missing)';
@@ -156,10 +176,23 @@ export function redact(s) {
156
176
  // HTTP
157
177
  // ---------------------------------------------------------------------------
158
178
 
179
+ // Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
180
+ function retryAfterMsOf(headers) {
181
+ const ms = Number(headers.get('retry-after-ms'));
182
+ if (headers.get('retry-after-ms') && Number.isFinite(ms)) return Math.max(0, ms);
183
+ const h = headers.get('retry-after');
184
+ if (!h) return null;
185
+ const sec = Number(h);
186
+ if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
187
+ const at = Date.parse(h);
188
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
189
+ }
190
+
159
191
  /**
160
192
  * Fetch an Azure URL trying the `api-key` header first and, on 401/403, retrying with
161
193
  * `Authorization: Bearer`. Never throws on HTTP status; a network error yields {status:0}.
162
- * @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, error?:Error}>}
194
+ * `retryAfterMs` carries the server's Retry-After hint (null when absent).
195
+ * @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, retryAfterMs:number|null, error?:Error}>}
163
196
  */
164
197
  export async function azFetch(url, { key, binary = false, headers = {}, method = 'GET', body } = {}) {
165
198
  const styles = key ? [{ 'api-key': key }, { Authorization: `Bearer ${key}` }] : [{}];
@@ -179,7 +212,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
179
212
  if (binary) parsed = Buffer.from(await r.arrayBuffer());
180
213
  else if (ct.includes('json')) parsed = await r.json().catch(() => null);
181
214
  else parsed = await r.text().catch(() => null);
182
- last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null };
215
+ last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null, retryAfterMs: retryAfterMsOf(r.headers) };
183
216
  if (r.status !== 401 && r.status !== 403) return last; // accepted (or a non-auth error)
184
217
  // else: this auth style was rejected — fall through and try the next
185
218
  }
@@ -190,26 +223,40 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
190
223
  // Images
191
224
  // ---------------------------------------------------------------------------
192
225
 
226
+ // POST JSON, retrying HTTP 429 up to `retries` times. Waits the server's Retry-After when given,
227
+ // else 10s, 20s, 40s…; either way at most 60s per wait. Low-tier (S0) image deployments hit this.
228
+ async function postJsonWith429Retry(url, { key, body, retries, onNote }) {
229
+ for (let i = 0; ; i++) {
230
+ const r = await azFetch(url, { key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
231
+ if (r.status !== 429 || i >= retries) return r;
232
+ const ms = Math.min(60000, r.retryAfterMs ?? 10000 * 2 ** i);
233
+ if (typeof onNote === 'function') onNote(`HTTP 429; retry ${i + 1}/${retries} in ${ms} ms`);
234
+ await sleep(ms);
235
+ }
236
+ }
237
+
193
238
  /**
194
239
  * Generate a still via the v1 images path; on failure fall back to the classic
195
240
  * `deployments/<dep>` path @2025-04-01-preview (discrete size). Returns a PNG Buffer.
196
- * `onNote(msg)` (optional) is invoked when the classic fallback is taken.
241
+ * HTTP 429 is retried on the same path first (`retry429` times) — the classic path shares the
242
+ * deployment's rate limit, so falling back on 429 would only fail again.
243
+ * `onNote(msg)` (optional) is invoked on 429 waits and when the classic fallback is taken.
197
244
  */
198
245
  export async function generateImage({
199
246
  endpoint, key, deployment, apiVersion = 'preview',
200
- prompt, size = '720x1280', quality = 'low', n = 1, onNote,
247
+ prompt, size = '720x1280', quality = 'low', n = 1, onNote, retry429 = 3,
201
248
  } = {}) {
202
249
  const genBody = { model: deployment, prompt, size, quality, n };
203
- let r = await azFetch(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
204
- key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(genBody),
250
+ let r = await postJsonWith429Retry(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
251
+ key, body: JSON.stringify(genBody), retries: retry429, onNote,
205
252
  });
206
253
  let note = 'v1';
207
- if (!(r.ok && r.body?.data?.[0]?.b64_json)) {
254
+ // no classic retry on 429 (shared limit) or on a lost connection (the v1 call may have billed)
255
+ if (!(r.ok && r.body?.data?.[0]?.b64_json) && r.status !== 429 && r.status !== 0) {
208
256
  note = 'classic-fallback(2025-04-01-preview)';
209
257
  if (typeof onNote === 'function') onNote(`generateImage: v1 path failed (HTTP ${r.status}); trying ${note}`);
210
- r = await azFetch(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
211
- key, method: 'POST', headers: { 'Content-Type': 'application/json' },
212
- body: JSON.stringify({ ...genBody, size: classicSize(size) }),
258
+ r = await postJsonWith429Retry(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
259
+ key, body: JSON.stringify({ ...genBody, size: classicSize(size) }), retries: retry429, onNote,
213
260
  });
214
261
  }
215
262
  const b64 = r.body?.data?.[0]?.b64_json;
@@ -273,30 +320,49 @@ export async function createVideo({
273
320
  fd.append('input_reference', new Blob([inputReferencePng], { type: 'image/png' }), 'input_reference.png');
274
321
  }
275
322
  const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
276
- if (!(r.ok && r.body?.id)) throw new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
323
+ if (!(r.ok && r.body?.id)) {
324
+ // status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
325
+ const err = new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
326
+ err.status = r.status;
327
+ err.errno = r.error?.cause?.code || r.error?.code || null;
328
+ throw err;
329
+ }
277
330
  return r.body;
278
331
  }
279
332
 
333
+ const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180 };
334
+ const POLL_RETRY_4XX = new Set([408, 429]);
335
+ const withoutUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
336
+
280
337
  /**
281
338
  * Poll a Sora job until a terminal status (or maxTicks). Polls first, then sleeps, so a job
282
- * that is already terminal returns without waiting. `onTick(status, i)` is optional.
339
+ * that is already terminal returns without waiting. `onTick(status, i)` is optional; `sleep`
340
+ * replaces the wait between polls. 408, 429, 5xx and network errors keep polling. Any other
341
+ * 4xx throws at once with `err.status` and `err.code`: a job the server no longer knows, or a
342
+ * key it refuses, never turns terminal.
283
343
  * @returns the last status object seen.
284
344
  */
285
- export async function pollVideo({
286
- endpoint, key, id, onTick, apiVersion = 'preview', intervalMs = 5000, maxTicks = 180,
287
- } = {}) {
288
- const url = `${endpoint}/openai/v1/videos/${id}?api-version=${apiVersion}`;
345
+ export async function pollVideo(opts = {}) {
346
+ const o = { ...POLL_DEFAULTS, sleep, ...withoutUndefined(opts) };
347
+ const url = `${o.endpoint}/openai/v1/videos/${o.id}?api-version=${o.apiVersion}`;
289
348
  let st = null;
290
- for (let i = 0; i < maxTicks; i++) {
291
- const r = await azFetch(url, { key, method: 'GET' });
292
- st = r.body || st;
293
- if (typeof onTick === 'function') onTick(st, i);
349
+ for (let i = 0; i < o.maxTicks; i++) {
350
+ const r = await azFetch(url, { key: o.key, method: 'GET' });
351
+ throwIfRefused(r, o.id);
352
+ if (r.ok) st = r.body || st;
353
+ if (typeof o.onTick === 'function') o.onTick(st, i);
294
354
  if (st && TERMINAL_STATUS.has(st.status)) break;
295
- await sleep(intervalMs);
355
+ await o.sleep(o.intervalMs);
296
356
  }
297
357
  return st;
298
358
  }
299
359
 
360
+ function throwIfRefused(r, id) {
361
+ if (r.status < 400 || r.status >= 500 || POLL_RETRY_4XX.has(r.status)) return;
362
+ const code = r.body?.error?.code || null;
363
+ throw Object.assign(new Error(`pollVideo: job ${id} answered HTTP ${r.status}${code ? ` ${code}` : ''}`), { status: r.status, code });
364
+ }
365
+
300
366
  /** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body. */
301
367
  export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview' } = {}) {
302
368
  const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
@@ -397,6 +463,22 @@ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
397
463
  return out;
398
464
  }
399
465
 
466
+ /**
467
+ * A still as a short clip: the hold of the hold-and-flight mode. The PNG repeats for `seconds`,
468
+ * encoded as the scrub engine wants (H.264 yuv420p, a keyframe every 8 frames, faststart, no
469
+ * audio). No generation call, so no cost. Every frame is the still, so a flight whose first or last
470
+ * frame is this still meets the hold exactly. `size` ('720x1280') scales; even sizes only. Returns out.
471
+ */
472
+ export async function stillToClip(png, out, { ffmpeg, seconds = 1, fps = 24, size } = {}) {
473
+ const bin = ffmpeg || await resolveFfmpeg();
474
+ fs.rmSync(out, { force: true }); // a file from an earlier run must not pass for this one
475
+ const vf = `${size ? `scale=${String(size).replace('x', ':')},` : ''}format=yuv420p`;
476
+ const r = runFf(bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
477
+ '-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out]);
478
+ if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`stillToClip failed: ${tail(r.stderr)}`);
479
+ return out;
480
+ }
481
+
400
482
  /** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
401
483
  export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
402
484
  const bin = ffmpeg || await resolveFfmpeg();
@@ -481,15 +563,19 @@ const VIDEO_USD_PER_SEC = 0.10;
481
563
  const IMAGE_USD_EACH = 0.02;
482
564
 
483
565
  /**
484
- * Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip; one image gen
485
- * per scene (two per scene in two-image mode). Returns per-category USD sub-costs and the total.
566
+ * Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip (the Sora rate:
567
+ * WAN rates cannot be read through an API); one image gen per scene (two per scene in two-image
568
+ * mode). mode 'chain' (default) makes one clip of `seconds` per scene; 'holdFlight' makes one
569
+ * flight of `flightSeconds` between each pair of scenes, the holds being encoded stills (free).
570
+ * Returns per-category USD sub-costs and the total.
486
571
  * @returns {{images:number, videos:number, usd:number}}
487
572
  */
488
- export function estimateCost({ nScenes, seconds = 4, twoImage = false } = {}) {
573
+ export function estimateCost({ nScenes, seconds = 4, twoImage = false, mode = 'chain', flightSeconds = 5 } = {}) {
489
574
  const n = Number(nScenes) || 0;
490
575
  const imageCount = twoImage ? n * 2 : n;
491
576
  const images = round2(imageCount * IMAGE_USD_EACH);
492
- const videos = round2(n * seconds * VIDEO_USD_PER_SEC);
577
+ const clipSeconds = mode === 'holdFlight' ? Math.max(0, n - 1) * flightSeconds : n * seconds;
578
+ const videos = round2(clipSeconds * VIDEO_USD_PER_SEC);
493
579
  const usd = round2(images + videos);
494
580
  return { images, videos, usd };
495
581
  }