@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,6 +18,7 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import os from 'node:os';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
21
22
|
// Default import (the CJS module.exports object) so ffmpeg helpers can read cp.spawnSync at
|
|
22
23
|
// call time — a destructured `import { spawnSync }` snapshots the binding and cannot be stubbed.
|
|
23
24
|
import cp from 'node:child_process';
|
|
@@ -27,6 +28,9 @@ import cp from 'node:child_process';
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
|
|
29
30
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
// A real wait between polls is at least 1 s: an interval of 0 must not poll a paid API in a busy loop.
|
|
32
|
+
const MIN_POLL_WAIT_MS = 1000;
|
|
33
|
+
const pollSleep = (ms) => sleep(Math.max(MIN_POLL_WAIT_MS, ms));
|
|
30
34
|
const round2 = (x) => Math.round(x * 100) / 100;
|
|
31
35
|
|
|
32
36
|
// Scheme+host of a URL, dropping any path/query — Azure endpoints are the origin only.
|
|
@@ -53,7 +57,9 @@ function truncate(x, n = 300) {
|
|
|
53
57
|
|
|
54
58
|
// Why an azFetch result has no HTTP answer, for error messages: " (EINSECURE)" and the like. The
|
|
55
59
|
// 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.
|
|
60
|
+
const why = (r) => (r.error ? ` (${r.errno || r.error.name})` : '');
|
|
61
|
+
// AbortSignal.timeout rejects with a DOMException whose numeric code (23) means nothing to a reader
|
|
62
|
+
export const errnoOf = (e) => (e?.name === 'TimeoutError' ? 'TimeoutError' : e?.cause?.code || e?.code || null);
|
|
57
63
|
|
|
58
64
|
// Last n chars of ffmpeg stderr (the useful part) for error messages.
|
|
59
65
|
const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
|
|
@@ -116,7 +122,7 @@ export function parseConsoleSample(text) {
|
|
|
116
122
|
*/
|
|
117
123
|
export function parseEnvFile(text) {
|
|
118
124
|
const env = {};
|
|
119
|
-
for (const line of String(text || '').split(/\r
|
|
125
|
+
for (const line of String(text || '').split(/\r\n|\r|\n/)) {
|
|
120
126
|
if (line.trimStart().startsWith('#')) continue;
|
|
121
127
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=([^\n]*)$/);
|
|
122
128
|
if (m) env[m[1]] = envValue(m[2]);
|
|
@@ -176,7 +182,8 @@ export function resolveCreds({ projectDir } = {}) {
|
|
|
176
182
|
const p = candidates.find((c) => fs.existsSync(c));
|
|
177
183
|
if (!p) return { _source: null };
|
|
178
184
|
const creds = { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
|
|
179
|
-
|
|
185
|
+
if (p === home) return creds;
|
|
186
|
+
return trackedByGit(p) ? { ...withoutMachineOnly(creds), _tracked: true } : withoutMachineOnly(creds);
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
function withoutMachineOnly(creds) {
|
|
@@ -190,17 +197,174 @@ function withoutMachineOnly(creds) {
|
|
|
190
197
|
* and null/undefined values are skipped. Returns the written path. A name that is not a variable
|
|
191
198
|
* name, or a value with a line break, throws before anything is written: either would add lines of
|
|
192
199
|
* its own (a FFMPEG_PATH, a second endpoint) to the file.
|
|
200
|
+
* The file's lines stay as a person wrote them (comments, `export` lines, names not set here, each
|
|
201
|
+
* line's own end, bytes in another encoding): only the line each set name is read from changes, and a
|
|
202
|
+
* name the file lacks is added at the end. The new file replaces the old one whole, so a write cut
|
|
203
|
+
* short by an error leaves the old one. A file with a second name (a hard link), one the new copy would
|
|
204
|
+
* give another owner, or one in a folder that takes no new file (or that Windows holds open) is written
|
|
205
|
+
* in place, as before. The home key and settings files are written where a link of theirs points; any other
|
|
206
|
+
* key file that is a link (one a repository can bring) is refused, as are a folder, a pipe and a link to nothing.
|
|
193
207
|
*/
|
|
194
208
|
export function persistCreds(creds, { target } = {}) {
|
|
195
209
|
if (!target) throw new Error('persistCreds: target path required');
|
|
196
|
-
const
|
|
197
|
-
const
|
|
210
|
+
const set = Object.entries(creds || {}).filter(([k, v]) => !k.startsWith('_') && v != null).map(([k, v]) => [k, String(v)]);
|
|
211
|
+
for (const [k, v] of set) envLine(k, v);
|
|
198
212
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
199
|
-
|
|
200
|
-
|
|
213
|
+
refuseTracked(target);
|
|
214
|
+
const file = keyFileAt(target);
|
|
215
|
+
writeKeyFile(file, mergedEnv(readIfAny(file), set));
|
|
201
216
|
return target;
|
|
202
217
|
}
|
|
203
218
|
|
|
219
|
+
// Where the key file's bytes live: the path, or what a link there points to (a plain write would
|
|
220
|
+
// follow it too). Anything but a regular file is refused: a pipe would block the write for good.
|
|
221
|
+
function keyFileAt(target) {
|
|
222
|
+
let st;
|
|
223
|
+
try { st = fs.lstatSync(target); } catch (e) { if (e.code === 'ENOENT') return target; throw e; }
|
|
224
|
+
const file = st.isSymbolicLink() ? linkedFile(target) : target;
|
|
225
|
+
if (!fs.statSync(file).isFile()) throw new Error(`persistCreds: ${target} is not a regular file; nothing was written`);
|
|
226
|
+
if (file !== target) refuseProjectLink(target);
|
|
227
|
+
return file;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A project key file git tracks would carry a key into the next commit (.gitignore does not untrack it).
|
|
231
|
+
function refuseTracked(target) {
|
|
232
|
+
if ([homeCredsPath(), homeSettingsPath()].includes(path.resolve(target)) || !trackedByGit(target)) return;
|
|
233
|
+
throw new Error(`persistCreds: ${target} is tracked by git, so a key written there would go into a commit; nothing was written. Untrack it (git rm --cached) and keep it in .gitignore, or use the home key file`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Whether git tracks `file`: true, or false when it does not or cannot say (no git, no repository).
|
|
237
|
+
// git runs from the home folder with -C: Windows looks for a program in the folder it starts in first,
|
|
238
|
+
// and a repository can bring a git.exe. fsmonitor is off, so a repository's config starts no hook.
|
|
239
|
+
function trackedByGit(file) {
|
|
240
|
+
const r = cp.spawnSync('git', ['-C', path.dirname(path.resolve(file)), '-c', 'core.fsmonitor=false', 'ls-files', '--error-unmatch', '--', path.basename(file)], {
|
|
241
|
+
cwd: os.homedir(), stdio: 'ignore', timeout: 5000, windowsHide: true,
|
|
242
|
+
});
|
|
243
|
+
return r.status === 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A project's key file can come with a repository, and a link there can lead to a file of the user's (a
|
|
247
|
+
// dotfile) or of the repository (its .git/config, a tracked file) that must not get KEY= lines: a link
|
|
248
|
+
// there is refused. The home key and settings files keep following a link anywhere (a dotfiles setup).
|
|
249
|
+
function refuseProjectLink(target) {
|
|
250
|
+
if ([homeCredsPath(), homeSettingsPath()].includes(path.resolve(target))) return;
|
|
251
|
+
throw new Error(`persistCreds: ${target} is a link, and a project key file is written only as a file of its own; nothing was written. Edit the file it points at yourself, or put a file of its own there (or use the home key file)`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function linkedFile(link) {
|
|
255
|
+
try { return fs.realpathSync(link); } catch (e) {
|
|
256
|
+
if (e.code === 'ENOENT') throw new Error(`persistCreds: ${link} is a link to nothing; nothing was written`);
|
|
257
|
+
throw e;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// latin1 reads each byte as one character, so a line in another encoding (a CP949 comment) is
|
|
262
|
+
// written back byte for byte.
|
|
263
|
+
function readIfAny(file) {
|
|
264
|
+
try { return fs.readFileSync(file, 'latin1'); } catch (e) { if (e.code === 'ENOENT') return ''; throw e; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The file's bytes with each set name's value in place. parseEnvFile reads a name's last assignment,
|
|
268
|
+
// so that line is the one replaced, unless it already reads as the new value; a name the file lacks
|
|
269
|
+
// is appended. Every line keeps its own end; an added one takes \r\n when the file has any. New text
|
|
270
|
+
// goes in as UTF-8.
|
|
271
|
+
function mergedEnv(text, set) {
|
|
272
|
+
const eol = eolOf(text);
|
|
273
|
+
const lines = splitLines(text, eol);
|
|
274
|
+
const lastAt = new Map(lines.map((l, i) => [assignedName(l.body), i]).filter(([k]) => k));
|
|
275
|
+
for (const [k, v] of set) {
|
|
276
|
+
const i = lastAt.get(k);
|
|
277
|
+
const line = asLatin1(envLine(k, v));
|
|
278
|
+
if (i === undefined) lines.push({ body: line, end: eol });
|
|
279
|
+
else if (parseEnvFile(asUtf8(lines[i].body))[k] !== v) lines[i].body = line;
|
|
280
|
+
}
|
|
281
|
+
return Buffer.from(lines.map((l) => l.body + l.end).join(''), 'latin1');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Each line with its own end; a last line without one gets `eol`.
|
|
285
|
+
// The line end an added line takes: \r\n when the file has any, else a lone \r when it has one, else \n.
|
|
286
|
+
const eolOf = (text) => (text.includes('\r\n') ? '\r\n' : text.includes('\r') ? '\r' : '\n');
|
|
287
|
+
|
|
288
|
+
function splitLines(text, eol) {
|
|
289
|
+
return (text.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) || []).map((l) => {
|
|
290
|
+
const m = /(?:\r\n|\r|\n)$/.exec(l);
|
|
291
|
+
return m ? { body: l.slice(0, m.index), end: m[0] } : { body: l, end: eol };
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const asLatin1 = (s) => Buffer.from(s, 'utf8').toString('latin1');
|
|
296
|
+
const asUtf8 = (s) => Buffer.from(s, 'latin1').toString('utf8');
|
|
297
|
+
|
|
298
|
+
// The name a line assigns, as parseEnvFile reads it (null for a comment, an `export` line, or text).
|
|
299
|
+
function assignedName(line) {
|
|
300
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
301
|
+
return m ? m[1] : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The new contents go to a fresh random name opened exclusively ('wx') and are renamed over the old
|
|
305
|
+
// file. A file with a second name (a hard link), or one the new copy would give another owner or
|
|
306
|
+
// group, is written in place: a rename would part it from its other name, or hand it to this user.
|
|
307
|
+
// So is one whose folder takes no new file, or whose rename the system refuses: the old in-place
|
|
308
|
+
// write still works there.
|
|
309
|
+
function writeKeyFile(file, body) {
|
|
310
|
+
const old = statIfAny(file);
|
|
311
|
+
if (old && old.nlink > 1) return writeInPlace(file, body);
|
|
312
|
+
const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
313
|
+
const fd = openNew(tmp);
|
|
314
|
+
if (fd === null) return writeInPlace(file, body);
|
|
315
|
+
let moved = false;
|
|
316
|
+
try {
|
|
317
|
+
moved = fillTemp(fd, body, old) && renamed(tmp, file);
|
|
318
|
+
} finally {
|
|
319
|
+
if (!moved) fs.rmSync(tmp, { force: true });
|
|
320
|
+
}
|
|
321
|
+
return moved ? chmodPrivate(file) : writeInPlace(file, body);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A read-only folder, one that is not this user's, or (on Windows) a file another program holds.
|
|
325
|
+
const NO_NEW_FILE = new Set(['EACCES', 'EPERM', 'EBUSY', 'EROFS']);
|
|
326
|
+
|
|
327
|
+
function openNew(tmp) {
|
|
328
|
+
try { return fs.openSync(tmp, 'wx', 0o600); } catch (e) { if (NO_NEW_FILE.has(e.code)) return null; throw e; }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function renamed(tmp, file) {
|
|
332
|
+
try { fs.renameSync(tmp, file); return true; } catch (e) { if (NO_NEW_FILE.has(e.code)) return false; throw e; }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Fill and close the new file. False, with nothing written, when its owner or group is not the old file's.
|
|
336
|
+
function fillTemp(fd, body, old) {
|
|
337
|
+
try {
|
|
338
|
+
const st = fs.fstatSync(fd);
|
|
339
|
+
if (old && (st.uid !== old.uid || st.gid !== old.gid)) return false;
|
|
340
|
+
fs.writeFileSync(fd, body);
|
|
341
|
+
syncIfAble(fd);
|
|
342
|
+
return true;
|
|
343
|
+
} finally {
|
|
344
|
+
fs.closeSync(fd);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// fsync where the file system does it. One that does not (EINVAL, ENOTSUP) still gets the whole new
|
|
349
|
+
// file by the rename; only its crash safety is less.
|
|
350
|
+
const NO_FSYNC = new Set(['EINVAL', 'ENOTSUP']);
|
|
351
|
+
function syncIfAble(fd) {
|
|
352
|
+
try { fs.fsyncSync(fd); } catch (e) { if (!NO_FSYNC.has(e.code)) throw e; }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function writeInPlace(file, body) {
|
|
356
|
+
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
357
|
+
return chmodPrivate(file);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function statIfAny(file) {
|
|
361
|
+
try { return fs.statSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function chmodPrivate(file) {
|
|
365
|
+
try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
|
|
366
|
+
}
|
|
367
|
+
|
|
204
368
|
// One KEY=VALUE line for persistCreds, refusing a name or value that would make lines of its own.
|
|
205
369
|
function envLine(k, v) {
|
|
206
370
|
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`);
|
|
@@ -242,6 +406,61 @@ const plainHeaders = (h) => (Array.isArray(h) || h instanceof Headers ? Object.f
|
|
|
242
406
|
/** Whether a key may go to this URL: over https, or over http only to this machine (a local mock or proxy). */
|
|
243
407
|
export const safeForKey = (u) => u.protocol === 'https:' || localHttp(u);
|
|
244
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Whether this process's fetch sends an http request to `u` through a proxy named in the environment:
|
|
411
|
+
* Node 22 and 24 do when NODE_USE_ENV_PROXY=1 or --use-env-proxy is on and http_proxy or HTTP_PROXY is
|
|
412
|
+
* set, unless NO_PROXY names the host. Over plain http the proxy reads the request, a key included.
|
|
413
|
+
* Read as Node 22.23 and 24.13 do (measured), and toward "proxied" where unsure: NODE_USE_ENV_PROXY
|
|
414
|
+
* other than empty or 0 is on (Node takes only 1); the flag on the command line or in NODE_OPTIONS is on
|
|
415
|
+
* in any spelling Node takes (`_` for `-`, quoted, with a value, even `=false`), and so is a Node
|
|
416
|
+
* config file, which can turn it on unseen; and the host
|
|
417
|
+
* must be named in no_proxy and NO_PROXY both, when set (Node takes no_proxy when it is set, even
|
|
418
|
+
* empty). An entry names it as `*`, the host, or the host with this URL's own port written out;
|
|
419
|
+
* a leading dot, a range or a default port does not count.
|
|
420
|
+
*/
|
|
421
|
+
export function envProxied(u, env = process.env, flags = process.execArgv) {
|
|
422
|
+
if (!envProxyOn(env, flags) || !(env.http_proxy || env.HTTP_PROXY)) return false;
|
|
423
|
+
const lists = [env.no_proxy, env.NO_PROXY].filter((list) => list !== undefined);
|
|
424
|
+
return !lists.length || !lists.every((list) => noProxyNames(list, u));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function envProxyOn(env, flags) {
|
|
428
|
+
if (env.NODE_USE_ENV_PROXY && env.NODE_USE_ENV_PROXY !== '0') return true;
|
|
429
|
+
const given = [...flags, env.NODE_OPTIONS || ''].join(' ');
|
|
430
|
+
return PROXY_FLAG.test(given) || CONFIG_FILE_FLAG.test(given);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// `--use-env-proxy` as Node takes it (`--no-use-env-proxy` turns it off), and the flags that load a
|
|
434
|
+
// Node config file, whose nodeOptions can turn it on without either showing here.
|
|
435
|
+
const PROXY_FLAG = /--use[-_]env[-_]proxy(?![\w-])/;
|
|
436
|
+
const CONFIG_FILE_FLAG = /--experimental[-_](?:default[-_])?config[-_]file(?![\w-])/;
|
|
437
|
+
|
|
438
|
+
// Entries are split at commas and spaces, and compared in lower case (URL gives the host in lower case).
|
|
439
|
+
const noProxyNames = (list, u) => list.toLowerCase().split(/[\s,]+/).some((x) => noProxyEntry(x, u));
|
|
440
|
+
|
|
441
|
+
function noProxyEntry(x, u) {
|
|
442
|
+
if (x === '*') return true;
|
|
443
|
+
const m = x.match(/^(\[[^\]]*\]|[^:[\]]*)(?::(\d+))?$/);
|
|
444
|
+
return Boolean(m) && m[1] === u.hostname && (m[2] === undefined || m[2] === u.port);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The environment as this module found it. Node takes the proxy settings when it starts, so a change
|
|
448
|
+
// made later does not take the proxy away: a key is held back when either says a proxy is in the way.
|
|
449
|
+
const ENV_AT_LOAD = { ...process.env };
|
|
450
|
+
|
|
451
|
+
/** Whether a key is held back from this URL (EPROXYKEY): http that an env proxy would carry, now or as Node started. */
|
|
452
|
+
export const keyHeldBack = (u) => u.protocol === 'http:' && (envProxied(u) || envProxied(u, ENV_AT_LOAD));
|
|
453
|
+
|
|
454
|
+
// A key goes only where safeForKey allows, and over http only straight to this machine: not through
|
|
455
|
+
// a proxy the environment puts in the way. Both refusals come before anything is sent.
|
|
456
|
+
function assertKeyMayGo(target, headers) {
|
|
457
|
+
if (!Object.keys(headers).some(isSecret)) return;
|
|
458
|
+
if (!safeForKey(target)) throw refused('EINSECURE', 'a key is sent only over https');
|
|
459
|
+
if (keyHeldBack(target)) {
|
|
460
|
+
throw refused('EPROXYKEY', 'a key over http goes only straight to this machine, and the environment sends http through a proxy (NO_PROXY can name the host)');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
245
464
|
// Where a redirect goes. The request was sent already, so a Location that is not a URL, or one that
|
|
246
465
|
// leaves https, ends it with a code that does not read as "nothing sent". http stays allowed only
|
|
247
466
|
// while the request is on this machine over http (a local mock); from anywhere else, an https
|
|
@@ -273,13 +492,14 @@ function nextHop(req, status, crossOrigin) {
|
|
|
273
492
|
* another origin without the key headers; a request with a body only by a 307 or 308 within its
|
|
274
493
|
* origin (fetch itself forwards a custom `api-key` header and re-sends the body on 307 and 308 to
|
|
275
494
|
* 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
|
|
277
|
-
* https. Throws as fetch does on
|
|
495
|
+
* URL with one is refused before anything is sent (cause.code EINSECURE), and so is an http one to this
|
|
496
|
+
* machine that an env proxy would carry (EPROXYKEY); no redirect leaves https. Throws as fetch does on
|
|
497
|
+
* a network error.
|
|
278
498
|
*/
|
|
279
499
|
export async function fetchGuarded(url, init = {}) {
|
|
280
500
|
let target = new URL(String(url));
|
|
281
501
|
let req = { ...init, headers: plainHeaders(init.headers), redirect: 'manual' };
|
|
282
|
-
|
|
502
|
+
assertKeyMayGo(target, req.headers);
|
|
283
503
|
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
|
284
504
|
const r = await globalThis.fetch(target.href, req);
|
|
285
505
|
const location = REDIRECT_STATUS.has(r.status) ? r.headers.get('location') : null;
|
|
@@ -304,35 +524,68 @@ function retryAfterMsOf(headers) {
|
|
|
304
524
|
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
305
525
|
}
|
|
306
526
|
|
|
527
|
+
// Each request ends within its limit. Without one, a server that takes the connection and never
|
|
528
|
+
// answers holds a request for undici's 300 s, and a poll of 180 requests for hours.
|
|
529
|
+
export const API_TIMEOUT_MS = 120_000;
|
|
530
|
+
export const DOWNLOAD_TIMEOUT_MS = 600_000;
|
|
531
|
+
// An image is made while its request waits, and is billed: it keeps the 300 s undici gave it.
|
|
532
|
+
const IMAGE_TIMEOUT_MS = 300_000;
|
|
533
|
+
|
|
307
534
|
/**
|
|
308
535
|
* Fetch an Azure URL trying the `api-key` header first and, on 401/403, retrying with
|
|
309
|
-
* `Authorization: Bearer`. Never throws on HTTP status; a network error
|
|
310
|
-
* `
|
|
311
|
-
*
|
|
536
|
+
* `Authorization: Bearer`. Never throws on HTTP status; a network error, before the answer or
|
|
537
|
+
* while its body is read, yields {status:0} with `error` (its name and codes only, no message text) and `errno` (a code such as
|
|
538
|
+
* ECONNRESET or TimeoutError). Each request is limited to `timeoutMs` (default 120 s, 600 s
|
|
539
|
+
* for a binary download). `retryAfterMs` carries the server's Retry-After hint (null when absent).
|
|
540
|
+
* @param {{key?:string, binary?:boolean, headers?:object, method?:string, body?:any, timeoutMs?:number}} [opts] method defaults to GET
|
|
541
|
+
* @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, retryAfterMs:number|null, error?:Error, errno?:string|null}>}
|
|
312
542
|
*/
|
|
313
|
-
export async function azFetch(url,
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
543
|
+
export async function azFetch(url, opts = {}) {
|
|
544
|
+
const { key, binary = false } = opts;
|
|
545
|
+
const limitMs = Math.ceil(opts.timeoutMs ?? (binary ? DOWNLOAD_TIMEOUT_MS : API_TIMEOUT_MS)); // AbortSignal.timeout takes whole ms only
|
|
546
|
+
let last;
|
|
547
|
+
for (const auth of authStyles(key)) {
|
|
548
|
+
last = await azAttempt(url, { ...opts, auth, binary, limitMs });
|
|
549
|
+
// accepted, a non-auth error, or a network error (not an auth problem): no other style is tried
|
|
550
|
+
if (last.status !== 401 && last.status !== 403) return last;
|
|
551
|
+
}
|
|
552
|
+
return last;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const authStyles = (key) => (key ? [{ 'api-key': key }, { Authorization: `Bearer ${key}` }] : [{}]);
|
|
556
|
+
|
|
557
|
+
// One request with one auth style. The body is read inside the try: a connection that drops
|
|
558
|
+
// mid-body is a network error like any other, not a throw past the caller's retry. So is a time limit
|
|
559
|
+
// AbortSignal.timeout will not take (not a whole number of ms).
|
|
560
|
+
async function azAttempt(url, { method = 'GET', headers = {}, body, auth, binary, limitMs }) {
|
|
561
|
+
const authStyle = Object.keys(auth)[0] || null;
|
|
562
|
+
try {
|
|
563
|
+
const opts = { method, headers: { ...headers, ...auth }, signal: AbortSignal.timeout(limitMs) };
|
|
318
564
|
if (body !== undefined) opts.body = body;
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
r = await fetchGuarded(url, opts);
|
|
322
|
-
} catch (e) {
|
|
323
|
-
last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
|
|
324
|
-
break; // network error is not an auth problem — do not retry other styles
|
|
325
|
-
}
|
|
565
|
+
const r = await fetchGuarded(url, opts);
|
|
326
566
|
const ct = r.headers.get('content-type') || '';
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
else parsed = await r.text().catch(() => null);
|
|
331
|
-
last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null, retryAfterMs: retryAfterMsOf(r.headers) };
|
|
332
|
-
if (r.status !== 401 && r.status !== 403) return last; // accepted (or a non-auth error)
|
|
333
|
-
// else: this auth style was rejected — fall through and try the next
|
|
567
|
+
return { status: r.status, ok: r.ok, ct, body: await azBody(r, ct, binary), authStyle, retryAfterMs: retryAfterMsOf(r.headers) };
|
|
568
|
+
} catch (e) {
|
|
569
|
+
return { status: 0, ok: false, ct: '', body: null, authStyle: null, error: codeOnly(e), errno: errnoOf(e) };
|
|
334
570
|
}
|
|
335
|
-
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// The error a request failed with, without its text: undici quotes a header value it refuses (a key
|
|
574
|
+
// with a NUL or a line break) in its message. Its name, its code and its cause's code stay.
|
|
575
|
+
function codeOnly(e) {
|
|
576
|
+
const { name, code, cause } = Object(e);
|
|
577
|
+
const err = Object.assign(new Error(errnoOf(e) || name || 'network error'), { name: name || 'Error' });
|
|
578
|
+
if (code !== undefined) err.code = code;
|
|
579
|
+
if (cause && cause.code) err.cause = { code: cause.code };
|
|
580
|
+
return err;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// A read cut short throws (the caller's catch makes it status 0); a JSON body that does not parse is null.
|
|
584
|
+
async function azBody(r, ct, binary) {
|
|
585
|
+
if (binary) return Buffer.from(await r.arrayBuffer());
|
|
586
|
+
const text = await r.text();
|
|
587
|
+
if (!ct.includes('json')) return text;
|
|
588
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
336
589
|
}
|
|
337
590
|
|
|
338
591
|
// ---------------------------------------------------------------------------
|
|
@@ -343,7 +596,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
343
596
|
// else 10s, 20s, 40s…; either way at most 60s per wait. Low-tier (S0) image deployments hit this.
|
|
344
597
|
async function postJsonWith429Retry(url, { key, body, retries, onNote }) {
|
|
345
598
|
for (let i = 0; ; i++) {
|
|
346
|
-
const r = await azFetch(url, { key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
|
599
|
+
const r = await azFetch(url, { key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body, timeoutMs: IMAGE_TIMEOUT_MS });
|
|
347
600
|
if (r.status !== 429 || i >= retries) return r;
|
|
348
601
|
const ms = Math.min(60000, r.retryAfterMs ?? 10000 * 2 ** i);
|
|
349
602
|
if (typeof onNote === 'function') onNote(`HTTP 429; retry ${i + 1}/${retries} in ${ms} ms`);
|
|
@@ -363,20 +616,24 @@ export async function generateImage({
|
|
|
363
616
|
prompt, size = '720x1280', quality = 'low', n = 1, onNote, retry429 = 3,
|
|
364
617
|
} = {}) {
|
|
365
618
|
const genBody = { model: deployment, prompt, size, quality, n };
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
});
|
|
619
|
+
const post = (url, body) => postJsonWith429Retry(url, { key, body: JSON.stringify(body), retries: retry429, onNote });
|
|
620
|
+
let r = await post(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, genBody);
|
|
369
621
|
let note = 'v1';
|
|
370
|
-
|
|
371
|
-
if (!(r.ok && r.body?.data?.[0]?.b64_json) && r.status !== 429 && r.status !== 0) {
|
|
622
|
+
if (classicWorthTrying(r)) {
|
|
372
623
|
note = 'classic-fallback(2025-04-01-preview)';
|
|
373
624
|
if (typeof onNote === 'function') onNote(`generateImage: v1 path failed (HTTP ${r.status}); trying ${note}`);
|
|
374
|
-
r = await
|
|
375
|
-
key, body: JSON.stringify({ ...genBody, size: classicSize(size) }), retries: retry429, onNote,
|
|
376
|
-
});
|
|
625
|
+
r = await post(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, { ...genBody, size: classicSize(size) });
|
|
377
626
|
}
|
|
378
|
-
|
|
379
|
-
|
|
627
|
+
return pngFrom(r, dataB64(r), `generateImage failed (${note})`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// no classic retry on 429 (shared limit) or on a lost connection (the v1 call may have billed)
|
|
631
|
+
const classicWorthTrying = (r) => !(r.ok && dataB64(r)) && r.status !== 429 && r.status !== 0;
|
|
632
|
+
const dataB64 = (r) => r.body?.data?.[0]?.b64_json;
|
|
633
|
+
|
|
634
|
+
// The PNG an image call answered with, or an error that starts with `failed`.
|
|
635
|
+
function pngFrom(r, b64, failed) {
|
|
636
|
+
if (!(r.ok && b64)) throw new Error(`${failed}: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
380
637
|
return Buffer.from(b64, 'base64');
|
|
381
638
|
}
|
|
382
639
|
|
|
@@ -384,29 +641,29 @@ export async function generateImage({
|
|
|
384
641
|
export async function editImage({
|
|
385
642
|
endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
|
|
386
643
|
} = {}) {
|
|
644
|
+
const r = await azFetch(`${endpoint}/openai/v1/images/edits?api-version=${apiVersion}`, {
|
|
645
|
+
key, method: 'POST', body: editForm({ deployment, prompt, imageBuf, maskBuf, size }), timeoutMs: IMAGE_TIMEOUT_MS,
|
|
646
|
+
});
|
|
647
|
+
return pngFrom(r, dataB64(r), 'editImage failed');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function editForm({ deployment, prompt, imageBuf, maskBuf, size }) {
|
|
387
651
|
const fd = new FormData();
|
|
388
652
|
fd.append('model', deployment);
|
|
389
653
|
fd.append('prompt', prompt);
|
|
390
654
|
if (size) fd.append('size', size);
|
|
391
655
|
fd.append('image', new Blob([imageBuf], { type: 'image/png' }), 'image.png');
|
|
392
656
|
if (maskBuf) fd.append('mask', new Blob([maskBuf], { type: 'image/png' }), 'mask.png');
|
|
393
|
-
|
|
394
|
-
key, method: 'POST', body: fd,
|
|
395
|
-
});
|
|
396
|
-
const b64 = r.body?.data?.[0]?.b64_json;
|
|
397
|
-
if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
398
|
-
return Buffer.from(b64, 'base64');
|
|
657
|
+
return fd;
|
|
399
658
|
}
|
|
400
659
|
|
|
401
660
|
/** Generate a still via the Black Forest Labs FLUX provider path. Returns a PNG Buffer. */
|
|
402
661
|
export async function generateImageFlux({ endpoint, key, prompt, width, height, apiVersion = 'preview' } = {}) {
|
|
403
662
|
const r = await azFetch(`${endpoint}/providers/blackforestlabs/v1/flux-2-pro?api-version=${apiVersion}`, {
|
|
404
663
|
key, method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
405
|
-
body: JSON.stringify({ prompt, width, height }),
|
|
664
|
+
body: JSON.stringify({ prompt, width, height }), timeoutMs: IMAGE_TIMEOUT_MS,
|
|
406
665
|
});
|
|
407
|
-
|
|
408
|
-
if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
409
|
-
return Buffer.from(b64, 'base64');
|
|
666
|
+
return pngFrom(r, dataB64(r) || r.body?.b64_json || r.body?.image, 'generateImageFlux failed');
|
|
410
667
|
}
|
|
411
668
|
|
|
412
669
|
// ---------------------------------------------------------------------------
|
|
@@ -427,6 +684,13 @@ export async function listVideos({ endpoint, key, apiVersion = 'preview' } = {})
|
|
|
427
684
|
export async function createVideo({
|
|
428
685
|
endpoint, key, model = 'sora-2', prompt, size = '720x1280', seconds = 4, inputReferencePng, apiVersion = 'preview',
|
|
429
686
|
} = {}) {
|
|
687
|
+
const body = videoForm({ model, prompt, size, seconds, inputReferencePng });
|
|
688
|
+
const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body });
|
|
689
|
+
if (!(r.ok && r.body?.id)) throw createVideoError(r);
|
|
690
|
+
return r.body;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function videoForm({ model, prompt, size, seconds, inputReferencePng }) {
|
|
430
694
|
const fd = new FormData();
|
|
431
695
|
fd.append('model', model);
|
|
432
696
|
fd.append('prompt', prompt);
|
|
@@ -435,44 +699,59 @@ export async function createVideo({
|
|
|
435
699
|
if (inputReferencePng) {
|
|
436
700
|
fd.append('input_reference', new Blob([inputReferencePng], { type: 'image/png' }), 'input_reference.png');
|
|
437
701
|
}
|
|
438
|
-
|
|
439
|
-
if (!(r.ok && r.body?.id)) {
|
|
440
|
-
// status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
|
|
441
|
-
const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
442
|
-
err.status = r.status;
|
|
443
|
-
err.errno = r.error?.cause?.code || r.error?.code || null;
|
|
444
|
-
throw err;
|
|
445
|
-
}
|
|
446
|
-
return r.body;
|
|
702
|
+
return fd;
|
|
447
703
|
}
|
|
448
704
|
|
|
449
|
-
|
|
705
|
+
// status 0 = network error; errno tells a refused connection (nothing sent) from a lost one.
|
|
706
|
+
// retryAfterMs is the server's wait for a 429.
|
|
707
|
+
function createVideoError(r) {
|
|
708
|
+
const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
709
|
+
return Object.assign(err, { status: r.status, errno: r.errno || null, retryAfterMs: r.retryAfterMs ?? null });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180, now: Date.now };
|
|
450
713
|
const POLL_RETRY_4XX = new Set([408, 429]);
|
|
451
714
|
const withoutUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
452
715
|
|
|
453
716
|
/**
|
|
454
|
-
* Poll a Sora job until a terminal status
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
717
|
+
* Poll a Sora job until a terminal status, maxTicks polls, or `timeoutMs` of wall time on the
|
|
718
|
+
* `now` clock (default maxTicks x intervalMs, at least one request's 120 s), whichever comes first. Each request is limited to
|
|
719
|
+
* the time left, so a server that stops answering cannot stretch the wait. Polls first, then
|
|
720
|
+
* sleeps, so a job that is already terminal returns without waiting. `onTick(status, i)` is
|
|
721
|
+
* optional; `sleep` replaces the wait between polls. 408, 429, 5xx and network errors keep
|
|
722
|
+
* polling. Any other 4xx throws at once with `err.status` and `err.code`: a job the server no
|
|
723
|
+
* longer knows, or a key it refuses, never turns terminal. So does a key held back before sending
|
|
724
|
+
* (err.status 0, err.code EINSECURE or EPROXYKEY): no later poll could send it.
|
|
459
725
|
* @returns the last status object seen.
|
|
460
726
|
*/
|
|
461
727
|
export async function pollVideo(opts = {}) {
|
|
462
|
-
const o = { ...POLL_DEFAULTS, sleep, ...withoutUndefined(opts) };
|
|
728
|
+
const o = { ...POLL_DEFAULTS, sleep: pollSleep, ...withoutUndefined(opts) };
|
|
463
729
|
const url = `${o.endpoint}/openai/v1/videos/${o.id}?api-version=${o.apiVersion}`;
|
|
730
|
+
const end = o.now() + (o.timeoutMs ?? Math.max(o.intervalMs * o.maxTicks, API_TIMEOUT_MS));
|
|
464
731
|
let st = null;
|
|
465
732
|
for (let i = 0; i < o.maxTicks; i++) {
|
|
466
|
-
const r = await azFetch(url, { key: o.key, method: 'GET' });
|
|
733
|
+
const r = await azFetch(url, { key: o.key, method: 'GET', timeoutMs: timeLeft(end, o.now()) });
|
|
734
|
+
throwIfHeldBack(r, o.id);
|
|
467
735
|
throwIfRefused(r, o.id);
|
|
468
736
|
if (r.ok) st = r.body || st;
|
|
469
737
|
if (typeof o.onTick === 'function') o.onTick(st, i);
|
|
470
|
-
if (
|
|
738
|
+
if (settled(st)) break;
|
|
471
739
|
await o.sleep(o.intervalMs);
|
|
740
|
+
if (o.now() >= end) break;
|
|
472
741
|
}
|
|
473
742
|
return st;
|
|
474
743
|
}
|
|
475
744
|
|
|
745
|
+
// A poll request's limit: the usual one, or less when the deadline is nearer (azFetch rounds it up to whole ms).
|
|
746
|
+
export const timeLeft = (end, t) => Math.min(API_TIMEOUT_MS, Math.max(1, Math.ceil(end - t)));
|
|
747
|
+
const settled = (st) => Boolean(st) && TERMINAL_STATUS.has(st.status);
|
|
748
|
+
|
|
749
|
+
export const HELD_KEY = new Set(['EINSECURE', 'EPROXYKEY']);
|
|
750
|
+
function throwIfHeldBack(r, id) {
|
|
751
|
+
if (r.status !== 0 || !HELD_KEY.has(r.errno)) return;
|
|
752
|
+
throw Object.assign(new Error(`pollVideo: job ${id}: the key was held back (${r.errno}); nothing was sent`), { status: 0, code: r.errno });
|
|
753
|
+
}
|
|
754
|
+
|
|
476
755
|
function throwIfRefused(r, id) {
|
|
477
756
|
if (r.status < 400 || r.status >= 500 || POLL_RETRY_4XX.has(r.status)) return;
|
|
478
757
|
const code = r.body?.error?.code || null;
|
|
@@ -513,6 +792,16 @@ export async function detectTwoImageSupport({ endpoint, key, apiVersion = 'previ
|
|
|
513
792
|
export async function createVideoTwoImage({
|
|
514
793
|
endpoint, key, model = 'sora-2', prompt = '', firstPng, lastPng, size = '720x1280', seconds = 4, apiVersion = 'preview',
|
|
515
794
|
} = {}) {
|
|
795
|
+
const r = await azFetch(`${endpoint}/openai/v1/video/generations/jobs?api-version=${apiVersion}`, {
|
|
796
|
+
key, method: 'POST', body: twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }),
|
|
797
|
+
});
|
|
798
|
+
if (!(r.ok && jobIdOf(r))) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
799
|
+
return r.body;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const jobIdOf = (r) => r.body?.id || r.body?.job_id;
|
|
803
|
+
|
|
804
|
+
function twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }) {
|
|
516
805
|
const fd = new FormData();
|
|
517
806
|
fd.append('model', model);
|
|
518
807
|
fd.append('prompt', prompt);
|
|
@@ -525,12 +814,7 @@ export async function createVideoTwoImage({
|
|
|
525
814
|
]));
|
|
526
815
|
fd.append('files', new Blob([firstPng], { type: 'image/png' }), 'first.png');
|
|
527
816
|
fd.append('files', new Blob([lastPng], { type: 'image/png' }), 'last.png');
|
|
528
|
-
|
|
529
|
-
key, method: 'POST', body: fd,
|
|
530
|
-
});
|
|
531
|
-
const id = r.body?.id || r.body?.job_id;
|
|
532
|
-
if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
533
|
-
return r.body;
|
|
817
|
+
return fd;
|
|
534
818
|
}
|
|
535
819
|
|
|
536
820
|
// ---------------------------------------------------------------------------
|
|
@@ -656,16 +940,25 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
|
|
|
656
940
|
*/
|
|
657
941
|
export async function concatClips(mp4List, out, { ffmpeg, fps = 30, width = 720, height = 1280 } = {}) {
|
|
658
942
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
943
|
+
const r = runFf(bin, concatArgs(clipList(mp4List), out, { fps, width, height }));
|
|
944
|
+
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
|
|
945
|
+
return out;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function clipList(mp4List) {
|
|
659
949
|
const list = Array.isArray(mp4List) ? mp4List : [];
|
|
660
950
|
if (list.length === 0) throw new Error('concatClips: no input clips');
|
|
951
|
+
return list;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// One -i per clip, each normalized (fps, scale, sar, pix_fmt) before the concat filter joins them.
|
|
955
|
+
function concatArgs(list, out, { fps, width, height }) {
|
|
661
956
|
const args = ['-y'];
|
|
662
957
|
for (const f of list) args.push('-i', f);
|
|
663
958
|
const norm = list.map((_, i) => `[${i}:v]fps=${fps},scale=${width}:${height},setsar=1,format=yuv420p[v${i}]`);
|
|
664
959
|
const chain = `${list.map((_, i) => `[v${i}]`).join('')}concat=n=${list.length}:v=1[v]`;
|
|
665
960
|
args.push('-filter_complex', `${norm.join(';')};${chain}`, '-map', '[v]', out);
|
|
666
|
-
|
|
667
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
|
|
668
|
-
return out;
|
|
961
|
+
return args;
|
|
669
962
|
}
|
|
670
963
|
|
|
671
964
|
// Probe an mp4's duration (seconds) by parsing ffmpeg's stderr banner. Returns null if absent.
|