@kaydash9999/banker-plugins 0.16.0 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +8 -4
- package/skills/3d-intro-build/references/azure-adapter.mjs +173 -44
- package/skills/3d-intro-build/references/verify-intro.mjs +27 -15
- package/skills/3d-intro-build/references/verify.md +1 -1
- package/skills/3d-intro-build/references/video-pool.mjs +58 -21
- package/skills/3d-intro-setup/SKILL.md +94 -53
- package/skills/3d-intro-setup/references/azure-adapter.mjs +173 -44
- package/skills/3d-intro-setup/references/video-pool.mjs +58 -21
|
@@ -36,8 +36,9 @@ function originOf(url) {
|
|
|
36
36
|
const u = new URL(raw);
|
|
37
37
|
return `${u.protocol}//${u.host}`;
|
|
38
38
|
} catch {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Not a URL: null, never the raw text, which may be a key pasted into the endpoint field.
|
|
40
|
+
const m = raw.match(/^(https?:\/\/[^/\s]+)/i);
|
|
41
|
+
return m ? m[1] : null;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -50,6 +51,10 @@ function truncate(x, n = 300) {
|
|
|
50
51
|
return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
|
|
55
|
+
// error's code or name only, never its text: fetch can quote a header value, a key included.
|
|
56
|
+
const why = (r) => (r.error ? ` (${r.error.cause?.code || r.error.code || r.error.name})` : '');
|
|
57
|
+
|
|
53
58
|
// Last n chars of ffmpeg stderr (the useful part) for error messages.
|
|
54
59
|
const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
|
|
55
60
|
|
|
@@ -77,7 +82,7 @@ export function parseConsoleSample(text) {
|
|
|
77
82
|
const out = { endpoint: null, deployment: null, apiKey: null, apiVersion: null };
|
|
78
83
|
|
|
79
84
|
// endpoint: an explicit kwarg (python/JS SDK) wins; else the origin of the first URL (curl).
|
|
80
|
-
let m = s.match(/(?:azure_endpoint|azure_openai_endpoint|base_url|endpoint)\s*[=:]\s*["']([^"']+)["']/i);
|
|
85
|
+
let m = s.match(/(?:azure_endpoint|azure_openai_endpoint|base_url|endpoint)\s*[=:]\s*["']([^"'\r\n]+)["']/i);
|
|
81
86
|
if (m) out.endpoint = originOf(m[1]);
|
|
82
87
|
else { m = s.match(/https?:\/\/[^\s"'`)]+/); if (m) out.endpoint = originOf(m[0]); }
|
|
83
88
|
|
|
@@ -89,13 +94,16 @@ export function parseConsoleSample(text) {
|
|
|
89
94
|
m = s.match(/\/deployments\/([^/?"'\s]+)/i);
|
|
90
95
|
if (m) out.deployment = decodeURIComponent(m[1]);
|
|
91
96
|
else {
|
|
92
|
-
m = s.match(/(?:azure_deployment|deployment_name|deployment|model)\s*[=:]\s*["']([^"']+)["']/i);
|
|
97
|
+
m = s.match(/(?:azure_deployment|deployment_name|deployment|model)\s*[=:]\s*["']([^"'\r\n]+)["']/i);
|
|
93
98
|
if (m) out.deployment = m[1];
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>"
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
// key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>", "api-key": "<k>",
|
|
102
|
+
// apiKey: '<k>' (python/JS SDK). An SDK value counts only in quotes: api_key=subscription_key or
|
|
103
|
+
// apiKey: process.env.X names a variable, and taking that name for the key would replace a real one.
|
|
104
|
+
m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i)
|
|
105
|
+
|| s.match(/api-key\s*:\s*([A-Za-z0-9._-]+)/i)
|
|
106
|
+
|| s.match(/api[-_]?key["']?\s*[:=]\s*["']([A-Za-z0-9._-]+)["']/i);
|
|
99
107
|
if (m) out.apiKey = m[1];
|
|
100
108
|
|
|
101
109
|
return out;
|
|
@@ -126,56 +134,164 @@ function envValue(raw) {
|
|
|
126
134
|
return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
/** The home key file, `~/.config/banker/3d-intro/env`: the keys when no project key file is used. */
|
|
138
|
+
export function homeCredsPath() {
|
|
139
|
+
return path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* This machine's settings file, `~/.config/banker/3d-intro/settings.env` (FFMPEG_PATH). It holds no
|
|
144
|
+
* key, so it can exist without making the home key file look set up.
|
|
145
|
+
*/
|
|
146
|
+
export function homeSettingsPath() {
|
|
147
|
+
return path.join(os.homedir(), '.config', 'banker', '3d-intro', 'settings.env');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The settings file as a KEY=VALUE map; {} when it is missing or cannot be read. */
|
|
151
|
+
export function readSettings() {
|
|
152
|
+
return readEnvQuietly(homeSettingsPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A KEY=VALUE file named by fileOf(); {} when there is none or it cannot be read (no home folder, no
|
|
156
|
+
// permission, a folder in its place).
|
|
157
|
+
function readEnvQuietly(fileOf) {
|
|
158
|
+
try { return parseEnvFile(fs.readFileSync(fileOf(), 'utf8')); } catch { return {}; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Settings that name a program to run. Only the shell or this machine's own files set them (the
|
|
162
|
+
// settings file, or the home key file from an earlier setup): a project's .env.3d-intro.local can come
|
|
163
|
+
// with a repository, and must not choose what runs on this machine.
|
|
164
|
+
const MACHINE_ONLY = ['FFMPEG_PATH'];
|
|
165
|
+
|
|
129
166
|
/**
|
|
130
167
|
* Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
|
|
131
|
-
* Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found).
|
|
168
|
+
* Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found). From a
|
|
169
|
+
* project file, FFMPEG_PATH is left out and returned under `_ignored` so the caller can say why.
|
|
132
170
|
*/
|
|
133
171
|
export function resolveCreds({ projectDir } = {}) {
|
|
172
|
+
const home = homeCredsPath();
|
|
134
173
|
const candidates = [];
|
|
135
174
|
if (projectDir) candidates.push(path.join(projectDir, '.env.3d-intro.local'));
|
|
136
|
-
candidates.push(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
return
|
|
175
|
+
candidates.push(home);
|
|
176
|
+
const p = candidates.find((c) => fs.existsSync(c));
|
|
177
|
+
if (!p) return { _source: null };
|
|
178
|
+
const creds = { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
|
|
179
|
+
return p === home ? creds : withoutMachineOnly(creds);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function withoutMachineOnly(creds) {
|
|
183
|
+
const ignored = Object.fromEntries(MACHINE_ONLY.filter((k) => creds[k] != null).map((k) => [k, creds[k]]));
|
|
184
|
+
for (const k of MACHINE_ONLY) delete creds[k];
|
|
185
|
+
return Object.keys(ignored).length ? { ...creds, _ignored: ignored } : creds;
|
|
141
186
|
}
|
|
142
187
|
|
|
143
188
|
/**
|
|
144
189
|
* Persist creds to a KEY=VALUE env file, chmod 0600 where supported. Keys starting with `_`
|
|
145
|
-
* and null/undefined values are skipped. Returns the written path.
|
|
190
|
+
* and null/undefined values are skipped. Returns the written path. A name that is not a variable
|
|
191
|
+
* name, or a value with a line break, throws before anything is written: either would add lines of
|
|
192
|
+
* its own (a FFMPEG_PATH, a second endpoint) to the file.
|
|
146
193
|
*/
|
|
147
194
|
export function persistCreds(creds, { target } = {}) {
|
|
148
195
|
if (!target) throw new Error('persistCreds: target path required');
|
|
149
|
-
const lines = [];
|
|
150
|
-
for (const [k, v] of Object.entries(creds || {})) {
|
|
151
|
-
if (k.startsWith('_') || v == null) continue;
|
|
152
|
-
lines.push(`${k}=${envLiteral(String(v))}`);
|
|
153
|
-
}
|
|
196
|
+
const lines = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => envLine(k, String(v)));
|
|
154
197
|
const body = lines.length ? `${lines.join('\n')}\n` : '';
|
|
155
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
198
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
156
199
|
fs.writeFileSync(target, body, { mode: 0o600 });
|
|
157
200
|
try { fs.chmodSync(target, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
|
|
158
201
|
return target;
|
|
159
202
|
}
|
|
160
203
|
|
|
204
|
+
// One KEY=VALUE line for persistCreds, refusing a name or value that would make lines of its own.
|
|
205
|
+
function envLine(k, v) {
|
|
206
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Error(`persistCreds: ${JSON.stringify(k)} is not a variable name; nothing was written`);
|
|
207
|
+
if (/[\r\n]/.test(v)) throw new Error(`persistCreds: ${k} contains a line break; nothing was written`);
|
|
208
|
+
return `${k}=${envLiteral(v)}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
// Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
|
|
162
212
|
function envLiteral(v) {
|
|
163
213
|
if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
|
|
164
214
|
return v.includes('"') ? `'${v}'` : `"${v}"`;
|
|
165
215
|
}
|
|
166
216
|
|
|
167
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* Redact a secret to its length only, `…(len)`: no character of it, since keys share fixed
|
|
219
|
+
* prefixes (`sk-ws-`) and a shown tail narrows them down. Falsy -> "(missing)".
|
|
220
|
+
*/
|
|
168
221
|
export function redact(s) {
|
|
169
222
|
if (!s) return '(missing)';
|
|
170
|
-
|
|
171
|
-
if (str.length <= 8) return `…(${str.length})`;
|
|
172
|
-
return `${str.slice(0, 4)}…${str.slice(-4)}(${str.length})`;
|
|
223
|
+
return `…(${String(s).length})`;
|
|
173
224
|
}
|
|
174
225
|
|
|
175
226
|
// ---------------------------------------------------------------------------
|
|
176
227
|
// HTTP
|
|
177
228
|
// ---------------------------------------------------------------------------
|
|
178
229
|
|
|
230
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
231
|
+
const SECRET_HEADERS = new Set(['authorization', 'api-key', 'proxy-authorization', 'cookie']);
|
|
232
|
+
const MAX_HOPS = 5;
|
|
233
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
234
|
+
const localHttp = (u) => u.protocol === 'http:' && LOOPBACK.has(u.hostname);
|
|
235
|
+
const refused = (code, message) => Object.assign(new TypeError(`fetch failed: ${message}`), { cause: { code } });
|
|
236
|
+
const isSecret = (name) => SECRET_HEADERS.has(name.toLowerCase());
|
|
237
|
+
const withoutSecrets = (headers) => Object.fromEntries(Object.entries(headers).filter(([k]) => !isSecret(k)));
|
|
238
|
+
// Every form fetch takes (an object, [name, value] pairs, Headers) as one plain object, so a key is
|
|
239
|
+
// found in any of them. Pairs would otherwise read as headers named "0", "1".
|
|
240
|
+
const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.fromEntries(new Headers(h)) : { ...h });
|
|
241
|
+
|
|
242
|
+
/** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
|
|
243
|
+
export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
|
|
244
|
+
|
|
245
|
+
// Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
|
|
246
|
+
// leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
|
|
247
|
+
// while the request is on this machine over http (a local mock); from anywhere else, an https
|
|
248
|
+
// localhost included, it would reach a service here or on the network.
|
|
249
|
+
function redirectTarget(location, from) {
|
|
250
|
+
let next;
|
|
251
|
+
try { next = new URL(location, from); } catch { throw refused('EBADREDIRECT', 'the redirect location is not a URL'); }
|
|
252
|
+
if (next.protocol === 'https:' || (localHttp(next) && localHttp(from))) return next;
|
|
253
|
+
throw refused('EINSECUREREDIRECT', 'a redirect away from https is not followed');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The request for the next hop of a redirect. A request with a body (a prompt, a frame, a paid submit)
|
|
257
|
+
// follows only a 307 or 308 within its origin, which send it again as it was. A 301, 302 or 303 would
|
|
258
|
+
// make it a GET, and another origin is another server: either way the next answer does not answer this
|
|
259
|
+
// request, and its 401 or 429 would read as this key's (a key retired, a submit sent again elsewhere).
|
|
260
|
+
// 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
|
|
261
|
+
// another origin goes without the secret headers.
|
|
262
|
+
function nextHop(req, status, crossOrigin) {
|
|
263
|
+
if (req.body != null && (crossOrigin || status < 307)) {
|
|
264
|
+
throw refused(crossOrigin ? 'ECROSSREDIRECT' : 'EBODYREDIRECT', 'a request with a body follows only a 307 or 308 within its origin');
|
|
265
|
+
}
|
|
266
|
+
const toGet = status === 303 || ((status === 301 || status === 302) && req.method === 'POST');
|
|
267
|
+
const next = toGet ? { ...req, method: 'GET', body: undefined } : req;
|
|
268
|
+
return crossOrigin ? { ...next, headers: withoutSecrets(next.headers) } : next;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* fetch that follows redirects by hand (at most 5): a request without a body as fetch would, but to
|
|
273
|
+
* another origin without the key headers; a request with a body only by a 307 or 308 within its
|
|
274
|
+
* origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
|
|
275
|
+
* any origin, and turns a POST into a GET on 301 to 303). A key goes only where safeForKey allows: an http
|
|
276
|
+
* URL with one is refused before anything is sent (cause.code EINSECURE), and no redirect leaves
|
|
277
|
+
* https. Throws as fetch does on a network error.
|
|
278
|
+
*/
|
|
279
|
+
export async function fetchGuarded(url, init = {}) {
|
|
280
|
+
let target = new URL(String(url));
|
|
281
|
+
let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
|
|
282
|
+
if (!safeForKey(target) && Object.keys(req.headers).some(isSecret)) throw refused('EINSECURE', 'a key is sent only over https');
|
|
283
|
+
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
|
284
|
+
const r = await globalThis.fetch(target.href, req);
|
|
285
|
+
const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
|
|
286
|
+
if (!location) return r;
|
|
287
|
+
try { await r.body?.cancel(); } catch { /* the redirect body is not needed */ }
|
|
288
|
+
const next = redirectTarget(location, target);
|
|
289
|
+
req = nextHop(req, r.status, next.origin !== target.origin);
|
|
290
|
+
target = next;
|
|
291
|
+
}
|
|
292
|
+
throw refused('ETOOMANYREDIRECTS', 'too many redirects');
|
|
293
|
+
}
|
|
294
|
+
|
|
179
295
|
// Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
|
|
180
296
|
function retryAfterMsOf(headers) {
|
|
181
297
|
const ms = Number(headers.get('retry-after-ms'));
|
|
@@ -202,7 +318,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
202
318
|
if (body !== undefined) opts.body = body;
|
|
203
319
|
let r;
|
|
204
320
|
try {
|
|
205
|
-
r = await
|
|
321
|
+
r = await fetchGuarded(url, opts);
|
|
206
322
|
} catch (e) {
|
|
207
323
|
last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
|
|
208
324
|
break; // network error is not an auth problem — do not retry other styles
|
|
@@ -260,7 +376,7 @@ export async function generateImage({
|
|
|
260
376
|
});
|
|
261
377
|
}
|
|
262
378
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
263
|
-
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status} ${truncate(r.body)}`);
|
|
379
|
+
if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
264
380
|
return Buffer.from(b64, 'base64');
|
|
265
381
|
}
|
|
266
382
|
|
|
@@ -278,7 +394,7 @@ export async function editImage({
|
|
|
278
394
|
key, method: 'POST', body: fd,
|
|
279
395
|
});
|
|
280
396
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
281
|
-
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
397
|
+
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
282
398
|
return Buffer.from(b64, 'base64');
|
|
283
399
|
}
|
|
284
400
|
|
|
@@ -289,7 +405,7 @@ export async function generateImageFlux({ endpoint, key, prompt, width, height,
|
|
|
289
405
|
body: JSON.stringify({ prompt, width, height }),
|
|
290
406
|
});
|
|
291
407
|
const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
|
|
292
|
-
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
408
|
+
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
293
409
|
return Buffer.from(b64, 'base64');
|
|
294
410
|
}
|
|
295
411
|
|
|
@@ -322,7 +438,7 @@ export async function createVideo({
|
|
|
322
438
|
const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
|
|
323
439
|
if (!(r.ok && r.body?.id)) {
|
|
324
440
|
// status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
|
|
325
|
-
const err = new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
441
|
+
const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
326
442
|
err.status = r.status;
|
|
327
443
|
err.errno = r.error?.cause?.code || r.error?.code || null;
|
|
328
444
|
throw err;
|
|
@@ -368,7 +484,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
|
|
|
368
484
|
const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
|
|
369
485
|
key, method: 'GET', binary: true,
|
|
370
486
|
});
|
|
371
|
-
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
|
|
487
|
+
if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
|
|
372
488
|
return r.body;
|
|
373
489
|
}
|
|
374
490
|
|
|
@@ -413,7 +529,7 @@ export async function createVideoTwoImage({
|
|
|
413
529
|
key, method: 'POST', body: fd,
|
|
414
530
|
});
|
|
415
531
|
const id = r.body?.id || r.body?.job_id;
|
|
416
|
-
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
532
|
+
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
417
533
|
return r.body;
|
|
418
534
|
}
|
|
419
535
|
|
|
@@ -423,30 +539,43 @@ export async function createVideoTwoImage({
|
|
|
423
539
|
|
|
424
540
|
/**
|
|
425
541
|
* Locate an ffmpeg binary without ever invoking a shell:
|
|
426
|
-
* 1) $FFMPEG_PATH
|
|
427
|
-
*
|
|
542
|
+
* 1) $FFMPEG_PATH from the shell, else FFMPEG_PATH in the settings file, else one an earlier setup
|
|
543
|
+
* left in the home key file; absolute paths only
|
|
544
|
+
* 2) the absolute $PATH entries (split on path.delimiter), for ffmpeg / ffmpeg.exe
|
|
428
545
|
* 3) the optional ffmpeg-static package (dynamic import — may be absent)
|
|
546
|
+
* A project's .env.3d-intro.local never names the program, and "." or another relative PATH entry is
|
|
547
|
+
* skipped: either would let the files of a project, a cloned repository say, choose what runs.
|
|
429
548
|
* Throws an actionable error if none is found.
|
|
430
549
|
*/
|
|
431
550
|
export async function resolveFfmpeg() {
|
|
432
|
-
const
|
|
433
|
-
if (
|
|
551
|
+
const found = namedFfmpeg() || ffmpegOnPath() || await staticFfmpeg();
|
|
552
|
+
if (!found) throw new Error('ffmpeg not found; run motion-graphic-setup or 3d-intro-setup');
|
|
553
|
+
return found;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The ffmpeg this machine names. Absolute only: a relative value would be looked up in the current
|
|
557
|
+
// folder, the project. The files are read only when the shell names nothing usable.
|
|
558
|
+
function namedFfmpeg() {
|
|
559
|
+
const usable = (p) => !!p && path.isAbsolute(p) && fs.existsSync(p);
|
|
560
|
+
if (usable(process.env.FFMPEG_PATH)) return process.env.FFMPEG_PATH;
|
|
561
|
+
return [readSettings(), readEnvQuietly(homeCredsPath)].map((s) => s.FFMPEG_PATH).find(usable) || null;
|
|
562
|
+
}
|
|
434
563
|
|
|
564
|
+
function ffmpegOnPath() {
|
|
435
565
|
const names = process.platform === 'win32' ? ['ffmpeg.exe', 'ffmpeg'] : ['ffmpeg'];
|
|
436
|
-
for (const dir of (process.env.PATH || '').split(path.delimiter).filter(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
440
|
-
}
|
|
566
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter).filter((d) => path.isAbsolute(d))) {
|
|
567
|
+
const hit = names.map((name) => path.join(dir, name)).find((c) => fs.existsSync(c));
|
|
568
|
+
if (hit) return hit;
|
|
441
569
|
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
442
572
|
|
|
573
|
+
async function staticFfmpeg() {
|
|
443
574
|
try {
|
|
444
575
|
const mod = await import('ffmpeg-static');
|
|
445
576
|
const p = mod?.default || mod;
|
|
446
|
-
|
|
447
|
-
} catch { /* ffmpeg-static not installed
|
|
448
|
-
|
|
449
|
-
throw new Error('ffmpeg not found; run motion-graphic-setup or 3d-intro-setup');
|
|
577
|
+
return typeof p === 'string' && fs.existsSync(p) ? p : null;
|
|
578
|
+
} catch { return null; /* ffmpeg-static not installed */ }
|
|
450
579
|
}
|
|
451
580
|
|
|
452
581
|
// Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
|
|
@@ -37,7 +37,7 @@ import path from 'node:path';
|
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import crypto from 'node:crypto';
|
|
39
39
|
import cp from 'node:child_process';
|
|
40
|
-
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact } from './azure-adapter.mjs';
|
|
40
|
+
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey } from './azure-adapter.mjs';
|
|
41
41
|
|
|
42
42
|
export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
|
|
43
43
|
const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
|
|
@@ -50,6 +50,8 @@ const PROBE_TASK_ID = '00000000-0000-0000-0000-000000000000';
|
|
|
50
50
|
|
|
51
51
|
const RATE_BASE_MS = 30_000;
|
|
52
52
|
const RATE_MAX_MS = 10 * 60_000;
|
|
53
|
+
// A server Retry-After is held to this range: 0 would retry at once, a huge value rest a key for years.
|
|
54
|
+
const RETRY_MIN_MS = 1000;
|
|
53
55
|
const QUOTA_STRIKES_TO_EXHAUST = 3;
|
|
54
56
|
const QUOTA_STRIKE_TTL_MS = 3_600_000;
|
|
55
57
|
const TRANSIENT_BASE_MS = 15_000;
|
|
@@ -72,7 +74,9 @@ const TRANSIENT_CODES = new Set([
|
|
|
72
74
|
'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
|
|
73
75
|
]);
|
|
74
76
|
// Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
|
|
75
|
-
|
|
77
|
+
// EINSECURE is fetchGuarded refusing a key over http before sending. Its redirect codes are not here:
|
|
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']);
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
82
|
* Map a WAN failure to a pool action.
|
|
@@ -115,11 +119,13 @@ function codeKind(code) {
|
|
|
115
119
|
|
|
116
120
|
// Endpoints are origins: a pasted console URL with a path would otherwise 404 every call. A value
|
|
117
121
|
// 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.
|
|
122
|
+
// null. The raw value is never echoed, because it may be a key. The key goes only over https: an
|
|
123
|
+
// http:// endpoint (one typo) would send it in the clear, so it counts as malformed too, except on
|
|
124
|
+
// this machine (a local mock or proxy). safeForKey is the same rule fetchGuarded enforces.
|
|
119
125
|
function originOf(url) {
|
|
120
126
|
try {
|
|
121
127
|
const u = new URL(String(url).trim());
|
|
122
|
-
return
|
|
128
|
+
return safeForKey(u) && u.host ? `${u.protocol}//${u.host}` : null;
|
|
123
129
|
} catch { return null; }
|
|
124
130
|
}
|
|
125
131
|
const fingerprint = (endpoint, key, model) => crypto.createHash('sha256').update(`${endpoint}\n${key}\n${model}`).digest('hex').slice(0, 16);
|
|
@@ -136,8 +142,8 @@ const malformedField = (e) => (e.badEndpoint ? 'endpoint' : e.badModel ? 'model'
|
|
|
136
142
|
|
|
137
143
|
/**
|
|
138
144
|
* 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
|
|
140
|
-
* (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
145
|
+
* An entry whose endpoint is not an https URL (`badEndpoint`; http only to this machine) or whose
|
|
146
|
+
* model is not one model id (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
141
147
|
*/
|
|
142
148
|
export function loadWanPool(creds = {}) {
|
|
143
149
|
const pool = [];
|
|
@@ -171,11 +177,20 @@ export function loadPoolState(file = defaultStatePath()) {
|
|
|
171
177
|
return { version: 1, entries: {} };
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
// Written through a fresh random name opened exclusively ('wx'), so a link someone planted at a
|
|
181
|
+
// guessable name is never followed; the folder is private when this creates it. A failed write or
|
|
182
|
+
// rename removes the temp file.
|
|
174
183
|
export function savePoolState(state, file = defaultStatePath()) {
|
|
175
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
176
|
-
const tmp = `${file}.${
|
|
177
|
-
fs.
|
|
178
|
-
|
|
184
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
185
|
+
const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
186
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
187
|
+
try {
|
|
188
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`); } finally { fs.closeSync(fd); }
|
|
189
|
+
fs.renameSync(tmp, file);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
fs.rmSync(tmp, { force: true });
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
179
194
|
try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
|
|
180
195
|
return file;
|
|
181
196
|
}
|
|
@@ -258,9 +273,9 @@ function pickEntry(pool, state, t, limit) {
|
|
|
258
273
|
function parseRetryAfter(h) {
|
|
259
274
|
if (!h) return null;
|
|
260
275
|
const sec = Number(h);
|
|
261
|
-
if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
|
|
262
276
|
const at = Date.parse(h);
|
|
263
|
-
|
|
277
|
+
const ms = Number.isFinite(sec) ? sec * 1000 : Number.isFinite(at) ? at - Date.now() : null;
|
|
278
|
+
return ms === null ? null : Math.min(RATE_MAX_MS, Math.max(RETRY_MIN_MS, ms));
|
|
264
279
|
}
|
|
265
280
|
|
|
266
281
|
const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
|
|
@@ -269,7 +284,7 @@ async function wanFetch(url, opts = {}) {
|
|
|
269
284
|
const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
|
|
270
285
|
let r;
|
|
271
286
|
try {
|
|
272
|
-
r = await
|
|
287
|
+
r = await fetchGuarded(url, {
|
|
273
288
|
method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
|
|
274
289
|
});
|
|
275
290
|
} catch (e) {
|
|
@@ -391,7 +406,13 @@ export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutM
|
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
/** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
|
|
409
|
+
// The video URL comes from the provider's answer: only https is fetched.
|
|
410
|
+
function httpsOnly(url) {
|
|
411
|
+
if (!/^https:\/\//i.test(String(url))) throw new Error('WAN video download refused: the video_url is not https');
|
|
412
|
+
}
|
|
413
|
+
|
|
394
414
|
export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {}) {
|
|
415
|
+
httpsOnly(url);
|
|
395
416
|
let last;
|
|
396
417
|
for (let i = 0; i < tries; i++) {
|
|
397
418
|
last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
|
@@ -436,9 +457,12 @@ export class ContentRejectedError extends Error {
|
|
|
436
457
|
|
|
437
458
|
// An error that must NOT be retried by resubmitting the leg: the task may exist and bill.
|
|
438
459
|
// kind: timeout | submit_unknown | download | no_video | task_lost. Carries taskId/label when known.
|
|
439
|
-
|
|
460
|
+
// A stop the caller can act on. `props` are plain properties (they show in logs); `hidden` ones (a
|
|
461
|
+
// signed download URL) read as err.<name> but stay out of JSON.stringify and console.error.
|
|
462
|
+
function stopError(kind, message, props = {}, hidden = {}) {
|
|
440
463
|
const err = new Error(message);
|
|
441
464
|
Object.assign(err, { kind, ...props });
|
|
465
|
+
for (const [k, v] of Object.entries(hidden)) Object.defineProperty(err, k, { value: v, enumerable: false });
|
|
442
466
|
return err;
|
|
443
467
|
}
|
|
444
468
|
|
|
@@ -530,8 +554,8 @@ async function succeededClip(ctx, e, taskId, p, seconds) {
|
|
|
530
554
|
try {
|
|
531
555
|
mp4 = await downloadWanVideo(videoUrl, { sleep: ctx.sleep });
|
|
532
556
|
} catch (err) {
|
|
533
|
-
// videoUrl is a signed URL:
|
|
534
|
-
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label,
|
|
557
|
+
// videoUrl is a signed URL (valid 24 h): readable as err.videoUrl for recovery, never in the message or a log
|
|
558
|
+
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label, seconds }, { videoUrl });
|
|
535
559
|
}
|
|
536
560
|
ctx.emit({ type: 'done', provider: 'wan', label: e.label, taskId });
|
|
537
561
|
return { mp4, provider: 'wan', label: e.label, model: shownModel(e), seconds: seconds ?? p.usage?.duration ?? null, taskId, usage: p.usage };
|
|
@@ -563,7 +587,7 @@ async function runWan(ctx) {
|
|
|
563
587
|
function usablePool(ctx) {
|
|
564
588
|
const all = loadWanPool(ctx.creds);
|
|
565
589
|
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
|
|
590
|
+
for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an https URL / not one model id)` });
|
|
567
591
|
if (all.length && bad.length === all.length) {
|
|
568
592
|
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
593
|
}
|
|
@@ -722,7 +746,8 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
|
|
|
722
746
|
*
|
|
723
747
|
* Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
|
|
724
748
|
* ContentRejectedError moderation — change the prompt/frame
|
|
725
|
-
* kind 'invalid_request' malformed request, ModelNotFound
|
|
749
|
+
* kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed or a
|
|
750
|
+
* Sora endpoint that is not https (config)
|
|
726
751
|
* kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
|
|
727
752
|
* kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
|
|
728
753
|
* ('failed': the task ended for a reason the pool does not know)
|
|
@@ -741,7 +766,7 @@ export async function generateClip(opts = {}) {
|
|
|
741
766
|
const r = await runWan(ctx);
|
|
742
767
|
if (r) return r;
|
|
743
768
|
} else if (p === 'sora') {
|
|
744
|
-
if (!
|
|
769
|
+
if (!soraReady(ctx)) continue;
|
|
745
770
|
tried.push('sora');
|
|
746
771
|
ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
|
|
747
772
|
return runSora(ctx);
|
|
@@ -750,6 +775,18 @@ export async function generateClip(opts = {}) {
|
|
|
750
775
|
throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
|
|
751
776
|
}
|
|
752
777
|
|
|
778
|
+
// Whether Sora can take the leg. Not set up: skipped with an event. An endpoint that is set but not
|
|
779
|
+
// https (http only to this machine) stops the leg before the key is sent in the clear.
|
|
780
|
+
function soraReady(ctx) {
|
|
781
|
+
const { AZURE_SORA_ENDPOINT: endpoint, AZURE_SORA_API_KEY: key } = ctx.creds;
|
|
782
|
+
if (!endpoint || !key) {
|
|
783
|
+
ctx.emit({ type: 'skip', provider: 'sora', reason: 'AZURE_SORA_ENDPOINT/API_KEY missing' });
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
if (!originOf(endpoint)) throw stopError('invalid_request', 'AZURE_SORA_ENDPOINT is not an https URL; fix the env file — nothing was sent', { provider: 'sora' });
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
|
|
753
790
|
/**
|
|
754
791
|
* Finish a clip whose task already exists (after a 'timeout' or 'download' error) without
|
|
755
792
|
* submitting anything new. FREE except for the work already billed. Same return shape as
|
|
@@ -763,7 +800,7 @@ export async function resumeClip(opts = {}) {
|
|
|
763
800
|
const ctx = makeCtx(o);
|
|
764
801
|
if (o.provider !== 'sora') return resumeWan(ctx, o);
|
|
765
802
|
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
|
|
803
|
+
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 });
|
|
767
804
|
}
|
|
768
805
|
return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
|
|
769
806
|
}
|
|
@@ -772,7 +809,7 @@ async function resumeWan(ctx, o) {
|
|
|
772
809
|
const e = loadWanPool(o.creds).find((x) => x.label === o.label);
|
|
773
810
|
if (!e) throw new Error(`resumeClip: ${o.label} is not in the WAN pool (was the key removed?)`);
|
|
774
811
|
if (e.badEndpoint) {
|
|
775
|
-
throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an
|
|
812
|
+
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 });
|
|
776
813
|
}
|
|
777
814
|
const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
|
|
778
815
|
if (out.clip) return out.clip;
|