@kaydash9999/banker-plugins 0.15.2 → 0.16.1
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 +65 -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 +238 -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 +204 -44
- 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 +817 -0
- package/skills/3d-intro-setup/SKILL.md +120 -47
- package/skills/3d-intro-setup/references/azure-adapter.mjs +204 -44
- package/skills/3d-intro-setup/references/video-pool.mjs +817 -0
- 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
|
@@ -36,8 +36,9 @@ function originOf(url) {
|
|
|
36
36
|
const u = new URL(raw);
|
|
37
37
|
return `${u.protocol}//${u.host}`;
|
|
38
38
|
} catch {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Not a URL: null, never the raw text, which may be a key pasted into the endpoint field.
|
|
40
|
+
const m = raw.match(/^(https?:\/\/[^/\s]+)/i);
|
|
41
|
+
return m ? m[1] : null;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -50,6 +51,10 @@ function truncate(x, n = 300) {
|
|
|
50
51
|
return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
|
|
55
|
+
// error's code or name only, never its text: fetch can quote a header value, a key included.
|
|
56
|
+
const why = (r) => (r.error ? ` (${r.error.cause?.code || r.error.code || r.error.name})` : '');
|
|
57
|
+
|
|
53
58
|
// Last n chars of ffmpeg stderr (the useful part) for error messages.
|
|
54
59
|
const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
|
|
55
60
|
|
|
@@ -93,25 +98,42 @@ export function parseConsoleSample(text) {
|
|
|
93
98
|
if (m) out.deployment = m[1];
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>"
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>", "api-key": "<k>",
|
|
102
|
+
// apiKey: '<k>' (python/JS SDK). An SDK value counts only in quotes: api_key=subscription_key or
|
|
103
|
+
// apiKey: process.env.X names a variable, and taking that name for the key would replace a real one.
|
|
104
|
+
m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i)
|
|
105
|
+
|| s.match(/api-key\s*:\s*([A-Za-z0-9._-]+)/i)
|
|
106
|
+
|| s.match(/api[-_]?key["']?\s*[:=]\s*["']([A-Za-z0-9._-]+)["']/i);
|
|
99
107
|
if (m) out.apiKey = m[1];
|
|
100
108
|
|
|
101
109
|
return out;
|
|
102
110
|
}
|
|
103
111
|
|
|
104
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Parse a KEY=VALUE .env body into a plain object, skipping comment lines. A quoted value keeps
|
|
114
|
+
* everything inside its quotes. In an unquoted value, a # that follows whitespace starts a comment
|
|
115
|
+
* (also right after `=`), so a note after a value never becomes part of an endpoint or a key.
|
|
116
|
+
*/
|
|
105
117
|
export function parseEnvFile(text) {
|
|
106
118
|
const env = {};
|
|
107
119
|
for (const line of String(text || '').split(/\r?\n/)) {
|
|
108
120
|
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]
|
|
121
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=([^\n]*)$/);
|
|
122
|
+
if (m) env[m[1]] = envValue(m[2]);
|
|
111
123
|
}
|
|
112
124
|
return env;
|
|
113
125
|
}
|
|
114
126
|
|
|
127
|
+
// `raw` is everything after `=`, so the whitespace before a leading # is still there. A stray
|
|
128
|
+
// trailing \r (a \r\r\n line end) is whitespace too.
|
|
129
|
+
function envValue(raw) {
|
|
130
|
+
const v = raw.trim();
|
|
131
|
+
const q = v[0];
|
|
132
|
+
const end = q === '"' || q === "'" ? v.indexOf(q, 1) : -1;
|
|
133
|
+
if (end > 0) return v.slice(1, end);
|
|
134
|
+
return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
/**
|
|
116
138
|
* Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
|
|
117
139
|
* Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found).
|
|
@@ -135,7 +157,7 @@ export function persistCreds(creds, { target } = {}) {
|
|
|
135
157
|
const lines = [];
|
|
136
158
|
for (const [k, v] of Object.entries(creds || {})) {
|
|
137
159
|
if (k.startsWith('_') || v == null) continue;
|
|
138
|
-
lines.push(`${k}=${String(v)}`);
|
|
160
|
+
lines.push(`${k}=${envLiteral(String(v))}`);
|
|
139
161
|
}
|
|
140
162
|
const body = lines.length ? `${lines.join('\n')}\n` : '';
|
|
141
163
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
@@ -144,22 +166,107 @@ export function persistCreds(creds, { target } = {}) {
|
|
|
144
166
|
return target;
|
|
145
167
|
}
|
|
146
168
|
|
|
147
|
-
|
|
169
|
+
// Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
|
|
170
|
+
function envLiteral(v) {
|
|
171
|
+
if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
|
|
172
|
+
return v.includes('"') ? `'${v}'` : `"${v}"`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
|
|
177
|
+
* prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
|
|
178
|
+
*/
|
|
148
179
|
export function redact(s) {
|
|
149
180
|
if (!s) return '(missing)';
|
|
150
|
-
|
|
151
|
-
if (str.length <= 8) return `…(${str.length})`;
|
|
152
|
-
return `${str.slice(0, 4)}…${str.slice(-4)}(${str.length})`;
|
|
181
|
+
return `…(${String(s).length})`;
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
// ---------------------------------------------------------------------------
|
|
156
185
|
// HTTP
|
|
157
186
|
// ---------------------------------------------------------------------------
|
|
158
187
|
|
|
188
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
189
|
+
const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
|
|
190
|
+
const MAX_HOPS = 5;
|
|
191
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
192
|
+
const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
|
|
193
|
+
const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
|
|
194
|
+
const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
|
|
195
|
+
const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
|
|
196
|
+
// Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
|
|
197
|
+
// found in any of them. Pairs would otherwise read as headers named "0", "1".
|
|
198
|
+
const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
|
|
199
|
+
|
|
200
|
+
/** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
|
|
201
|
+
export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
|
|
202
|
+
|
|
203
|
+
// Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
|
|
204
|
+
// leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
|
|
205
|
+
// while the request is on this machine over http (a local mock); from anywhere else, an https
|
|
206
|
+
// localhost included, it would reach a service here or on the network.
|
|
207
|
+
function redirectTarget(location, from) {
|
|
208
|
+
let next;
|
|
209
|
+
try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
|
|
210
|
+
if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
|
|
211
|
+
throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
|
|
215
|
+
// follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
|
|
216
|
+
// make it a GET, and another origin is another server: either way the next answer does not answer this
|
|
217
|
+
// request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
|
|
218
|
+
// Without a body, a 303, and a 301 or 302 after a POST, go on as a GET, as browsers do, and a hop to
|
|
219
|
+
// another origin goes without the secret headers.
|
|
220
|
+
function nextHop(req, status, crossOrigin) {
|
|
221
|
+
if (req.body != null && (crossOrigin || status < 307)) {
|
|
222
|
+
throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
|
|
223
|
+
}
|
|
224
|
+
const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
|
|
225
|
+
const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
|
|
226
|
+
return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
|
|
231
|
+
* another origin without the key headers; a request with a body only by a 307 or 308 within its
|
|
232
|
+
* origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
|
|
233
|
+
* any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
|
|
234
|
+
* URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
|
|
235
|
+
* https. Throws as fetch does on a network error.
|
|
236
|
+
*/
|
|
237
|
+
export async function fetchGuarded(url, init = {}) {
|
|
238
|
+
let target = new URL(String(url));
|
|
239
|
+
let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
|
|
240
|
+
if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
|
|
241
|
+
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
|
242
|
+
const r = await globalThis.fetch(target.href, req);
|
|
243
|
+
const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
|
|
244
|
+
if (!location) return r;
|
|
245
|
+
try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
|
|
246
|
+
const next = redirectTarget(location, target);
|
|
247
|
+
req = nextHop(req, r.status, next.origin !== target.origin);
|
|
248
|
+
target = next;
|
|
249
|
+
}
|
|
250
|
+
throw refused('ETOOMANYREDIRECTS', 'too many redirects');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
|
|
254
|
+
function retryAfterMsOf(headers) {
|
|
255
|
+
const ms = Number(headers.get('retry-after-ms'));
|
|
256
|
+
if (headers.get('retry-after-ms') && Number.isFinite(ms)) return Math.max(0, ms);
|
|
257
|
+
const h = headers.get('retry-after');
|
|
258
|
+
if (!h) return null;
|
|
259
|
+
const sec = Number(h);
|
|
260
|
+
if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
|
|
261
|
+
const at = Date.parse(h);
|
|
262
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
159
265
|
/**
|
|
160
266
|
* Fetch an Azure URL trying the `api-key` header first and, on 401/403, retrying with
|
|
161
267
|
* `Authorization: Bearer`. Never throws on HTTP status; a network error yields {status:0}.
|
|
162
|
-
*
|
|
268
|
+
* `retryAfterMs` carries the server's Retry-After hint (null when absent).
|
|
269
|
+
* @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, retryAfterMs:number|null, error?:Error}>}
|
|
163
270
|
*/
|
|
164
271
|
export async function azFetch(url, { key, binary = false, headers = {}, method = 'GET', body } = {}) {
|
|
165
272
|
const styles = key ? [{ 'api-key': key }, { Authorization: `Bearer ${key}` }] : [{}];
|
|
@@ -169,7 +276,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
169
276
|
if (body !== undefined) opts.body = body;
|
|
170
277
|
let r;
|
|
171
278
|
try {
|
|
172
|
-
r = await
|
|
279
|
+
r = await fetchGuarded(url, opts);
|
|
173
280
|
} catch (e) {
|
|
174
281
|
last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
|
|
175
282
|
break; // network error is not an auth problem — do not retry other styles
|
|
@@ -179,7 +286,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
179
286
|
if (binary) parsed = Buffer.from(await r.arrayBuffer());
|
|
180
287
|
else if (ct.includes('json')) parsed = await r.json().catch(() => null);
|
|
181
288
|
else parsed = await r.text().catch(() => null);
|
|
182
|
-
last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null };
|
|
289
|
+
last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null, retryAfterMs: retryAfterMsOf(r.headers) };
|
|
183
290
|
if (r.status !== 401 && r.status !== 403) return last; // accepted (or a non-auth error)
|
|
184
291
|
// else: this auth style was rejected — fall through and try the next
|
|
185
292
|
}
|
|
@@ -190,30 +297,44 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
190
297
|
// Images
|
|
191
298
|
// ---------------------------------------------------------------------------
|
|
192
299
|
|
|
300
|
+
// POST JSON, retrying HTTP 429 up to `retries` times. Waits the server's Retry-After when given,
|
|
301
|
+
// else 10s, 20s, 40s…; either way at most 60s per wait. Low-tier (S0) image deployments hit this.
|
|
302
|
+
async function postJsonWith429Retry(url, { key, body, retries, onNote }) {
|
|
303
|
+
for (let i = 0; ; i++) {
|
|
304
|
+
const r = await azFetch(url, { key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
|
305
|
+
if (r.status !== 429 || i >= retries) return r;
|
|
306
|
+
const ms = Math.min(60000, r.retryAfterMs ?? 10000 * 2 ** i);
|
|
307
|
+
if (typeof onNote === 'function') onNote(`HTTP 429; retry ${i + 1}/${retries} in ${ms} ms`);
|
|
308
|
+
await sleep(ms);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
193
312
|
/**
|
|
194
313
|
* Generate a still via the v1 images path; on failure fall back to the classic
|
|
195
314
|
* `deployments/<dep>` path @2025-04-01-preview (discrete size). Returns a PNG Buffer.
|
|
196
|
-
*
|
|
315
|
+
* HTTP 429 is retried on the same path first (`retry429` times) — the classic path shares the
|
|
316
|
+
* deployment's rate limit, so falling back on 429 would only fail again.
|
|
317
|
+
* `onNote(msg)` (optional) is invoked on 429 waits and when the classic fallback is taken.
|
|
197
318
|
*/
|
|
198
319
|
export async function generateImage({
|
|
199
320
|
endpoint, key, deployment, apiVersion = 'preview',
|
|
200
|
-
prompt, size = '720x1280', quality = 'low', n = 1, onNote,
|
|
321
|
+
prompt, size = '720x1280', quality = 'low', n = 1, onNote, retry429 = 3,
|
|
201
322
|
} = {}) {
|
|
202
323
|
const genBody = { model: deployment, prompt, size, quality, n };
|
|
203
|
-
let r = await
|
|
204
|
-
key,
|
|
324
|
+
let r = await postJsonWith429Retry(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
|
|
325
|
+
key, body: JSON.stringify(genBody), retries: retry429, onNote,
|
|
205
326
|
});
|
|
206
327
|
let note = 'v1';
|
|
207
|
-
|
|
328
|
+
// no classic retry on 429 (shared limit) or on a lost connection (the v1 call may have billed)
|
|
329
|
+
if (!(r.ok && r.body?.data?.[0]?.b64_json) && r.status !== 429 && r.status !== 0) {
|
|
208
330
|
note = 'classic-fallback(2025-04-01-preview)';
|
|
209
331
|
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) }),
|
|
332
|
+
r = await postJsonWith429Retry(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
|
|
333
|
+
key, body: JSON.stringify({ ...genBody, size: classicSize(size) }), retries: retry429, onNote,
|
|
213
334
|
});
|
|
214
335
|
}
|
|
215
336
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
216
|
-
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status} ${truncate(r.body)}`);
|
|
337
|
+
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
217
338
|
return Buffer.from(b64, 'base64');
|
|
218
339
|
}
|
|
219
340
|
|
|
@@ -231,7 +352,7 @@ export async function editImage({
|
|
|
231
352
|
key, method: 'POST', body: fd,
|
|
232
353
|
});
|
|
233
354
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
234
|
-
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
355
|
+
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
235
356
|
return Buffer.from(b64, 'base64');
|
|
236
357
|
}
|
|
237
358
|
|
|
@@ -242,7 +363,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
|
|
|
242
363
|
body: JSON.stringify({ prompt, width, height }),
|
|
243
364
|
});
|
|
244
365
|
const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
|
|
245
|
-
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
366
|
+
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
246
367
|
return Buffer.from(b64, 'base64');
|
|
247
368
|
}
|
|
248
369
|
|
|
@@ -273,36 +394,55 @@ export async function createVideo({
|
|
|
273
394
|
fd.append('input_reference', new Blob([inputReferencePng], { type: 'image/png' }), 'input_reference.png');
|
|
274
395
|
}
|
|
275
396
|
const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
|
|
276
|
-
if (!(r.ok && r.body?.id))
|
|
397
|
+
if (!(r.ok && r.body?.id)) {
|
|
398
|
+
// status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
|
|
399
|
+
const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
400
|
+
err.status = r.status;
|
|
401
|
+
err.errno = r.error?.cause?.code || r.error?.code || null;
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
277
404
|
return r.body;
|
|
278
405
|
}
|
|
279
406
|
|
|
407
|
+
const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180 };
|
|
408
|
+
const POLL_RETRY_4XX = new Set([408, 429]);
|
|
409
|
+
const withoutUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
410
|
+
|
|
280
411
|
/**
|
|
281
412
|
* 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
|
|
413
|
+
* that is already terminal returns without waiting. `onTick(status, i)` is optional; `sleep`
|
|
414
|
+
* replaces the wait between polls. 408, 429, 5xx and network errors keep polling. Any other
|
|
415
|
+
* 4xx throws at once with `err.status` and `err.code`: a job the server no longer knows, or a
|
|
416
|
+
* key it refuses, never turns terminal.
|
|
283
417
|
* @returns the last status object seen.
|
|
284
418
|
*/
|
|
285
|
-
export async function pollVideo({
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const url = `${endpoint}/openai/v1/videos/${id}?api-version=${apiVersion}`;
|
|
419
|
+
export async function pollVideo(opts = {}) {
|
|
420
|
+
const o = { ...POLL_DEFAULTS, sleep, ...withoutUndefined(opts) };
|
|
421
|
+
const url = `${o.endpoint}/openai/v1/videos/${o.id}?api-version=${o.apiVersion}`;
|
|
289
422
|
let st = null;
|
|
290
|
-
for (let i = 0; i < maxTicks; i++) {
|
|
291
|
-
const r = await azFetch(url, { key, method: 'GET' });
|
|
292
|
-
|
|
293
|
-
if (
|
|
423
|
+
for (let i = 0; i < o.maxTicks; i++) {
|
|
424
|
+
const r = await azFetch(url, { key: o.key, method: 'GET' });
|
|
425
|
+
throwIfRefused(r, o.id);
|
|
426
|
+
if (r.ok) st = r.body || st;
|
|
427
|
+
if (typeof o.onTick === 'function') o.onTick(st, i);
|
|
294
428
|
if (st && TERMINAL_STATUS.has(st.status)) break;
|
|
295
|
-
await sleep(intervalMs);
|
|
429
|
+
await o.sleep(o.intervalMs);
|
|
296
430
|
}
|
|
297
431
|
return st;
|
|
298
432
|
}
|
|
299
433
|
|
|
434
|
+
function throwIfRefused(r, id) {
|
|
435
|
+
if (r.status < 400 || r.status >= 500 || POLL_RETRY_4XX.has(r.status)) return;
|
|
436
|
+
const code = r.body?.error?.code || null;
|
|
437
|
+
throw Object.assign(new Error(`pollVideo: job ${id} answered HTTP ${r.status}${code ? ` ${code}` : ''}`), { status: r.status, code });
|
|
438
|
+
}
|
|
439
|
+
|
|
300
440
|
/** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body. */
|
|
301
441
|
export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview' } = {}) {
|
|
302
442
|
const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
|
|
303
443
|
key, method: 'GET', binary: true,
|
|
304
444
|
});
|
|
305
|
-
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
|
|
445
|
+
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
|
|
306
446
|
return r.body;
|
|
307
447
|
}
|
|
308
448
|
|
|
@@ -347,7 +487,7 @@ export async function createVideoTwoImage({
|
|
|
347
487
|
key, method: 'POST', body: fd,
|
|
348
488
|
});
|
|
349
489
|
const id = r.body?.id || r.body?.job_id;
|
|
350
|
-
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
490
|
+
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
351
491
|
return r.body;
|
|
352
492
|
}
|
|
353
493
|
|
|
@@ -397,6 +537,22 @@ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
|
|
|
397
537
|
return out;
|
|
398
538
|
}
|
|
399
539
|
|
|
540
|
+
/**
|
|
541
|
+
* A still as a short clip: the hold of the hold-and-flight mode. The PNG repeats for `seconds`,
|
|
542
|
+
* encoded as the scrub engine wants (H.264 yuv420p, a keyframe every 8 frames, faststart, no
|
|
543
|
+
* audio). No generation call, so no cost. Every frame is the still, so a flight whose first or last
|
|
544
|
+
* frame is this still meets the hold exactly. `size` ('720x1280') scales; even sizes only. Returns out.
|
|
545
|
+
*/
|
|
546
|
+
export async function stillToClip(png, out, { ffmpeg, seconds = 1, fps = 24, size } = {}) {
|
|
547
|
+
const bin = ffmpeg || await resolveFfmpeg();
|
|
548
|
+
fs.rmSync(out, { force: true }); // a file from an earlier run must not pass for this one
|
|
549
|
+
const vf = `${size ? `scale=${String(size).replace('x', ':')},` : ''}format=yuv420p`;
|
|
550
|
+
const r = runFf(bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
|
|
551
|
+
'-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out]);
|
|
552
|
+
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`stillToClip failed: ${tail(r.stderr)}`);
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
|
|
400
556
|
/** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
|
|
401
557
|
export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
|
|
402
558
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
@@ -481,15 +637,19 @@ const VIDEO_USD_PER_SEC = 0.10;
|
|
|
481
637
|
const IMAGE_USD_EACH = 0.02;
|
|
482
638
|
|
|
483
639
|
/**
|
|
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
|
|
640
|
+
* Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip (the Sora rate:
|
|
641
|
+
* WAN rates cannot be read through an API); one image gen per scene (two per scene in two-image
|
|
642
|
+
* mode). mode 'chain' (default) makes one clip of `seconds` per scene; 'holdFlight' makes one
|
|
643
|
+
* flight of `flightSeconds` between each pair of scenes, the holds being encoded stills (free).
|
|
644
|
+
* Returns per-category USD sub-costs and the total.
|
|
486
645
|
* @returns {{images:number, videos:number, usd:number}}
|
|
487
646
|
*/
|
|
488
|
-
export function estimateCost({ nScenes, seconds = 4, twoImage = false } = {}) {
|
|
647
|
+
export function estimateCost({ nScenes, seconds = 4, twoImage = false, mode = 'chain', flightSeconds = 5 } = {}) {
|
|
489
648
|
const n = Number(nScenes) || 0;
|
|
490
649
|
const imageCount = twoImage ? n * 2 : n;
|
|
491
650
|
const images = round2(imageCount * IMAGE_USD_EACH);
|
|
492
|
-
const
|
|
651
|
+
const clipSeconds = mode === 'holdFlight' ? Math.max(0, n - 1) * flightSeconds : n * seconds;
|
|
652
|
+
const videos = round2(clipSeconds * VIDEO_USD_PER_SEC);
|
|
493
653
|
const usd = round2(images + videos);
|
|
494
654
|
return { images, videos, usd };
|
|
495
655
|
}
|