@kaydash9999/banker-plugins 0.21.0 → 0.23.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 (129) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +69 -0
  4. package/PRIVACY.md +8 -3
  5. package/README.md +78 -15
  6. package/bin/banker.js +154 -32
  7. package/bin/lib/codex-hooks.mjs +123 -20
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +204 -0
  10. package/bin/lib/command-skill.cjs +50 -0
  11. package/bin/lib/telemetry-config.mjs +2 -1
  12. package/codex/command-skills/front-qa/SKILL.md +56 -0
  13. package/codex/command-skills/setup/SKILL.md +87 -0
  14. package/codex/hooks.json +29 -0
  15. package/codex/manifest.json +18 -15
  16. package/codex/skills/graceful-pause/SKILL.md +2 -2
  17. package/codex/skills/progress/scripts/progress.mjs +44 -14
  18. package/codex/skills/teamwork/SKILL.md +137 -0
  19. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
  20. package/codex/transform-matrix.md +5 -3
  21. package/commands/setup.md +2 -2
  22. package/hooks/obsidize-hook.mjs +75 -32
  23. package/hooks/telemetry-count-codex.mjs +303 -35
  24. package/hooks/update-fetch.mjs +2 -1
  25. package/hooks/update-notify.mjs +146 -13
  26. package/package.json +3 -1
  27. package/skills/3d-intro-build/SKILL.md +101 -20
  28. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  29. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  30. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  31. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  32. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  33. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  34. package/skills/3d-intro-build/references/prompts.md +51 -4
  35. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  36. package/skills/3d-intro-build/references/step-nav.css +29 -0
  37. package/skills/3d-intro-build/references/step-nav.js +277 -106
  38. package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
  39. package/skills/3d-intro-build/references/verify.md +6 -0
  40. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  41. package/skills/3d-intro-setup/SKILL.md +20 -7
  42. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  43. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  44. package/skills/all-in-one/SKILL.md +125 -86
  45. package/skills/anti-drm/SKILL.md +1 -1
  46. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  47. package/skills/arch-diagram/build_pptx_template.py +47 -28
  48. package/skills/audit-web-page/SKILL.md +1 -1
  49. package/skills/audit-web-page/harness.py +0 -1
  50. package/skills/lineage/lineage.py +1435 -533
  51. package/skills/motion-graphic-make/SKILL.md +1 -1
  52. package/skills/obsidizer/SKILL.md +20 -5
  53. package/skills/omc-patch/SKILL.md +1 -1
  54. package/skills/payload-mon/SKILL.md +2 -2
  55. package/skills/play-qa/SKILL.md +7 -5
  56. package/skills/progress-mon/README.md +162 -0
  57. package/skills/progress-mon/SKILL.md +165 -0
  58. package/skills/progress-mon/assets/fixture/README.md +1 -0
  59. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  60. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  61. package/skills/progress-mon/assets/fixture/config.json +30 -0
  62. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  63. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  64. package/skills/progress-mon/assets/fixture/items.json +14 -0
  65. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  66. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  67. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  68. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  69. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  70. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  71. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  72. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  73. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  74. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  75. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  76. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  77. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  78. package/skills/progress-mon/references/arch.md +280 -0
  79. package/skills/progress-mon/references/authoring.md +199 -0
  80. package/skills/progress-mon/references/config.md +115 -0
  81. package/skills/progress-mon/references/flow.md +75 -0
  82. package/skills/progress-mon/references/inventory.md +58 -0
  83. package/skills/progress-mon/references/items.md +74 -0
  84. package/skills/progress-mon/references/req-spec.md +74 -0
  85. package/skills/progress-mon/references/reqs.md +30 -0
  86. package/skills/progress-mon/scripts/index.js +31 -0
  87. package/skills/progress-mon/scripts/pm-check.mjs +526 -0
  88. package/skills/progress-mon/scripts/pm-config.mjs +341 -0
  89. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  90. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  91. package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
  92. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  93. package/skills/progress-mon/scripts/pm-server.mjs +735 -0
  94. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  95. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  96. package/skills/ralph-qa/SKILL.md +105 -16
  97. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  98. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  99. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  100. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  101. package/skills/ralph-qa/references/seat-run.mjs +159 -155
  102. package/skills/remains/SKILL.md +2 -1
  103. package/skills/remains/scripts/boxes.mjs +6 -2
  104. package/skills/rfp-author/scripts/md2docx.py +63 -34
  105. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  106. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  107. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  108. package/skills/setup-mcp/SKILL.md +1 -1
  109. package/skills/setup-omc-hud/SKILL.md +2 -1
  110. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  111. package/skills/smart-compact/SKILL.md +1 -1
  112. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  113. package/skills/theme-selection/SKILL.md +1 -1
  114. package/skills/tone-compact/SKILL.md +1 -1
  115. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  116. package/skills/ultra-chain/SKILL.md +34 -16
  117. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  118. package/skills/ultra-init/SKILL.md +30 -22
  119. package/skills/ultra-ui-qa/SKILL.md +1 -1
  120. package/skills/ultraqa/NOTICE.md +34 -0
  121. package/skills/ultraqa/SKILL.md +196 -0
  122. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  123. package/skills/ultraqa/references/run-folder.mjs +299 -0
  124. package/skills/ultraqa/references/tmux-drive.md +52 -0
  125. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  126. package/skills/update-banker/SKILL.md +32 -7
  127. package/skills/vertical-pptx/SKILL.md +1 -1
  128. package/skills/vertical-pptx/build_a4p.js +20 -2
  129. package/skills/vertical-pptx/build_a4p.py +560 -359
@@ -5,7 +5,7 @@
5
5
  * Takes a project directory that already holds the generated stills + clips (+ an intro.json
6
6
  * manifest describing them) and produces the intro page:
7
7
  * <outDir>/
8
- * index.html (head of index-template.html; no inline script or style)
8
+ * index.html (head of index-template.html; no inline script or style; preloads the first posters)
9
9
  * scrub-engine.js (copied verbatim from references/)
10
10
  * scrub-engine.css (the CSS the engine injects, taken from scrub-engine.js unchanged)
11
11
  * scrub-engine.LICENSE.md (scroll-world's MIT notice, which ships with the engine files)
@@ -13,10 +13,11 @@
13
13
  * intro-fixes.css (layout, button text and overflow fixes for the engine, every page)
14
14
  * intro.js (the mountScrollWorld config and call)
15
15
  * panel-glass.css (only with "panel": "glass")
16
- * step-nav.js/.css (only with "stepNav": scene-by-scene navigation)
16
+ * step-nav.js/.css (scene-by-scene navigation; on unless "stepNav": false)
17
17
  * assets/<id>.<ext> (scene posters/stills)
18
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)
19
+ * assets/vid/connN.mp4 (optional connectors: the flights between scenes, or dive mode's exits)
20
+ * assets/connN-poster.<ext> (optional connectorStills: a connector's own first frame as its poster)
20
21
  * Nothing inline, so the page runs under a strict Content-Security-Policy (no 'unsafe-inline';
21
22
  * media-src needs blob:). The engine still tries to inject its <style>: a site with a CSP adds the
22
23
  * reported `styleHash` to style-src to keep that refusal out of the console (scrub-engine.css
@@ -40,7 +41,8 @@
40
41
  * "diveScroll": 1.3, "connScroll": 0.9, "crossfade": 0.12, // optional
41
42
  * "nav": true, "atmosphere": true, // optional (only false is emitted)
42
43
  * "panel": "glass", // optional frosted-glass copy panel
43
- * "stepNav": { "end": "#main", ... }, // optional, see step-nav.js for the options
44
+ * "panelAlpha": 0.7, // optional glass alpha; default: panelAlpha() below
45
+ * "stepNav": { "end": "#main", ... }, // optional, default on ({}); false turns it off; options in step-nav.js
44
46
  * "sections": [ // required, >= 1, in order
45
47
  * { "id":"sceneA", "label":"Scene A",
46
48
  * "still":"still-1.png", "clip":"dive-1.mp4", // required (relative to projectDir)
@@ -51,7 +53,10 @@
51
53
  * "cta": { "primary":{"label":"","href":""}, "secondary":{"label":"","href":""} } } // last only
52
54
  * ],
53
55
  * "connectors": ["conn-1.mp4", null, ...], // optional, len = sections.length-1
54
- * "connectorsMobile": ["conn-1-m.mp4", ...] // optional, same length
56
+ * "connectorsMobile": ["conn-1-m.mp4", ...], // optional, same length
57
+ * "connectorStills": ["conn-1-first.png", ...] // optional, same length: each connector's poster,
58
+ * // its clip's actual first frame (dive mode's exits).
59
+ * // Without it the engine shows the next scene's still.
55
60
  * }
56
61
  *
57
62
  * Usage:
@@ -73,10 +78,17 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
73
78
  const osPath = (base, posixRel) => path.join(base, ...posixRel.split('/'));
74
79
 
75
80
  // Copy one source asset (path relative to projectDir) to a posix dest URL under outDir.
81
+ // Only files inside the project folder (after links) go into assets/: a manifest naming ../ or a link
82
+ // out of the folder would otherwise publish any readable file with the page.
76
83
  function copyAsset(projectDir, outDir, srcRel, destPosix) {
77
84
  const src = path.join(projectDir, ...String(srcRel).split(/[\\/]/));
78
85
  if (!fs.existsSync(src)) throw new Error(`assemble: asset not found: ${srcRel} (looked at ${src})`);
86
+ const root = fs.realpathSync(projectDir);
87
+ const real = fs.realpathSync(src);
88
+ if (real !== root && !real.startsWith(root + path.sep)) throw Object.assign(new Error(`assemble: asset outside the project folder: ${srcRel} (resolves to ${real}); copy it into ${root} first`), { code: 'EOUTSIDE' });
79
89
  const dest = osPath(outDir, destPosix);
90
+ const out = path.resolve(outDir);
91
+ if (!path.resolve(dest).startsWith(out + path.sep)) throw Object.assign(new Error(`assemble: asset would be written outside the output folder: ${destPosix}`), { code: 'EOUTSIDE' });
80
92
  fs.mkdirSync(path.dirname(dest), { recursive: true });
81
93
  fs.copyFileSync(src, dest);
82
94
  return destPosix;
@@ -97,8 +109,12 @@ const MEDIA = [
97
109
  ['clipMobile', (id, src) => `assets/vid/${id}-m${extOf(src) || '.mp4'}`],
98
110
  ];
99
111
 
112
+ // A section id becomes a file name under assets/, so it is letters, digits, '-' and '_' only (no ../ or a separator).
113
+ const SECTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
114
+
100
115
  function requireFields(s, i) {
101
116
  if (!s || !s.id) throw new Error(`assemble: section[${i}] missing "id"`);
117
+ if (!SECTION_ID_RE.test(String(s.id))) throw new Error(`assemble: section[${i}] id ${JSON.stringify(String(s.id)).slice(0, 80)} must be letters, digits, '-' or '_' (it names files under assets/)`);
102
118
  for (const k of ['still', 'clip']) if (!s[k]) throw new Error(`assemble: section[${i}] (${s.id}) missing "${k}"`);
103
119
  }
104
120
 
@@ -114,13 +130,17 @@ function sectionOut(s, i, copy) {
114
130
 
115
131
  // Connectors: one per gap between sections, in order; a null entry means "crossfade directly".
116
132
  function connectorsOut(list, n, suffix, copy) {
133
+ return gapsOut(list, n, copy, (i, src) => `assets/vid/conn${i + 1}${suffix}${extOf(src) || '.mp4'}`);
134
+ }
135
+
136
+ // One copied file (or null) per gap between sections.
137
+ function gapsOut(list, n, copy, dest) {
117
138
  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));
139
+ return Array.from({ length: Math.max(0, n - 1) }, (_, i) => (src[i] ? copy(src[i], dest(i, src[i])) : null));
120
140
  }
121
141
 
122
142
  // The config in the shape mountScrollWorld reads (see scrub-engine.js).
123
- function engineConfig(m, sections, connectors, connectorsMobile) {
143
+ function engineConfig(m, sections, { connectors, connectorsMobile, connectorStills }) {
124
144
  const config = {};
125
145
  for (const k of ['brand', 'cta']) if (m[k]) config[k] = m[k];
126
146
  config.hint = m.hint || 'scroll to fly in';
@@ -130,10 +150,13 @@ function engineConfig(m, sections, connectors, connectorsMobile) {
130
150
  for (const k of ['nav', 'atmosphere']) if (m[k] === false) config[k] = false;
131
151
  config.sections = sections;
132
152
  config.connectors = connectors;
133
- if (connectorsMobile.some(Boolean)) config.connectorsMobile = connectorsMobile;
134
- return config;
153
+ return Object.assign(config, setLists({ connectorsMobile, connectorStills }));
135
154
  }
136
155
 
156
+ // The per-gap lists that hold at least one file. connectorStills is the engine's opt-in connector
157
+ // poster (see the banker line in scrub-engine.js and NOTICE.md).
158
+ const setLists = (lists) => Object.fromEntries(Object.entries(lists).filter(([, list]) => list.some(Boolean)));
159
+
137
160
  /**
138
161
  * Build the exact mountScrollWorld config object from a manifest, copying every referenced
139
162
  * asset into outDir/assets. Returns { config, copied:[destPosix,...] }.
@@ -144,15 +167,53 @@ function buildConfig(manifest, projectDir, outDir) {
144
167
  const copied = [];
145
168
  const copy = (src, dest) => { copied.push(copyAsset(projectDir, outDir, src, dest)); return dest; };
146
169
  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 };
170
+ const gaps = {
171
+ connectors: connectorsOut(manifest.connectors, sectionsOut.length, '', copy),
172
+ connectorsMobile: connectorsOut(manifest.connectorsMobile, sectionsOut.length, '-m', copy),
173
+ connectorStills: gapsOut(manifest.connectorStills, sectionsOut.length, copy, (i, src) => `assets/conn${i + 1}-poster${extOf(src)}`),
174
+ };
175
+ return { config: engineConfig(manifest, sectionsOut, gaps), copied };
150
176
  }
151
177
 
152
178
  // ---- page generation -------------------------------------------------------
153
179
 
154
180
  const PANELS = { glass: 'panel-glass.css' };
155
181
 
182
+ /** The step navigation options: on by default ({}), off only with "stepNav": false. */
183
+ export const navOf = (manifest) => (manifest.stepNav === false ? null : (manifest.stepNav && typeof manifest.stepNav === 'object' ? manifest.stepNav : {}));
184
+
185
+ // ---- glass panel alpha ---------------------------------------------------------
186
+
187
+ const lin = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
188
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
189
+ const ratio = (a, b) => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05);
190
+ const hexRgb = (h) => { const m = /^#?([\da-f]{6})$/i.exec(String(h || '').trim()); return m ? [0, 2, 4].map((k) => parseInt(m[1].slice(k, k + 2), 16)) : null; };
191
+ // The glass panel's colours (panel-glass.css): its shade, and its text (title, body, number); the
192
+ // label line mixes 30% of the accent into white.
193
+ export const GLASS = { panel: [11, 18, 32], text: ['#ffffff', '#F1F5F9', '#E2E8F0'].map(hexRgb), alpha: 0.36 };
194
+
195
+ /**
196
+ * The lowest glass alpha (0.01 steps) at which every panel text colour keeps `min` contrast even over a
197
+ * white pixel (the worst a scene can put behind the panel), the label colour from each accent included.
198
+ */
199
+ export function panelAlpha(accents = [], { min = 4.5, panel = GLASS.panel } = {}) {
200
+ const labels = accents.map(hexRgb).filter(Boolean).map((a) => a.map((c) => 0.3 * c + 0.7 * 255));
201
+ const texts = [...GLASS.text, ...labels];
202
+ for (let k = 0; k <= 100; k++) {
203
+ const bg = panel.map((c) => (k / 100) * c + (1 - k / 100) * 255);
204
+ if (texts.every((t) => ratio(t, bg) >= min)) return k / 100;
205
+ }
206
+ return 1;
207
+ }
208
+
209
+ // The alpha the page gets: the manifest's panelAlpha (a person's choice) wins, else panelAlpha().
210
+ function glassAlpha(manifest) {
211
+ const given = Number(manifest.panelAlpha);
212
+ if (manifest.panelAlpha != null && given >= 0 && given <= 1) return { alpha: given, from: 'manifest' };
213
+ const accents = [manifest.theme?.accent, ...(manifest.sections || []).map((s) => s.accent)];
214
+ return { alpha: panelAlpha(accents), from: 'computed' };
215
+ }
216
+
156
217
  /**
157
218
  * The CSS the engine injects into <head>, exactly: the `css` template literal wrapped in @layer sw.
158
219
  * A template literal turns CRLF and CR into LF, so a checkout with CRLF line ends (git on Windows)
@@ -186,11 +247,25 @@ function themeCss(styleInner, theme = {}) {
186
247
 
187
248
  function stylesheets(manifest) {
188
249
  return ['scrub-engine.css', 'theme.css', 'intro-fixes.css', manifest.panel ? PANELS[manifest.panel] : null,
189
- manifest.stepNav ? 'step-nav.css' : null].filter(Boolean);
250
+ navOf(manifest) ? 'step-nav.css' : null].filter(Boolean);
251
+ }
252
+
253
+ /**
254
+ * The posters of the first `n` segments in scroll order (scene still, then the connector's own poster
255
+ * when it has one): the engine makes its posters loading=lazy, so on a fast first scroll the first
256
+ * seams could show a poster not yet loaded. A preload link fetches them with the page.
257
+ */
258
+ export function firstPosters(config, n = 3) {
259
+ const out = [];
260
+ (config.sections || []).forEach((s, i) => {
261
+ out.push(s.still);
262
+ if (config.connectors?.[i] && config.connectorStills?.[i]) out.push(config.connectorStills[i]);
263
+ });
264
+ return [...new Set(out.filter(Boolean))].slice(0, n);
190
265
  }
191
266
 
192
267
  /** index.html from the template's head: title, description and lang set, its inline style replaced by links. */
193
- function renderIndexHtml(template, manifest) {
268
+ function renderIndexHtml(template, manifest, config = {}) {
194
269
  const brandName = manifest.brand?.name || 'BRAND';
195
270
  const subject = manifest.subject || 'SUBJECT';
196
271
  const title = manifest.pageTitle || `${brandName} - the world of ${subject}`;
@@ -202,13 +277,23 @@ function renderIndexHtml(template, manifest) {
202
277
  head = head.replace(/<title>[^<]*<\/title>/, `<title>${escHtml(title)}</title>`);
203
278
  head = head.replace(/(<meta name="description" content=")[^"]*(")/, `$1${escAttr(desc)}$2`);
204
279
  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');
280
+ const links = [...stylesheets(manifest).map((f) => ` <link rel="stylesheet" href="${f}" />`),
281
+ ...firstPosters(config).map((f) => ` <link rel="preload" as="image" href="${escAttr(f)}" />`)].join('\n');
206
282
  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)
283
+ const scripts = ['scrub-engine.js', navOf(manifest) ? 'step-nav.js' : null, 'intro.js'].filter(Boolean)
208
284
  .map((f) => ` <script src="${f}"></script>`).join('\n');
209
285
  return `${head}${scripts}\n</body>\n</html>\n`;
210
286
  }
211
287
 
288
+ // A scene a jump shows (an anchor, a route dot under reduced motion, a scrollbar drag) paints nothing until
289
+ // its poster is decoded, and the engine decodes posters async: a jump showed an empty background for
290
+ // 0.1-0.35 s. The page decodes every poster at load and again once scrolling stops.
291
+ const WARM = [
292
+ "var warm = function () { [].forEach.call(world.querySelectorAll('.sw-scene__still'), function (i) { if (i.decode) i.decode().catch(function () {}); }); };",
293
+ "warm(); window.addEventListener('load', warm);",
294
+ "var warmT = 0; window.addEventListener('scroll', function () { clearTimeout(warmT); warmT = setTimeout(warm, 400); }, { passive: true });",
295
+ ];
296
+
212
297
  // The config and the mount, out of the page so no inline script is needed.
213
298
  function introJs(config, nav) {
214
299
  const json = (v) => JSON.stringify(v, null, 2).replace(/\n/g, '\n ');
@@ -216,6 +301,7 @@ function introJs(config, nav) {
216
301
  ? [`var nav = ${json(nav === true ? {} : nav)};`, 'if (window.IntroStepNav) window.IntroStepNav.prefetch(config, nav);',
217
302
  'mountScrollWorld(world, config);', 'if (window.IntroStepNav) window.IntroStepNav.attach(world, config, nav);']
218
303
  : ['mountScrollWorld(world, config);'];
304
+ lines.push(...WARM);
219
305
  return `// Generated by assemble.mjs from intro.json. Kept out of index.html so the page runs under a
220
306
  // Content-Security-Policy without 'unsafe-inline'.
221
307
  (function () {
@@ -242,8 +328,12 @@ function writeStatic(out, manifest, template) {
242
328
  const style = /<style>([\s\S]*?)<\/style>/.exec(template);
243
329
  fs.writeFileSync(path.join(out, 'theme.css'), themeCss(style ? style[1] : '', manifest.theme));
244
330
  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);
331
+ navOf(manifest) ? 'step-nav.css' : null, navOf(manifest) ? 'step-nav.js' : null].filter(Boolean);
246
332
  for (const f of copies) fs.copyFileSync(path.join(HERE, f), path.join(out, f));
333
+ if (manifest.panel === 'glass') {
334
+ const css = path.join(out, PANELS.glass);
335
+ fs.writeFileSync(css, fs.readFileSync(css, 'utf8').replace(/(--intro-panel-alpha:\s*)[\d.]+;/, `$1${glassAlpha(manifest).alpha};`));
336
+ }
247
337
  return `'sha256-${crypto.createHash('sha256').update(css).digest('base64')}'`;
248
338
  }
249
339
 
@@ -252,7 +342,7 @@ function writeStatic(out, manifest, template) {
252
342
  /**
253
343
  * Assemble the intro page. Copies the engine, its fixes and all referenced assets into outDir and
254
344
  * writes index.html, intro.js and the stylesheets (see the header).
255
- * @returns {{ outDir:string, index:string, engine:string, assets:string[], styleHash:string }}
345
+ * @returns {{ outDir:string, index:string, engine:string, assets:string[], styleHash:string, panelAlpha?:{alpha,from} }}
256
346
  */
257
347
  export function assemble({ projectDir, outDir, manifestPath } = {}) {
258
348
  if (!projectDir) throw new Error('assemble: projectDir is required');
@@ -270,11 +360,11 @@ export function assemble({ projectDir, outDir, manifestPath } = {}) {
270
360
  const { config, copied } = buildConfig(manifest, proj, out);
271
361
  const template = fs.readFileSync(path.join(HERE, 'index-template.html'), 'utf8');
272
362
  const styleHash = writeStatic(out, manifest, template);
273
- fs.writeFileSync(path.join(out, 'intro.js'), introJs(config, manifest.stepNav));
363
+ fs.writeFileSync(path.join(out, 'intro.js'), introJs(config, navOf(manifest)));
274
364
  const indexPath = path.join(out, 'index.html');
275
- fs.writeFileSync(indexPath, renderIndexHtml(template, manifest));
365
+ fs.writeFileSync(indexPath, renderIndexHtml(template, manifest, config));
276
366
 
277
- return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied, styleHash };
367
+ return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied, styleHash, ...(manifest.panel === 'glass' ? { panelAlpha: glassAlpha(manifest) } : {}) };
278
368
  }
279
369
 
280
370
  // ---- CLI --------------------------------------------------------------------
@@ -307,6 +397,7 @@ if (invokedDirectly()) {
307
397
  console.log(`assembled ${r.assets.length} asset(s) -> ${r.outDir}`);
308
398
  console.log(`index: ${r.index}`);
309
399
  console.log(`engine: ${r.engine}`);
400
+ if (r.panelAlpha) console.log(`glass panel alpha: ${r.panelAlpha.alpha} (${r.panelAlpha.from === 'manifest' ? 'the manifest\'s panelAlpha' : 'computed: contrast 4.5 even over white'})`);
310
401
  console.log(`CSP: the engine injects one <style>; to allow it add ${r.styleHash} to style-src (scrub-engine.css carries the same rules)`);
311
402
  console.log(`\npreview: node ${path.join(HERE, 'serve.mjs')} ${r.outDir} [--csp strict]`);
312
403
  } catch (e) {
@@ -173,6 +173,9 @@ const MACHINE_ONLY = ['FFMPEG_PATH'];
173
173
  * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
174
174
  * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found). From a
175
175
  * project file, FFMPEG_PATH is left out and returned under `_ignored` so the caller can say why.
176
+ * A project file git tracks gives no value at all: only `_source`, `_tracked: true` and `_names`
177
+ * (the names it sets). It may have come with the repository, so its endpoint would receive the
178
+ * prompts, the frames and the key; the calls refuse creds without an endpoint and a key.
176
179
  */
177
180
  export function resolveCreds({ projectDir } = {}) {
178
181
  const home = homeCredsPath();
@@ -183,7 +186,14 @@ export function resolveCreds({ projectDir } = {}) {
183
186
  if (!p) return { _source: null };
184
187
  const creds = { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
185
188
  if (p === home) return creds;
186
- return trackedByGit(p) ? { ...withoutMachineOnly(creds), _tracked: true } : withoutMachineOnly(creds);
189
+ if (trackedByGit(p)) return { _source: p, _tracked: true, _names: Object.keys(creds).filter((k) => k !== '_source') };
190
+ return withoutMachineOnly(creds);
191
+ }
192
+
193
+ // The paid and listing calls need both; resolveCreds gives neither from a project key file git tracks.
194
+ function requireConn(fn, endpoint, key) {
195
+ if (endpoint && key) return;
196
+ throw new Error(`${fn}: endpoint and key are required; nothing was sent (resolveCreds gives no value from a project key file git tracks: untrack it with git rm --cached, or use the home key file)`);
187
197
  }
188
198
 
189
199
  function withoutMachineOnly(creds) {
@@ -615,6 +625,7 @@ export async function generateImage({
615
625
  endpoint, key, deployment, apiVersion = 'preview',
616
626
  prompt, size = '720x1280', quality = 'low', n = 1, onNote, retry429 = 3,
617
627
  } = {}) {
628
+ requireConn('generateImage', endpoint, key);
618
629
  const genBody = { model: deployment, prompt, size, quality, n };
619
630
  const post = (url, body) => postJsonWith429Retry(url, { key, body: JSON.stringify(body), retries: retry429, onNote });
620
631
  let r = await post(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, genBody);
@@ -641,6 +652,7 @@ function pngFrom(r, b64, failed) {
641
652
  export async function editImage({
642
653
  endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
643
654
  } = {}) {
655
+ requireConn('editImage', endpoint, key);
644
656
  const r = await azFetch(`${endpoint}/openai/v1/images/edits?api-version=${apiVersion}`, {
645
657
  key, method: 'POST', body: editForm({ deployment, prompt, imageBuf, maskBuf, size }), timeoutMs: IMAGE_TIMEOUT_MS,
646
658
  });
@@ -659,6 +671,7 @@ function editForm({ deployment, prompt, imageBuf, maskBuf, size }) {
659
671
 
660
672
  /** Generate a still via the Black Forest Labs FLUX provider path. Returns a PNG Buffer. */
661
673
  export async function generateImageFlux({ endpoint, key, prompt, width, height, apiVersion = 'preview' } = {}) {
674
+ requireConn('generateImageFlux', endpoint, key);
662
675
  const r = await azFetch(`${endpoint}/providers/blackforestlabs/v1/flux-2-pro?api-version=${apiVersion}`, {
663
676
  key, method: 'POST', headers: { 'Content-Type': 'application/json' },
664
677
  body: JSON.stringify({ prompt, width, height }), timeoutMs: IMAGE_TIMEOUT_MS,
@@ -684,6 +697,7 @@ export async function listVideos({ endpoint, key, apiVersion = 'preview' } = {})
684
697
  export async function createVideo({
685
698
  endpoint, key, model = 'sora-2', prompt, size = '720x1280', seconds = 4, inputReferencePng, apiVersion = 'preview',
686
699
  } = {}) {
700
+ requireConn('createVideo', endpoint, key);
687
701
  const body = videoForm({ model, prompt, size, seconds, inputReferencePng });
688
702
  const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body });
689
703
  if (!(r.ok && r.body?.id)) throw createVideoError(r);
@@ -704,11 +718,14 @@ function videoForm({ model, prompt, size, seconds, inputReferencePng }) {
704
718
 
705
719
  // status 0 = network error; errno tells a refused connection (nothing sent) from a lost one.
706
720
  // retryAfterMs is the server's wait for a 429.
721
+ // code is the answer's error.code (DeploymentNotFound and the like), for a 404 or 410 the caller reads.
707
722
  function createVideoError(r) {
708
723
  const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
709
- return Object.assign(err, { status: r.status, errno: r.errno || null, retryAfterMs: r.retryAfterMs ?? null });
724
+ return Object.assign(err, { status: r.status, errno: r.errno || null, retryAfterMs: r.retryAfterMs ?? null, code: errorCodeOf(r) });
710
725
  }
711
726
 
727
+ const errorCodeOf = (r) => (typeof r.body?.error?.code === 'string' ? r.body.error.code : null);
728
+
712
729
  const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180, now: Date.now };
713
730
  const POLL_RETRY_4XX = new Set([408, 429]);
714
731
  const withoutUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
@@ -758,15 +775,87 @@ function throwIfRefused(r, id) {
758
775
  throw Object.assign(new Error(`pollVideo: job ${id} answered HTTP ${r.status}${code ? ` ${code}` : ''}`), { status: r.status, code });
759
776
  }
760
777
 
761
- /** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body. */
778
+ /** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body, with err.status. */
762
779
  export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview' } = {}) {
763
780
  const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
764
781
  key, method: 'GET', binary: true,
765
782
  });
766
- if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
783
+ if (!(r.status === 200 && r.body?.length)) throw Object.assign(new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`), { status: r.status });
767
784
  return r.body;
768
785
  }
769
786
 
787
+ // ---------------------------------------------------------------------------
788
+ // Sora deployment probe (FREE: two GETs, nothing is generated)
789
+ // ---------------------------------------------------------------------------
790
+
791
+ const DEPLOYMENT_API_VERSION = '2022-12-01';
792
+ /** A retirement date this many days away or nearer is reported (`soon`). */
793
+ export const RETIRE_SOON_DAYS = 14;
794
+ const DAY_MS = 86_400_000;
795
+ const RETIRED_LIFECYCLE = new Set(['retired', 'removed']);
796
+
797
+ /**
798
+ * FREE check of a Sora deployment that makes no job. POST /openai/v1/videos checks every parameter
799
+ * before it looks the deployment up (a deployment that does not exist answers the same 400 as one
800
+ * that does, measured 2026-10-29), and a POST that passes the checks is a paid generation. So the
801
+ * check is two GETs:
802
+ * GET /openai/deployments/<name>?api-version=2022-12-01 200 {model, status}, or 404 when there is none
803
+ * GET /openai/v1/models?api-version=preview the model's lifecycle_status and deprecation.inference
804
+ * state: deployed | no_deployment | retired | auth | unreachable | unknown | unset (no endpoint or key: nothing sent). `retired` means the
805
+ * deployment answered 410, the model list calls the model retired, or its inference retirement date
806
+ * has passed. `retiresAt` (ms) and `daysLeft` come from the model list; when the deployment names the
807
+ * model without its version, the newest dated version is taken (`versionInferred`). `soon` is true
808
+ * within RETIRE_SOON_DAYS. The result holds no key and no URL.
809
+ */
810
+ export async function probeSoraDeployment({ endpoint, key, deployment = 'sora-2', now = Date.now } = {}) {
811
+ // no endpoint or key (unset, or creds from a key file git tracks, which give no values): nothing is sent
812
+ if (!endpoint || !key) return { deployment, status: null, code: null, errno: null, state: 'unset' };
813
+ const d = await azFetch(`${endpoint}/openai/deployments/${encodeURIComponent(deployment)}?api-version=${DEPLOYMENT_API_VERSION}`, { key, method: 'GET' });
814
+ const base = { deployment, status: d.status, code: errorCodeOf(d), errno: d.errno || null };
815
+ if (d.status !== 200) return { ...base, state: deploymentState(d.status) };
816
+ const { model, deploymentStatus } = deploymentOf(d.body);
817
+ const life = model ? await modelLifecycle(endpoint, key, model) : {};
818
+ return withSchedule({ ...base, deploymentStatus, model, ...life }, now());
819
+ }
820
+
821
+ const deploymentOf = (b) => ({ model: typeof b?.model === 'string' ? b.model : null, deploymentStatus: b?.status ?? null });
822
+
823
+ const DEPLOYMENT_STATE = new Map([[404, 'no_deployment'], [410, 'retired'], [401, 'auth'], [403, 'auth'], [0, 'unreachable']]);
824
+ const deploymentState = (status) => DEPLOYMENT_STATE.get(status) || 'unknown';
825
+
826
+ // The model's entry in the resource's model list: its lifecycle and inference retirement date.
827
+ async function modelLifecycle(endpoint, key, model) {
828
+ const r = await azFetch(`${endpoint}/openai/v1/models?api-version=preview`, { key, method: 'GET' });
829
+ const m = r.ok ? modelEntry(r.body?.data, model) : null;
830
+ if (!m) return { modelVersion: null, lifecycle: null, retiresAt: null };
831
+ return { modelVersion: m.entry.id, versionInferred: m.inferred, lifecycle: m.entry.lifecycle_status ?? null, retiresAt: msOf(m.entry.deprecation?.inference) };
832
+ }
833
+
834
+ // The entry named exactly `model`, else the newest `<model>-YYYY-MM-DD` one (a deployment names only the model).
835
+ function modelEntry(list, model) {
836
+ if (!Array.isArray(list)) return null;
837
+ const exact = list.find((x) => x?.id === model);
838
+ if (exact) return { entry: exact, inferred: false };
839
+ const dated = (id) => typeof id === 'string' && id.startsWith(`${model}-`) && /^\d{4}-\d{2}-\d{2}$/.test(id.slice(model.length + 1));
840
+ const versions = list.filter((x) => dated(x?.id)).sort((a, b) => b.id.localeCompare(a.id));
841
+ return versions.length ? { entry: versions[0], inferred: true } : null;
842
+ }
843
+
844
+ // deprecation.inference is unix seconds; an ISO date is read too.
845
+ function msOf(v) {
846
+ if (typeof v === 'number' && Number.isFinite(v)) return v * 1000;
847
+ const t = typeof v === 'string' ? Date.parse(v) : NaN;
848
+ return Number.isFinite(t) ? t : null;
849
+ }
850
+
851
+ function withSchedule(r, t) {
852
+ const daysLeft = r.retiresAt == null ? null : Math.ceil((r.retiresAt - t) / DAY_MS);
853
+ const gone = (r.retiresAt != null && r.retiresAt <= t) || RETIRED_LIFECYCLE.has(r.lifecycle);
854
+ const ready = !r.deploymentStatus || r.deploymentStatus === 'succeeded';
855
+ const state = gone ? 'retired' : ready ? 'deployed' : 'unknown';
856
+ return { ...r, state, daysLeft, soon: !gone && daysLeft != null && daysLeft <= RETIRE_SOON_DAYS };
857
+ }
858
+
770
859
  /**
771
860
  * Probe whether the legacy two-image jobs API is available. Never throws.
772
861
  * @returns {Promise<boolean>} true when the legacy jobs endpoint exists and is authorized.
@@ -792,6 +881,7 @@ export async function detectTwoImageSupport({ endpoint, key, apiVersion = 'previ
792
881
  export async function createVideoTwoImage({
793
882
  endpoint, key, model = 'sora-2', prompt = '', firstPng, lastPng, size = '720x1280', seconds = 4, apiVersion = 'preview',
794
883
  } = {}) {
884
+ requireConn('createVideoTwoImage', endpoint, key);
795
885
  const r = await azFetch(`${endpoint}/openai/v1/video/generations/jobs?api-version=${apiVersion}`, {
796
886
  key, method: 'POST', body: twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }),
797
887
  });
@@ -900,21 +990,6 @@ export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
900
990
  return out;
901
991
  }
902
992
 
903
- /**
904
- * Tone/color continuity pass on srcPng. Stock ffmpeg has no true "match A's histogram to B"
905
- * filter, so this uses histeq — per-channel histogram equalization — the simplest robust
906
- * histogram normalization, which keeps a chained clip's first frame from drifting in
907
- * contrast/tone. refPng is accepted for API symmetry and a future CLUT-based exact match; it
908
- * is not fed to ffmpeg today (an unmapped extra input would make ffmpeg error). Returns out.
909
- */
910
- export async function colorMatch(srcPng, refPng, out, { ffmpeg } = {}) {
911
- const bin = ffmpeg || await resolveFfmpeg();
912
- void refPng; // reserved (see doc comment)
913
- const r = runFf(bin, ['-y', '-i', srcPng, '-vf', 'format=rgb24,histeq', '-frames:v', '1', out]);
914
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`colorMatch failed: ${tail(r.stderr)}`);
915
- return out;
916
- }
917
-
918
993
  /**
919
994
  * Crossfade clip A into clip B with an `xfade` transition of `sec` seconds. `offset` (opts) is
920
995
  * where the fade starts in A; when omitted it is derived from A's probed duration (dur - sec).
@@ -988,15 +1063,26 @@ const IMAGE_USD_EACH = 0.02;
988
1063
  * Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip (the Sora rate:
989
1064
  * WAN rates cannot be read through an API); one image gen per scene (two per scene in two-image
990
1065
  * mode). mode 'chain' (default) makes one clip of `seconds` per scene; 'holdFlight' makes one
991
- * flight of `flightSeconds` between each pair of scenes, the holds being encoded stills (free).
1066
+ * flight of `flightSeconds` between each pair of scenes, the holds being encoded stills (free);
1067
+ * 'dive' makes one clip of `diveSeconds` per scene (the dives and the finale) and, with `exit`, one
1068
+ * exit of `exitSeconds` between each pair of scenes (6 scenes: $4.8 without, the default, $6.8 with `exit: true`).
992
1069
  * Returns per-category USD sub-costs and the total.
993
1070
  * @returns {{images:number, videos:number, usd:number}}
994
1071
  */
995
- export function estimateCost({ nScenes, seconds = 4, twoImage = false, mode = 'chain', flightSeconds = 5 } = {}) {
996
- const n = Number(nScenes) || 0;
997
- const imageCount = twoImage ? n * 2 : n;
1072
+ const COST_DEFAULTS = { seconds: 4, twoImage: false, mode: 'chain', flightSeconds: 5, diveSeconds: 8, exitSeconds: 4, exit: false };
1073
+ // The clip seconds an intro bills, by mode.
1074
+ const CLIP_SECONDS = {
1075
+ chain: (n, o) => n * o.seconds,
1076
+ holdFlight: (n, o) => Math.max(0, n - 1) * o.flightSeconds,
1077
+ dive: (n, o) => n * o.diveSeconds + (o.exit ? Math.max(0, n - 1) * o.exitSeconds : 0),
1078
+ };
1079
+
1080
+ export function estimateCost(opts = {}) {
1081
+ const o = { ...COST_DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined)) };
1082
+ const n = Number(o.nScenes) || 0;
1083
+ const imageCount = o.twoImage ? n * 2 : n;
998
1084
  const images = round2(imageCount * IMAGE_USD_EACH);
999
- const clipSeconds = mode === 'holdFlight' ? Math.max(0, n - 1) * flightSeconds : n * seconds;
1085
+ const clipSeconds = (CLIP_SECONDS[o.mode] || CLIP_SECONDS.chain)(n, o);
1000
1086
  const videos = round2(clipSeconds * VIDEO_USD_PER_SEC);
1001
1087
  const usd = round2(images + videos);
1002
1088
  return { images, videos, usd };