@kaydash9999/banker-plugins 0.20.2 → 0.20.5
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 +179 -1
- package/PRIVACY.md +13 -3
- package/README.md +25 -2
- package/bin/banker.js +84 -40
- package/bin/lib/telemetry-config.mjs +31 -16
- package/codex/manifest.json +1 -1
- package/codex/transform-matrix.md +1 -1
- package/commands/setup.md +23 -0
- package/hooks/progress.mjs +143 -38
- package/hooks/register.mjs +13 -3
- package/hooks/update-checkin.mjs +15 -7
- package/hooks/update-fetch.mjs +15 -9
- package/hooks/update-notify.mjs +2 -9
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +11 -5
- package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-build/references/curate.mjs +48 -14
- package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
- package/skills/3d-intro-build/references/prompts.md +4 -4
- package/skills/3d-intro-build/references/serve.mjs +6 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
- package/skills/3d-intro-build/references/video-pool.mjs +250 -135
- package/skills/3d-intro-setup/SKILL.md +41 -10
- package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
- package/skills/anti-drm/SKILL.md +6 -3
- package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
- package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
- package/skills/audit-mock/SKILL.md +1 -1
- package/skills/audit-security/SKILL.md +64 -2
- package/skills/deep-research/SKILL.md +5 -0
- package/skills/docs-setup/SKILL.md +37 -7
- package/skills/lineage/SKILL.md +12 -12
- package/skills/lineage/lineage.py +399 -300
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/motion-graphic-make/references/curate.mjs +48 -14
- package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
- package/skills/motion-graphic-make/references/serve.mjs +6 -1
- package/skills/motion-graphic-setup/SKILL.md +58 -13
- package/skills/obsidizer/SKILL.md +1 -1
- package/skills/obsidizer/obsidize.mjs +8 -1
- package/skills/omc-patch/SKILL.md +80 -23
- package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
- package/skills/payload-mon/SKILL.md +4 -2
- package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
- package/skills/payload-mon/scripts/payload-size.mjs +4 -1
- package/skills/pdf-vision-extract/SKILL.md +11 -6
- package/skills/ralph-qa/SKILL.md +125 -151
- package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
- package/skills/ralph-qa/references/payload-scan.mjs +22 -7
- package/skills/ralph-qa/references/seat-payload.mjs +646 -0
- package/skills/ralph-qa/references/seat-run.mjs +956 -0
- package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
- package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
- package/skills/setup-harness-factory/SKILL.md +17 -1
- package/skills/setup-insane-search/SKILL.md +16 -0
- package/skills/setup-java/SKILL.md +20 -1
- package/skills/setup-lsp/SKILL.md +56 -13
- package/skills/setup-mcp/SKILL.md +73 -4
- package/skills/setup-node/SKILL.md +31 -6
- package/skills/setup-omc/SKILL.md +24 -0
- package/skills/setup-omc-hud/SKILL.md +9 -6
- package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
- package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
- package/skills/setup-playwright/SKILL.md +45 -2
- package/skills/setup-pwsh/SKILL.md +19 -0
- package/skills/setup-python/SKILL.md +45 -7
- package/skills/setup-sandbox/SKILL.md +30 -5
- package/skills/setup-stitch/SKILL.md +22 -0
- package/skills/setup-tmux/SKILL.md +24 -4
- package/skills/tone-compact/SKILL.md +2 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
- package/skills/ultra-chain/SKILL.md +4 -3
- package/skills/ultra-chain/references/preview-lib.mjs +51 -15
- package/skills/ultra-chain/references/serve.mjs +6 -1
- package/skills/ultra-interview/SKILL.md +1 -0
- package/skills/update-banker/SKILL.md +19 -3
- package/skills/vertical-pptx/SKILL.md +6 -3
- package/skills/vertical-pptx-setup/SKILL.md +76 -20
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
* WAN_EXHAUSTED_TTL_HOURS how long an exhausted key rests before one retry (default 24)
|
|
19
19
|
* VIDEO_PROVIDER_ORDER comma list of wan,sora (default wan,sora)
|
|
20
20
|
*
|
|
21
|
-
* Billing rule: a leg is submitted to a second key ONLY when the first submission
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* Billing rule: a leg is submitted to a second key ONLY when the first submission is taken not to
|
|
22
|
+
* have billed (the server rejected it, the connection failed before the request was sent, or the
|
|
23
|
+
* task ended FAILED/CANCELED with a known key, capacity or transient code). That a FAILED/CANCELED
|
|
24
|
+
* task is not billed is an assumption the official docs do not confirm; if it is wrong, one leg can
|
|
25
|
+
* be submitted up to (keys x 4 + 2) times. Every other outcome throws, with the taskId when a task
|
|
26
|
+
* exists, so the caller can resume (resumeClip) instead of paying twice. A config error (every WAN
|
|
27
|
+
* entry malformed, ModelNotFound) never falls back to Sora.
|
|
26
28
|
*
|
|
27
29
|
* Pool state lives in ~/.config/banker/3d-intro/video-pool-state.json (0600). It holds
|
|
28
30
|
* FINGERPRINTS (sha256 of endpoint+key+model), never a key, so a rotated key or a changed model
|
|
@@ -37,7 +39,10 @@ import path from 'node:path';
|
|
|
37
39
|
import os from 'node:os';
|
|
38
40
|
import crypto from 'node:crypto';
|
|
39
41
|
import cp from 'node:child_process';
|
|
40
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack,
|
|
44
|
+
API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft,
|
|
45
|
+
} from './azure-adapter.mjs';
|
|
41
46
|
|
|
42
47
|
export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
|
|
43
48
|
const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
|
|
@@ -56,10 +61,10 @@ const QUOTA_STRIKES_TO_EXHAUST = 3;
|
|
|
56
61
|
const QUOTA_STRIKE_TTL_MS = 3_600_000;
|
|
57
62
|
const TRANSIENT_BASE_MS = 15_000;
|
|
58
63
|
const UNKNOWN_POLLS_TO_FAIL = 5;
|
|
59
|
-
const API_TIMEOUT_MS = 120_000;
|
|
60
|
-
const DOWNLOAD_TIMEOUT_MS = 600_000;
|
|
61
64
|
|
|
62
|
-
|
|
65
|
+
// A real wait is at least 1 s: an interval of 0 must not poll a paid API in a busy loop.
|
|
66
|
+
const MIN_REAL_WAIT_MS = 1000;
|
|
67
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, Math.max(MIN_REAL_WAIT_MS, ms)));
|
|
63
68
|
|
|
64
69
|
// ---------------------------------------------------------------------------
|
|
65
70
|
// Error classification
|
|
@@ -74,9 +79,9 @@ const TRANSIENT_CODES = new Set([
|
|
|
74
79
|
'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
|
|
75
80
|
]);
|
|
76
81
|
// Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
|
|
77
|
-
// EINSECURE
|
|
78
|
-
// a redirect answers a request that was sent.
|
|
79
|
-
const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL', 'EINSECURE']);
|
|
82
|
+
// EINSECURE and EPROXYKEY are fetchGuarded refusing a key over http (or through an env proxy) before
|
|
83
|
+
// sending. Its redirect codes are not here: a redirect answers a request that was sent.
|
|
84
|
+
const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL', 'EINSECURE', 'EPROXYKEY']);
|
|
80
85
|
|
|
81
86
|
/**
|
|
82
87
|
* Map a WAN failure to a pool action.
|
|
@@ -92,8 +97,7 @@ const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOS
|
|
|
92
97
|
export function classifyWanError({ status, code } = {}) {
|
|
93
98
|
const kind = codeKind(code);
|
|
94
99
|
if (kind) return kind;
|
|
95
|
-
if (status === 401) return '
|
|
96
|
-
if (status === 403) return 'denied';
|
|
100
|
+
if (status === 403 || status === 401) return 'denied';
|
|
97
101
|
if (status === 429) return 'rate';
|
|
98
102
|
return !status || status >= 500 ? 'transient' : 'invalid_request';
|
|
99
103
|
}
|
|
@@ -245,6 +249,21 @@ export function poolSummary({ creds = {}, statePath = defaultStatePath(), now =
|
|
|
245
249
|
const inflight = new Map();
|
|
246
250
|
|
|
247
251
|
function pickEntry(pool, state, t, limit) {
|
|
252
|
+
const { ready, soonest, busy } = scanPool(pool, state, t, limit);
|
|
253
|
+
if (ready.length) {
|
|
254
|
+
// least recently used first: spreads requests over the pool, so no single key takes every rate hit
|
|
255
|
+
ready.sort((a, b) => (state.entries[a.fp]?.lastUsedAt || 0) - (state.entries[b.fp]?.lastUsedAt || 0) || a.n - b.n);
|
|
256
|
+
return { entry: ready[0] };
|
|
257
|
+
}
|
|
258
|
+
// a busy key frees up as soon as one of our own tasks finishes — always worth waiting for
|
|
259
|
+
if (busy) return { entry: null, waitMs: 1000, busy: true };
|
|
260
|
+
if (soonest != null) return { entry: null, waitMs: Math.max(0, soonest - t) };
|
|
261
|
+
return { entry: null, waitMs: null };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The pool at time t: the entries that can take a task now, the soonest end of a cooldown, and
|
|
265
|
+
// whether a usable key is only at its in-flight limit.
|
|
266
|
+
function scanPool(pool, state, t, limit) {
|
|
248
267
|
const ready = [];
|
|
249
268
|
let soonest = null;
|
|
250
269
|
let busy = false;
|
|
@@ -255,15 +274,7 @@ function pickEntry(pool, state, t, limit) {
|
|
|
255
274
|
if ((inflight.get(e.fp) || 0) >= limit) { busy = true; continue; }
|
|
256
275
|
ready.push(e);
|
|
257
276
|
}
|
|
258
|
-
|
|
259
|
-
// least recently used first: spreads requests over the pool, so no single key takes every rate hit
|
|
260
|
-
ready.sort((a, b) => (state.entries[a.fp]?.lastUsedAt || 0) - (state.entries[b.fp]?.lastUsedAt || 0) || a.n - b.n);
|
|
261
|
-
return { entry: ready[0] };
|
|
262
|
-
}
|
|
263
|
-
// a busy key frees up as soon as one of our own tasks finishes — always worth waiting for
|
|
264
|
-
if (busy) return { entry: null, waitMs: 1000, busy: true };
|
|
265
|
-
if (soonest != null) return { entry: null, waitMs: Math.max(0, soonest - t) };
|
|
266
|
-
return { entry: null, waitMs: null };
|
|
277
|
+
return { ready, soonest, busy };
|
|
267
278
|
}
|
|
268
279
|
|
|
269
280
|
// ---------------------------------------------------------------------------
|
|
@@ -280,21 +291,23 @@ function parseRetryAfter(h) {
|
|
|
280
291
|
|
|
281
292
|
const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
|
|
282
293
|
|
|
294
|
+
// The body is read inside the try: a connection that drops mid-body is a network failure, which
|
|
295
|
+
// the caller's retry sees, not a throw past it.
|
|
283
296
|
async function wanFetch(url, opts = {}) {
|
|
284
297
|
const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
|
|
285
|
-
let r;
|
|
286
298
|
try {
|
|
287
|
-
r = await fetchGuarded(url, {
|
|
299
|
+
const r = await fetchGuarded(url, {
|
|
288
300
|
method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
|
|
289
301
|
});
|
|
302
|
+
const retryAfterMs = parseRetryAfter(r.headers.get('retry-after'));
|
|
303
|
+
if (o.binary) return { status: r.status, ok: r.ok, body: Buffer.from(await r.arrayBuffer()), code: null, message: null, retryAfterMs };
|
|
304
|
+
return { status: r.status, ok: r.ok, ...(await parsedBody(r)), retryAfterMs };
|
|
290
305
|
} catch (e) {
|
|
291
306
|
return networkFailure(e);
|
|
292
307
|
}
|
|
293
|
-
const retryAfterMs = parseRetryAfter(r.headers.get('retry-after'));
|
|
294
|
-
if (o.binary) return { status: r.status, ok: r.ok, body: Buffer.from(await r.arrayBuffer()), code: null, message: null, retryAfterMs };
|
|
295
|
-
return { status: r.status, ok: r.ok, ...(await parsedBody(r)), retryAfterMs };
|
|
296
308
|
}
|
|
297
309
|
|
|
310
|
+
|
|
298
311
|
function wanHeaders({ key, body }) {
|
|
299
312
|
const headers = {};
|
|
300
313
|
if (key) headers.Authorization = `Bearer ${key}`;
|
|
@@ -302,8 +315,6 @@ function wanHeaders({ key, body }) {
|
|
|
302
315
|
return headers;
|
|
303
316
|
}
|
|
304
317
|
|
|
305
|
-
// AbortSignal.timeout rejects with a DOMException whose numeric code means nothing to a reader
|
|
306
|
-
const errnoOf = (e) => (e?.name === 'TimeoutError' ? 'TimeoutError' : e?.cause?.code || e?.code || null);
|
|
307
318
|
|
|
308
319
|
// A fetch that threw. Its message can quote a request header in full (undici names an invalid
|
|
309
320
|
// header value, Authorization included), so only the error code travels on, never the text.
|
|
@@ -312,8 +323,9 @@ function networkFailure(e) {
|
|
|
312
323
|
return { status: 0, ok: false, body: null, code: null, message: errno || e?.name || 'network error', retryAfterMs: null, preSend: PRE_SEND_ERRORS.has(errno), errno };
|
|
313
324
|
}
|
|
314
325
|
|
|
326
|
+
// A read cut short throws: wanFetch's catch makes it a network failure, as for a binary body.
|
|
315
327
|
async function parsedBody(r) {
|
|
316
|
-
const text = await r.text()
|
|
328
|
+
const text = await r.text();
|
|
317
329
|
let parsed = null;
|
|
318
330
|
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
|
|
319
331
|
return { body: parsed, code: parsed?.code || null, message: parsed?.message || null };
|
|
@@ -351,58 +363,90 @@ export async function frameDataUri(buf) {
|
|
|
351
363
|
|
|
352
364
|
/** WAN generation parameters for an output size like '720x1280'. */
|
|
353
365
|
export function wanParams({ size = '720x1280', seconds = 5, hasFrame = false, creds = {}, overrides = {} } = {}) {
|
|
366
|
+
const { w, h } = sizeOf(size);
|
|
367
|
+
const resolution = wanResolution(creds, Math.min(w, h));
|
|
368
|
+
const ratio = wanRatio(w, h, hasFrame);
|
|
369
|
+
const duration = Math.min(30, Math.max(2, Math.round(Number(seconds) || 5)));
|
|
370
|
+
const promptExtend = String(creds.WAN_PROMPT_EXTEND || '').toLowerCase() === 'true';
|
|
371
|
+
return { resolution, ratio, duration, audio: false, watermark: false, prompt_extend: promptExtend, ...overrides };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// An output size like '720x1280' as numbers; 720x1280 when it is not one.
|
|
375
|
+
function sizeOf(size) {
|
|
354
376
|
const m = String(size).match(/^(\d+)x(\d+)$/);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
377
|
+
return m ? { w: +m[1], h: +m[2] } : { w: 720, h: 1280 };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const wanResolution = (creds, short) => creds.WAN_RESOLUTION || (short >= 1080 ? '1080P' : short >= 720 ? '720P' : '480P');
|
|
381
|
+
|
|
382
|
+
// with a first frame, 'adaptive' follows the frame's own aspect, so nothing gets cropped
|
|
383
|
+
function wanRatio(w, h, hasFrame) {
|
|
384
|
+
if (hasFrame) return 'adaptive';
|
|
358
385
|
const g = (a, b) => (b ? g(b, a % b) : a);
|
|
359
386
|
const d = g(w, h);
|
|
360
387
|
const exact = `${w / d}:${h / d}`;
|
|
361
|
-
|
|
362
|
-
const ratio = hasFrame ? 'adaptive' : (WAN_RATIOS.has(exact) ? exact : 'adaptive');
|
|
363
|
-
const duration = Math.min(30, Math.max(2, Math.round(Number(seconds) || 5)));
|
|
364
|
-
const promptExtend = String(creds.WAN_PROMPT_EXTEND || '').toLowerCase() === 'true';
|
|
365
|
-
return { resolution, ratio, duration, audio: false, watermark: false, prompt_extend: promptExtend, ...overrides };
|
|
388
|
+
return WAN_RATIOS.has(exact) ? exact : 'adaptive';
|
|
366
389
|
}
|
|
367
390
|
|
|
368
391
|
/** Submit one WAN task. Returns the wanFetch result plus taskId when accepted. PAID CALL. */
|
|
369
392
|
export async function wanCreateTask({ entry, prompt, firstFramePng, lastFramePng, parameters } = {}) {
|
|
370
|
-
|
|
371
|
-
const media = [];
|
|
372
|
-
if (firstFramePng) media.push({ type: 'first_frame', url: await frameDataUri(firstFramePng) });
|
|
373
|
-
if (lastFramePng) media.push({ type: 'last_frame', url: await frameDataUri(lastFramePng) });
|
|
374
|
-
const input = { prompt };
|
|
375
|
-
if (media.length) input.media = media;
|
|
393
|
+
const input = { prompt, ...(await wanMedia(firstFramePng, lastFramePng)) };
|
|
376
394
|
const r = await wanFetch(`${entry.endpoint}${WAN_CREATE_PATH}`, {
|
|
377
395
|
key: entry.key, method: 'POST', body: { model: entry.model, input, parameters },
|
|
378
396
|
});
|
|
379
397
|
return { ...r, taskId: r.ok ? r.body?.output?.task_id || null : null };
|
|
380
398
|
}
|
|
381
399
|
|
|
400
|
+
// The frames a task starts (and ends) on, as WAN's input.media; nothing without frames.
|
|
401
|
+
async function wanMedia(firstFramePng, lastFramePng) {
|
|
402
|
+
if (lastFramePng && !firstFramePng) throw new Error('wanCreateTask: lastFramePng needs firstFramePng');
|
|
403
|
+
const media = [];
|
|
404
|
+
if (firstFramePng) media.push({ type: 'first_frame', url: await frameDataUri(firstFramePng) });
|
|
405
|
+
if (lastFramePng) media.push({ type: 'last_frame', url: await frameDataUri(lastFramePng) });
|
|
406
|
+
return media.length ? { media } : {};
|
|
407
|
+
}
|
|
408
|
+
|
|
382
409
|
/**
|
|
383
410
|
* Poll a WAN task to a terminal state. Returns {state, output} where state is
|
|
384
|
-
* SUCCEEDED | FAILED | CANCELED | UNKNOWN | TIMEOUT | HTTP (a 4xx other than 429 on the poll itself
|
|
411
|
+
* SUCCEEDED | FAILED | CANCELED | UNKNOWN | TIMEOUT | HTTP (a 4xx other than 429 on the poll itself, or
|
|
412
|
+
* status 0 with code EINSECURE or EPROXYKEY: the key was held back and nothing was sent).
|
|
413
|
+
* The poll ends after `timeoutMs` of wall time on the `now` clock: each request is limited to the
|
|
414
|
+
* time left, so a server that stops answering cannot stretch the wait. TIMEOUT carries `elapsedMs`.
|
|
385
415
|
*/
|
|
386
|
-
export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutMs = 30 * 60_000, sleep = realSleep, onTick } = {}) {
|
|
416
|
+
export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutMs = 30 * 60_000, sleep = realSleep, now = Date.now, onTick } = {}) {
|
|
387
417
|
const url = `${entry.endpoint}${WAN_TASK_PATH}${encodeURIComponent(taskId)}`;
|
|
388
418
|
const ticks = Math.max(1, Math.ceil(timeoutMs / Math.max(1, intervalMs)));
|
|
389
|
-
|
|
419
|
+
const start = now();
|
|
420
|
+
const end = start + timeoutMs;
|
|
421
|
+
const seen = { unknown: 0 };
|
|
390
422
|
for (let i = 0; i < ticks; i++) {
|
|
391
|
-
const r = await wanFetch(url, { key: entry.key });
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (typeof onTick === 'function') onTick(out.task_status, i);
|
|
395
|
-
if (out.task_status === 'SUCCEEDED' || out.task_status === 'FAILED' || out.task_status === 'CANCELED') {
|
|
396
|
-
return { state: out.task_status, output: out, usage: r.body?.usage || null };
|
|
397
|
-
}
|
|
398
|
-
if (out.task_status === 'UNKNOWN' && ++unknown >= UNKNOWN_POLLS_TO_FAIL) return { state: 'UNKNOWN', output: out };
|
|
399
|
-
} else if (r.status >= 400 && r.status < 500 && r.status !== 429) {
|
|
400
|
-
return { state: 'HTTP', output: { code: r.code, message: r.message }, status: r.status };
|
|
401
|
-
}
|
|
402
|
-
// 429/5xx/network on a poll: keep polling — the task itself is still running
|
|
423
|
+
const r = await wanFetch(url, { key: entry.key, timeoutMs: timeLeft(end, now()) });
|
|
424
|
+
const outcome = pollOutcome(r, seen, onTick, i);
|
|
425
|
+
if (outcome) return outcome;
|
|
403
426
|
await sleep(intervalMs);
|
|
427
|
+
if (now() >= end) break;
|
|
404
428
|
}
|
|
405
|
-
return { state: 'TIMEOUT', output: {} };
|
|
429
|
+
return { state: 'TIMEOUT', output: {}, elapsedMs: now() - start };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const WAN_TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED']);
|
|
433
|
+
const pollOutcome = (r, seen, onTick, i) => (r.ok ? answerOutcome(r.body, seen, onTick, i) : refusedPoll(r));
|
|
434
|
+
|
|
435
|
+
// What a poll's answer ends the poll with, or null to keep polling (a task still on its way).
|
|
436
|
+
function answerOutcome(body, seen, onTick, i) {
|
|
437
|
+
const out = body?.output || {};
|
|
438
|
+
if (typeof onTick === 'function') onTick(out.task_status, i);
|
|
439
|
+
if (WAN_TERMINAL.has(out.task_status)) return { state: out.task_status, output: out, usage: body?.usage || null };
|
|
440
|
+
if (out.task_status === 'UNKNOWN' && ++seen.unknown >= UNKNOWN_POLLS_TO_FAIL) return { state: 'UNKNOWN', output: out };
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A 4xx other than 429 on the poll itself ends it; 429/5xx/network keep polling — the task is still running.
|
|
445
|
+
// So does a key held back before sending (EINSECURE, EPROXYKEY): no later poll could send it either.
|
|
446
|
+
function refusedPoll(r) {
|
|
447
|
+
if (r.status >= 400 && r.status < 500 && r.status !== 429) return { state: 'HTTP', output: { code: r.code, message: r.message }, status: r.status };
|
|
448
|
+
if (r.status === 0 && HELD_KEY.has(r.errno)) return { state: 'HTTP', output: { code: r.errno, message: r.message }, status: 0 };
|
|
449
|
+
return null;
|
|
406
450
|
}
|
|
407
451
|
|
|
408
452
|
/** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
|
|
@@ -478,30 +522,39 @@ export function snapSoraSeconds(sec) {
|
|
|
478
522
|
|
|
479
523
|
function applyFailure(file, entry, kind, { code, status, retryAfterMs, message }, t, creds, emit) {
|
|
480
524
|
const reason = code || (status ? `HTTP ${status}` : 'network');
|
|
481
|
-
updateEntry(file, entry, (st) => {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
return {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
525
|
+
updateEntry(file, entry, (st) => failedState(st, kind, { reason, retryAfterMs, message, t, creds }), emit);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// An entry's state after a failure of this kind at time f.t. Strikes count CONSECUTIVE hits: anything
|
|
529
|
+
// older than the window they guard is forgotten.
|
|
530
|
+
function failedState(st, kind, f) {
|
|
531
|
+
const fresh = Boolean(st.lastFailAt && f.t - st.lastFailAt < RATE_MAX_MS * 2);
|
|
532
|
+
const base = { ...st, reason: f.reason, lastFailAt: f.t };
|
|
533
|
+
if (kind === 'unusable') return { ...base, status: 'invalid', at: f.t };
|
|
534
|
+
if (kind === 'denied' || kind === 'exhausted') return { ...base, status: 'exhausted', at: f.t, until: f.t + exhaustedTtlMs(f.creds), quotaStrikes: 0 };
|
|
535
|
+
if (kind === 'quota') return quotaState(base, strikesAfter(fresh, st.quotaStrikes), f);
|
|
536
|
+
if (kind === 'rate') return rateState(base, strikesAfter(fresh, st.rateStrikes), f);
|
|
537
|
+
return transientState(base, strikesAfter(fresh, st.transientStrikes), f);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const strikesAfter = (fresh, n) => (fresh ? n || 0 : 0) + 1;
|
|
541
|
+
|
|
542
|
+
function quotaState(base, strikes, { reason, retryAfterMs, t }) {
|
|
543
|
+
if (strikes >= QUOTA_STRIKES_TO_EXHAUST) {
|
|
544
|
+
return { ...base, status: 'exhausted', reason: `${reason} x${strikes}`, quotaStrikes: 0, at: t, until: t + QUOTA_STRIKE_TTL_MS };
|
|
545
|
+
}
|
|
546
|
+
return { ...base, quotaStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? RATE_BASE_MS * 2 ** (strikes - 1)) };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function rateState(base, strikes, { retryAfterMs, t }) {
|
|
550
|
+
return { ...base, rateStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? Math.min(RATE_MAX_MS, RATE_BASE_MS * 2 ** (strikes - 1))) };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function transientState(base, n, { reason, message, t }) {
|
|
554
|
+
return {
|
|
555
|
+
...base, transientStrikes: n, cooldownUntil: t + Math.min(RATE_MAX_MS, TRANSIENT_BASE_MS * n),
|
|
556
|
+
reason: message ? `${reason}: ${String(message).slice(0, 120)}` : reason,
|
|
557
|
+
};
|
|
505
558
|
}
|
|
506
559
|
|
|
507
560
|
// Idempotent per task: resuming a task whose download failed must not count it twice.
|
|
@@ -516,7 +569,7 @@ function recordSuccess(file, entry, t, taskId, emit) {
|
|
|
516
569
|
// task may have billed; returns {fail} only when the task ended without output for a known reason.
|
|
517
570
|
async function finishWanTask(ctx, e, taskId, parameters) {
|
|
518
571
|
const p = await wanPollTask({
|
|
519
|
-
entry: e, taskId, intervalMs: ctx.pollIntervalMs, timeoutMs: ctx.pollTimeoutMs, sleep: ctx.sleep,
|
|
572
|
+
entry: e, taskId, intervalMs: ctx.pollIntervalMs, timeoutMs: ctx.pollTimeoutMs, sleep: ctx.sleep, now: ctx.now,
|
|
520
573
|
onTick: (s, i) => ctx.emit({ type: 'poll', provider: 'wan', label: e.label, taskId, status: s, tick: i }),
|
|
521
574
|
});
|
|
522
575
|
if (p.state === 'SUCCEEDED') return { clip: await succeededClip(ctx, e, taskId, p, parameters.duration) };
|
|
@@ -536,10 +589,10 @@ function failedTask(e, taskId, p) {
|
|
|
536
589
|
// The poll could not tell how the task ended: keep the taskId so nothing is submitted twice.
|
|
537
590
|
function lostTask(ctx, e, taskId, p, seconds) {
|
|
538
591
|
if (p.state === 'TIMEOUT') {
|
|
539
|
-
return stopError('timeout', `WAN task ${taskId} (${e.label}) still running after ${
|
|
592
|
+
return stopError('timeout', `WAN task ${taskId} (${e.label}) still running after polling for ${p.elapsedMs} ms; resume with resumeClip`, { provider: 'wan', taskId, label: e.label, seconds });
|
|
540
593
|
}
|
|
541
594
|
if (p.state === 'HTTP') {
|
|
542
|
-
if (p.status === 401) applyFailure(ctx.statePath, e,
|
|
595
|
+
if (p.status === 401) applyFailure(ctx.statePath, e, classifyWanError({ status: 401, code: p.output.code }), { code: p.output.code, status: p.status }, ctx.now(), ctx.creds, ctx.emit);
|
|
543
596
|
return stopError('task_lost', `WAN poll for task ${taskId} (${e.label}) failed: HTTP ${p.status} ${p.output.code || ''}; check the Model Studio console before resubmitting`, { provider: 'wan', taskId, label: e.label, status: p.status, code: p.output.code });
|
|
544
597
|
}
|
|
545
598
|
return stopError('task_lost', `WAN task ${taskId} (${e.label}) reports UNKNOWN (expired or not found); check the console before resubmitting`, { provider: 'wan', taskId, label: e.label });
|
|
@@ -582,20 +635,37 @@ async function runWan(ctx) {
|
|
|
582
635
|
return poolUnavailable(ctx, `gave up after ${attempts} attempts`);
|
|
583
636
|
}
|
|
584
637
|
|
|
585
|
-
// The usable part of the WAN pool. Malformed entries
|
|
586
|
-
// entry is
|
|
638
|
+
// The usable part of the WAN pool. Malformed entries, and those whose key an env proxy would carry, are
|
|
639
|
+
// skipped with an event. When every configured entry is one of these the leg stops: a config error must
|
|
640
|
+
// not turn into a paid Sora clip.
|
|
587
641
|
function usablePool(ctx) {
|
|
588
642
|
const all = loadWanPool(ctx.creds);
|
|
589
|
-
const
|
|
590
|
-
for (const
|
|
591
|
-
if (all.length &&
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const pool = all.filter((e) => !malformedField(e));
|
|
643
|
+
const refusals = all.map((e) => [e, entryRefusal(e)]).filter(([, r]) => r);
|
|
644
|
+
for (const [e, r] of refusals) ctx.emit({ type: 'skip', provider: 'wan', label: e.label, reason: r.reason });
|
|
645
|
+
if (all.length && refusals.length === all.length) throw everyEntryRefused(refusals);
|
|
646
|
+
const refused = new Set(refusals.map(([e]) => e));
|
|
647
|
+
const pool = all.filter((e) => !refused.has(e));
|
|
595
648
|
if (!pool.length) ctx.emit({ type: 'skip', provider: 'wan', reason: 'no usable WAN_<n>_ENDPOINT + WAN_<n>_API_KEY entries' });
|
|
596
649
|
return pool;
|
|
597
650
|
}
|
|
598
651
|
|
|
652
|
+
// fetchGuarded would hold the key back from this origin (EPROXYKEY): http that an env proxy carries.
|
|
653
|
+
const proxyHolds = (origin) => Boolean(origin) && keyHeldBack(new URL(origin));
|
|
654
|
+
const HELD_BACK = 'the endpoint is http and the environment sends http through a proxy, so the key is held back (EPROXYKEY); name the host in NO_PROXY or use https';
|
|
655
|
+
|
|
656
|
+
// Why an entry cannot take a leg here, or null: a malformed field, or a key the env proxy would carry.
|
|
657
|
+
function entryRefusal(e) {
|
|
658
|
+
const field = malformedField(e);
|
|
659
|
+
if (field) return { tag: field, reason: `${field} is malformed (not an https URL / not one model id)` };
|
|
660
|
+
return proxyHolds(e.endpoint) ? { tag: 'EPROXYKEY', reason: HELD_BACK } : null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function everyEntryRefused(refusals) {
|
|
664
|
+
const held = refusals.some(([, r]) => r.tag === 'EPROXYKEY');
|
|
665
|
+
const list = refusals.map(([e, r]) => `${e.label} ${r.tag}`).join(', ');
|
|
666
|
+
return stopError('invalid_request', `every WAN entry is ${held ? 'malformed or held back' : 'malformed'} (${list}); fix the env file${held ? ' or the proxy setting' : ''} — nothing was sent, and Sora was not used`, { provider: 'wan' });
|
|
667
|
+
}
|
|
668
|
+
|
|
599
669
|
// Wait when a key frees up soon: an in-flight slot always, a cooldown only within maxRateWaitMs.
|
|
600
670
|
async function waitForKey(ctx, pick) {
|
|
601
671
|
if (!pick.busy && (pick.waitMs == null || pick.waitMs > ctx.maxRateWaitMs)) return false;
|
|
@@ -630,10 +700,11 @@ async function attemptOnKey(ctx, e, parameters, attempt) {
|
|
|
630
700
|
// server, and a second key would bill the same leg twice.
|
|
631
701
|
function assertSubmitSettled(e, c) {
|
|
632
702
|
if (c.status === 0 && !c.preSend) {
|
|
633
|
-
throw stopError('submit_unknown', `WAN submit to ${e.label}
|
|
703
|
+
throw stopError('submit_unknown', `WAN submit to ${e.label} got no usable answer (${c.message || 'network error'}); the task may exist — check the Model Studio console before resubmitting`, { provider: 'wan', label: e.label });
|
|
634
704
|
}
|
|
635
|
-
if (!c.taskId && (c.ok || (c.status >= 500 && !c.code))) {
|
|
636
|
-
// a 2xx without task_id or a
|
|
705
|
+
if (!c.taskId && (c.ok || (c.status >= 500 && !codeKind(c.code)))) {
|
|
706
|
+
// a 2xx without task_id, or a 5xx without a code the pool knows (a bare gateway 5xx, a new
|
|
707
|
+
// server code): the backend may have accepted the task
|
|
637
708
|
throw stopError('submit_unknown', `WAN submit to ${e.label} got an ambiguous answer (HTTP ${c.status}, no task_id); the task may exist — check the Model Studio console before resubmitting`, { provider: 'wan', label: e.label, status: c.status });
|
|
638
709
|
}
|
|
639
710
|
}
|
|
@@ -660,10 +731,11 @@ function soraConn(creds) {
|
|
|
660
731
|
// Poll an accepted Sora job to a downloaded clip; same stop rules as finishWanTask.
|
|
661
732
|
async function finishSoraJob(ctx, jobId, secs) {
|
|
662
733
|
const conn = soraConn(ctx.creds);
|
|
734
|
+
const t0 = ctx.now();
|
|
663
735
|
const st = await pollSoraJob(ctx, conn, jobId);
|
|
664
736
|
if (st && SORA_FAILED.has(st.status)) throw soraFailure(st, jobId);
|
|
665
737
|
if (!st || !SORA_DONE.has(st.status)) {
|
|
666
|
-
throw stopError('timeout', `Sora job ${jobId} still ${st?.status ?? 'unknown'} after ${ctx.
|
|
738
|
+
throw stopError('timeout', `Sora job ${jobId} still ${st?.status ?? 'unknown'} after polling for ${ctx.now() - t0} ms; resume with resumeClip`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', seconds: secs });
|
|
667
739
|
}
|
|
668
740
|
const mp4 = await downloadSoraClip(conn, jobId, secs);
|
|
669
741
|
ctx.emit({ type: 'done', provider: 'sora', label: 'AZURE_SORA', taskId: jobId });
|
|
@@ -676,8 +748,8 @@ async function pollSoraJob(ctx, conn, jobId) {
|
|
|
676
748
|
const interval = Math.max(0, ctx.pollIntervalMs);
|
|
677
749
|
try {
|
|
678
750
|
return await pollVideo({
|
|
679
|
-
endpoint: conn.endpoint, key: conn.key, id: jobId, apiVersion: conn.apiVersion, sleep: ctx.sleep,
|
|
680
|
-
intervalMs: interval, maxTicks: Math.max(1, Math.ceil(ctx.pollTimeoutMs / Math.max(1, interval))),
|
|
751
|
+
endpoint: conn.endpoint, key: conn.key, id: jobId, apiVersion: conn.apiVersion, sleep: ctx.sleep, now: ctx.now,
|
|
752
|
+
intervalMs: interval, maxTicks: Math.max(1, Math.ceil(ctx.pollTimeoutMs / Math.max(1, interval))), timeoutMs: ctx.pollTimeoutMs,
|
|
681
753
|
onTick: (s, i) => ctx.emit({ type: 'poll', provider: 'sora', taskId: jobId, status: s?.status, tick: i }),
|
|
682
754
|
});
|
|
683
755
|
} catch (e) {
|
|
@@ -701,34 +773,58 @@ async function downloadSoraClip(conn, jobId, secs) {
|
|
|
701
773
|
}
|
|
702
774
|
|
|
703
775
|
async function runSora(ctx) {
|
|
704
|
-
const
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
776
|
+
const conn = soraConn(ctx.creds);
|
|
777
|
+
const secs = snapSoraSeconds(ctx.seconds);
|
|
778
|
+
if (ctx.lastFramePng) ctx.emit({ type: 'note', provider: 'sora', message: 'Sora takes one input_reference; the last frame is ignored (forward-chaining)' });
|
|
779
|
+
const job = await submitSora(ctx, conn, secs);
|
|
780
|
+
return finishSoraJob(ctx, job.id, secs);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const SORA_SUBMIT_TRIES = 5;
|
|
784
|
+
|
|
785
|
+
// A 429 is a refusal, so retrying it cannot double-bill; a lost connection or a bare 5xx can. Each
|
|
786
|
+
// wait is the server's Retry-After, else 30 s doubling, from 1 s to 10 min. A Retry-After longer
|
|
787
|
+
// than maxRateWaitMs is not waited out: the leg stops as `rate` at once, as WAN's waits do.
|
|
788
|
+
async function submitSora(ctx, conn, secs) {
|
|
709
789
|
for (let i = 0; ; i++) {
|
|
710
790
|
try {
|
|
711
|
-
emit({ type: 'submit', provider: 'sora', label: 'AZURE_SORA', model, attempt: i + 1 });
|
|
712
|
-
|
|
713
|
-
|
|
791
|
+
ctx.emit({ type: 'submit', provider: 'sora', label: 'AZURE_SORA', model: conn.model, attempt: i + 1 });
|
|
792
|
+
return await createVideo({
|
|
793
|
+
endpoint: conn.endpoint, key: conn.key, model: conn.model, prompt: ctx.prompt, size: ctx.size, seconds: secs, inputReferencePng: ctx.firstFramePng, apiVersion: conn.apiVersion,
|
|
794
|
+
});
|
|
714
795
|
} catch (e) {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
if (/moderation|content_policy|safety/i.test(e.message)) throw new ContentRejectedError('sora', 'moderation', e.message);
|
|
723
|
-
if ((e.status === 0 && !PRE_SEND_ERRORS.has(e.errno)) || e.status >= 500 || (e.status >= 200 && e.status < 300)) {
|
|
724
|
-
throw stopError('submit_unknown', `Sora submit got no usable answer (${e.status === 0 ? e.errno || 'network' : `HTTP ${e.status}`}); the job may exist — check Azure before resubmitting`, { provider: 'sora', status: e.status });
|
|
725
|
-
}
|
|
726
|
-
throw stopError(e.status === 0 ? 'unreachable' : 'invalid_request', e.message, { provider: 'sora', status: e.status });
|
|
796
|
+
if (e.status !== 429 || i >= SORA_SUBMIT_TRIES - 1) throw soraSubmitError(e);
|
|
797
|
+
const want = e.retryAfterMs ?? RATE_BASE_MS * 2 ** i;
|
|
798
|
+
if (want > ctx.maxRateWaitMs) throw soraRateStop(want, ctx.maxRateWaitMs, e.retryAfterMs != null);
|
|
799
|
+
const ms = Math.min(RATE_MAX_MS, Math.max(RETRY_MIN_MS, want));
|
|
800
|
+
ctx.emit({ type: 'wait', provider: 'sora', ms, reason: 'HTTP 429' });
|
|
801
|
+
await ctx.sleep(ms);
|
|
727
802
|
}
|
|
728
803
|
}
|
|
729
|
-
return finishSoraJob(ctx, job.id, secs);
|
|
730
804
|
}
|
|
731
805
|
|
|
806
|
+
// The stop for a Sora 429 that asks for a longer wait than the caller allows; no job was made.
|
|
807
|
+
function soraRateStop(waitMs, maxRateWaitMs, asked) {
|
|
808
|
+
const wait = asked ? `with a Retry-After of ${Math.ceil(waitMs / 1000)} s` : `again, and its next wait (${Math.ceil(waitMs / 1000)} s) is`;
|
|
809
|
+
return stopError('rate', `Sora answered HTTP 429 ${wait}, past maxRateWaitMs (${maxRateWaitMs} ms); no job was made; wait, then generate the leg again`, { provider: 'sora', status: 429, retryAfterMs: asked ? waitMs : null });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// The stop for a Sora submit that made no job, or may have made one.
|
|
813
|
+
function soraSubmitError(e) {
|
|
814
|
+
if (/moderation|content_policy|safety/i.test(e.message)) return new ContentRejectedError('sora', 'moderation', e.message);
|
|
815
|
+
if (e.status === 429) {
|
|
816
|
+
return stopError('rate', `Sora still answered HTTP 429 after ${SORA_SUBMIT_TRIES} tries; no job was made — wait, then generate the leg again`, { provider: 'sora', status: 429 });
|
|
817
|
+
}
|
|
818
|
+
if (soraMayHaveJob(e)) {
|
|
819
|
+
const answer = e.status === 0 ? e.errno || 'network' : `HTTP ${e.status}`;
|
|
820
|
+
return stopError('submit_unknown', `Sora submit got no usable answer (${answer}); the job may exist — check Azure before resubmitting`, { provider: 'sora', status: e.status });
|
|
821
|
+
}
|
|
822
|
+
return stopError(e.status === 0 ? 'unreachable' : 'invalid_request', e.message, { provider: 'sora', status: e.status });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// A lost connection, a 5xx, or a 2xx without an id: the request may have reached Sora.
|
|
826
|
+
const soraMayHaveJob = (e) => (e.status === 0 && !PRE_SEND_ERRORS.has(e.errno)) || e.status >= 500 || (e.status >= 200 && e.status < 300);
|
|
827
|
+
|
|
732
828
|
// Explicit `undefined`/`null` options fall back to the defaults instead of overriding them.
|
|
733
829
|
const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null));
|
|
734
830
|
|
|
@@ -746,35 +842,47 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
|
|
|
746
842
|
*
|
|
747
843
|
* Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
|
|
748
844
|
* ContentRejectedError moderation — change the prompt/frame
|
|
749
|
-
* kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed
|
|
750
|
-
* Sora endpoint that is not https
|
|
845
|
+
* kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed, a
|
|
846
|
+
* Sora endpoint that is not https, or creds from a project key file
|
|
847
|
+
* git tracks (config; nothing is sent)
|
|
751
848
|
* kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
|
|
752
849
|
* kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
|
|
753
850
|
* ('failed': the task ended for a reason the pool does not know)
|
|
754
851
|
* kind 'unreachable' Sora host refused the connection (nothing was sent)
|
|
852
|
+
* kind 'rate' Sora kept answering 429, or asked (Retry-After, err.retryAfterMs) for a
|
|
853
|
+
* wait past maxRateWaitMs (no job was made); wait, then call again
|
|
755
854
|
* @returns {Promise<{mp4:Buffer, provider:'wan'|'sora', label:string, model:string, seconds:number, taskId:string, usage:object|null}>}
|
|
756
855
|
*/
|
|
757
856
|
export async function generateClip(opts = {}) {
|
|
758
857
|
const o = { ...DEFAULTS, statePath: defaultStatePath(), sleep: realSleep, now: Date.now, ...defined(opts) };
|
|
759
858
|
if (!o.creds) throw new Error('generateClip: creds required (resolveCreds())');
|
|
859
|
+
// a project key file git tracks may have come with the repository: its endpoint would get the prompt and the frames
|
|
860
|
+
if (o.creds._tracked) {
|
|
861
|
+
throw stopError('invalid_request', `generateClip: the project key file is tracked by git (${o.creds._source}); untrack it (git rm --cached) or use the home key file`);
|
|
862
|
+
}
|
|
760
863
|
if (!o.prompt) throw new Error('generateClip: prompt required');
|
|
761
864
|
const ctx = makeCtx(o);
|
|
762
865
|
const tried = [];
|
|
763
866
|
for (const p of o.order || parseProviderOrder(o.creds.VIDEO_PROVIDER_ORDER)) {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const r = await runWan(ctx);
|
|
767
|
-
if (r) return r;
|
|
768
|
-
} else if (p === 'sora') {
|
|
769
|
-
if (!soraReady(ctx)) continue;
|
|
770
|
-
tried.push('sora');
|
|
771
|
-
ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
|
|
772
|
-
return runSora(ctx);
|
|
773
|
-
}
|
|
867
|
+
const clip = await tryProvider(ctx, p, tried);
|
|
868
|
+
if (clip) return clip;
|
|
774
869
|
}
|
|
775
870
|
throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
|
|
776
871
|
}
|
|
777
872
|
|
|
873
|
+
// One provider's go at the leg: the clip, or null to go on to the next. Sora, once it runs, ends the
|
|
874
|
+
// leg (with a clip or a throw).
|
|
875
|
+
async function tryProvider(ctx, p, tried) {
|
|
876
|
+
if (p === 'wan') {
|
|
877
|
+
tried.push('wan');
|
|
878
|
+
return runWan(ctx);
|
|
879
|
+
}
|
|
880
|
+
if (p !== 'sora' || !soraReady(ctx)) return null;
|
|
881
|
+
tried.push('sora');
|
|
882
|
+
ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
|
|
883
|
+
return runSora(ctx);
|
|
884
|
+
}
|
|
885
|
+
|
|
778
886
|
// Whether Sora can take the leg. Not set up: skipped with an event. An endpoint that is set but not
|
|
779
887
|
// https (http only to this machine) stops the leg before the key is sent in the clear.
|
|
780
888
|
function soraReady(ctx) {
|
|
@@ -784,6 +892,7 @@ function soraReady(ctx) {
|
|
|
784
892
|
return false;
|
|
785
893
|
}
|
|
786
894
|
if (!originOf(endpoint)) throw stopError('invalid_request', 'AZURE_SORA_ENDPOINT is not an https URL; fix the env file — nothing was sent', { provider: 'sora' });
|
|
895
|
+
if (proxyHolds(originOf(endpoint))) throw stopError('invalid_request', `AZURE_SORA_ENDPOINT: ${HELD_BACK} — nothing was sent`, { provider: 'sora' });
|
|
787
896
|
return true;
|
|
788
897
|
}
|
|
789
898
|
|
|
@@ -802,6 +911,9 @@ export async function resumeClip(opts = {}) {
|
|
|
802
911
|
if (!originOf(o.creds.AZURE_SORA_ENDPOINT) || !o.creds.AZURE_SORA_API_KEY) {
|
|
803
912
|
throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an https URL and AZURE_SORA_API_KEY (run 3d-intro-setup); nothing was sent', { provider: 'sora', taskId: o.taskId });
|
|
804
913
|
}
|
|
914
|
+
if (proxyHolds(originOf(o.creds.AZURE_SORA_ENDPOINT))) {
|
|
915
|
+
throw stopError('invalid_request', `resumeClip: AZURE_SORA_ENDPOINT: ${HELD_BACK} — nothing was sent`, { provider: 'sora', taskId: o.taskId });
|
|
916
|
+
}
|
|
805
917
|
return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
|
|
806
918
|
}
|
|
807
919
|
|
|
@@ -811,6 +923,9 @@ async function resumeWan(ctx, o) {
|
|
|
811
923
|
if (e.badEndpoint) {
|
|
812
924
|
throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an https URL; fix the env file — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
|
|
813
925
|
}
|
|
926
|
+
if (proxyHolds(e.endpoint)) {
|
|
927
|
+
throw stopError('invalid_request', `resumeClip: ${o.label}: ${HELD_BACK} — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
|
|
928
|
+
}
|
|
814
929
|
const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
|
|
815
930
|
if (out.clip) return out.clip;
|
|
816
931
|
throw stopError('failed', `WAN task ${o.taskId} (${o.label}) ended ${out.fail.code}: ${out.fail.message || ''}; it produced no clip — generate the leg again`, { provider: 'wan', taskId: o.taskId, label: o.label, code: out.fail.code });
|