@kaydash9999/banker-plugins 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +48 -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 +235 -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 +115 -29
  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 +780 -0
  30. package/skills/3d-intro-setup/SKILL.md +49 -12
  31. package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
  32. package/skills/3d-intro-setup/references/video-pool.mjs +780 -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
@@ -0,0 +1,780 @@
1
+ /*
2
+ * Video provider pool for the 3d-intro skills (build + setup).
3
+ * Primary : Alibaba Cloud Model Studio WAN (e.g. wan3.0-video-prime) over a POOL of API keys.
4
+ * Fallback: Azure Sora-2 (via azure-adapter.mjs) once every WAN key is exhausted or unusable.
5
+ *
6
+ * CANONICAL COPY: skills/3d-intro-build/references/video-pool.mjs
7
+ * skills/3d-intro-setup/references/video-pool.mjs is a byte-identical mirror kept in sync by
8
+ * scripts/sync-adapter.js (run `node scripts/sync-adapter.js --check` in CI).
9
+ * Edit the build copy only; never the setup copy.
10
+ *
11
+ * Pool config (KEY=VALUE in the same env file resolveCreds() reads):
12
+ * WAN_<n>_ENDPOINT / WAN_<n>_API_KEY one pool entry per n (any count, any gaps)
13
+ * WAN_<n>_MODEL per-entry model override (optional)
14
+ * WAN_MODEL pool-wide model (default wan3.0-video-prime)
15
+ * WAN_RESOLUTION 480P | 720P | 1080P (default: derived from size)
16
+ * WAN_PROMPT_EXTEND true | false (default false — keeps chained prompts literal)
17
+ * WAN_MAX_CONCURRENT in-flight tasks per key (default 5, the documented limit)
18
+ * WAN_EXHAUSTED_TTL_HOURS how long an exhausted key rests before one retry (default 24)
19
+ * VIDEO_PROVIDER_ORDER comma list of wan,sora (default wan,sora)
20
+ *
21
+ * Billing rule: a leg is submitted to a second key ONLY when the first submission provably did
22
+ * not start a task (the server rejected it, the connection failed before the request was sent, or
23
+ * the task ended FAILED/CANCELED with a known key, capacity or transient code). Every other outcome
24
+ * throws, with the taskId when a task exists, so the caller can resume (resumeClip) instead of
25
+ * paying twice. A config error (every WAN entry malformed, ModelNotFound) never falls back to Sora.
26
+ *
27
+ * Pool state lives in ~/.config/banker/3d-intro/video-pool-state.json (0600). It holds
28
+ * FINGERPRINTS (sha256 of endpoint+key+model), never a key, so a rotated key or a changed model
29
+ * starts with a clean record. State is advisory: a failed write is reported, never fatal.
30
+ * Concurrent processes share the file last-write-wins; the per-key in-flight limit is per process.
31
+ *
32
+ * Runtime: Node >=18 builtins only. globalThis.fetch and cp.spawnSync are read at call time so
33
+ * unit tests can stub them. No hardcoded secrets, keys, or endpoints.
34
+ */
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import os from 'node:os';
38
+ import crypto from 'node:crypto';
39
+ import cp from 'node:child_process';
40
+ import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact } from './azure-adapter.mjs';
41
+
42
+ export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
43
+ const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
44
+ const WAN_TASK_PATH = '/api/v1/tasks/';
45
+ const WAN_RATIOS = new Set(['21:9', '16:9', '4:3', '1:1', '3:4', '9:16']);
46
+ const SORA_SECONDS = [4, 8, 12];
47
+ const SORA_DONE = new Set(['completed', 'succeeded']);
48
+ const SORA_FAILED = new Set(['failed', 'cancelled', 'canceled', 'error']);
49
+ const PROBE_TASK_ID = '00000000-0000-0000-0000-000000000000';
50
+
51
+ const RATE_BASE_MS = 30_000;
52
+ const RATE_MAX_MS = 10 * 60_000;
53
+ const QUOTA_STRIKES_TO_EXHAUST = 3;
54
+ const QUOTA_STRIKE_TTL_MS = 3_600_000;
55
+ const TRANSIENT_BASE_MS = 15_000;
56
+ const UNKNOWN_POLLS_TO_FAIL = 5;
57
+ const API_TIMEOUT_MS = 120_000;
58
+ const DOWNLOAD_TIMEOUT_MS = 600_000;
59
+
60
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Error classification
64
+ // ---------------------------------------------------------------------------
65
+
66
+ // Codes from the Model Studio error-code reference, grouped by what the pool does with them.
67
+ const DENIED_CODES = new Set(['AccessDenied', 'Model.AccessDenied', 'Workspace.AccessDenied']);
68
+ const EXHAUSTED_CODES = new Set(['Arrearage', 'BudgetLimitExceeded', 'PostpaidBillOverdue', 'PrepaidBillOverdue']);
69
+ const RATE_CODES = new Set(['Throttling', 'Throttling.RateQuota', 'Throttling.BurstRate']);
70
+ const CONTENT_CODES = new Set(['DataInspectionFailed', 'IPInfringementSuspect']);
71
+ const TRANSIENT_CODES = new Set([
72
+ 'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
73
+ ]);
74
+ // Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
75
+ const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL']);
76
+
77
+ /**
78
+ * Map a WAN failure to a pool action.
79
+ * unusable key rejected (InvalidApiKey) — retired until the key changes
80
+ * denied key/workspace lacks access to the model — rests WAN_EXHAUSTED_TTL_HOURS
81
+ * exhausted quota or billing stop — rests WAN_EXHAUSTED_TTL_HOURS
82
+ * quota Throttling.AllocationQuota (rate OR allocation limit) — cooldown; 3 in a row = 1 h rest
83
+ * rate request-rate limit — cooldown only, never retires the key
84
+ * transient server-side trouble — short cooldown, next key
85
+ * content / invalid_request — not a key problem; thrown to the caller
86
+ * @returns {'unusable'|'denied'|'exhausted'|'quota'|'rate'|'transient'|'content'|'invalid_request'}
87
+ */
88
+ export function classifyWanError({ status, code } = {}) {
89
+ const kind = codeKind(code);
90
+ if (kind) return kind;
91
+ if (status === 401) return 'unusable';
92
+ if (status === 403) return 'denied';
93
+ if (status === 429) return 'rate';
94
+ return !status || status >= 500 ? 'transient' : 'invalid_request';
95
+ }
96
+
97
+ const CODE_KIND = new Map([
98
+ ['InvalidApiKey', 'unusable'], ['ModelNotFound', 'invalid_request'], ['Throttling.AllocationQuota', 'quota'],
99
+ ...[...DENIED_CODES].map((c) => [c, 'denied']), ...[...EXHAUSTED_CODES].map((c) => [c, 'exhausted']),
100
+ ...[...RATE_CODES].map((c) => [c, 'rate']), ...[...CONTENT_CODES].map((c) => [c, 'content']),
101
+ ...[...TRANSIENT_CODES].map((c) => [c, 'transient']),
102
+ ]);
103
+
104
+ // The pool action a documented code calls for, or null for a code the pool does not know.
105
+ function codeKind(code) {
106
+ if (!code) return null;
107
+ if (CODE_KIND.has(code)) return CODE_KIND.get(code);
108
+ if (/^InvalidParameter/.test(code)) return 'invalid_request';
109
+ return /^AllocationQuota/.test(code) ? 'exhausted' : null;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Pool entries + persisted state
114
+ // ---------------------------------------------------------------------------
115
+
116
+ // Endpoints are origins: a pasted console URL with a path would otherwise 404 every call. A value
117
+ // new URL() rejects (a bare host, a value with spaces, a key pasted into the wrong field) gives
118
+ // null. The raw value is never echoed, because it may be a key.
119
+ function originOf(url) {
120
+ try {
121
+ const u = new URL(String(url).trim());
122
+ return /^https?:$/.test(u.protocol) && u.host ? `${u.protocol}//${u.host}` : null;
123
+ } catch { return null; }
124
+ }
125
+ const fingerprint = (endpoint, key, model) => crypto.createHash('sha256').update(`${endpoint}\n${key}\n${model}`).digest('hex').slice(0, 16);
126
+ // A model id is one token (wan3.0-video-prime). A space fails every call, and an sk- value is a
127
+ // key pasted into the wrong field, so neither counts as a model.
128
+ const MODEL_RE = /^[A-Za-z0-9][\w.-]*$/;
129
+ const isModelId = (model) => MODEL_RE.test(model) && !/^sk-/i.test(model);
130
+ const hostOf = (endpoint) => (endpoint ? new URL(endpoint).host : '(invalid endpoint)');
131
+ const shownModel = (e) => (e.badModel ? '(invalid model)' : e.model);
132
+ // What a report may show about an entry: no raw value of a field that failed validation.
133
+ const shownEntry = (e) => ({ label: e.label, host: hostOf(e.endpoint), model: shownModel(e) });
134
+ // The field that makes an entry unusable, or null.
135
+ const malformedField = (e) => (e.badEndpoint ? 'endpoint' : e.badModel ? 'model' : null);
136
+
137
+ /**
138
+ * Build the WAN pool from creds: every WAN_<n>_ENDPOINT with a matching WAN_<n>_API_KEY, by n.
139
+ * An entry whose endpoint is not an http(s) URL (`badEndpoint`) or whose model is not one model id
140
+ * (`badModel`) is kept so the preflight can report it; generateClip skips it.
141
+ */
142
+ export function loadWanPool(creds = {}) {
143
+ const pool = [];
144
+ for (const k of Object.keys(creds)) {
145
+ const m = k.match(/^WAN_(\d+)_ENDPOINT$/);
146
+ if (m && creds[k] && creds[`WAN_${m[1]}_API_KEY`]) pool.push(poolEntry(creds, m[1]));
147
+ }
148
+ return pool.sort((a, b) => a.n - b.n || a.label.localeCompare(b.label));
149
+ }
150
+
151
+ // id keeps the literal digits so WAN_01_* never pairs with WAN_1_*
152
+ function poolEntry(creds, id) {
153
+ const endpoint = originOf(creds[`WAN_${id}_ENDPOINT`]);
154
+ const key = creds[`WAN_${id}_API_KEY`];
155
+ const model = String(creds[`WAN_${id}_MODEL`] || creds.WAN_MODEL || WAN_DEFAULT_MODEL).trim();
156
+ return {
157
+ n: Number(id), label: `WAN_${id}`, endpoint, key, model, fp: fingerprint(endpoint, key, model),
158
+ badEndpoint: !endpoint, badModel: !isModelId(model),
159
+ };
160
+ }
161
+
162
+ export function defaultStatePath() {
163
+ return path.join(os.homedir(), '.config', 'banker', '3d-intro', 'video-pool-state.json');
164
+ }
165
+
166
+ export function loadPoolState(file = defaultStatePath()) {
167
+ try {
168
+ const s = JSON.parse(fs.readFileSync(file, 'utf8'));
169
+ if (s && typeof s === 'object' && s.entries && typeof s.entries === 'object') return s;
170
+ } catch { /* missing or corrupt — start clean */ }
171
+ return { version: 1, entries: {} };
172
+ }
173
+
174
+ export function savePoolState(state, file = defaultStatePath()) {
175
+ fs.mkdirSync(path.dirname(file), { recursive: true });
176
+ const tmp = `${file}.${process.pid}.tmp`;
177
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
178
+ fs.renameSync(tmp, file);
179
+ try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
180
+ return file;
181
+ }
182
+
183
+ // Read-modify-write one entry so updates from other runs since our last read are kept. State is
184
+ // advisory: a write failure (read-only HOME, a Windows rename lock) is reported, never thrown —
185
+ // a clip that is already paid for must still reach the caller.
186
+ function updateEntry(file, entry, fn, emit) {
187
+ try {
188
+ const state = loadPoolState(file);
189
+ const cur = state.entries[entry.fp] || { label: entry.label, status: 'ok', okCount: 0 };
190
+ state.entries[entry.fp] = { ...fn({ ...cur }), label: entry.label };
191
+ savePoolState(state, file);
192
+ } catch (e) {
193
+ if (typeof emit === 'function') emit({ type: 'state-error', label: entry.label, message: e.message });
194
+ }
195
+ }
196
+
197
+ /** Clear recorded state for all entries, or only for the given labels (e.g. ['WAN_2']). */
198
+ export function resetPoolState({ statePath = defaultStatePath(), creds, labels } = {}) {
199
+ const state = loadPoolState(statePath);
200
+ if (!labels) state.entries = {};
201
+ else {
202
+ const fps = new Set(loadWanPool(creds).filter((e) => labels.includes(e.label)).map((e) => e.fp));
203
+ for (const fp of Object.keys(state.entries)) if (fps.has(fp)) delete state.entries[fp];
204
+ }
205
+ return savePoolState(state, statePath);
206
+ }
207
+
208
+ const exhaustedTtlMs = (creds) => (Number(creds.WAN_EXHAUSTED_TTL_HOURS) || 24) * 3_600_000;
209
+
210
+ // One entry's effective availability at time t.
211
+ function entryStatus(st, t) {
212
+ if (!st) return { status: 'ok' };
213
+ if (st.status === 'invalid') return { status: 'invalid', reason: st.reason };
214
+ if (st.status === 'exhausted' && (!st.until || t < st.until)) return { status: 'exhausted', reason: st.reason, until: st.until };
215
+ if (st.cooldownUntil && t < st.cooldownUntil) return { status: 'cooldown', reason: st.reason, until: st.cooldownUntil };
216
+ return { status: 'ok' };
217
+ }
218
+
219
+ /** Per-entry availability for reporting. Never includes a key, nor a malformed endpoint or model. */
220
+ export function poolSummary({ creds = {}, statePath = defaultStatePath(), now = Date.now() } = {}) {
221
+ const state = loadPoolState(statePath);
222
+ return loadWanPool(creds).map((e) => {
223
+ const st = state.entries[e.fp];
224
+ const field = malformedField(e);
225
+ return { ...shownEntry(e), okCount: st?.okCount || 0, ...(field ? { status: 'malformed', reason: field } : entryStatus(st, now)) };
226
+ });
227
+ }
228
+
229
+ // In-process in-flight counter per fingerprint (the documented limit is per key).
230
+ const inflight = new Map();
231
+
232
+ function pickEntry(pool, state, t, limit) {
233
+ const ready = [];
234
+ let soonest = null;
235
+ let busy = false;
236
+ for (const e of pool) {
237
+ const s = entryStatus(state.entries[e.fp], t);
238
+ if (s.status === 'invalid' || s.status === 'exhausted') continue;
239
+ if (s.status === 'cooldown') { soonest = soonest == null ? s.until : Math.min(soonest, s.until); continue; }
240
+ if ((inflight.get(e.fp) || 0) >= limit) { busy = true; continue; }
241
+ ready.push(e);
242
+ }
243
+ if (ready.length) {
244
+ // least recently used first: spreads requests over the pool, so no single key takes every rate hit
245
+ ready.sort((a, b) => (state.entries[a.fp]?.lastUsedAt || 0) - (state.entries[b.fp]?.lastUsedAt || 0) || a.n - b.n);
246
+ return { entry: ready[0] };
247
+ }
248
+ // a busy key frees up as soon as one of our own tasks finishes — always worth waiting for
249
+ if (busy) return { entry: null, waitMs: 1000, busy: true };
250
+ if (soonest != null) return { entry: null, waitMs: Math.max(0, soonest - t) };
251
+ return { entry: null, waitMs: null };
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // WAN HTTP
256
+ // ---------------------------------------------------------------------------
257
+
258
+ function parseRetryAfter(h) {
259
+ if (!h) return null;
260
+ const sec = Number(h);
261
+ if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
262
+ const at = Date.parse(h);
263
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
264
+ }
265
+
266
+ const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
267
+
268
+ async function wanFetch(url, opts = {}) {
269
+ const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
270
+ let r;
271
+ try {
272
+ r = await globalThis.fetch(url, {
273
+ method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
274
+ });
275
+ } catch (e) {
276
+ return networkFailure(e);
277
+ }
278
+ const retryAfterMs = parseRetryAfter(r.headers.get('retry-after'));
279
+ if (o.binary) return { status: r.status, ok: r.ok, body: Buffer.from(await r.arrayBuffer()), code: null, message: null, retryAfterMs };
280
+ return { status: r.status, ok: r.ok, ...(await parsedBody(r)), retryAfterMs };
281
+ }
282
+
283
+ function wanHeaders({ key, body }) {
284
+ const headers = {};
285
+ if (key) headers.Authorization = `Bearer ${key}`;
286
+ if (body !== undefined) Object.assign(headers, { 'Content-Type': 'application/json', 'X-DashScope-Async': 'enable' });
287
+ return headers;
288
+ }
289
+
290
+ // AbortSignal.timeout rejects with a DOMException whose numeric code means nothing to a reader
291
+ const errnoOf = (e) => (e?.name === 'TimeoutError' ? 'TimeoutError' : e?.cause?.code || e?.code || null);
292
+
293
+ // A fetch that threw. Its message can quote a request header in full (undici names an invalid
294
+ // header value, Authorization included), so only the error code travels on, never the text.
295
+ function networkFailure(e) {
296
+ const errno = errnoOf(e);
297
+ return { status: 0, ok: false, body: null, code: null, message: errno || e?.name || 'network error', retryAfterMs: null, preSend: PRE_SEND_ERRORS.has(errno), errno };
298
+ }
299
+
300
+ async function parsedBody(r) {
301
+ const text = await r.text().catch(() => '');
302
+ let parsed = null;
303
+ try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
304
+ return { body: parsed, code: parsed?.code || null, message: parsed?.message || null };
305
+ }
306
+
307
+ // PNG color type 4/6 or a tRNS chunk means transparency; WAN rejects PNGs with an alpha channel.
308
+ function pngHasAlpha(buf) {
309
+ if (buf.length < 26 || buf.readUInt32BE(0) !== 0x89504e47) return false;
310
+ const colorType = buf[25];
311
+ return colorType === 4 || colorType === 6 || buf.includes(Buffer.from('tRNS'));
312
+ }
313
+
314
+ async function stripAlpha(buf) {
315
+ const bin = await resolveFfmpeg();
316
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'video-pool-'));
317
+ try {
318
+ const src = path.join(dir, 'in.png');
319
+ const out = path.join(dir, 'out.png');
320
+ fs.writeFileSync(src, buf);
321
+ const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8' });
322
+ if (!fs.existsSync(out)) throw new Error(`stripAlpha failed: ${String(r.stderr || '').slice(-400)}`);
323
+ return fs.readFileSync(out);
324
+ } finally {
325
+ fs.rmSync(dir, { recursive: true, force: true });
326
+ }
327
+ }
328
+
329
+ /** Encode a PNG/JPEG frame as the data URI WAN accepts, dropping any PNG alpha channel first. */
330
+ export async function frameDataUri(buf) {
331
+ const b = Buffer.isBuffer(buf) ? buf : fs.readFileSync(buf);
332
+ if (b[0] === 0xff && b[1] === 0xd8) return `data:image/jpeg;base64,${b.toString('base64')}`;
333
+ const png = pngHasAlpha(b) ? await stripAlpha(b) : b;
334
+ return `data:image/png;base64,${png.toString('base64')}`;
335
+ }
336
+
337
+ /** WAN generation parameters for an output size like '720x1280'. */
338
+ export function wanParams({ size = '720x1280', seconds = 5, hasFrame = false, creds = {}, overrides = {} } = {}) {
339
+ const m = String(size).match(/^(\d+)x(\d+)$/);
340
+ const w = m ? +m[1] : 720, h = m ? +m[2] : 1280;
341
+ const short = Math.min(w, h);
342
+ const resolution = creds.WAN_RESOLUTION || (short >= 1080 ? '1080P' : short >= 720 ? '720P' : '480P');
343
+ const g = (a, b) => (b ? g(b, a % b) : a);
344
+ const d = g(w, h);
345
+ const exact = `${w / d}:${h / d}`;
346
+ // with a first frame, 'adaptive' follows the frame's own aspect, so nothing gets cropped
347
+ const ratio = hasFrame ? 'adaptive' : (WAN_RATIOS.has(exact) ? exact : 'adaptive');
348
+ const duration = Math.min(30, Math.max(2, Math.round(Number(seconds) || 5)));
349
+ const promptExtend = String(creds.WAN_PROMPT_EXTEND || '').toLowerCase() === 'true';
350
+ return { resolution, ratio, duration, audio: false, watermark: false, prompt_extend: promptExtend, ...overrides };
351
+ }
352
+
353
+ /** Submit one WAN task. Returns the wanFetch result plus taskId when accepted. PAID CALL. */
354
+ export async function wanCreateTask({ entry, prompt, firstFramePng, lastFramePng, parameters } = {}) {
355
+ if (lastFramePng && !firstFramePng) throw new Error('wanCreateTask: lastFramePng needs firstFramePng');
356
+ const media = [];
357
+ if (firstFramePng) media.push({ type: 'first_frame', url: await frameDataUri(firstFramePng) });
358
+ if (lastFramePng) media.push({ type: 'last_frame', url: await frameDataUri(lastFramePng) });
359
+ const input = { prompt };
360
+ if (media.length) input.media = media;
361
+ const r = await wanFetch(`${entry.endpoint}${WAN_CREATE_PATH}`, {
362
+ key: entry.key, method: 'POST', body: { model: entry.model, input, parameters },
363
+ });
364
+ return { ...r, taskId: r.ok ? r.body?.output?.task_id || null : null };
365
+ }
366
+
367
+ /**
368
+ * Poll a WAN task to a terminal state. Returns {state, output} where state is
369
+ * SUCCEEDED | FAILED | CANCELED | UNKNOWN | TIMEOUT | HTTP (a 4xx other than 429 on the poll itself).
370
+ */
371
+ export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutMs = 30 * 60_000, sleep = realSleep, onTick } = {}) {
372
+ const url = `${entry.endpoint}${WAN_TASK_PATH}${encodeURIComponent(taskId)}`;
373
+ const ticks = Math.max(1, Math.ceil(timeoutMs / Math.max(1, intervalMs)));
374
+ let unknown = 0;
375
+ for (let i = 0; i < ticks; i++) {
376
+ const r = await wanFetch(url, { key: entry.key });
377
+ if (r.ok) {
378
+ const out = r.body?.output || {};
379
+ if (typeof onTick === 'function') onTick(out.task_status, i);
380
+ if (out.task_status === 'SUCCEEDED' || out.task_status === 'FAILED' || out.task_status === 'CANCELED') {
381
+ return { state: out.task_status, output: out, usage: r.body?.usage || null };
382
+ }
383
+ if (out.task_status === 'UNKNOWN' && ++unknown >= UNKNOWN_POLLS_TO_FAIL) return { state: 'UNKNOWN', output: out };
384
+ } else if (r.status >= 400 && r.status < 500 && r.status !== 429) {
385
+ return { state: 'HTTP', output: { code: r.code, message: r.message }, status: r.status };
386
+ }
387
+ // 429/5xx/network on a poll: keep polling — the task itself is still running
388
+ await sleep(intervalMs);
389
+ }
390
+ return { state: 'TIMEOUT', output: {} };
391
+ }
392
+
393
+ /** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
394
+ export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {}) {
395
+ let last;
396
+ for (let i = 0; i < tries; i++) {
397
+ last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
398
+ if (last.status === 200 && last.body?.length) return last.body;
399
+ if (i < tries - 1) await sleep(2000 * (i + 1));
400
+ }
401
+ throw new Error(`WAN video download failed: HTTP ${last?.status}`);
402
+ }
403
+
404
+ /**
405
+ * FREE auth check for every pool entry: GET a nonexistent task. 200 (task_status UNKNOWN) means
406
+ * the key and workspace endpoint authenticate; 401 InvalidApiKey means they do not. Remaining
407
+ * quota is NOT visible this way — Model Studio has no documented quota API.
408
+ */
409
+ export async function probeWanPool({ creds = {} } = {}) {
410
+ const results = [];
411
+ for (const e of loadWanPool(creds)) results.push(await probeEntry(e));
412
+ return results;
413
+ }
414
+
415
+ async function probeEntry(e) {
416
+ const base = { ...shownEntry(e), key: redact(e.key) };
417
+ const field = malformedField(e);
418
+ if (field) return { ...base, status: 0, code: field === 'endpoint' ? 'BadEndpoint' : 'BadModel', authOk: false };
419
+ const r = await wanFetch(`${e.endpoint}${WAN_TASK_PATH}${PROBE_TASK_ID}`, { key: e.key });
420
+ return { ...base, status: r.status, code: r.code, authOk: r.status === 200 };
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Orchestration: WAN pool -> Sora fallback
425
+ // ---------------------------------------------------------------------------
426
+
427
+ /** Error for a prompt/frame that content moderation rejected; switching keys cannot fix it. */
428
+ export class ContentRejectedError extends Error {
429
+ constructor(provider, code, message) {
430
+ super(`${provider} rejected the content (${code}): ${message || ''}`.trim());
431
+ this.kind = 'content';
432
+ this.provider = provider;
433
+ this.code = code;
434
+ }
435
+ }
436
+
437
+ // An error that must NOT be retried by resubmitting the leg: the task may exist and bill.
438
+ // kind: timeout | submit_unknown | download | no_video | task_lost. Carries taskId/label when known.
439
+ function stopError(kind, message, props = {}) {
440
+ const err = new Error(message);
441
+ Object.assign(err, { kind, ...props });
442
+ return err;
443
+ }
444
+
445
+ export function parseProviderOrder(s) {
446
+ const list = String(s || '').split(',').map((x) => x.trim().toLowerCase()).filter((x) => x === 'wan' || x === 'sora');
447
+ return list.length ? [...new Set(list)] : ['wan', 'sora'];
448
+ }
449
+
450
+ export function snapSoraSeconds(sec) {
451
+ const s = Number(sec) || 4;
452
+ return SORA_SECONDS.reduce((best, v) => (Math.abs(v - s) < Math.abs(best - s) ? v : best), SORA_SECONDS[0]);
453
+ }
454
+
455
+ function applyFailure(file, entry, kind, { code, status, retryAfterMs, message }, t, creds, emit) {
456
+ const reason = code || (status ? `HTTP ${status}` : 'network');
457
+ updateEntry(file, entry, (st) => {
458
+ // strikes count CONSECUTIVE hits: anything older than the window they guard is forgotten
459
+ const fresh = st.lastFailAt && t - st.lastFailAt < RATE_MAX_MS * 2;
460
+ const base = { ...st, reason, lastFailAt: t };
461
+ if (kind === 'unusable') return { ...base, status: 'invalid', at: t };
462
+ if (kind === 'denied' || kind === 'exhausted') return { ...base, status: 'exhausted', at: t, until: t + exhaustedTtlMs(creds), quotaStrikes: 0 };
463
+ if (kind === 'quota') {
464
+ const strikes = (fresh ? st.quotaStrikes || 0 : 0) + 1;
465
+ if (strikes >= QUOTA_STRIKES_TO_EXHAUST) {
466
+ return { ...base, status: 'exhausted', reason: `${reason} x${strikes}`, quotaStrikes: 0, at: t, until: t + QUOTA_STRIKE_TTL_MS };
467
+ }
468
+ return { ...base, quotaStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? RATE_BASE_MS * 2 ** (strikes - 1)) };
469
+ }
470
+ if (kind === 'rate') {
471
+ const strikes = (fresh ? st.rateStrikes || 0 : 0) + 1;
472
+ return { ...base, rateStrikes: strikes, cooldownUntil: t + (retryAfterMs ?? Math.min(RATE_MAX_MS, RATE_BASE_MS * 2 ** (strikes - 1))) };
473
+ }
474
+ // transient
475
+ const n = (fresh ? st.transientStrikes || 0 : 0) + 1;
476
+ return {
477
+ ...base, transientStrikes: n, cooldownUntil: t + Math.min(RATE_MAX_MS, TRANSIENT_BASE_MS * n),
478
+ reason: message ? `${reason}: ${String(message).slice(0, 120)}` : reason,
479
+ };
480
+ }, emit);
481
+ }
482
+
483
+ // Idempotent per task: resuming a task whose download failed must not count it twice.
484
+ function recordSuccess(file, entry, t, taskId, emit) {
485
+ updateEntry(file, entry, (st) => ({
486
+ ...st, status: 'ok', reason: null, rateStrikes: 0, quotaStrikes: 0, transientStrikes: 0, cooldownUntil: null, until: null,
487
+ okCount: (st.okCount || 0) + (st.lastOkTaskId === taskId ? 0 : 1), lastOkAt: t, lastOkTaskId: taskId,
488
+ }), emit);
489
+ }
490
+
491
+ // Poll an accepted WAN task to a downloaded clip. Throws stopError for every outcome where the
492
+ // task may have billed; returns {fail} only when the task ended without output for a known reason.
493
+ async function finishWanTask(ctx, e, taskId, parameters) {
494
+ const p = await wanPollTask({
495
+ entry: e, taskId, intervalMs: ctx.pollIntervalMs, timeoutMs: ctx.pollTimeoutMs, sleep: ctx.sleep,
496
+ onTick: (s, i) => ctx.emit({ type: 'poll', provider: 'wan', label: e.label, taskId, status: s, tick: i }),
497
+ });
498
+ if (p.state === 'SUCCEEDED') return { clip: await succeededClip(ctx, e, taskId, p, parameters.duration) };
499
+ if (p.state === 'FAILED' || p.state === 'CANCELED') return { fail: failedTask(e, taskId, p) };
500
+ throw lostTask(ctx, e, taskId, p, parameters.duration);
501
+ }
502
+
503
+ // A FAILED or CANCELED task is resubmitted only when its code says why nothing came out: a key or
504
+ // capacity problem, or a known transient server error. An unknown or missing code may be the
505
+ // input's fault, or a failure that billed, so the leg stops with the taskId instead of trying every key.
506
+ function failedTask(e, taskId, p) {
507
+ const code = p.output.code || null;
508
+ if (codeKind(code)) return { code, status: null, message: p.output.message };
509
+ throw stopError('failed', `WAN task ${taskId} (${e.label}) ended ${p.state}${code ? ` (${code})` : ''} for a reason the pool does not know: ${p.output.message || 'no message'}; check the task in the Model Studio console before generating the leg again`, { provider: 'wan', taskId, label: e.label, code: code || p.state });
510
+ }
511
+
512
+ // The poll could not tell how the task ended: keep the taskId so nothing is submitted twice.
513
+ function lostTask(ctx, e, taskId, p, seconds) {
514
+ if (p.state === 'TIMEOUT') {
515
+ return stopError('timeout', `WAN task ${taskId} (${e.label}) still running after ${ctx.pollTimeoutMs} ms; resume with resumeClip`, { provider: 'wan', taskId, label: e.label, seconds });
516
+ }
517
+ if (p.state === 'HTTP') {
518
+ if (p.status === 401) applyFailure(ctx.statePath, e, 'unusable', { code: p.output.code, status: p.status }, ctx.now(), ctx.creds, ctx.emit);
519
+ 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 });
520
+ }
521
+ 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 });
522
+ }
523
+
524
+ // SUCCEEDED: the clip is paid for from here on — record it before touching the network again
525
+ async function succeededClip(ctx, e, taskId, p, seconds) {
526
+ recordSuccess(ctx.statePath, e, ctx.now(), taskId, ctx.emit);
527
+ const videoUrl = p.output.video_url;
528
+ if (!videoUrl) throw stopError('no_video', `WAN task ${taskId} (${e.label}) succeeded without a video_url`, { provider: 'wan', taskId, label: e.label });
529
+ let mp4;
530
+ try {
531
+ mp4 = await downloadWanVideo(videoUrl, { sleep: ctx.sleep });
532
+ } catch (err) {
533
+ // videoUrl is a signed URL: kept as a property for recovery (valid 24 h), never in the message
534
+ throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label, videoUrl, seconds });
535
+ }
536
+ ctx.emit({ type: 'done', provider: 'wan', label: e.label, taskId });
537
+ return { mp4, provider: 'wan', label: e.label, model: shownModel(e), seconds: seconds ?? p.usage?.duration ?? null, taskId, usage: p.usage };
538
+ }
539
+
540
+ async function runWan(ctx) {
541
+ const pool = usablePool(ctx);
542
+ if (!pool.length) return null;
543
+ const limit = Math.max(1, Math.floor(Number(ctx.creds.WAN_MAX_CONCURRENT) || 5));
544
+ const parameters = wanParams({ size: ctx.size, seconds: ctx.seconds, hasFrame: !!ctx.firstFramePng, creds: ctx.creds, overrides: ctx.wanOverrides });
545
+ const maxAttempts = pool.length * (QUOTA_STRIKES_TO_EXHAUST + 1) + 2;
546
+ let attempts = 0;
547
+ while (attempts < maxAttempts) {
548
+ const pick = pickEntry(pool, loadPoolState(ctx.statePath), ctx.now(), limit);
549
+ if (!pick.entry) {
550
+ if (await waitForKey(ctx, pick)) continue;
551
+ return poolUnavailable(ctx);
552
+ }
553
+ attempts++;
554
+ const out = await attemptOnKey(ctx, pick.entry, parameters, attempts);
555
+ if (out.clip) return out.clip;
556
+ recordFailure(ctx, pick.entry, out.fail);
557
+ }
558
+ return poolUnavailable(ctx, `gave up after ${attempts} attempts`);
559
+ }
560
+
561
+ // The usable part of the WAN pool. Malformed entries are skipped with an event. When every configured
562
+ // entry is malformed the leg stops: a config error must not turn into a paid Sora clip.
563
+ function usablePool(ctx) {
564
+ const all = loadWanPool(ctx.creds);
565
+ const bad = all.filter(malformedField);
566
+ for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an http(s) URL / not one model id)` });
567
+ if (all.length && bad.length === all.length) {
568
+ throw stopError('invalid_request', `every WAN entry is malformed (${bad.map((b) => `${b.label} ${malformedField(b)}`).join(', ')}); fix the env file — nothing was sent, and Sora was not used`, { provider: 'wan' });
569
+ }
570
+ const pool = all.filter((e) => !malformedField(e));
571
+ if (!pool.length) ctx.emit({ type: 'skip', provider: 'wan', reason: 'no usable WAN_<n>_ENDPOINT + WAN_<n>_API_KEY entries' });
572
+ return pool;
573
+ }
574
+
575
+ // Wait when a key frees up soon: an in-flight slot always, a cooldown only within maxRateWaitMs.
576
+ async function waitForKey(ctx, pick) {
577
+ if (!pick.busy && (pick.waitMs == null || pick.waitMs > ctx.maxRateWaitMs)) return false;
578
+ ctx.emit({ type: 'wait', provider: 'wan', ms: pick.waitMs, reason: pick.busy ? 'every usable key is at its in-flight limit' : 'every usable key is cooling down' });
579
+ await ctx.sleep(pick.waitMs);
580
+ return true;
581
+ }
582
+
583
+ function poolUnavailable(ctx, reason) {
584
+ const summary = poolSummary({ creds: ctx.creds, statePath: ctx.statePath, now: ctx.now() });
585
+ ctx.emit(reason ? { type: 'pool-unavailable', provider: 'wan', reason, summary } : { type: 'pool-unavailable', provider: 'wan', summary });
586
+ return null;
587
+ }
588
+
589
+ // One paid submission on one key. Returns {clip} or {fail} (the task provably produced nothing);
590
+ // throws when resubmitting could bill the leg twice.
591
+ async function attemptOnKey(ctx, e, parameters, attempt) {
592
+ updateEntry(ctx.statePath, e, (st) => ({ ...st, lastUsedAt: ctx.now() }), ctx.emit);
593
+ inflight.set(e.fp, (inflight.get(e.fp) || 0) + 1);
594
+ try {
595
+ ctx.emit({ type: 'submit', provider: 'wan', label: e.label, model: e.model, attempt });
596
+ const c = await wanCreateTask({ entry: e, prompt: ctx.prompt, firstFramePng: ctx.firstFramePng, lastFramePng: ctx.lastFramePng, parameters });
597
+ assertSubmitSettled(e, c);
598
+ if (c.taskId) return await finishWanTask(ctx, e, c.taskId, parameters);
599
+ return { fail: { code: c.code, status: c.status, retryAfterMs: c.retryAfterMs, message: c.message } };
600
+ } finally {
601
+ inflight.set(e.fp, Math.max(0, (inflight.get(e.fp) || 1) - 1));
602
+ }
603
+ }
604
+
605
+ // A submission whose fate is unknown must not go to another key: the request may have reached the
606
+ // server, and a second key would bill the same leg twice.
607
+ function assertSubmitSettled(e, c) {
608
+ if (c.status === 0 && !c.preSend) {
609
+ throw stopError('submit_unknown', `WAN submit to ${e.label} lost its connection (${c.message || 'network error'}); the task may exist — check the Model Studio console before resubmitting`, { provider: 'wan', label: e.label });
610
+ }
611
+ if (!c.taskId && (c.ok || (c.status >= 500 && !c.code))) {
612
+ // a 2xx without task_id or a bare gateway 5xx: the backend may have accepted the task
613
+ 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 });
614
+ }
615
+ }
616
+
617
+ // A provable non-start: moderation and request errors end the leg; key problems are recorded so
618
+ // the next pick avoids that key.
619
+ function recordFailure(ctx, e, fail) {
620
+ const kind = classifyWanError(fail);
621
+ ctx.emit({ type: 'fail', provider: 'wan', label: e.label, kind, code: fail.code, status: fail.status });
622
+ if (kind === 'content') throw new ContentRejectedError('wan', fail.code, fail.message);
623
+ if (kind === 'invalid_request') {
624
+ throw stopError('invalid_request', `WAN rejected the request (${fail.code || `HTTP ${fail.status}`}): ${fail.message || ''}`, { provider: 'wan', code: fail.code, label: e.label });
625
+ }
626
+ applyFailure(ctx.statePath, e, kind, fail, ctx.now(), ctx.creds, ctx.emit);
627
+ }
628
+
629
+ function soraConn(creds) {
630
+ return {
631
+ endpoint: creds.AZURE_SORA_ENDPOINT, key: creds.AZURE_SORA_API_KEY,
632
+ apiVersion: creds.AZURE_SORA_API_VERSION || 'preview', model: creds.AZURE_SORA_DEPLOYMENT || 'sora-2',
633
+ };
634
+ }
635
+
636
+ // Poll an accepted Sora job to a downloaded clip; same stop rules as finishWanTask.
637
+ async function finishSoraJob(ctx, jobId, secs) {
638
+ const conn = soraConn(ctx.creds);
639
+ const st = await pollSoraJob(ctx, conn, jobId);
640
+ if (st && SORA_FAILED.has(st.status)) throw soraFailure(st, jobId);
641
+ if (!st || !SORA_DONE.has(st.status)) {
642
+ throw stopError('timeout', `Sora job ${jobId} still ${st?.status ?? 'unknown'} after ${ctx.pollTimeoutMs} ms; resume with resumeClip`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', seconds: secs });
643
+ }
644
+ const mp4 = await downloadSoraClip(conn, jobId, secs);
645
+ ctx.emit({ type: 'done', provider: 'sora', label: 'AZURE_SORA', taskId: jobId });
646
+ return { mp4, provider: 'sora', label: 'AZURE_SORA', model: conn.model, seconds: secs, taskId: jobId, usage: null };
647
+ }
648
+
649
+ // pollVideo throws on a 4xx other than 408/429 (the job is gone, or the key is refused): such a job
650
+ // never turns terminal, so the leg ends at once as task_lost instead of polling to the deadline.
651
+ async function pollSoraJob(ctx, conn, jobId) {
652
+ const interval = Math.max(0, ctx.pollIntervalMs);
653
+ try {
654
+ return await pollVideo({
655
+ endpoint: conn.endpoint, key: conn.key, id: jobId, apiVersion: conn.apiVersion, sleep: ctx.sleep,
656
+ intervalMs: interval, maxTicks: Math.max(1, Math.ceil(ctx.pollTimeoutMs / Math.max(1, interval))),
657
+ onTick: (s, i) => ctx.emit({ type: 'poll', provider: 'sora', taskId: jobId, status: s?.status, tick: i }),
658
+ });
659
+ } catch (e) {
660
+ if (!e.status) throw e;
661
+ throw stopError('task_lost', `Sora poll for job ${jobId} failed: HTTP ${e.status}${e.code ? ` ${e.code}` : ''}; check the job in Azure before resubmitting`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', status: e.status, code: e.code });
662
+ }
663
+ }
664
+
665
+ function soraFailure(st, jobId) {
666
+ const code = st.error?.code || st.status;
667
+ if (/moderation|content_policy|safety/i.test(`${code} ${st.error?.message || ''}`)) return new ContentRejectedError('sora', code, st.error?.message);
668
+ return stopError('failed', `Sora job ${jobId} ended ${st.status}: ${JSON.stringify(st.error || '')}`, { provider: 'sora', taskId: jobId, code });
669
+ }
670
+
671
+ async function downloadSoraClip(conn, jobId, secs) {
672
+ try {
673
+ return await downloadVideo({ endpoint: conn.endpoint, key: conn.key, id: jobId, apiVersion: conn.apiVersion });
674
+ } catch (err) {
675
+ throw stopError('download', `${err.message} for Sora job ${jobId}; resume with resumeClip`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', seconds: secs });
676
+ }
677
+ }
678
+
679
+ async function runSora(ctx) {
680
+ const { creds, prompt, firstFramePng, lastFramePng, size, seconds, emit, sleep } = ctx;
681
+ const { endpoint, key, apiVersion, model } = soraConn(creds);
682
+ const secs = snapSoraSeconds(seconds);
683
+ if (lastFramePng) emit({ type: 'note', provider: 'sora', message: 'Sora takes one input_reference; the last frame is ignored (forward-chaining)' });
684
+ let job;
685
+ for (let i = 0; ; i++) {
686
+ try {
687
+ emit({ type: 'submit', provider: 'sora', label: 'AZURE_SORA', model, attempt: i + 1 });
688
+ job = await createVideo({ endpoint, key, model, prompt, size, seconds: secs, inputReferencePng: firstFramePng, apiVersion });
689
+ break;
690
+ } catch (e) {
691
+ // a 429 is a refusal, so retrying it cannot double-bill; a lost connection or a bare 5xx can
692
+ if (e.status === 429 && i < 4) {
693
+ const ms = Math.min(RATE_MAX_MS, RATE_BASE_MS * 2 ** i);
694
+ emit({ type: 'wait', provider: 'sora', ms, reason: 'HTTP 429' });
695
+ await sleep(ms);
696
+ continue;
697
+ }
698
+ if (/moderation|content_policy|safety/i.test(e.message)) throw new ContentRejectedError('sora', 'moderation', e.message);
699
+ if ((e.status === 0 && !PRE_SEND_ERRORS.has(e.errno)) || e.status >= 500 || (e.status >= 200 && e.status < 300)) {
700
+ 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 });
701
+ }
702
+ throw stopError(e.status === 0 ? 'unreachable' : 'invalid_request', e.message, { provider: 'sora', status: e.status });
703
+ }
704
+ }
705
+ return finishSoraJob(ctx, job.id, secs);
706
+ }
707
+
708
+ // Explicit `undefined`/`null` options fall back to the defaults instead of overriding them.
709
+ const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null));
710
+
711
+ function makeCtx(opts) {
712
+ const emit = (e) => { if (typeof opts.onEvent === 'function') opts.onEvent(e); };
713
+ return { ...opts, emit };
714
+ }
715
+
716
+ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTimeoutMs: 30 * 60_000, maxRateWaitMs: 120_000 };
717
+
718
+ /**
719
+ * Generate ONE clip: try providers in VIDEO_PROVIDER_ORDER (default wan, sora). WAN rotates its
720
+ * key pool and records exhaustion; when no WAN key is usable, the next provider (Sora) runs.
721
+ * PAID CALL. firstFramePng seeds the clip (forward-chaining); lastFramePng (WAN only) pins the end.
722
+ *
723
+ * Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
724
+ * ContentRejectedError moderation — change the prompt/frame
725
+ * kind 'invalid_request' malformed request, ModelNotFound or every WAN entry malformed (config)
726
+ * kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
727
+ * kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
728
+ * ('failed': the task ended for a reason the pool does not know)
729
+ * kind 'unreachable' Sora host refused the connection (nothing was sent)
730
+ * @returns {Promise<{mp4:Buffer, provider:'wan'|'sora', label:string, model:string, seconds:number, taskId:string, usage:object|null}>}
731
+ */
732
+ export async function generateClip(opts = {}) {
733
+ const o = { ...DEFAULTS, statePath: defaultStatePath(), sleep: realSleep, now: Date.now, ...defined(opts) };
734
+ if (!o.creds) throw new Error('generateClip: creds required (resolveCreds())');
735
+ if (!o.prompt) throw new Error('generateClip: prompt required');
736
+ const ctx = makeCtx(o);
737
+ const tried = [];
738
+ for (const p of o.order || parseProviderOrder(o.creds.VIDEO_PROVIDER_ORDER)) {
739
+ if (p === 'wan') {
740
+ tried.push('wan');
741
+ const r = await runWan(ctx);
742
+ if (r) return r;
743
+ } else if (p === 'sora') {
744
+ if (!o.creds.AZURE_SORA_ENDPOINT || !o.creds.AZURE_SORA_API_KEY) { ctx.emit({ type: 'skip', provider: 'sora', reason: 'AZURE_SORA_ENDPOINT/API_KEY missing' }); continue; }
745
+ tried.push('sora');
746
+ ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
747
+ return runSora(ctx);
748
+ }
749
+ }
750
+ throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
751
+ }
752
+
753
+ /**
754
+ * Finish a clip whose task already exists (after a 'timeout' or 'download' error) without
755
+ * submitting anything new. FREE except for the work already billed. Same return shape as
756
+ * generateClip. Pass the error's `seconds` (or the leg's) to have it echoed back in the result.
757
+ */
758
+ export async function resumeClip(opts = {}) {
759
+ // no default seconds here: an unknown length is reported from the task's usage, not invented
760
+ const { seconds: _unused, ...defaults } = DEFAULTS;
761
+ const o = { ...defaults, statePath: defaultStatePath(), sleep: realSleep, now: Date.now, ...defined(opts) };
762
+ if (!o.creds || !o.taskId) throw new Error('resumeClip: creds and taskId required');
763
+ const ctx = makeCtx(o);
764
+ if (o.provider !== 'sora') return resumeWan(ctx, o);
765
+ if (!originOf(o.creds.AZURE_SORA_ENDPOINT) || !o.creds.AZURE_SORA_API_KEY) {
766
+ throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an http(s) URL and AZURE_SORA_API_KEY (run 3d-intro-setup); nothing was sent', { provider: 'sora', taskId: o.taskId });
767
+ }
768
+ return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
769
+ }
770
+
771
+ async function resumeWan(ctx, o) {
772
+ const e = loadWanPool(o.creds).find((x) => x.label === o.label);
773
+ if (!e) throw new Error(`resumeClip: ${o.label} is not in the WAN pool (was the key removed?)`);
774
+ if (e.badEndpoint) {
775
+ throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an http(s) URL; fix the env file — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
776
+ }
777
+ const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
778
+ if (out.clip) return out.clip;
779
+ 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 });
780
+ }