@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +31 -9
- package/codex/manifest.json +6 -3
- package/codex/transform-matrix.md +2 -0
- package/commands/progress.md +10 -0
- package/commands/setup.md +1 -1
- package/hooks/graceful-pause.mjs +31 -71
- package/hooks/hooks.json +11 -1
- package/hooks/progress-fallback.mjs +10 -0
- package/hooks/progress.mjs +427 -0
- package/hooks/register.mjs +82 -5
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +235 -122
- package/skills/3d-intro-build/references/NOTICE.md +2 -1
- package/skills/3d-intro-build/references/assemble.mjs +164 -108
- package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-build/references/curate.mjs +573 -267
- package/skills/3d-intro-build/references/intro-fixes.css +61 -0
- package/skills/3d-intro-build/references/panel-glass.css +55 -0
- package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
- package/skills/3d-intro-build/references/prompts.md +32 -9
- package/skills/3d-intro-build/references/serve.mjs +111 -103
- package/skills/3d-intro-build/references/step-nav.css +59 -0
- package/skills/3d-intro-build/references/step-nav.js +503 -0
- package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
- package/skills/3d-intro-build/references/verify.md +45 -0
- package/skills/3d-intro-build/references/video-pool.mjs +780 -0
- package/skills/3d-intro-setup/SKILL.md +49 -12
- package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
- package/skills/lineage/SKILL.md +12 -8
- package/skills/lineage/lineage.py +190 -59
- package/skills/motion-graphic-make/SKILL.md +73 -28
- package/skills/motion-graphic-make/references/curate.mjs +607 -0
- package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
- package/skills/motion-graphic-make/references/serve.mjs +131 -0
- package/skills/omc-patch/SKILL.md +143 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
- package/skills/remains/SKILL.md +125 -0
- package/skills/remains/scripts/boxes.mjs +448 -0
- 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 +
|
|
6
|
-
*
|
|
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 (
|
|
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
|
|
12
|
-
* assets/vid/connN.mp4 (optional connectors)
|
|
13
|
-
*
|
|
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
|
|
16
|
-
* no shell
|
|
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
|
|
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
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
// ----
|
|
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
|
|
149
|
-
function setToken(
|
|
150
|
-
if (!value) return
|
|
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
|
|
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
|
-
/**
|
|
156
|
-
function renderIndexHtml(template,
|
|
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}
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
head =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
189
230
|
const escAttr = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
|
195
|
-
* writes index.html
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
110
|
-
if (m) env[m[1]] = m[2]
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
204
|
-
key,
|
|
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
|
-
|
|
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
|
|
211
|
-
key,
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
if (
|
|
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
|
|
485
|
-
* per scene (two per scene in two-image
|
|
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
|
|
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
|
}
|