@mrgoonie/multix 1.0.7 → 1.0.9

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/dist/index.js ADDED
@@ -0,0 +1,1189 @@
1
+ // src/core/env-loader.ts
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { parse as dotenvParse } from "dotenv";
6
+
7
+ // src/core/redact.ts
8
+ function redact(key) {
9
+ if (key.length <= 10) return "***";
10
+ return `${key.slice(0, 6)}...${key.slice(-4)}`;
11
+ }
12
+ function redactUrl(url) {
13
+ try {
14
+ const u = new URL(url);
15
+ if (u.origin === "null") return `${u.protocol}[redacted]`;
16
+ const suffix = u.search || u.hash ? "?[redacted]" : "";
17
+ return `${u.origin}${u.pathname}${suffix}`;
18
+ } catch {
19
+ const cut = url.search(/[?#]/);
20
+ return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`;
21
+ }
22
+ }
23
+
24
+ // src/core/errors.ts
25
+ var MultixError = class extends Error {
26
+ constructor(message, code, options) {
27
+ super(message, options);
28
+ this.code = code;
29
+ this.name = "MultixError";
30
+ Error.captureStackTrace?.(
31
+ this,
32
+ this.constructor
33
+ );
34
+ }
35
+ code;
36
+ };
37
+ var ConfigError = class extends MultixError {
38
+ constructor(message, options) {
39
+ super(message, "CONFIG_ERROR", options);
40
+ this.name = "ConfigError";
41
+ }
42
+ };
43
+ var ProviderError = class extends MultixError {
44
+ constructor(message, provider, options) {
45
+ super(message, "PROVIDER_ERROR", options);
46
+ this.provider = provider;
47
+ this.name = "ProviderError";
48
+ }
49
+ provider;
50
+ };
51
+ var ValidationError = class extends MultixError {
52
+ constructor(message) {
53
+ super(message, "VALIDATION_ERROR");
54
+ this.name = "ValidationError";
55
+ }
56
+ };
57
+ var HttpError = class extends MultixError {
58
+ constructor(status, snippet, url, retryAfterMs, options) {
59
+ super(`HTTP ${status} from ${redactUrl(url)}: ${snippet}`, "HTTP_ERROR", options);
60
+ this.status = status;
61
+ this.snippet = snippet;
62
+ this.retryAfterMs = retryAfterMs;
63
+ this.url = redactUrl(url);
64
+ Object.defineProperty(this, "rawUrl", { value: url, enumerable: false });
65
+ this.name = "HttpError";
66
+ }
67
+ status;
68
+ snippet;
69
+ retryAfterMs;
70
+ /** Request URL with query string and fragment redacted — safe to log or serialize. */
71
+ url;
72
+ };
73
+
74
+ // src/core/env-loader.ts
75
+ function envNameKey(name, platform = process.platform) {
76
+ return platform === "win32" ? name.toUpperCase() : name;
77
+ }
78
+ var loaded = false;
79
+ var loadedFiles = [];
80
+ var fileOrigins = /* @__PURE__ */ new Map();
81
+ var userFileValues = {};
82
+ var warned = /* @__PURE__ */ new Set();
83
+ var LEGACY_SKILL_DIRS = ["ai-multimodal", "ak-ai-multimodal"];
84
+ function legacyFilesUnder(root) {
85
+ const claude = path.join(root, ".claude");
86
+ return [
87
+ ...LEGACY_SKILL_DIRS.map((dir) => path.join(claude, "skills", dir, ".env")),
88
+ path.join(claude, "skills", ".env"),
89
+ path.join(claude, ".env")
90
+ ];
91
+ }
92
+ function samePath(a, b) {
93
+ const norm = (p) => {
94
+ const r = path.resolve(p);
95
+ return process.platform === "win32" ? r.toLowerCase() : r;
96
+ };
97
+ return norm(a) === norm(b);
98
+ }
99
+ function legacyClaudeEnvFiles(cwd, home) {
100
+ const files = legacyFilesUnder(cwd);
101
+ if (home && !samePath(home, cwd)) files.push(...legacyFilesUnder(home));
102
+ return files;
103
+ }
104
+ function legacyClaudeEnvEnabled(env = process.env) {
105
+ return isTruthy(env.MULTIX_LEGACY_CLAUDE_ENV);
106
+ }
107
+ var LEGACY_ALLOWED_NAME = /^[A-Z][A-Z0-9_]*_(API_KEY(_\d+)?|API_TOKEN|ACCOUNT_ID|GATEWAY_ID)$|^(FAL_KEY|ARK_API_KEY)$/;
108
+ function isLegacyAllowedName(name) {
109
+ return LEGACY_ALLOWED_NAME.test(name);
110
+ }
111
+ function isReadableFile(file) {
112
+ try {
113
+ return fs.statSync(file).isFile();
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+ function parseFile(file) {
119
+ if (!isReadableFile(file)) return void 0;
120
+ try {
121
+ return dotenvParse(fs.readFileSync(file));
122
+ } catch {
123
+ return void 0;
124
+ }
125
+ }
126
+ function fileMaySet(name, value) {
127
+ const current = process.env[name];
128
+ if (current === void 0) return true;
129
+ if (current.trim() !== "" || value.trim() === "") return false;
130
+ return fileOrigins.get(envNameKey(name))?.value === current;
131
+ }
132
+ function loadFile(file, source, accept = () => true, onRefused) {
133
+ const parsed = parseFile(file);
134
+ if (!parsed) return void 0;
135
+ for (const [name, value] of Object.entries(parsed)) {
136
+ if (!accept(name)) {
137
+ onRefused?.(name);
138
+ continue;
139
+ }
140
+ if (!fileMaySet(name, value)) continue;
141
+ process.env[name] = value;
142
+ fileOrigins.set(envNameKey(name), { source, file, value });
143
+ }
144
+ loadedFiles.push(file);
145
+ return parsed;
146
+ }
147
+ function byNameKey(values) {
148
+ const out = {};
149
+ for (const [name, value] of Object.entries(values ?? {})) {
150
+ const key = envNameKey(name);
151
+ if (!(key in out)) out[key] = value;
152
+ }
153
+ return out;
154
+ }
155
+ var MAX_LISTED_NAMES = 10;
156
+ function listNames(names) {
157
+ const shown = names.slice(0, MAX_LISTED_NAMES).join(", ");
158
+ const rest = names.length - MAX_LISTED_NAMES;
159
+ return rest > 0 ? `${shown} and ${rest} more` : shown;
160
+ }
161
+ function loadEnv({ isProjectEnvName: isProjectEnvName2 }) {
162
+ if (loaded) return;
163
+ loaded = true;
164
+ loadedFiles = [];
165
+ fileOrigins = /* @__PURE__ */ new Map();
166
+ userFileValues = {};
167
+ const cwd = process.cwd();
168
+ const home = process.env.MULTIX_DISABLE_HOME_ENV ? void 0 : os.homedir();
169
+ const projectFile = path.resolve(cwd, ".env");
170
+ const refused = [];
171
+ loadFile(projectFile, "project", isProjectEnvName2, (name) => refused.push(name));
172
+ if (refused.length > 0) {
173
+ warnOnce(
174
+ "project-env-refused",
175
+ `Ignoring ${refused.length} variable(s) in project env file ${projectFile}: ${listNames(refused)}. A project .env may only set provider keys and documented multix settings; set anything else in your shell.`
176
+ );
177
+ }
178
+ if (home) userFileValues = byNameKey(loadFile(path.join(home, ".multix", ".env"), "user"));
179
+ if (legacyClaudeEnvEnabled()) {
180
+ const projectLegacy = new Set(legacyFilesUnder(cwd));
181
+ const projectLegacyName = (name) => isLegacyAllowedName(name) && isProjectEnvName2(name);
182
+ for (const file of legacyClaudeEnvFiles(cwd, home)) {
183
+ if (projectLegacy.has(file)) loadFile(file, "project", projectLegacyName);
184
+ else loadFile(file, "legacy", isLegacyAllowedName);
185
+ }
186
+ }
187
+ }
188
+ function isTruthy(value) {
189
+ return value !== void 0 && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
190
+ }
191
+ var ALLOW_PROJECT_BASE_URL_ENV = "MULTIX_ALLOW_PROJECT_BASE_URL";
192
+ var OPT_IN_HINT = `To use the project value for one command, run it as \`${ALLOW_PROJECT_BASE_URL_ENV}=1 multix ...\`. Do not put ${ALLOW_PROJECT_BASE_URL_ENV} in ~/.multix/.env: that trusts the .env of every directory you run multix in.`;
193
+ function warnOnce(key, message) {
194
+ if (warned.has(key)) return;
195
+ warned.add(key);
196
+ console.warn(`\u26A0 ${message}`);
197
+ }
198
+ function resolveKey(name) {
199
+ return process.env[name] || void 0;
200
+ }
201
+
202
+ // src/core/http-client.ts
203
+ import fs2 from "fs";
204
+ import path2 from "path";
205
+ import { Readable } from "stream";
206
+ import { pipeline } from "stream/promises";
207
+
208
+ // src/core/fetch-error.ts
209
+ var MAX_CAUSE_DEPTH = 4;
210
+ var TLS_CODES = /* @__PURE__ */ new Set([
211
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
212
+ "SELF_SIGNED_CERT_IN_CHAIN",
213
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
214
+ "ERR_TLS_CERT_ALTNAME_INVALID"
215
+ ]);
216
+ function kindOfCode(code) {
217
+ if (typeof code !== "string") return void 0;
218
+ if (code === "ERR_INVALID_URL") return "url";
219
+ if (code === "ERR_INVALID_CHAR" || code === "ERR_INVALID_HTTP_TOKEN") return "header";
220
+ if (TLS_CODES.has(code) || code.startsWith("CERT_") || code.startsWith("UNABLE_TO_")) {
221
+ return "tls";
222
+ }
223
+ if (code === "UND_ERR_INVALID_ARG" || code === "UND_ERR_NOT_SUPPORTED") return "request";
224
+ return void 0;
225
+ }
226
+ function kindOfMessage(message) {
227
+ if (typeof message !== "string") return void 0;
228
+ if (/Invalid URL|Failed to parse URL/i.test(message)) return "url";
229
+ if (/ByteString|is not a legal HTTP header value|invalid header value|invalid header name/i.test(
230
+ message
231
+ )) {
232
+ return "header";
233
+ }
234
+ if (/duplex option is required|cannot have body|unexpected redirect/i.test(message)) {
235
+ return "request";
236
+ }
237
+ return void 0;
238
+ }
239
+ function causeChain(err2) {
240
+ const out = [];
241
+ let current = err2;
242
+ for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current && typeof current === "object"; depth++) {
243
+ const e = current;
244
+ out.push(e);
245
+ if (Array.isArray(e.errors)) {
246
+ for (const member of e.errors) if (member && typeof member === "object") out.push(member);
247
+ }
248
+ current = e.cause;
249
+ }
250
+ return out;
251
+ }
252
+ function deterministicKind(err2) {
253
+ for (const e of causeChain(err2)) {
254
+ const kind = kindOfCode(e.code) ?? kindOfMessage(e.message);
255
+ if (kind) return kind;
256
+ }
257
+ return void 0;
258
+ }
259
+ function scrub(message) {
260
+ return message.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, (u) => redactUrl(u)).replace(/[?#](?!\[redacted\])[^\s"'<>]*=[^\s"'<>]*/g, "?[redacted]");
261
+ }
262
+ function messageOf(e) {
263
+ return typeof e?.message === "string" ? scrub(e.message) : "";
264
+ }
265
+ function detailOf(err2) {
266
+ const chain = causeChain(err2);
267
+ const parts = [messageOf(chain[0]), messageOf(chain[1])].filter(Boolean);
268
+ return parts.length > 0 ? parts.join(": ") : scrub(String(err2));
269
+ }
270
+ function toFetchError(err2, url, opts = {}) {
271
+ const options = { cause: err2 };
272
+ const prefix = opts.prefix ?? "";
273
+ const target = opts.target ?? redactUrl(url);
274
+ switch (deterministicKind(err2)) {
275
+ case "url":
276
+ return new ConfigError(`${prefix}Invalid request URL: ${target}`, options);
277
+ case "header":
278
+ return new ConfigError(
279
+ `${prefix}Invalid HTTP header value in the request to ${target}. Check the API key and other header values for non-ASCII characters such as smart quotes, or line breaks.`,
280
+ options
281
+ );
282
+ case "tls":
283
+ case "request":
284
+ return new ProviderError(
285
+ `${prefix}Request to ${target} failed: ${detailOf(err2)}`,
286
+ opts.provider ?? "http",
287
+ options
288
+ );
289
+ default:
290
+ return new HttpError(0, `${prefix}${detailOf(err2)}`, url, void 0, options);
291
+ }
292
+ }
293
+
294
+ // src/core/redirect-fetch.ts
295
+ var MAX_REDIRECTS = 5;
296
+ var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
297
+ var BODY_HEADERS = ["content-type", "content-length", "content-encoding", "content-language"];
298
+ function withoutBodyHeaders(headers) {
299
+ if (headers === void 0) return void 0;
300
+ const copy = new Headers(headers);
301
+ for (const name of BODY_HEADERS) copy.delete(name);
302
+ return copy;
303
+ }
304
+ async function fetchFollowingRedirects(url, init = {}, opts = {}) {
305
+ const doFetch = opts.fetch ?? ((input, i) => globalThis.fetch(input, i));
306
+ const { validateUrl } = opts;
307
+ const maxRedirects = opts.maxRedirects ?? MAX_REDIRECTS;
308
+ let current = url;
309
+ let hopInit = { ...init, redirect: "manual" };
310
+ for (let hop = 0; ; hop++) {
311
+ await validateUrl?.(current);
312
+ let response;
313
+ try {
314
+ response = await doFetch(current, hopInit);
315
+ } catch (cause) {
316
+ if (!opts.wrapNetworkErrors) throw cause;
317
+ throw toFetchError(cause, current);
318
+ }
319
+ if (validateUrl && response.url && response.url !== current) {
320
+ try {
321
+ await validateUrl(response.url);
322
+ } catch (err2) {
323
+ await response.body?.cancel().catch(() => {
324
+ });
325
+ throw err2;
326
+ }
327
+ }
328
+ const location = response.headers.get("location");
329
+ if (!REDIRECT_STATUSES.has(response.status) || !location) {
330
+ return { response, url: current };
331
+ }
332
+ let next;
333
+ try {
334
+ next = new URL(location, current);
335
+ } catch {
336
+ }
337
+ const crossOrigin = next !== void 0 && next.origin !== new URL(current).origin;
338
+ if (crossOrigin && opts.crossOrigin === "stop") return { response, url: current };
339
+ await response.body?.cancel().catch(() => {
340
+ });
341
+ if (hop >= maxRedirects) {
342
+ throw new HttpError(response.status, `too many redirects (over ${maxRedirects})`, current);
343
+ }
344
+ if (!next) {
345
+ throw new HttpError(response.status, "redirect with a malformed Location header", current);
346
+ }
347
+ if (crossOrigin) {
348
+ if (opts.crossOrigin === "error") {
349
+ throw new HttpError(
350
+ response.status,
351
+ `refusing to follow a redirect to another origin (${next.origin})`,
352
+ current
353
+ );
354
+ }
355
+ hopInit = { ...hopInit, headers: void 0 };
356
+ }
357
+ const method = (hopInit.method ?? "GET").toUpperCase();
358
+ const toGet = response.status === 303 && method !== "GET" && method !== "HEAD" || (response.status === 301 || response.status === 302) && method === "POST";
359
+ if (toGet) {
360
+ hopInit = {
361
+ ...hopInit,
362
+ method: "GET",
363
+ body: void 0,
364
+ headers: withoutBodyHeaders(hopInit.headers)
365
+ };
366
+ }
367
+ current = next.toString();
368
+ }
369
+ }
370
+ async function fetchWithValidatedRedirects(url, opts = {}) {
371
+ const { signal, headers, validateUrl } = opts;
372
+ const init = { ...signal ? { signal } : {}, ...headers ? { headers } : {} };
373
+ if (!validateUrl && !headers) {
374
+ const doFetch = opts.fetch ?? ((input, i) => globalThis.fetch(input, i));
375
+ try {
376
+ return { response: await doFetch(url, init), url };
377
+ } catch (cause) {
378
+ throw toFetchError(cause, url);
379
+ }
380
+ }
381
+ return fetchFollowingRedirects(url, init, {
382
+ validateUrl,
383
+ maxRedirects: opts.maxRedirects,
384
+ fetch: opts.fetch,
385
+ wrapNetworkErrors: true
386
+ });
387
+ }
388
+
389
+ // src/core/fetch-json.ts
390
+ var DEFAULT_TIMEOUT_MS = 12e4;
391
+ var SNIPPET_MAX = 500;
392
+ function parseRetryAfter(value, now = Date.now()) {
393
+ if (!value) return void 0;
394
+ const trimmed = value.trim();
395
+ if (/^\d+(\.\d+)?$/.test(trimmed)) return Math.round(Number(trimmed) * 1e3);
396
+ const date = Date.parse(trimmed);
397
+ if (Number.isNaN(date)) return void 0;
398
+ return Math.max(0, date - now);
399
+ }
400
+ async function httpErrorFromResponse(response, url) {
401
+ let snippet = "";
402
+ try {
403
+ const text = await response.text();
404
+ snippet = text.slice(0, SNIPPET_MAX);
405
+ } catch {
406
+ snippet = "(could not read body)";
407
+ }
408
+ return new HttpError(
409
+ response.status,
410
+ snippet,
411
+ url,
412
+ parseRetryAfter(response.headers.get("retry-after"))
413
+ );
414
+ }
415
+ var NON_CREDENTIAL_HEADERS = /* @__PURE__ */ new Set(["accept", "accept-language", "content-type", "user-agent"]);
416
+ function carriesCredentialHeaders(headers) {
417
+ return Object.keys(headers).some((name) => !NON_CREDENTIAL_HEADERS.has(name.toLowerCase()));
418
+ }
419
+ async function fetchJson(opts) {
420
+ const { url, method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
421
+ const doFetch = opts.fetch ?? ((input, init2) => globalThis.fetch(input, init2));
422
+ const init = {
423
+ method,
424
+ headers: {
425
+ "Content-Type": "application/json",
426
+ ...headers
427
+ },
428
+ signal: AbortSignal.timeout(timeoutMs)
429
+ };
430
+ if (body !== void 0) {
431
+ init.body = JSON.stringify(body);
432
+ }
433
+ const { response } = await fetchFollowingRedirects(url, init, {
434
+ fetch: doFetch,
435
+ crossOrigin: carriesCredentialHeaders(headers) ? "error" : "drop-headers",
436
+ wrapNetworkErrors: true
437
+ });
438
+ if (!response.ok) {
439
+ throw await httpErrorFromResponse(response, url);
440
+ }
441
+ let text;
442
+ try {
443
+ text = await response.text();
444
+ } catch (cause) {
445
+ const msg = cause instanceof Error ? cause.message : String(cause);
446
+ throw new HttpError(0, `Failed to read response body: ${msg}`, url);
447
+ }
448
+ if (text.trim() === "") return void 0;
449
+ try {
450
+ return JSON.parse(text);
451
+ } catch {
452
+ throw new HttpError(
453
+ response.status,
454
+ `Invalid JSON response: ${text.slice(0, SNIPPET_MAX)}`,
455
+ url
456
+ );
457
+ }
458
+ }
459
+
460
+ // src/core/http-client.ts
461
+ async function httpJson(opts) {
462
+ return fetchJson(opts);
463
+ }
464
+ var MAX_DOWNLOAD_ENV = "MULTIX_MAX_DOWNLOAD_MB";
465
+ function formatMb(bytes) {
466
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
467
+ }
468
+ var DownloadTooLargeError = class extends MultixError {
469
+ constructor(limitBytes, url) {
470
+ super(
471
+ `Download from ${redactUrl(url)} exceeds the ${formatMb(limitBytes)} limit (set ${MAX_DOWNLOAD_ENV} to change it)`,
472
+ "DOWNLOAD_TOO_LARGE"
473
+ );
474
+ this.limitBytes = limitBytes;
475
+ this.name = "DownloadTooLargeError";
476
+ }
477
+ limitBytes;
478
+ };
479
+ function removeQuietly(file) {
480
+ try {
481
+ fs2.rmSync(file, { force: true });
482
+ } catch {
483
+ }
484
+ }
485
+ function replaceFile(from, to) {
486
+ try {
487
+ fs2.renameSync(from, to);
488
+ } catch (err2) {
489
+ const code = err2.code;
490
+ const existing = fs2.statSync(to, { throwIfNoEntry: false });
491
+ if (!existing?.isFile() || code !== "EEXIST" && code !== "EPERM" && code !== "EACCES") {
492
+ throw err2;
493
+ }
494
+ fs2.rmSync(to, { force: true });
495
+ fs2.renameSync(from, to);
496
+ }
497
+ }
498
+ async function downloadToFile(url, opts) {
499
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
500
+ const { response, url: finalUrl } = await fetchWithValidatedRedirects(url, {
501
+ signal,
502
+ headers: opts.headers,
503
+ validateUrl: opts.validateUrl,
504
+ fetch: opts.fetch
505
+ });
506
+ if (!response.ok) {
507
+ await response.body?.cancel().catch(() => {
508
+ });
509
+ throw new HttpError(response.status, "(download failed)", finalUrl);
510
+ }
511
+ if (!response.body) {
512
+ throw new HttpError(0, "Response body is null", finalUrl);
513
+ }
514
+ const body = response.body;
515
+ const { maxBytes } = opts;
516
+ const declared = Number(response.headers.get("content-length") ?? Number.NaN);
517
+ if (maxBytes !== void 0 && Number.isFinite(declared) && declared > maxBytes) {
518
+ await body.cancel().catch(() => {
519
+ });
520
+ throw new DownloadTooLargeError(maxBytes, finalUrl);
521
+ }
522
+ let dest;
523
+ try {
524
+ dest = typeof opts.dest === "function" ? opts.dest(response) : opts.dest;
525
+ fs2.mkdirSync(path2.dirname(dest), { recursive: true });
526
+ } catch (err2) {
527
+ await body.cancel().catch(() => {
528
+ });
529
+ throw err2;
530
+ }
531
+ let bytes = 0;
532
+ async function* countBytes(source) {
533
+ for await (const chunk of source) {
534
+ bytes += chunk.byteLength;
535
+ if (maxBytes !== void 0 && bytes > maxBytes)
536
+ throw new DownloadTooLargeError(maxBytes, finalUrl);
537
+ yield chunk;
538
+ }
539
+ }
540
+ const part = `${dest}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.part`;
541
+ let fileStream;
542
+ try {
543
+ fileStream = fs2.createWriteStream(part);
544
+ await pipeline(
545
+ Readable.fromWeb(body),
546
+ countBytes,
547
+ fileStream
548
+ );
549
+ replaceFile(part, dest);
550
+ } catch (err2) {
551
+ if (fileStream && !fileStream.closed) {
552
+ await new Promise((resolve) => fileStream?.once("close", () => resolve()));
553
+ }
554
+ removeQuietly(part);
555
+ throw err2;
556
+ }
557
+ return { dest, contentType: response.headers.get("content-type") ?? void 0, bytes };
558
+ }
559
+ async function downloadFile(url, dest, timeoutMs = DEFAULT_TIMEOUT_MS, headers) {
560
+ const result = await downloadToFile(url, { dest, timeoutMs, headers });
561
+ return result.contentType;
562
+ }
563
+ async function fetchBytes(urlOrDataUri) {
564
+ if (urlOrDataUri.startsWith("data:")) {
565
+ const commaIdx = urlOrDataUri.indexOf(",");
566
+ if (commaIdx === -1) throw new Error("Malformed data URI: missing comma");
567
+ const encoded = urlOrDataUri.slice(commaIdx + 1);
568
+ return Buffer.from(encoded, "base64");
569
+ }
570
+ let response;
571
+ try {
572
+ response = await globalThis.fetch(urlOrDataUri, {
573
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
574
+ });
575
+ } catch (cause) {
576
+ throw toFetchError(cause, urlOrDataUri);
577
+ }
578
+ if (!response.ok) {
579
+ throw new HttpError(response.status, "(bytes fetch failed)", urlOrDataUri);
580
+ }
581
+ return Buffer.from(await response.arrayBuffer());
582
+ }
583
+
584
+ // src/providers/atlascloud/commands/env.ts
585
+ import { openAsBlob } from "fs";
586
+ import fs3 from "fs/promises";
587
+ import path3 from "path";
588
+
589
+ // src/core/logger.ts
590
+ var ANSI = {
591
+ reset: "\x1B[0m",
592
+ bold: "\x1B[1m",
593
+ green: "\x1B[32m",
594
+ yellow: "\x1B[33m",
595
+ red: "\x1B[31m",
596
+ blue: "\x1B[34m",
597
+ cyan: "\x1B[36m",
598
+ dim: "\x1B[2m"
599
+ };
600
+ function c(color, text) {
601
+ if (process.env.NO_COLOR || !process.stdout.isTTY) return text;
602
+ return `${color}${text}${ANSI.reset}`;
603
+ }
604
+ function createLogger(opts) {
605
+ const { verbose } = opts;
606
+ return {
607
+ info(msg) {
608
+ console.log(c(ANSI.blue, ` ${msg}`));
609
+ },
610
+ success(msg) {
611
+ console.log(c(ANSI.green, `\u2713 ${msg}`));
612
+ },
613
+ warn(msg) {
614
+ console.warn(c(ANSI.yellow, `\u26A0 ${msg}`));
615
+ },
616
+ error(msg) {
617
+ console.error(c(ANSI.red, `\u2717 ${msg}`));
618
+ },
619
+ debug(msg) {
620
+ if (verbose) console.log(c(ANSI.dim, ` [debug] ${msg}`));
621
+ },
622
+ header(msg) {
623
+ const line = "=".repeat(60);
624
+ console.log(c(ANSI.bold + ANSI.cyan, `
625
+ ${line}
626
+ ${msg}
627
+ ${line}
628
+ `));
629
+ }
630
+ };
631
+ }
632
+ var defaultLogger = createLogger({ verbose: false });
633
+
634
+ // src/core/async-poll.ts
635
+ var PollTimeoutError = class extends MultixError {
636
+ constructor(message, lastStatus) {
637
+ super(message, "POLL_TIMEOUT");
638
+ this.lastStatus = lastStatus;
639
+ this.name = "PollTimeoutError";
640
+ }
641
+ lastStatus;
642
+ };
643
+ function isTransientHttpError(error) {
644
+ if (!(error instanceof HttpError)) return false;
645
+ const { status } = error;
646
+ return status === 408 || status === 429 || isRetryableServerStatus(status);
647
+ }
648
+ function isRetryableServerStatus(status) {
649
+ if (status === 501 || status === 505) return false;
650
+ return status === 0 || status >= 500;
651
+ }
652
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
653
+ async function pollUntil(opts) {
654
+ const now = opts.now ?? Date.now;
655
+ const sleep = opts.sleep ?? defaultSleep;
656
+ const random = opts.random ?? Math.random;
657
+ const isTransient = opts.isTransientError ?? isTransientHttpError;
658
+ const maxInterval = opts.maxIntervalMs ?? 15e3;
659
+ const minInterval = opts.minIntervalMs ?? 500;
660
+ const factor = opts.backoffFactor ?? 1.5;
661
+ const jitter = opts.jitter ?? true;
662
+ const deadline = now() + opts.timeoutMs;
663
+ let interval = opts.intervalMs ?? 3e3;
664
+ let attempt = 0;
665
+ let last;
666
+ let lastError;
667
+ for (; ; ) {
668
+ attempt++;
669
+ let retryAfterMs;
670
+ try {
671
+ last = await opts.fetchStatus({ remainingMs: Math.max(1, deadline - now()), attempt });
672
+ lastError = void 0;
673
+ opts.onPoll?.(last, attempt);
674
+ if (opts.isTerminal(last)) return last;
675
+ } catch (error) {
676
+ if (!isTransient(error)) throw error;
677
+ lastError = error;
678
+ if (error instanceof HttpError) retryAfterMs = error.retryAfterMs;
679
+ }
680
+ const remaining = deadline - now();
681
+ if (remaining <= 0) {
682
+ const hint = opts.resumeHint ? ` ${opts.resumeHint}` : "";
683
+ const cause = lastError instanceof Error ? ` Last error: ${lastError.message}.` : "";
684
+ throw new PollTimeoutError(
685
+ `Timed out after ${opts.timeoutMs}ms waiting for job.${cause}${hint}`,
686
+ last
687
+ );
688
+ }
689
+ let delay = retryAfterMs ?? interval;
690
+ if (jitter && retryAfterMs === void 0) delay *= 0.8 + random() * 0.4;
691
+ await sleep(Math.min(Math.max(delay, minInterval), remaining));
692
+ interval = Math.min(interval * factor, maxInterval);
693
+ }
694
+ }
695
+
696
+ // src/providers/atlascloud/models.ts
697
+ var DEFAULT_ATLAS_BASE_URL = "https://api.atlascloud.ai/api/v1";
698
+ var ATLAS_ROUTING = {
699
+ name: "ATLASCLOUD_BASE_URL",
700
+ defaultUrl: DEFAULT_ATLAS_BASE_URL,
701
+ keyEnvs: ["ATLASCLOUD_API_KEY"]
702
+ };
703
+
704
+ // src/providers/atlascloud/prediction-output.ts
705
+ var EXT_CATEGORY = {
706
+ png: "image",
707
+ jpg: "image",
708
+ jpeg: "image",
709
+ webp: "image",
710
+ gif: "image",
711
+ bmp: "image",
712
+ avif: "image",
713
+ mp4: "video",
714
+ webm: "video",
715
+ mov: "video",
716
+ mkv: "video",
717
+ avi: "video",
718
+ mp3: "audio",
719
+ wav: "audio",
720
+ aac: "audio",
721
+ m4a: "audio",
722
+ flac: "audio",
723
+ ogg: "audio",
724
+ opus: "audio",
725
+ weba: "audio",
726
+ pcm: "audio",
727
+ glb: "3d",
728
+ gltf: "3d",
729
+ obj: "3d",
730
+ fbx: "3d",
731
+ stl: "3d",
732
+ ply: "3d",
733
+ usdz: "3d",
734
+ usdc: "3d",
735
+ usd: "3d",
736
+ zip: "3d"
737
+ };
738
+ var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([...Object.keys(EXT_CATEGORY), "svg"]);
739
+
740
+ // src/providers/atlascloud/upload-mime.ts
741
+ var UPLOAD_MIME_TYPES = {
742
+ png: "image/png",
743
+ jpg: "image/jpeg",
744
+ jpeg: "image/jpeg",
745
+ webp: "image/webp",
746
+ gif: "image/gif",
747
+ bmp: "image/bmp",
748
+ avif: "image/avif",
749
+ heic: "image/heic",
750
+ mp4: "video/mp4",
751
+ mov: "video/quicktime",
752
+ webm: "video/webm",
753
+ mkv: "video/x-matroska",
754
+ avi: "video/x-msvideo",
755
+ mp3: "audio/mpeg",
756
+ wav: "audio/wav",
757
+ m4a: "audio/mp4",
758
+ aac: "audio/aac",
759
+ ogg: "audio/ogg",
760
+ flac: "audio/flac",
761
+ opus: "audio/opus"
762
+ };
763
+ var UPLOAD_EXTENSIONS = Object.keys(UPLOAD_MIME_TYPES);
764
+
765
+ // src/providers/atlascloud/commands/env.ts
766
+ var ATLAS_MODEL_ENV = {
767
+ image: "ATLASCLOUD_IMAGE_MODEL",
768
+ imageEdit: "ATLASCLOUD_IMAGE_EDIT_MODEL",
769
+ video: "ATLASCLOUD_VIDEO_MODEL",
770
+ imageToVideo: "ATLASCLOUD_IMAGE_TO_VIDEO_MODEL",
771
+ tts: "ATLASCLOUD_TTS_MODEL",
772
+ stt: "ATLASCLOUD_STT_MODEL",
773
+ textTo3d: "ATLASCLOUD_TEXT_TO_3D_MODEL",
774
+ imageTo3d: "ATLASCLOUD_IMAGE_TO_3D_MODEL"
775
+ };
776
+ var MAX_UPLOAD_ENV = "ATLASCLOUD_MAX_UPLOAD_MB";
777
+
778
+ // src/providers/byteplus/models.ts
779
+ var DEFAULT_BYTEPLUS_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3";
780
+ var BYTEPLUS_ROUTING = {
781
+ name: "BYTEPLUS_BASE_URL",
782
+ defaultUrl: DEFAULT_BYTEPLUS_BASE_URL,
783
+ keyEnvs: ["BYTEPLUS_API_KEY", "ARK_API_KEY"]
784
+ };
785
+
786
+ // src/providers/cloudflare/models.ts
787
+ var CLOUDFLARE_ACCOUNT_ID_ENV = "CLOUDFLARE_ACCOUNT_ID";
788
+ var CLOUDFLARE_GATEWAY_ID_ENV = "CLOUDFLARE_AI_GATEWAY_ID";
789
+ var CLOUDFLARE_TOKEN_ENV = "CLOUDFLARE_API_TOKEN";
790
+ var GATEWAY_REPLICATE_TOKEN_ENV = "REPLICATE_API_TOKEN";
791
+ var CLOUDFLARE_VIDEO_CREDENTIALS = [
792
+ CLOUDFLARE_TOKEN_ENV,
793
+ GATEWAY_REPLICATE_TOKEN_ENV
794
+ ];
795
+
796
+ // src/providers/fal/models.ts
797
+ var DEFAULT_FAL_BASE_URL = "https://queue.fal.run";
798
+ var FAL_ROUTING = {
799
+ name: "FAL_BASE_URL",
800
+ defaultUrl: DEFAULT_FAL_BASE_URL,
801
+ keyEnvs: ["FAL_KEY"]
802
+ };
803
+
804
+ // src/providers/gemini/local-files.ts
805
+ import fs4 from "fs";
806
+ import path5 from "path";
807
+
808
+ // src/providers/gemini/client.ts
809
+ var BASE = "https://generativelanguage.googleapis.com";
810
+ var FILES_BASE = `${BASE}/upload/v1beta/files`;
811
+ var GENERATE_BASE = `${BASE}/v1beta/models`;
812
+
813
+ // src/providers/gemini/task-resolver.ts
814
+ import path4 from "path";
815
+
816
+ // src/providers/gemini/local-files.ts
817
+ var INLINE_MAX_BYTES = 20 * 1024 * 1024;
818
+ var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
819
+
820
+ // src/providers/heygen/models.ts
821
+ var DEFAULT_HEYGEN_BASE_URL = "https://api.heygen.com";
822
+ var HEYGEN_ROUTING = {
823
+ name: "HEYGEN_BASE_URL",
824
+ defaultUrl: DEFAULT_HEYGEN_BASE_URL,
825
+ keyEnvs: ["HEYGEN_API_KEY"]
826
+ };
827
+ var LIMITS = {
828
+ agentPromptMax: 1e4,
829
+ agentFilesMax: 20,
830
+ scriptMax: 5e3,
831
+ speechTextMax: 5e3,
832
+ /** POST /v3/assets single-request upload cap (32 MB). */
833
+ assetUploadMaxBytes: 32 * 1024 * 1024,
834
+ modelVideoPromptMax: 32e3,
835
+ modelVideoMinDuration: 5,
836
+ modelVideoMaxDuration: 15,
837
+ modelVideoReferenceImagesMax: 9,
838
+ modelVideoReferenceVideosMax: 3,
839
+ modelVideoReferenceAudioMax: 3,
840
+ /** Combined cap across reference images, videos and audio. */
841
+ modelVideoReferenceTotalMax: 12,
842
+ avatarListMax: 50,
843
+ voiceListMax: 100,
844
+ stylesListMax: 100
845
+ };
846
+
847
+ // src/providers/leonardo/models.ts
848
+ var DEFAULT_LEONARDO_BASE_URL = "https://cloud.leonardo.ai/api/rest/v1";
849
+ var LEONARDO_ROUTING = {
850
+ name: "LEONARDO_BASE_URL",
851
+ defaultUrl: DEFAULT_LEONARDO_BASE_URL,
852
+ keyEnvs: ["LEONARDO_API_KEY"]
853
+ };
854
+
855
+ // src/providers/replicate/models.ts
856
+ var DEFAULT_REPLICATE_BASE_URL = "https://api.replicate.com/v1";
857
+ var REPLICATE_ROUTING = {
858
+ name: "REPLICATE_BASE_URL",
859
+ defaultUrl: DEFAULT_REPLICATE_BASE_URL,
860
+ keyEnvs: ["REPLICATE_API_TOKEN", "REPLICATE_API_KEY"]
861
+ };
862
+
863
+ // src/providers/provider-keys.ts
864
+ function keyNames(routing) {
865
+ const [envPrimary, envFallback] = routing.keyEnvs;
866
+ return envFallback ? { envPrimary, envFallback } : { envPrimary };
867
+ }
868
+ var PROVIDER_KEYS = [
869
+ {
870
+ name: "Gemini",
871
+ envPrimary: "GEMINI_API_KEY",
872
+ optional: false,
873
+ link: "https://aistudio.google.com/apikey"
874
+ },
875
+ {
876
+ name: "OpenRouter",
877
+ envPrimary: "OPENROUTER_API_KEY",
878
+ optional: true,
879
+ link: "https://openrouter.ai/settings/keys"
880
+ },
881
+ {
882
+ name: "OpenAI",
883
+ envPrimary: "OPENAI_API_KEY",
884
+ optional: true,
885
+ link: "https://platform.openai.com/api-keys"
886
+ },
887
+ {
888
+ name: "MiniMax",
889
+ envPrimary: "MINIMAX_API_KEY",
890
+ optional: true,
891
+ link: "https://platform.minimax.io/user-center/basic-information/interface-key"
892
+ },
893
+ {
894
+ name: "Leonardo.Ai",
895
+ ...keyNames(LEONARDO_ROUTING),
896
+ optional: true,
897
+ link: "https://app.leonardo.ai/settings/api-keys"
898
+ },
899
+ {
900
+ name: "BytePlus",
901
+ ...keyNames(BYTEPLUS_ROUTING),
902
+ optional: true,
903
+ link: "https://console.byteplus.com/auth/api-keys"
904
+ },
905
+ {
906
+ name: "ElevenLabs",
907
+ envPrimary: "ELEVENLABS_API_KEY",
908
+ optional: true,
909
+ link: "https://elevenlabs.io/app/settings/api-keys"
910
+ },
911
+ {
912
+ name: "fal.ai",
913
+ ...keyNames(FAL_ROUTING),
914
+ optional: true,
915
+ link: "https://fal.ai/dashboard/keys"
916
+ },
917
+ {
918
+ name: "Atlas Cloud",
919
+ ...keyNames(ATLAS_ROUTING),
920
+ optional: true,
921
+ link: "https://www.atlascloud.ai/console/api-keys"
922
+ },
923
+ {
924
+ name: "Replicate",
925
+ ...keyNames(REPLICATE_ROUTING),
926
+ optional: true,
927
+ link: "https://replicate.com/account/api-tokens"
928
+ },
929
+ {
930
+ name: "HeyGen",
931
+ ...keyNames(HEYGEN_ROUTING),
932
+ optional: true,
933
+ link: "https://app.heygen.com/developers/api"
934
+ }
935
+ ];
936
+
937
+ // src/project-env-names.ts
938
+ var PROJECT_ENV_EXCLUDED = /* @__PURE__ */ new Set([
939
+ ALLOW_PROJECT_BASE_URL_ENV,
940
+ "MULTIX_LEGACY_CLAUDE_ENV"
941
+ ]);
942
+ var OWN_SETTINGS = [
943
+ "MULTIX_OUTPUT_DIR",
944
+ "MULTIX_IMAGE_FORMAT",
945
+ MAX_DOWNLOAD_ENV,
946
+ UPLOAD_MIN_KBPS_ENV,
947
+ // Read before the project file loads, so setting it there has no effect.
948
+ "MULTIX_DISABLE_HOME_ENV"
949
+ ];
950
+ var INLINE_SETTINGS = [
951
+ "GEMINI_MODEL",
952
+ "MULTIMODAL_MODEL",
953
+ "IMAGE_GEN_MODEL",
954
+ "GEMINI_IMAGE_GEN_MODEL",
955
+ "VIDEO_GEN_MODEL",
956
+ "GEMINI_TTS_MODEL",
957
+ "TTS_MODEL",
958
+ "OPENAI_IMAGE_MODEL",
959
+ "OPENAI_TTS_MODEL",
960
+ "OPENAI_STT_MODEL",
961
+ "OPENROUTER_IMAGE_MODEL",
962
+ "OPENROUTER_VIDEO_MODEL",
963
+ "OPENROUTER_FALLBACK_MODELS",
964
+ "OPENROUTER_SITE_URL",
965
+ "OPENROUTER_APP_NAME",
966
+ "MINIMAX_IMAGE_MODEL",
967
+ "MINIMAX_VIDEO_MODEL",
968
+ "MINIMAX_SPEECH_MODEL",
969
+ "MINIMAX_MUSIC_MODEL",
970
+ "LEONARDO_DEFAULT_MODEL",
971
+ "LEONARDO_VIDEO_MODEL",
972
+ "BYTEPLUS_IMAGE_MODEL",
973
+ "BYTEPLUS_VIDEO_MODEL",
974
+ "BYTEPLUS_3D_MODEL",
975
+ "BYTEPLUS_VIDEO_PARAMS_MODE",
976
+ "FAL_IMAGE_MODEL",
977
+ "FAL_VIDEO_MODEL",
978
+ "FAL_VIDEO_IMAGE_MODEL",
979
+ "REPLICATE_IMAGE_MODEL",
980
+ "REPLICATE_VIDEO_MODEL",
981
+ "CLOUDFLARE_AI_IMAGE_MODEL",
982
+ "CLOUDFLARE_AI_TTS_MODEL",
983
+ // Read in providers/cloudflare/client.ts (collectPayloadHeader).
984
+ "CLOUDFLARE_AI_GATEWAY_COLLECT_LOG_PAYLOAD",
985
+ "NO_COLOR"
986
+ ];
987
+ var ROUTING = [
988
+ ATLAS_ROUTING,
989
+ BYTEPLUS_ROUTING,
990
+ FAL_ROUTING,
991
+ HEYGEN_ROUTING,
992
+ LEONARDO_ROUTING,
993
+ REPLICATE_ROUTING
994
+ ];
995
+ var PROJECT_ENV_NAMES = /* @__PURE__ */ new Set([
996
+ ...PROVIDER_KEYS.flatMap((p) => p.envFallback ? [p.envPrimary, p.envFallback] : [p.envPrimary]),
997
+ ...ROUTING.flatMap((r) => [r.name, ...r.keyEnvs]),
998
+ CLOUDFLARE_ACCOUNT_ID_ENV,
999
+ CLOUDFLARE_GATEWAY_ID_ENV,
1000
+ ...CLOUDFLARE_VIDEO_CREDENTIALS,
1001
+ ...Object.values(ATLAS_MODEL_ENV),
1002
+ MAX_UPLOAD_ENV,
1003
+ ...OWN_SETTINGS,
1004
+ ...INLINE_SETTINGS
1005
+ ]);
1006
+ var GEMINI_NUMBERED_KEY = /^GEMINI_API_KEY_\d+$/;
1007
+ function isProjectEnvName(name, platform = process.platform) {
1008
+ const key = envNameKey(name, platform);
1009
+ return PROJECT_ENV_NAMES.has(key) || GEMINI_NUMBERED_KEY.test(key);
1010
+ }
1011
+
1012
+ // src/core/result.ts
1013
+ function ok(data) {
1014
+ return { status: "success", ...data };
1015
+ }
1016
+ function err(message) {
1017
+ return { status: "error", error: message };
1018
+ }
1019
+ function isOk(result) {
1020
+ return result.status === "success";
1021
+ }
1022
+ function isErr(result) {
1023
+ return result.status === "error";
1024
+ }
1025
+
1026
+ // src/core/output-dir.ts
1027
+ import fs5 from "fs";
1028
+ import path6 from "path";
1029
+ var cachedOutputDir;
1030
+ function getOutputDir() {
1031
+ if (cachedOutputDir) return cachedOutputDir;
1032
+ const dir = process.env.MULTIX_OUTPUT_DIR ? path6.resolve(process.env.MULTIX_OUTPUT_DIR) : path6.resolve(process.cwd(), "multix-output");
1033
+ fs5.mkdirSync(dir, { recursive: true });
1034
+ cachedOutputDir = dir;
1035
+ return dir;
1036
+ }
1037
+
1038
+ // src/core/json-input.ts
1039
+ import fs6 from "fs";
1040
+ function parseJsonInput(raw, flag = "--input") {
1041
+ let text;
1042
+ if (raw.startsWith("@")) {
1043
+ try {
1044
+ text = fs6.readFileSync(raw.slice(1), "utf8");
1045
+ } catch (e) {
1046
+ const msg = e instanceof Error ? e.message : String(e);
1047
+ throw new ValidationError(`Cannot read ${flag} file: ${msg}`);
1048
+ }
1049
+ } else {
1050
+ text = raw;
1051
+ }
1052
+ try {
1053
+ const parsed = JSON.parse(text);
1054
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1055
+ throw new Error("input must be a JSON object");
1056
+ }
1057
+ return parsed;
1058
+ } catch (e) {
1059
+ const msg = e instanceof Error ? e.message : String(e);
1060
+ throw new ValidationError(`Invalid ${flag} JSON: ${msg}`);
1061
+ }
1062
+ }
1063
+
1064
+ // src/core/video-thumb.ts
1065
+ import fs7 from "fs";
1066
+ import path7 from "path";
1067
+ var THUMB_KEYS = /* @__PURE__ */ new Set([
1068
+ "cover_image_url",
1069
+ "coverImageUrl",
1070
+ "cover_url",
1071
+ "coverUrl",
1072
+ "thumbnail_url",
1073
+ "thumbnailUrl",
1074
+ "thumb_url",
1075
+ "thumbUrl",
1076
+ "preview_url",
1077
+ "previewUrl",
1078
+ "first_frame_url",
1079
+ "firstFrameUrl",
1080
+ "poster_url",
1081
+ "posterUrl",
1082
+ "image_url",
1083
+ "imageUrl"
1084
+ ]);
1085
+ var IMAGE_EXT_RE = /\.(jpe?g|png|webp|gif|bmp)(\?|$)/i;
1086
+ function isImageUrl(value) {
1087
+ if (typeof value !== "string") return false;
1088
+ if (!/^https?:\/\//i.test(value)) return false;
1089
+ return IMAGE_EXT_RE.test(value);
1090
+ }
1091
+ function detectThumbUrl(obj) {
1092
+ if (obj === null || obj === void 0) return null;
1093
+ if (Array.isArray(obj)) {
1094
+ for (const item of obj) {
1095
+ const found = detectThumbUrl(item);
1096
+ if (found) return found;
1097
+ }
1098
+ return null;
1099
+ }
1100
+ if (typeof obj !== "object") return null;
1101
+ const record = obj;
1102
+ for (const [key, value] of Object.entries(record)) {
1103
+ if (THUMB_KEYS.has(key) && isImageUrl(value)) return value;
1104
+ }
1105
+ for (const value of Object.values(record)) {
1106
+ if (value && typeof value === "object") {
1107
+ const found = detectThumbUrl(value);
1108
+ if (found) return found;
1109
+ }
1110
+ }
1111
+ return null;
1112
+ }
1113
+ function resultDest(result) {
1114
+ const dest = result?.dest;
1115
+ return typeof dest === "string" ? dest : void 0;
1116
+ }
1117
+ async function downloadThumbBeside(thumbUrl, videoPath, logger, download = downloadFile) {
1118
+ const match = thumbUrl.match(IMAGE_EXT_RE);
1119
+ const ext = match?.[1] ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
1120
+ const dir = path7.dirname(videoPath);
1121
+ const base = path7.basename(videoPath, path7.extname(videoPath));
1122
+ const planned = path7.join(dir, `${base}_thumb.${ext}`);
1123
+ try {
1124
+ const dest = resultDest(await download(thumbUrl, planned)) ?? planned;
1125
+ logger?.success(`Saved thumb ${dest}`);
1126
+ return dest;
1127
+ } catch (e) {
1128
+ logger?.warn(`Thumb download failed: ${e instanceof Error ? e.message : String(e)}`);
1129
+ return null;
1130
+ }
1131
+ }
1132
+ async function maybeDownloadThumb(source, videoPath, opts = {}) {
1133
+ if (opts.skip) return null;
1134
+ const url = detectThumbUrl(source);
1135
+ if (!url) {
1136
+ opts.logger?.debug("No thumbnail URL detected in provider response");
1137
+ return null;
1138
+ }
1139
+ const saved = await downloadThumbBeside(url, videoPath, opts.logger, opts.download);
1140
+ if (saved && opts.copyTo) {
1141
+ const copyExt = path7.extname(saved);
1142
+ const copyBase = path7.basename(opts.copyTo, path7.extname(opts.copyTo));
1143
+ const copyDir = path7.dirname(path7.resolve(opts.copyTo));
1144
+ const copyDest = path7.join(copyDir, `${copyBase}_thumb${copyExt}`);
1145
+ fs7.mkdirSync(copyDir, { recursive: true });
1146
+ fs7.copyFileSync(saved, copyDest);
1147
+ opts.logger?.success(`Copied thumb to ${copyDest}`);
1148
+ }
1149
+ return saved;
1150
+ }
1151
+
1152
+ // src/index.ts
1153
+ function loadEnv2(options = { isProjectEnvName }) {
1154
+ loadEnv(options);
1155
+ }
1156
+ export {
1157
+ ConfigError,
1158
+ HttpError,
1159
+ MultixError,
1160
+ PROJECT_ENV_EXCLUDED,
1161
+ PROJECT_ENV_NAMES,
1162
+ PollTimeoutError,
1163
+ ProviderError,
1164
+ ValidationError,
1165
+ createLogger,
1166
+ defaultLogger,
1167
+ detectThumbUrl,
1168
+ downloadFile,
1169
+ downloadThumbBeside,
1170
+ err,
1171
+ fetchBytes,
1172
+ fetchJson,
1173
+ getOutputDir,
1174
+ httpErrorFromResponse,
1175
+ httpJson,
1176
+ isErr,
1177
+ isOk,
1178
+ isProjectEnvName,
1179
+ isTransientHttpError,
1180
+ loadEnv2 as loadEnv,
1181
+ maybeDownloadThumb,
1182
+ ok,
1183
+ parseJsonInput,
1184
+ parseRetryAfter,
1185
+ pollUntil,
1186
+ redact,
1187
+ redactUrl,
1188
+ resolveKey
1189
+ };