@kaydash9999/banker-plugins 0.22.0 → 0.23.2
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 +68 -0
- package/PRIVACY.md +1 -0
- package/README.md +39 -13
- package/bin/banker.js +118 -22
- package/bin/lib/codex-hooks.mjs +116 -18
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +93 -1
- package/bin/lib/command-skill.cjs +37 -6
- package/codex/command-skills/front-qa/SKILL.md +1 -1
- package/codex/command-skills/setup/SKILL.md +4 -4
- package/codex/hooks.json +5 -5
- package/codex/manifest.json +18 -15
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +143 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
- package/codex/transform-matrix.md +5 -3
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +238 -57
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +82 -20
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +284 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +1 -1
- package/skills/play-qa/SKILL.md +6 -4
- package/skills/progress-mon/README.md +164 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +549 -0
- package/skills/progress-mon/scripts/pm-config.mjs +343 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +781 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +107 -17
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +200 -159
- package/skills/remains/SKILL.md +2 -0
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/ultra-chain/SKILL.md +32 -14
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +197 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -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 (
|
|
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
|
-
* "
|
|
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", ...]
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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}" />`)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
|
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 };
|