@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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +65 -0
  4. package/README.md +31 -9
  5. package/codex/manifest.json +6 -3
  6. package/codex/transform-matrix.md +2 -0
  7. package/commands/progress.md +10 -0
  8. package/commands/setup.md +1 -1
  9. package/hooks/graceful-pause.mjs +31 -71
  10. package/hooks/hooks.json +11 -1
  11. package/hooks/progress-fallback.mjs +10 -0
  12. package/hooks/progress.mjs +427 -0
  13. package/hooks/register.mjs +82 -5
  14. package/package.json +1 -1
  15. package/skills/3d-intro-build/SKILL.md +238 -122
  16. package/skills/3d-intro-build/references/NOTICE.md +2 -1
  17. package/skills/3d-intro-build/references/assemble.mjs +164 -108
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +204 -44
  19. package/skills/3d-intro-build/references/curate.mjs +573 -267
  20. package/skills/3d-intro-build/references/intro-fixes.css +61 -0
  21. package/skills/3d-intro-build/references/panel-glass.css +55 -0
  22. package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
  23. package/skills/3d-intro-build/references/prompts.md +32 -9
  24. package/skills/3d-intro-build/references/serve.mjs +111 -103
  25. package/skills/3d-intro-build/references/step-nav.css +59 -0
  26. package/skills/3d-intro-build/references/step-nav.js +503 -0
  27. package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
  28. package/skills/3d-intro-build/references/verify.md +45 -0
  29. package/skills/3d-intro-build/references/video-pool.mjs +817 -0
  30. package/skills/3d-intro-setup/SKILL.md +120 -47
  31. package/skills/3d-intro-setup/references/azure-adapter.mjs +204 -44
  32. package/skills/3d-intro-setup/references/video-pool.mjs +817 -0
  33. package/skills/motion-graphic-make/SKILL.md +73 -28
  34. package/skills/motion-graphic-make/references/curate.mjs +607 -0
  35. package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
  36. package/skills/motion-graphic-make/references/serve.mjs +131 -0
  37. package/skills/omc-patch/SKILL.md +143 -0
  38. package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
  39. package/skills/remains/SKILL.md +125 -0
  40. package/skills/remains/scripts/boxes.mjs +448 -0
  41. 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
- const m = raw.match(/^(https?:\/\/[^/]+)/i);
40
- return m ? m[1] : raw.replace(/\/+$/, '');
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>" (python/JS SDK).
97
- m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i);
98
- if (!m) m = s.match(/api[-_]?key["']?\s*[:=]\s*["']?\s*([A-Za-z0-9._-]+)/i);
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
- /** Parse a KEY=VALUE .env body into a plain object (strips surrounding quotes, skips comments). */
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*=\s*(.*?)\s*$/);
110
- if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
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
- /** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
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
- const str = String(s);
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
- * @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, error?:Error}>}
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 globalThis.fetch(url, opts);
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
- * `onNote(msg)` (optional) is invoked when the classic fallback is taken.
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 azFetch(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
204
- key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(genBody),
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
- if (!(r.ok && r.body?.data?.[0]?.b64_json)) {
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 azFetch(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
211
- key, method: 'POST', headers: { 'Content-Type': 'application/json' },
212
- body: JSON.stringify({ ...genBody, size: classicSize(size) }),
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)) throw new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
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
- endpoint, key, id, onTick, apiVersion = 'preview', intervalMs = 5000, maxTicks = 180,
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
- st = r.body || st;
293
- if (typeof onTick === 'function') onTick(st, i);
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; one image gen
485
- * per scene (two per scene in two-image mode). Returns per-category USD sub-costs and the total.
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 videos = round2(n * seconds * VIDEO_USD_PER_SEC);
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
  }