@mrgoonie/multix 0.0.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/cli.js +2234 -0
- package/package.json +71 -0
- package/skill/SKILL.md +156 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/core/errors.ts
|
|
13
|
+
var MultixError, ConfigError, ProviderError, ValidationError, HttpError;
|
|
14
|
+
var init_errors = __esm({
|
|
15
|
+
"src/core/errors.ts"() {
|
|
16
|
+
"use strict";
|
|
17
|
+
MultixError = class extends Error {
|
|
18
|
+
constructor(message, code) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.name = "MultixError";
|
|
22
|
+
if (Error.captureStackTrace) {
|
|
23
|
+
Error.captureStackTrace(this, this.constructor);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
code;
|
|
27
|
+
};
|
|
28
|
+
ConfigError = class extends MultixError {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message, "CONFIG_ERROR");
|
|
31
|
+
this.name = "ConfigError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
ProviderError = class extends MultixError {
|
|
35
|
+
constructor(message, provider) {
|
|
36
|
+
super(message, "PROVIDER_ERROR");
|
|
37
|
+
this.provider = provider;
|
|
38
|
+
this.name = "ProviderError";
|
|
39
|
+
}
|
|
40
|
+
provider;
|
|
41
|
+
};
|
|
42
|
+
ValidationError = class extends MultixError {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message, "VALIDATION_ERROR");
|
|
45
|
+
this.name = "ValidationError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
HttpError = class extends MultixError {
|
|
49
|
+
constructor(status, snippet, url) {
|
|
50
|
+
super(`HTTP ${status} from ${url}: ${snippet}`, "HTTP_ERROR");
|
|
51
|
+
this.status = status;
|
|
52
|
+
this.snippet = snippet;
|
|
53
|
+
this.url = url;
|
|
54
|
+
this.name = "HttpError";
|
|
55
|
+
}
|
|
56
|
+
status;
|
|
57
|
+
snippet;
|
|
58
|
+
url;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// src/core/http-client.ts
|
|
64
|
+
var http_client_exports = {};
|
|
65
|
+
__export(http_client_exports, {
|
|
66
|
+
downloadFile: () => downloadFile,
|
|
67
|
+
fetchBytes: () => fetchBytes,
|
|
68
|
+
httpJson: () => httpJson
|
|
69
|
+
});
|
|
70
|
+
import fs2 from "fs";
|
|
71
|
+
import path2 from "path";
|
|
72
|
+
import { pipeline } from "stream/promises";
|
|
73
|
+
async function httpJson(opts) {
|
|
74
|
+
const { url, method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
|
75
|
+
const init = {
|
|
76
|
+
method,
|
|
77
|
+
headers: {
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
...headers
|
|
80
|
+
},
|
|
81
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
82
|
+
};
|
|
83
|
+
if (body !== void 0) {
|
|
84
|
+
init.body = JSON.stringify(body);
|
|
85
|
+
}
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await globalThis.fetch(url, init);
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
91
|
+
throw new HttpError(0, msg, url);
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
let snippet = "";
|
|
95
|
+
try {
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
snippet = text.slice(0, SNIPPET_MAX);
|
|
98
|
+
} catch {
|
|
99
|
+
snippet = "(could not read body)";
|
|
100
|
+
}
|
|
101
|
+
throw new HttpError(response.status, snippet, url);
|
|
102
|
+
}
|
|
103
|
+
return response.json();
|
|
104
|
+
}
|
|
105
|
+
async function downloadFile(url, dest, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
106
|
+
fs2.mkdirSync(path2.dirname(dest), { recursive: true });
|
|
107
|
+
let response;
|
|
108
|
+
try {
|
|
109
|
+
response = await globalThis.fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
112
|
+
throw new HttpError(0, msg, url);
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new HttpError(response.status, "(download failed)", url);
|
|
116
|
+
}
|
|
117
|
+
if (!response.body) {
|
|
118
|
+
throw new HttpError(0, "Response body is null", url);
|
|
119
|
+
}
|
|
120
|
+
const fileStream = fs2.createWriteStream(dest);
|
|
121
|
+
await pipeline(response.body, fileStream);
|
|
122
|
+
}
|
|
123
|
+
async function fetchBytes(urlOrDataUri) {
|
|
124
|
+
if (urlOrDataUri.startsWith("data:")) {
|
|
125
|
+
const commaIdx = urlOrDataUri.indexOf(",");
|
|
126
|
+
if (commaIdx === -1) throw new Error("Malformed data URI: missing comma");
|
|
127
|
+
const encoded = urlOrDataUri.slice(commaIdx + 1);
|
|
128
|
+
return Buffer.from(encoded, "base64");
|
|
129
|
+
}
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await globalThis.fetch(urlOrDataUri, {
|
|
133
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
|
|
134
|
+
});
|
|
135
|
+
} catch (cause) {
|
|
136
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
137
|
+
throw new HttpError(0, msg, urlOrDataUri);
|
|
138
|
+
}
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new HttpError(response.status, "(bytes fetch failed)", urlOrDataUri);
|
|
141
|
+
}
|
|
142
|
+
return Buffer.from(await response.arrayBuffer());
|
|
143
|
+
}
|
|
144
|
+
var DEFAULT_TIMEOUT_MS, SNIPPET_MAX;
|
|
145
|
+
var init_http_client = __esm({
|
|
146
|
+
"src/core/http-client.ts"() {
|
|
147
|
+
"use strict";
|
|
148
|
+
init_errors();
|
|
149
|
+
DEFAULT_TIMEOUT_MS = 12e4;
|
|
150
|
+
SNIPPET_MAX = 500;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// src/cli.ts
|
|
155
|
+
import { createRequire } from "module";
|
|
156
|
+
import { Command } from "commander";
|
|
157
|
+
|
|
158
|
+
// src/providers/gemini/commands/analyze.ts
|
|
159
|
+
init_errors();
|
|
160
|
+
import fs5 from "fs";
|
|
161
|
+
|
|
162
|
+
// src/core/logger.ts
|
|
163
|
+
var ANSI = {
|
|
164
|
+
reset: "\x1B[0m",
|
|
165
|
+
bold: "\x1B[1m",
|
|
166
|
+
green: "\x1B[32m",
|
|
167
|
+
yellow: "\x1B[33m",
|
|
168
|
+
red: "\x1B[31m",
|
|
169
|
+
blue: "\x1B[34m",
|
|
170
|
+
cyan: "\x1B[36m",
|
|
171
|
+
dim: "\x1B[2m"
|
|
172
|
+
};
|
|
173
|
+
function c(color, text) {
|
|
174
|
+
if (process.env.NO_COLOR || !process.stdout.isTTY) return text;
|
|
175
|
+
return `${color}${text}${ANSI.reset}`;
|
|
176
|
+
}
|
|
177
|
+
function createLogger(opts) {
|
|
178
|
+
const { verbose } = opts;
|
|
179
|
+
return {
|
|
180
|
+
info(msg) {
|
|
181
|
+
console.log(c(ANSI.blue, ` ${msg}`));
|
|
182
|
+
},
|
|
183
|
+
success(msg) {
|
|
184
|
+
console.log(c(ANSI.green, `\u2713 ${msg}`));
|
|
185
|
+
},
|
|
186
|
+
warn(msg) {
|
|
187
|
+
console.warn(c(ANSI.yellow, `\u26A0 ${msg}`));
|
|
188
|
+
},
|
|
189
|
+
error(msg) {
|
|
190
|
+
console.error(c(ANSI.red, `\u2717 ${msg}`));
|
|
191
|
+
},
|
|
192
|
+
debug(msg) {
|
|
193
|
+
if (verbose) console.log(c(ANSI.dim, ` [debug] ${msg}`));
|
|
194
|
+
},
|
|
195
|
+
header(msg) {
|
|
196
|
+
const line = "=".repeat(60);
|
|
197
|
+
console.log(c(ANSI.bold + ANSI.cyan, `
|
|
198
|
+
${line}
|
|
199
|
+
${msg}
|
|
200
|
+
${line}
|
|
201
|
+
`));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
var defaultLogger = createLogger({ verbose: false });
|
|
206
|
+
|
|
207
|
+
// src/providers/gemini/client.ts
|
|
208
|
+
import fs3 from "fs";
|
|
209
|
+
import path4 from "path";
|
|
210
|
+
|
|
211
|
+
// src/core/env-loader.ts
|
|
212
|
+
import fs from "fs";
|
|
213
|
+
import os from "os";
|
|
214
|
+
import path from "path";
|
|
215
|
+
import { config as dotenvConfig } from "dotenv";
|
|
216
|
+
var loaded = false;
|
|
217
|
+
function loadEnv() {
|
|
218
|
+
if (loaded) return;
|
|
219
|
+
loaded = true;
|
|
220
|
+
const cwdEnv = path.resolve(process.cwd(), ".env");
|
|
221
|
+
if (fs.existsSync(cwdEnv)) {
|
|
222
|
+
dotenvConfig({ path: cwdEnv, override: false });
|
|
223
|
+
}
|
|
224
|
+
if (!process.env.MULTIX_DISABLE_HOME_ENV) {
|
|
225
|
+
const homeEnv = path.join(os.homedir(), ".multix", ".env");
|
|
226
|
+
if (fs.existsSync(homeEnv)) {
|
|
227
|
+
dotenvConfig({ path: homeEnv, override: false });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function resolveKey(name) {
|
|
232
|
+
return process.env[name] || void 0;
|
|
233
|
+
}
|
|
234
|
+
function redact(key) {
|
|
235
|
+
if (key.length <= 10) return "***";
|
|
236
|
+
return `${key.slice(0, 6)}...${key.slice(-4)}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/providers/gemini/client.ts
|
|
240
|
+
init_errors();
|
|
241
|
+
init_http_client();
|
|
242
|
+
|
|
243
|
+
// src/providers/gemini/task-resolver.ts
|
|
244
|
+
import path3 from "path";
|
|
245
|
+
var MIME_MAP = {
|
|
246
|
+
// Audio
|
|
247
|
+
".mp3": "audio/mp3",
|
|
248
|
+
".wav": "audio/wav",
|
|
249
|
+
".aac": "audio/aac",
|
|
250
|
+
".flac": "audio/flac",
|
|
251
|
+
".ogg": "audio/ogg",
|
|
252
|
+
".aiff": "audio/aiff",
|
|
253
|
+
".m4a": "audio/mp4",
|
|
254
|
+
// Image
|
|
255
|
+
".jpg": "image/jpeg",
|
|
256
|
+
".jpeg": "image/jpeg",
|
|
257
|
+
".png": "image/png",
|
|
258
|
+
".webp": "image/webp",
|
|
259
|
+
".heic": "image/heic",
|
|
260
|
+
".heif": "image/heif",
|
|
261
|
+
".gif": "image/gif",
|
|
262
|
+
".bmp": "image/bmp",
|
|
263
|
+
// Video
|
|
264
|
+
".mp4": "video/mp4",
|
|
265
|
+
".mpeg": "video/mpeg",
|
|
266
|
+
".mov": "video/quicktime",
|
|
267
|
+
".avi": "video/x-msvideo",
|
|
268
|
+
".flv": "video/x-flv",
|
|
269
|
+
".mpg": "video/mpeg",
|
|
270
|
+
".webm": "video/webm",
|
|
271
|
+
".wmv": "video/x-ms-wmv",
|
|
272
|
+
".3gpp": "video/3gpp",
|
|
273
|
+
// Document
|
|
274
|
+
".pdf": "application/pdf",
|
|
275
|
+
".txt": "text/plain",
|
|
276
|
+
".html": "text/html",
|
|
277
|
+
".htm": "text/html",
|
|
278
|
+
".md": "text/markdown",
|
|
279
|
+
".csv": "text/csv",
|
|
280
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
281
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
282
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
283
|
+
};
|
|
284
|
+
function getMimeType(filePath) {
|
|
285
|
+
const ext = path3.extname(filePath).toLowerCase();
|
|
286
|
+
return MIME_MAP[ext] ?? "application/octet-stream";
|
|
287
|
+
}
|
|
288
|
+
function requiresProcessingWait(mimeType) {
|
|
289
|
+
return mimeType.startsWith("audio/") || mimeType.startsWith("video/");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/providers/gemini/client.ts
|
|
293
|
+
var BASE = "https://generativelanguage.googleapis.com";
|
|
294
|
+
var FILES_BASE = `${BASE}/upload/v1beta/files`;
|
|
295
|
+
var GENERATE_BASE = `${BASE}/v1beta/models`;
|
|
296
|
+
function requireApiKey() {
|
|
297
|
+
const key = resolveKey("GEMINI_API_KEY");
|
|
298
|
+
if (!key)
|
|
299
|
+
throw new ConfigError(
|
|
300
|
+
"GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
|
|
301
|
+
);
|
|
302
|
+
return key;
|
|
303
|
+
}
|
|
304
|
+
function authHeaders(apiKey) {
|
|
305
|
+
return { "x-goog-api-key": apiKey };
|
|
306
|
+
}
|
|
307
|
+
async function uploadFile(filePath, opts = {}) {
|
|
308
|
+
const apiKey = requireApiKey();
|
|
309
|
+
const { timeoutMs = 3e5, logger } = opts;
|
|
310
|
+
const mimeType = getMimeType(filePath);
|
|
311
|
+
const fileBytes = fs3.readFileSync(filePath);
|
|
312
|
+
const displayName = path4.basename(filePath);
|
|
313
|
+
const boundary = `multix_${Date.now()}`;
|
|
314
|
+
const metaJson = JSON.stringify({ file: { displayName, mimeType } });
|
|
315
|
+
const body = Buffer.concat([
|
|
316
|
+
Buffer.from(
|
|
317
|
+
`--${boundary}\r
|
|
318
|
+
Content-Type: application/json; charset=UTF-8\r
|
|
319
|
+
\r
|
|
320
|
+
${metaJson}\r
|
|
321
|
+
--${boundary}\r
|
|
322
|
+
Content-Type: ${mimeType}\r
|
|
323
|
+
\r
|
|
324
|
+
`
|
|
325
|
+
),
|
|
326
|
+
fileBytes,
|
|
327
|
+
Buffer.from(`\r
|
|
328
|
+
--${boundary}--`)
|
|
329
|
+
]);
|
|
330
|
+
logger?.debug(`Uploading ${displayName} (${(fileBytes.length / 1024 / 1024).toFixed(2)} MB)`);
|
|
331
|
+
const resp = await globalThis.fetch(FILES_BASE, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: {
|
|
334
|
+
"x-goog-api-key": apiKey,
|
|
335
|
+
"Content-Type": `multipart/related; boundary=${boundary}`,
|
|
336
|
+
"Content-Length": String(body.length),
|
|
337
|
+
"X-Goog-Upload-Protocol": "multipart"
|
|
338
|
+
},
|
|
339
|
+
body,
|
|
340
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
341
|
+
});
|
|
342
|
+
if (!resp.ok) {
|
|
343
|
+
const txt = await resp.text();
|
|
344
|
+
throw new ProviderError(
|
|
345
|
+
`File upload failed (HTTP ${resp.status}): ${txt.slice(0, 500)}`,
|
|
346
|
+
"gemini"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const data = await resp.json();
|
|
350
|
+
let fileRef = data.file;
|
|
351
|
+
if (requiresProcessingWait(mimeType)) {
|
|
352
|
+
const deadline = Date.now() + timeoutMs;
|
|
353
|
+
while (fileRef.state === "PROCESSING" && Date.now() < deadline) {
|
|
354
|
+
await sleep(2e3);
|
|
355
|
+
fileRef = await httpJson({
|
|
356
|
+
url: `${BASE}/v1beta/${fileRef.name}`,
|
|
357
|
+
headers: authHeaders(apiKey)
|
|
358
|
+
}).then((r) => r.file);
|
|
359
|
+
logger?.debug(`Processing... state=${fileRef.state}`);
|
|
360
|
+
}
|
|
361
|
+
if (fileRef.state === "FAILED")
|
|
362
|
+
throw new ProviderError(`File processing failed: ${filePath}`, "gemini");
|
|
363
|
+
if (fileRef.state === "PROCESSING")
|
|
364
|
+
throw new ProviderError(`Processing timeout: ${filePath}`, "gemini");
|
|
365
|
+
}
|
|
366
|
+
logger?.debug(`Uploaded: ${fileRef.name} (${fileRef.uri})`);
|
|
367
|
+
return fileRef;
|
|
368
|
+
}
|
|
369
|
+
async function generateContent(req) {
|
|
370
|
+
const apiKey = requireApiKey();
|
|
371
|
+
return httpJson({
|
|
372
|
+
url: `${GENERATE_BASE}/${req.model}:generateContent`,
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers: authHeaders(apiKey),
|
|
375
|
+
body: {
|
|
376
|
+
contents: req.contents,
|
|
377
|
+
...req.generationConfig ? { generationConfig: req.generationConfig } : {}
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
function extractText(resp) {
|
|
382
|
+
const parts = resp.candidates?.[0]?.content?.parts ?? [];
|
|
383
|
+
return parts.filter((p) => p.text !== void 0).map((p) => p.text ?? "").join("");
|
|
384
|
+
}
|
|
385
|
+
function extractImages(resp) {
|
|
386
|
+
const parts = resp.candidates?.[0]?.content?.parts ?? [];
|
|
387
|
+
return parts.filter(
|
|
388
|
+
(p) => p.inlineData !== void 0
|
|
389
|
+
).map((p) => ({ mimeType: p.inlineData.mimeType, data: p.inlineData.data }));
|
|
390
|
+
}
|
|
391
|
+
function inlinePart(filePath) {
|
|
392
|
+
const data = fs3.readFileSync(filePath).toString("base64");
|
|
393
|
+
const mimeType = getMimeType(filePath);
|
|
394
|
+
return { inlineData: { mimeType, data } };
|
|
395
|
+
}
|
|
396
|
+
function shouldUseFileApi(filePath) {
|
|
397
|
+
const stat = fs3.statSync(filePath);
|
|
398
|
+
return stat.size > 20 * 1024 * 1024;
|
|
399
|
+
}
|
|
400
|
+
function sleep(ms) {
|
|
401
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/providers/gemini/models.ts
|
|
405
|
+
var IMAGE_MODEL_DEFAULT = "gemini-3.1-flash-image-preview";
|
|
406
|
+
var VIDEO_MODEL_DEFAULT = "veo-3.1-generate-preview";
|
|
407
|
+
var ANALYSIS_MODEL_DEFAULT = "gemini-2.5-flash";
|
|
408
|
+
var DOC_MODEL_DEFAULT = "gemini-2.5-flash";
|
|
409
|
+
var IMAGEN_MODELS = /* @__PURE__ */ new Set([
|
|
410
|
+
"imagen-4.0-generate-001",
|
|
411
|
+
"imagen-4.0-ultra-generate-001",
|
|
412
|
+
"imagen-4.0-fast-generate-001"
|
|
413
|
+
]);
|
|
414
|
+
var GEMINI_IMAGE_MODELS = /* @__PURE__ */ new Set([
|
|
415
|
+
"gemini-3.1-flash-image-preview",
|
|
416
|
+
"gemini-3-pro-image-preview",
|
|
417
|
+
"gemini-2.5-flash-image",
|
|
418
|
+
"gemini-2.5-flash-image-preview",
|
|
419
|
+
...IMAGEN_MODELS
|
|
420
|
+
]);
|
|
421
|
+
var ASPECT_RATIOS = [
|
|
422
|
+
"1:1",
|
|
423
|
+
"2:3",
|
|
424
|
+
"3:2",
|
|
425
|
+
"3:4",
|
|
426
|
+
"4:3",
|
|
427
|
+
"4:5",
|
|
428
|
+
"5:4",
|
|
429
|
+
"9:16",
|
|
430
|
+
"16:9",
|
|
431
|
+
"21:9"
|
|
432
|
+
];
|
|
433
|
+
var IMAGE_SIZES = ["1K", "2K", "4K"];
|
|
434
|
+
function getDefaultModel(task) {
|
|
435
|
+
if (task === "generate") {
|
|
436
|
+
return process.env.IMAGE_GEN_MODEL ?? process.env.GEMINI_IMAGE_GEN_MODEL ?? IMAGE_MODEL_DEFAULT;
|
|
437
|
+
}
|
|
438
|
+
if (task === "generate-video") {
|
|
439
|
+
return process.env.VIDEO_GEN_MODEL ?? VIDEO_MODEL_DEFAULT;
|
|
440
|
+
}
|
|
441
|
+
return process.env.MULTIMODAL_MODEL ?? process.env.GEMINI_MODEL ?? ANALYSIS_MODEL_DEFAULT;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/providers/gemini/result-formatter.ts
|
|
445
|
+
import fs4 from "fs";
|
|
446
|
+
import path5 from "path";
|
|
447
|
+
function formatResults(results, format, outputPath) {
|
|
448
|
+
const content = renderFormat(results, format);
|
|
449
|
+
if (outputPath) {
|
|
450
|
+
fs4.mkdirSync(path5.dirname(path5.resolve(outputPath)), { recursive: true });
|
|
451
|
+
fs4.writeFileSync(outputPath, content, "utf8");
|
|
452
|
+
} else {
|
|
453
|
+
process.stdout.write(content);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function renderFormat(results, format) {
|
|
457
|
+
switch (format) {
|
|
458
|
+
case "json":
|
|
459
|
+
return `${JSON.stringify(results, null, 2)}
|
|
460
|
+
`;
|
|
461
|
+
case "csv":
|
|
462
|
+
return renderCsv(results);
|
|
463
|
+
case "markdown":
|
|
464
|
+
return renderMarkdown(results);
|
|
465
|
+
default:
|
|
466
|
+
return renderText(results);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function renderText(results) {
|
|
470
|
+
return results.map((r) => {
|
|
471
|
+
const header = `[${r.file}]
|
|
472
|
+
Status: ${r.status}`;
|
|
473
|
+
if (r.status === "success" && r.response) return `${header}
|
|
474
|
+
Result:
|
|
475
|
+
${r.response}
|
|
476
|
+
`;
|
|
477
|
+
if (r.status === "error") return `${header}
|
|
478
|
+
Error: ${r.error}
|
|
479
|
+
`;
|
|
480
|
+
return `${header}
|
|
481
|
+
`;
|
|
482
|
+
}).join("\n");
|
|
483
|
+
}
|
|
484
|
+
function renderCsv(results) {
|
|
485
|
+
const header = "file,status,response,error\n";
|
|
486
|
+
const rows = results.map((r) => {
|
|
487
|
+
const csvCell = (v) => `"${(v ?? "").replace(/"/g, '""')}"`;
|
|
488
|
+
return [csvCell(r.file), csvCell(r.status), csvCell(r.response), csvCell(r.error)].join(",");
|
|
489
|
+
});
|
|
490
|
+
return `${header + rows.join("\n")}
|
|
491
|
+
`;
|
|
492
|
+
}
|
|
493
|
+
function renderMarkdown(results) {
|
|
494
|
+
const lines = ["# Results\n"];
|
|
495
|
+
for (const r of results) {
|
|
496
|
+
lines.push(`## ${r.file}
|
|
497
|
+
`);
|
|
498
|
+
lines.push(`**Status**: ${r.status}
|
|
499
|
+
`);
|
|
500
|
+
if (r.status === "success" && r.response) lines.push(`**Response**:
|
|
501
|
+
|
|
502
|
+
${r.response}
|
|
503
|
+
`);
|
|
504
|
+
if (r.status === "error") lines.push(`**Error**: ${r.error}
|
|
505
|
+
`);
|
|
506
|
+
lines.push("---\n");
|
|
507
|
+
}
|
|
508
|
+
return lines.join("\n");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/providers/gemini/commands/analyze.ts
|
|
512
|
+
function registerAnalyzeCommand(parent) {
|
|
513
|
+
parent.command("analyze").description("Analyze files with Gemini (images, video, audio, documents)").requiredOption("--files <paths...>", "Input file paths to analyze").option("--prompt <text>", "Analysis prompt", "Analyze this content").option("--model <id>", "Gemini model to use").option("--format <fmt>", "Output format: text|json|csv|markdown", "text").option("--output <path>", "Write results to file instead of stdout").option("-v, --verbose", "Verbose logging").action(
|
|
514
|
+
async (opts) => {
|
|
515
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
516
|
+
const model = opts.model ?? getDefaultModel("analyze");
|
|
517
|
+
for (const f of opts.files) {
|
|
518
|
+
if (!fs5.existsSync(f)) throw new ValidationError(`File not found: ${f}`);
|
|
519
|
+
}
|
|
520
|
+
const results = [];
|
|
521
|
+
for (let i = 0; i < opts.files.length; i++) {
|
|
522
|
+
const filePath = opts.files[i];
|
|
523
|
+
logger.debug(`[${i + 1}/${opts.files.length}] Processing: ${filePath}`);
|
|
524
|
+
try {
|
|
525
|
+
const useApi = shouldUseFileApi(filePath);
|
|
526
|
+
let contentParts;
|
|
527
|
+
if (useApi) {
|
|
528
|
+
logger.debug("File >20MB \u2014 using Files API");
|
|
529
|
+
const ref = await uploadFile(filePath, { logger });
|
|
530
|
+
contentParts = [
|
|
531
|
+
{ text: opts.prompt },
|
|
532
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
533
|
+
];
|
|
534
|
+
} else {
|
|
535
|
+
contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
|
|
536
|
+
}
|
|
537
|
+
const resp = await generateContent({
|
|
538
|
+
model,
|
|
539
|
+
contents: [{ parts: contentParts }]
|
|
540
|
+
});
|
|
541
|
+
results.push({
|
|
542
|
+
file: filePath,
|
|
543
|
+
status: "success",
|
|
544
|
+
response: extractText(resp)
|
|
545
|
+
});
|
|
546
|
+
} catch (e) {
|
|
547
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
548
|
+
logger.error(`Failed: ${filePath} \u2014 ${msg}`);
|
|
549
|
+
results.push({ file: filePath, status: "error", error: msg });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
formatResults(results, opts.format, opts.output);
|
|
553
|
+
logger.debug(
|
|
554
|
+
`Done: ${results.filter((r) => r.status === "success").length}/${results.length} succeeded`
|
|
555
|
+
);
|
|
556
|
+
const failed = results.filter((r) => r.status === "error").length;
|
|
557
|
+
if (failed > 0) process.exit(1);
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/providers/gemini/commands/extract.ts
|
|
563
|
+
init_errors();
|
|
564
|
+
import fs6 from "fs";
|
|
565
|
+
function registerExtractCommand(parent) {
|
|
566
|
+
parent.command("extract").description("Extract structured information from files with Gemini").requiredOption("--files <paths...>", "Input file paths").requiredOption("--prompt <text>", "Extraction prompt (required)").option("--model <id>", "Gemini model to use").option("--format <fmt>", "Output format: text|json|csv|markdown", "text").option("--output <path>", "Write results to file").option("-v, --verbose", "Verbose logging").action(
|
|
567
|
+
async (opts) => {
|
|
568
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
569
|
+
const model = opts.model ?? getDefaultModel("extract");
|
|
570
|
+
for (const f of opts.files) {
|
|
571
|
+
if (!fs6.existsSync(f)) throw new ValidationError(`File not found: ${f}`);
|
|
572
|
+
}
|
|
573
|
+
const generationConfig = opts.format === "json" ? { responseMimeType: "application/json" } : void 0;
|
|
574
|
+
const results = [];
|
|
575
|
+
for (let i = 0; i < opts.files.length; i++) {
|
|
576
|
+
const filePath = opts.files[i];
|
|
577
|
+
logger.debug(`[${i + 1}/${opts.files.length}] Extracting: ${filePath}`);
|
|
578
|
+
try {
|
|
579
|
+
const useApi = shouldUseFileApi(filePath);
|
|
580
|
+
let contentParts;
|
|
581
|
+
if (useApi) {
|
|
582
|
+
const ref = await uploadFile(filePath, { logger });
|
|
583
|
+
contentParts = [
|
|
584
|
+
{ text: opts.prompt },
|
|
585
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
586
|
+
];
|
|
587
|
+
} else {
|
|
588
|
+
contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
|
|
589
|
+
}
|
|
590
|
+
const resp = await generateContent({
|
|
591
|
+
model,
|
|
592
|
+
contents: [{ parts: contentParts }],
|
|
593
|
+
generationConfig
|
|
594
|
+
});
|
|
595
|
+
results.push({ file: filePath, status: "success", response: extractText(resp) });
|
|
596
|
+
} catch (e) {
|
|
597
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
598
|
+
logger.error(`Failed: ${filePath} \u2014 ${msg}`);
|
|
599
|
+
results.push({ file: filePath, status: "error", error: msg });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
formatResults(results, opts.format, opts.output);
|
|
603
|
+
const failed = results.filter((r) => r.status === "error").length;
|
|
604
|
+
if (failed > 0) process.exit(1);
|
|
605
|
+
}
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/providers/gemini/commands/generate-video.ts
|
|
610
|
+
import fs8 from "fs";
|
|
611
|
+
import path7 from "path";
|
|
612
|
+
init_errors();
|
|
613
|
+
init_http_client();
|
|
614
|
+
|
|
615
|
+
// src/core/output-dir.ts
|
|
616
|
+
import fs7 from "fs";
|
|
617
|
+
import path6 from "path";
|
|
618
|
+
var cachedOutputDir;
|
|
619
|
+
function getOutputDir() {
|
|
620
|
+
if (cachedOutputDir) return cachedOutputDir;
|
|
621
|
+
const dir = process.env.MULTIX_OUTPUT_DIR ? path6.resolve(process.env.MULTIX_OUTPUT_DIR) : path6.resolve(process.cwd(), "multix-output");
|
|
622
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
623
|
+
cachedOutputDir = dir;
|
|
624
|
+
return dir;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/providers/gemini/commands/generate-video.ts
|
|
628
|
+
var BASE2 = "https://generativelanguage.googleapis.com";
|
|
629
|
+
function registerGenerateVideoCommand(parent) {
|
|
630
|
+
parent.command("generate-video").description("Generate video with Veo via Gemini API [EXPERIMENTAL]").requiredOption("--prompt <text>", "Video generation prompt").option("--model <id>", "Veo model to use").option("--resolution <res>", "Video resolution: 720p|1080p", "1080p").option("--aspect-ratio <ratio>", "Aspect ratio", "16:9").option(
|
|
631
|
+
"--reference-images <paths...>",
|
|
632
|
+
"Reference images (first=opening frame, second=closing)"
|
|
633
|
+
).option("--upload-timeout <ms>", "Upload timeout in ms", "120000").option("--output <path>", "Copy generated video to this path").option("-v, --verbose", "Verbose logging").action(
|
|
634
|
+
async (opts) => {
|
|
635
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
636
|
+
const apiKey = resolveKey("GEMINI_API_KEY");
|
|
637
|
+
if (!apiKey) throw new ValidationError("GEMINI_API_KEY is not set");
|
|
638
|
+
const model = opts.model ?? getDefaultModel("generate-video");
|
|
639
|
+
if (!model.startsWith("veo-")) {
|
|
640
|
+
throw new ValidationError(
|
|
641
|
+
`Video generation requires a Veo model (got '${model}'). Use: veo-3.1-generate-preview, veo-3.0-generate-001, etc.`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (!["720p", "1080p"].includes(opts.resolution)) {
|
|
645
|
+
throw new ValidationError("--resolution must be 720p or 1080p");
|
|
646
|
+
}
|
|
647
|
+
if (!ASPECT_RATIOS.includes(opts.aspectRatio)) {
|
|
648
|
+
throw new ValidationError(`Invalid aspect ratio: ${opts.aspectRatio}`);
|
|
649
|
+
}
|
|
650
|
+
logger.debug(`Submitting video generation with ${model}...`);
|
|
651
|
+
logger.info("This may take 30s\u20136 minutes. Veo requires billing.");
|
|
652
|
+
const config = {
|
|
653
|
+
aspectRatio: opts.aspectRatio,
|
|
654
|
+
resolution: opts.resolution
|
|
655
|
+
};
|
|
656
|
+
const contentParts = [{ text: opts.prompt }];
|
|
657
|
+
if (opts.referenceImages?.length) {
|
|
658
|
+
for (const imgPath of opts.referenceImages.slice(0, 2)) {
|
|
659
|
+
if (!fs8.existsSync(imgPath))
|
|
660
|
+
throw new ValidationError(`Reference image not found: ${imgPath}`);
|
|
661
|
+
const data = fs8.readFileSync(imgPath).toString("base64");
|
|
662
|
+
const ext = path7.extname(imgPath).toLowerCase();
|
|
663
|
+
const mimeType = ext === ".png" ? "image/png" : "image/jpeg";
|
|
664
|
+
contentParts.push({ inlineData: { mimeType, data } });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const headers = { "x-goog-api-key": apiKey, "Content-Type": "application/json" };
|
|
668
|
+
let operation;
|
|
669
|
+
try {
|
|
670
|
+
operation = await httpJson({
|
|
671
|
+
url: `${BASE2}/v1beta/models/${model}:generateVideos`,
|
|
672
|
+
method: "POST",
|
|
673
|
+
headers,
|
|
674
|
+
body: {
|
|
675
|
+
contents: [{ parts: contentParts }],
|
|
676
|
+
generationConfig: config
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
} catch (e) {
|
|
680
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
681
|
+
throw new ProviderError(`Video generation submit failed: ${msg}`, "gemini");
|
|
682
|
+
}
|
|
683
|
+
const operationName = operation.name;
|
|
684
|
+
logger.debug(`Operation: ${operationName}`);
|
|
685
|
+
const deadline = Date.now() + 10 * 60 * 1e3;
|
|
686
|
+
let done = false;
|
|
687
|
+
let finalOp = operation;
|
|
688
|
+
while (!done && Date.now() < deadline) {
|
|
689
|
+
await sleep2(1e4);
|
|
690
|
+
finalOp = await httpJson({
|
|
691
|
+
url: `${BASE2}/v1beta/${operationName}`,
|
|
692
|
+
headers
|
|
693
|
+
});
|
|
694
|
+
done = finalOp.done === true;
|
|
695
|
+
if (!done)
|
|
696
|
+
logger.debug(
|
|
697
|
+
`Still generating... (${Math.round((deadline - Date.now()) / 1e3)}s left)`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
if (!done) throw new ProviderError("Video generation timed out after 10 minutes", "gemini");
|
|
701
|
+
if (finalOp.error)
|
|
702
|
+
throw new ProviderError(`Veo error: ${JSON.stringify(finalOp.error)}`, "gemini");
|
|
703
|
+
const videoUri = finalOp.response?.generatedSamples?.[0]?.video?.uri ?? finalOp.response?.generatedVideos?.[0]?.video?.uri;
|
|
704
|
+
if (!videoUri) {
|
|
705
|
+
throw new ProviderError(
|
|
706
|
+
`Unexpected Veo response structure: ${JSON.stringify(finalOp.response)}`,
|
|
707
|
+
"gemini"
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const outDir = getOutputDir();
|
|
711
|
+
const dest = path7.join(outDir, `veo_generated_${Date.now()}.mp4`);
|
|
712
|
+
const { downloadFile: downloadFile2 } = await Promise.resolve().then(() => (init_http_client(), http_client_exports));
|
|
713
|
+
await downloadFile2(videoUri, dest);
|
|
714
|
+
logger.success(`Saved video: ${dest}`);
|
|
715
|
+
if (opts.output) {
|
|
716
|
+
fs8.mkdirSync(path7.dirname(path7.resolve(opts.output)), { recursive: true });
|
|
717
|
+
fs8.copyFileSync(dest, opts.output);
|
|
718
|
+
logger.success(`Copied to: ${opts.output}`);
|
|
719
|
+
}
|
|
720
|
+
console.log(`
|
|
721
|
+
Generated video: ${dest}`);
|
|
722
|
+
}
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
function sleep2(ms) {
|
|
726
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/providers/gemini/commands/generate.ts
|
|
730
|
+
init_errors();
|
|
731
|
+
import fs9 from "fs";
|
|
732
|
+
import path8 from "path";
|
|
733
|
+
function registerGenerateCommand(parent) {
|
|
734
|
+
parent.command("generate").description("Generate images with Gemini (Nano Banana / Imagen 4)").requiredOption("--prompt <text>", "Image generation prompt").option("--model <id>", "Gemini image model to use").option("--aspect-ratio <ratio>", `Aspect ratio (${ASPECT_RATIOS.join("|")})`, "1:1").option("--num-images <n>", "Number of images to generate (1-4)", "1").option("--size <sz>", `Image size (${IMAGE_SIZES.join("|")})`).option("--output <path>", "Copy first generated image to this path").option("-v, --verbose", "Verbose logging").action(
|
|
735
|
+
async (opts) => {
|
|
736
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
737
|
+
const model = opts.model ?? getDefaultModel("generate");
|
|
738
|
+
const numImages = Math.min(Math.max(Number.parseInt(opts.numImages, 10) || 1, 1), 4);
|
|
739
|
+
if (!ASPECT_RATIOS.includes(opts.aspectRatio)) {
|
|
740
|
+
throw new ValidationError(
|
|
741
|
+
`Invalid aspect ratio: ${opts.aspectRatio}. Valid: ${ASPECT_RATIOS.join(", ")}`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (opts.size && !IMAGE_SIZES.includes(opts.size)) {
|
|
745
|
+
throw new ValidationError(`Invalid size: ${opts.size}. Valid: ${IMAGE_SIZES.join(", ")}`);
|
|
746
|
+
}
|
|
747
|
+
logger.debug(`Generating ${numImages} image(s) with model: ${model}`);
|
|
748
|
+
logger.debug(`Aspect ratio: ${opts.aspectRatio}${opts.size ? `, size: ${opts.size}` : ""}`);
|
|
749
|
+
const imageConfig = { aspectRatio: opts.aspectRatio };
|
|
750
|
+
if (opts.size) imageConfig.imageSize = opts.size;
|
|
751
|
+
const generationConfig = {
|
|
752
|
+
responseModalities: ["IMAGE"],
|
|
753
|
+
imageConfig
|
|
754
|
+
};
|
|
755
|
+
const savedFiles = [];
|
|
756
|
+
for (let i = 0; i < numImages; i++) {
|
|
757
|
+
try {
|
|
758
|
+
const resp = await generateContent({
|
|
759
|
+
model,
|
|
760
|
+
contents: [{ parts: [{ text: opts.prompt }] }],
|
|
761
|
+
generationConfig
|
|
762
|
+
});
|
|
763
|
+
const images = extractImages(resp);
|
|
764
|
+
if (images.length === 0) {
|
|
765
|
+
logger.warn(
|
|
766
|
+
`Request ${i + 1}: no images in response \u2014 model may not support image generation`
|
|
767
|
+
);
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
for (const img of images) {
|
|
771
|
+
const outDir = getOutputDir();
|
|
772
|
+
const ext = img.mimeType.includes("png") ? "png" : "jpg";
|
|
773
|
+
const fname = `gemini_generated_${Date.now()}_${i}.${ext}`;
|
|
774
|
+
const dest = path8.join(outDir, fname);
|
|
775
|
+
fs9.writeFileSync(dest, Buffer.from(img.data, "base64"));
|
|
776
|
+
savedFiles.push(dest);
|
|
777
|
+
logger.success(`Saved: ${dest}`);
|
|
778
|
+
}
|
|
779
|
+
} catch (e) {
|
|
780
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
781
|
+
logger.error(`Generation failed: ${msg}`);
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (savedFiles.length === 0) {
|
|
786
|
+
logger.error("No images were generated");
|
|
787
|
+
process.exit(1);
|
|
788
|
+
}
|
|
789
|
+
if (opts.output && savedFiles[0]) {
|
|
790
|
+
fs9.mkdirSync(path8.dirname(path8.resolve(opts.output)), { recursive: true });
|
|
791
|
+
fs9.copyFileSync(savedFiles[0], opts.output);
|
|
792
|
+
logger.success(`Copied to: ${opts.output}`);
|
|
793
|
+
}
|
|
794
|
+
console.log(`
|
|
795
|
+
Generated ${savedFiles.length} image(s):`);
|
|
796
|
+
for (const f of savedFiles) console.log(` ${f}`);
|
|
797
|
+
}
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/providers/gemini/commands/transcribe.ts
|
|
802
|
+
init_errors();
|
|
803
|
+
import fs10 from "fs";
|
|
804
|
+
function registerTranscribeCommand(parent) {
|
|
805
|
+
parent.command("transcribe").description("Transcribe audio or video files with Gemini").requiredOption("--files <paths...>", "Audio/video file paths to transcribe").option("--prompt <text>", "Transcription prompt", "Generate a transcript with timestamps").option("--model <id>", "Gemini model to use").option("--format <fmt>", "Output format: text|json|csv|markdown", "text").option("--output <path>", "Write results to file").option("-v, --verbose", "Verbose logging").action(
|
|
806
|
+
async (opts) => {
|
|
807
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
808
|
+
const model = opts.model ?? getDefaultModel("transcribe");
|
|
809
|
+
for (const f of opts.files) {
|
|
810
|
+
if (!fs10.existsSync(f)) throw new ValidationError(`File not found: ${f}`);
|
|
811
|
+
}
|
|
812
|
+
const results = [];
|
|
813
|
+
for (let i = 0; i < opts.files.length; i++) {
|
|
814
|
+
const filePath = opts.files[i];
|
|
815
|
+
logger.debug(`[${i + 1}/${opts.files.length}] Transcribing: ${filePath}`);
|
|
816
|
+
try {
|
|
817
|
+
const useApi = shouldUseFileApi(filePath);
|
|
818
|
+
let contentParts;
|
|
819
|
+
if (useApi) {
|
|
820
|
+
logger.debug("File >20MB \u2014 using Files API");
|
|
821
|
+
const ref = await uploadFile(filePath, { logger });
|
|
822
|
+
contentParts = [
|
|
823
|
+
{ text: opts.prompt },
|
|
824
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
825
|
+
];
|
|
826
|
+
} else {
|
|
827
|
+
contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
|
|
828
|
+
}
|
|
829
|
+
const resp = await generateContent({
|
|
830
|
+
model,
|
|
831
|
+
contents: [{ parts: contentParts }]
|
|
832
|
+
});
|
|
833
|
+
results.push({
|
|
834
|
+
file: filePath,
|
|
835
|
+
status: "success",
|
|
836
|
+
response: extractText(resp)
|
|
837
|
+
});
|
|
838
|
+
} catch (e) {
|
|
839
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
840
|
+
logger.error(`Failed: ${filePath} \u2014 ${msg}`);
|
|
841
|
+
results.push({ file: filePath, status: "error", error: msg });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
formatResults(results, opts.format, opts.output);
|
|
845
|
+
const failed = results.filter((r) => r.status === "error").length;
|
|
846
|
+
if (failed > 0) process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/providers/gemini/commands/index.ts
|
|
852
|
+
function registerGeminiCommands(program2) {
|
|
853
|
+
const gemini = program2.command("gemini").description("Gemini AI operations: analyze, transcribe, extract, generate images/video");
|
|
854
|
+
registerAnalyzeCommand(gemini);
|
|
855
|
+
registerTranscribeCommand(gemini);
|
|
856
|
+
registerExtractCommand(gemini);
|
|
857
|
+
registerGenerateCommand(gemini);
|
|
858
|
+
registerGenerateVideoCommand(gemini);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// src/providers/minimax/commands/generate-music.ts
|
|
862
|
+
init_errors();
|
|
863
|
+
|
|
864
|
+
// src/providers/minimax/client.ts
|
|
865
|
+
init_errors();
|
|
866
|
+
init_http_client();
|
|
867
|
+
var MINIMAX_BASE_URL = "https://api.minimax.io/v1";
|
|
868
|
+
function requireMinimaxKey() {
|
|
869
|
+
const key = resolveKey("MINIMAX_API_KEY");
|
|
870
|
+
if (!key) {
|
|
871
|
+
throw new ConfigError(
|
|
872
|
+
"MINIMAX_API_KEY is not set. Get one at https://platform.minimax.io/user-center/basic-information/interface-key"
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
return key;
|
|
876
|
+
}
|
|
877
|
+
function authHeaders2(apiKey) {
|
|
878
|
+
return {
|
|
879
|
+
Authorization: `Bearer ${apiKey}`,
|
|
880
|
+
"Content-Type": "application/json"
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
async function apiPost(endpoint, payload, apiKey, opts = {}) {
|
|
884
|
+
const url = `${MINIMAX_BASE_URL}/${endpoint}`;
|
|
885
|
+
opts.logger?.debug(`POST ${url}`);
|
|
886
|
+
const data = await httpJson({
|
|
887
|
+
url,
|
|
888
|
+
method: "POST",
|
|
889
|
+
headers: authHeaders2(apiKey),
|
|
890
|
+
body: payload,
|
|
891
|
+
timeoutMs: opts.timeoutMs ?? 12e4
|
|
892
|
+
});
|
|
893
|
+
const baseResp = data.base_resp;
|
|
894
|
+
if (baseResp && baseResp.status_code !== 0) {
|
|
895
|
+
throw new ProviderError(
|
|
896
|
+
`MiniMax API error (code ${baseResp.status_code}): ${baseResp.status_msg ?? "unknown"}`,
|
|
897
|
+
"minimax"
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
return data;
|
|
901
|
+
}
|
|
902
|
+
async function apiGet(endpoint, params, apiKey) {
|
|
903
|
+
const url = new URL(`${MINIMAX_BASE_URL}/${endpoint}`);
|
|
904
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
905
|
+
return httpJson({ url: url.toString(), headers: authHeaders2(apiKey) });
|
|
906
|
+
}
|
|
907
|
+
async function pollAsyncTask(taskId, taskType, apiKey, opts = {}) {
|
|
908
|
+
const { intervalMs = 1e4, timeoutMs = 6e5, logger } = opts;
|
|
909
|
+
const deadline = Date.now() + timeoutMs;
|
|
910
|
+
let elapsed = 0;
|
|
911
|
+
while (Date.now() < deadline) {
|
|
912
|
+
const result = await apiGet(`query/${taskType}`, { task_id: taskId }, apiKey);
|
|
913
|
+
const status = result.status ?? "Unknown";
|
|
914
|
+
if (elapsed > 0 && elapsed % 3e4 < intervalMs) {
|
|
915
|
+
logger?.debug(`Polling... ${Math.round(elapsed / 1e3)}s elapsed, status: ${status}`);
|
|
916
|
+
}
|
|
917
|
+
if (status === "Success") return result;
|
|
918
|
+
if (status === "Failed" || status === "Error") {
|
|
919
|
+
throw new ProviderError(`Task ${taskId} failed: ${JSON.stringify(result)}`, "minimax");
|
|
920
|
+
}
|
|
921
|
+
await sleep3(intervalMs);
|
|
922
|
+
elapsed += intervalMs;
|
|
923
|
+
}
|
|
924
|
+
throw new ProviderError(`Task ${taskId} timed out after ${timeoutMs / 1e3}s`, "minimax");
|
|
925
|
+
}
|
|
926
|
+
async function downloadMinimaxFile(fileId, apiKey, dest, logger) {
|
|
927
|
+
const result = await apiGet(
|
|
928
|
+
"files/retrieve",
|
|
929
|
+
{ file_id: fileId },
|
|
930
|
+
apiKey
|
|
931
|
+
);
|
|
932
|
+
const downloadUrl = result.file?.download_url;
|
|
933
|
+
if (!downloadUrl) {
|
|
934
|
+
throw new ProviderError(`No download URL for file ${fileId}`, "minimax");
|
|
935
|
+
}
|
|
936
|
+
logger?.debug(`Downloading to: ${dest}`);
|
|
937
|
+
await downloadFile(downloadUrl, dest);
|
|
938
|
+
return dest;
|
|
939
|
+
}
|
|
940
|
+
function sleep3(ms) {
|
|
941
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/providers/minimax/generators/music.ts
|
|
945
|
+
init_http_client();
|
|
946
|
+
import fs11 from "fs";
|
|
947
|
+
import path9 from "path";
|
|
948
|
+
async function generateMinimaxMusic(opts) {
|
|
949
|
+
const {
|
|
950
|
+
apiKey,
|
|
951
|
+
lyrics = "",
|
|
952
|
+
prompt = "",
|
|
953
|
+
model = "music-2.5",
|
|
954
|
+
outputFormat = "mp3",
|
|
955
|
+
output,
|
|
956
|
+
logger
|
|
957
|
+
} = opts;
|
|
958
|
+
if (!lyrics && !prompt) {
|
|
959
|
+
return {
|
|
960
|
+
status: "error",
|
|
961
|
+
error: "Either --lyrics or --prompt is required for music generation"
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
const payload = {
|
|
965
|
+
model,
|
|
966
|
+
output_format: "url",
|
|
967
|
+
audio_setting: {
|
|
968
|
+
sample_rate: 44100,
|
|
969
|
+
bitrate: 128e3,
|
|
970
|
+
format: outputFormat,
|
|
971
|
+
channel: 1
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
if (lyrics) payload.lyrics = lyrics.slice(0, 3500);
|
|
975
|
+
if (prompt) payload.prompt = prompt.slice(0, 2e3);
|
|
976
|
+
logger?.debug(`Generating music with ${model}...`);
|
|
977
|
+
let resp;
|
|
978
|
+
try {
|
|
979
|
+
resp = await apiPost("music_generation", payload, apiKey, {
|
|
980
|
+
logger,
|
|
981
|
+
timeoutMs: 3e5
|
|
982
|
+
});
|
|
983
|
+
} catch (e) {
|
|
984
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
985
|
+
}
|
|
986
|
+
const audioData = resp.data?.audio;
|
|
987
|
+
if (!audioData) {
|
|
988
|
+
return { status: "error", error: "No audio in response" };
|
|
989
|
+
}
|
|
990
|
+
const durationMs = resp.extra_info?.music_duration ?? 0;
|
|
991
|
+
const outDir = getOutputDir();
|
|
992
|
+
const dest = path9.join(outDir, `minimax_music_${Date.now()}.${outputFormat}`);
|
|
993
|
+
try {
|
|
994
|
+
if (audioData.startsWith("http")) {
|
|
995
|
+
const bytes = await fetchBytes(audioData);
|
|
996
|
+
fs11.writeFileSync(dest, bytes);
|
|
997
|
+
} else {
|
|
998
|
+
const audioBytes = Buffer.from(audioData, "hex");
|
|
999
|
+
fs11.writeFileSync(dest, audioBytes);
|
|
1000
|
+
}
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
return {
|
|
1003
|
+
status: "error",
|
|
1004
|
+
error: `Failed to save audio: ${e instanceof Error ? e.message : String(e)}`
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
const durSec = durationMs / 1e3;
|
|
1008
|
+
logger?.success(`Saved: ${dest} (${durSec.toFixed(1)}s)`);
|
|
1009
|
+
if (output) {
|
|
1010
|
+
fs11.mkdirSync(path9.dirname(path9.resolve(output)), { recursive: true });
|
|
1011
|
+
fs11.copyFileSync(dest, output);
|
|
1012
|
+
}
|
|
1013
|
+
return { status: "success", generatedAudio: dest, durationMs, model };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/providers/minimax/models.ts
|
|
1017
|
+
var MINIMAX_IMAGE_MODELS = /* @__PURE__ */ new Set(["image-01", "image-01-live"]);
|
|
1018
|
+
var MINIMAX_VIDEO_MODELS = /* @__PURE__ */ new Set([
|
|
1019
|
+
"MiniMax-Hailuo-2.3",
|
|
1020
|
+
"MiniMax-Hailuo-2.3-Fast",
|
|
1021
|
+
"MiniMax-Hailuo-02",
|
|
1022
|
+
"S2V-01"
|
|
1023
|
+
]);
|
|
1024
|
+
var MINIMAX_SPEECH_MODELS = /* @__PURE__ */ new Set([
|
|
1025
|
+
"speech-2.8-hd",
|
|
1026
|
+
"speech-2.8-turbo",
|
|
1027
|
+
"speech-2.6-hd",
|
|
1028
|
+
"speech-2.6-turbo",
|
|
1029
|
+
"speech-02-hd",
|
|
1030
|
+
"speech-02-turbo"
|
|
1031
|
+
]);
|
|
1032
|
+
var MINIMAX_MUSIC_MODELS = /* @__PURE__ */ new Set(["music-2.5", "music-2.0"]);
|
|
1033
|
+
var ALL_MINIMAX_MODELS = /* @__PURE__ */ new Set([
|
|
1034
|
+
...MINIMAX_IMAGE_MODELS,
|
|
1035
|
+
...MINIMAX_VIDEO_MODELS,
|
|
1036
|
+
...MINIMAX_SPEECH_MODELS,
|
|
1037
|
+
...MINIMAX_MUSIC_MODELS
|
|
1038
|
+
]);
|
|
1039
|
+
var TASK_DEFAULTS = {
|
|
1040
|
+
image: process.env.MINIMAX_IMAGE_MODEL ?? "image-01",
|
|
1041
|
+
video: process.env.MINIMAX_VIDEO_MODEL ?? "MiniMax-Hailuo-2.3",
|
|
1042
|
+
speech: process.env.MINIMAX_SPEECH_MODEL ?? "speech-2.8-hd",
|
|
1043
|
+
music: process.env.MINIMAX_MUSIC_MODEL ?? "music-2.5"
|
|
1044
|
+
};
|
|
1045
|
+
var VIDEO_DURATIONS = [6, 10];
|
|
1046
|
+
var VIDEO_RESOLUTIONS = ["720P", "1080P"];
|
|
1047
|
+
var SPEECH_FORMATS = ["mp3", "wav", "flac", "pcm"];
|
|
1048
|
+
|
|
1049
|
+
// src/providers/minimax/commands/generate-music.ts
|
|
1050
|
+
function registerMinimaxGenerateMusicCommand(parent) {
|
|
1051
|
+
parent.command("generate-music").description("Generate music with MiniMax").option("--lyrics <str>", "Song lyrics (use --lyrics or --prompt)").option("--prompt <str>", "Music style/description prompt").option("--model <id>", `Music model (${[...MINIMAX_MUSIC_MODELS].join("|")})`).option("--output-format <fmt>", "Audio format: mp3|wav|flac", "mp3").option("--output <path>", "Copy output audio to this path").option("-v, --verbose", "Verbose logging").action(
|
|
1052
|
+
async (opts) => {
|
|
1053
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1054
|
+
const apiKey = requireMinimaxKey();
|
|
1055
|
+
if (!opts.lyrics && !opts.prompt) {
|
|
1056
|
+
throw new ValidationError("Either --lyrics or --prompt is required");
|
|
1057
|
+
}
|
|
1058
|
+
const model = opts.model ?? TASK_DEFAULTS.music;
|
|
1059
|
+
const result = await generateMinimaxMusic({
|
|
1060
|
+
apiKey,
|
|
1061
|
+
lyrics: opts.lyrics,
|
|
1062
|
+
prompt: opts.prompt,
|
|
1063
|
+
model,
|
|
1064
|
+
outputFormat: opts.outputFormat,
|
|
1065
|
+
output: opts.output,
|
|
1066
|
+
logger
|
|
1067
|
+
});
|
|
1068
|
+
if (result.status === "error") {
|
|
1069
|
+
logger.error(result.error ?? "Unknown error");
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
console.log(`
|
|
1073
|
+
Generated music: ${result.generatedAudio}`);
|
|
1074
|
+
if (result.durationMs) {
|
|
1075
|
+
console.log(`Duration: ${(result.durationMs / 1e3).toFixed(1)}s`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// src/providers/minimax/commands/generate-speech.ts
|
|
1082
|
+
init_errors();
|
|
1083
|
+
|
|
1084
|
+
// src/providers/minimax/generators/speech.ts
|
|
1085
|
+
import fs12 from "fs";
|
|
1086
|
+
import path10 from "path";
|
|
1087
|
+
async function generateMinimaxSpeech(opts) {
|
|
1088
|
+
const {
|
|
1089
|
+
apiKey,
|
|
1090
|
+
text,
|
|
1091
|
+
model = "speech-2.8-hd",
|
|
1092
|
+
voice = "English_expressive_narrator",
|
|
1093
|
+
emotion = "neutral",
|
|
1094
|
+
outputFormat = "mp3",
|
|
1095
|
+
rate = 1,
|
|
1096
|
+
output,
|
|
1097
|
+
logger
|
|
1098
|
+
} = opts;
|
|
1099
|
+
const payload = {
|
|
1100
|
+
model,
|
|
1101
|
+
text: text.slice(0, 1e4),
|
|
1102
|
+
stream: false,
|
|
1103
|
+
language_boost: "auto",
|
|
1104
|
+
output_format: "hex",
|
|
1105
|
+
voice_setting: {
|
|
1106
|
+
voice_id: voice,
|
|
1107
|
+
speed: rate,
|
|
1108
|
+
vol: 1,
|
|
1109
|
+
pitch: 0,
|
|
1110
|
+
emotion
|
|
1111
|
+
},
|
|
1112
|
+
audio_setting: {
|
|
1113
|
+
sample_rate: 32e3,
|
|
1114
|
+
bitrate: 128e3,
|
|
1115
|
+
format: outputFormat,
|
|
1116
|
+
channel: 1
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
logger?.debug(`Generating speech with ${model}, voice: ${voice}`);
|
|
1120
|
+
let resp;
|
|
1121
|
+
try {
|
|
1122
|
+
resp = await apiPost("t2a_v2", payload, apiKey, { logger });
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
1125
|
+
}
|
|
1126
|
+
const audioHex = resp.data?.audio;
|
|
1127
|
+
if (!audioHex) {
|
|
1128
|
+
return { status: "error", error: "No audio in response" };
|
|
1129
|
+
}
|
|
1130
|
+
const audioBytes = Buffer.from(audioHex, "hex");
|
|
1131
|
+
const ext = ["mp3", "wav", "flac"].includes(outputFormat) ? outputFormat : "mp3";
|
|
1132
|
+
const outDir = getOutputDir();
|
|
1133
|
+
const dest = path10.join(outDir, `minimax_speech_${Date.now()}.${ext}`);
|
|
1134
|
+
fs12.writeFileSync(dest, audioBytes);
|
|
1135
|
+
logger?.success(`Saved: ${dest} (${(audioBytes.length / 1024).toFixed(1)} KB)`);
|
|
1136
|
+
if (output) {
|
|
1137
|
+
fs12.mkdirSync(path10.dirname(path10.resolve(output)), { recursive: true });
|
|
1138
|
+
fs12.copyFileSync(dest, output);
|
|
1139
|
+
}
|
|
1140
|
+
return { status: "success", generatedAudio: dest, model };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/providers/minimax/commands/generate-speech.ts
|
|
1144
|
+
function registerMinimaxGenerateSpeechCommand(parent) {
|
|
1145
|
+
parent.command("generate-speech").description("Generate speech (TTS) with MiniMax").option("--text <str>", "Text to speak (use --text or --prompt)").option("--prompt <str>", "Alias for --text").option("--model <id>", `Speech model (${[...MINIMAX_SPEECH_MODELS].join("|")})`).option("--voice <id>", "Voice ID", "English_expressive_narrator").option(
|
|
1146
|
+
"--emotion <e>",
|
|
1147
|
+
"Emotion (neutral|happy|sad|angry|fearful|disgusted|surprised)",
|
|
1148
|
+
"neutral"
|
|
1149
|
+
).option("--output-format <fmt>", `Audio format (${SPEECH_FORMATS.join("|")})`, "mp3").option("--rate <n>", "Speech rate (0.5\u20132.0)", "1.0").option("--output <path>", "Copy output audio to this path").option("-v, --verbose", "Verbose logging").action(
|
|
1150
|
+
async (opts) => {
|
|
1151
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1152
|
+
const apiKey = requireMinimaxKey();
|
|
1153
|
+
const text = opts.text ?? opts.prompt;
|
|
1154
|
+
if (!text) throw new ValidationError("Either --text or --prompt is required");
|
|
1155
|
+
if (!SPEECH_FORMATS.includes(opts.outputFormat)) {
|
|
1156
|
+
throw new ValidationError(`--output-format must be one of: ${SPEECH_FORMATS.join(", ")}`);
|
|
1157
|
+
}
|
|
1158
|
+
const model = opts.model ?? TASK_DEFAULTS.speech;
|
|
1159
|
+
const rate = Number.parseFloat(opts.rate);
|
|
1160
|
+
const result = await generateMinimaxSpeech({
|
|
1161
|
+
apiKey,
|
|
1162
|
+
text,
|
|
1163
|
+
model,
|
|
1164
|
+
voice: opts.voice,
|
|
1165
|
+
emotion: opts.emotion,
|
|
1166
|
+
outputFormat: opts.outputFormat,
|
|
1167
|
+
rate,
|
|
1168
|
+
output: opts.output,
|
|
1169
|
+
logger
|
|
1170
|
+
});
|
|
1171
|
+
if (result.status === "error") {
|
|
1172
|
+
logger.error(result.error ?? "Unknown error");
|
|
1173
|
+
process.exit(1);
|
|
1174
|
+
}
|
|
1175
|
+
console.log(`
|
|
1176
|
+
Generated audio: ${result.generatedAudio}`);
|
|
1177
|
+
}
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// src/providers/minimax/commands/generate-video.ts
|
|
1182
|
+
init_errors();
|
|
1183
|
+
|
|
1184
|
+
// src/providers/minimax/generators/video.ts
|
|
1185
|
+
import fs13 from "fs";
|
|
1186
|
+
import path11 from "path";
|
|
1187
|
+
async function generateMinimaxVideo(opts) {
|
|
1188
|
+
const {
|
|
1189
|
+
apiKey,
|
|
1190
|
+
prompt,
|
|
1191
|
+
model = "MiniMax-Hailuo-2.3",
|
|
1192
|
+
duration = 6,
|
|
1193
|
+
resolution = "1080P",
|
|
1194
|
+
firstFrame,
|
|
1195
|
+
output,
|
|
1196
|
+
logger
|
|
1197
|
+
} = opts;
|
|
1198
|
+
const payload = { prompt, model, duration, resolution };
|
|
1199
|
+
if (firstFrame) payload.first_frame_image = firstFrame;
|
|
1200
|
+
logger?.debug(`Submitting video generation with ${model}...`);
|
|
1201
|
+
let taskResp;
|
|
1202
|
+
try {
|
|
1203
|
+
taskResp = await apiPost("video_generation", payload, apiKey, {
|
|
1204
|
+
logger,
|
|
1205
|
+
timeoutMs: 6e4
|
|
1206
|
+
});
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
1209
|
+
}
|
|
1210
|
+
const taskId = taskResp.task_id;
|
|
1211
|
+
if (!taskId) {
|
|
1212
|
+
return { status: "error", error: `No task_id in response: ${JSON.stringify(taskResp)}` };
|
|
1213
|
+
}
|
|
1214
|
+
logger?.debug(`Task ID: ${taskId}, polling...`);
|
|
1215
|
+
const start = Date.now();
|
|
1216
|
+
let pollResult;
|
|
1217
|
+
try {
|
|
1218
|
+
pollResult = await pollAsyncTask(taskId, "video_generation", apiKey, {
|
|
1219
|
+
intervalMs: 1e4,
|
|
1220
|
+
timeoutMs: 6e5,
|
|
1221
|
+
logger
|
|
1222
|
+
});
|
|
1223
|
+
} catch (e) {
|
|
1224
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
1225
|
+
}
|
|
1226
|
+
const fileId = pollResult.file_id;
|
|
1227
|
+
if (!fileId) {
|
|
1228
|
+
return { status: "error", error: `No file_id in poll result: ${JSON.stringify(pollResult)}` };
|
|
1229
|
+
}
|
|
1230
|
+
const outDir = getOutputDir();
|
|
1231
|
+
const dest = path11.join(outDir, `minimax_video_${Date.now()}.mp4`);
|
|
1232
|
+
try {
|
|
1233
|
+
await downloadMinimaxFile(fileId, apiKey, dest, logger);
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
1236
|
+
}
|
|
1237
|
+
const elapsed = (Date.now() - start) / 1e3;
|
|
1238
|
+
const stat = fs13.statSync(dest);
|
|
1239
|
+
const fileSizeMb = stat.size / (1024 * 1024);
|
|
1240
|
+
if (output) {
|
|
1241
|
+
fs13.mkdirSync(path11.dirname(path11.resolve(output)), { recursive: true });
|
|
1242
|
+
fs13.copyFileSync(dest, output);
|
|
1243
|
+
}
|
|
1244
|
+
logger?.debug(`Generated in ${elapsed.toFixed(1)}s, size: ${fileSizeMb.toFixed(2)} MB`);
|
|
1245
|
+
return {
|
|
1246
|
+
status: "success",
|
|
1247
|
+
generatedVideo: dest,
|
|
1248
|
+
generationTime: elapsed,
|
|
1249
|
+
fileSizeMb,
|
|
1250
|
+
model
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/providers/minimax/commands/generate-video.ts
|
|
1255
|
+
function registerMinimaxGenerateVideoCommand(parent) {
|
|
1256
|
+
parent.command("generate-video").description("Generate video with MiniMax Hailuo models (async)").requiredOption("--prompt <text>", "Video generation prompt").option("--model <id>", `Model (${[...MINIMAX_VIDEO_MODELS].join("|")})`).option("--duration <n>", `Duration in seconds (${VIDEO_DURATIONS.join("|")})`, "6").option("--resolution <res>", `Resolution (${VIDEO_RESOLUTIONS.join("|")})`, "1080P").option("--first-frame <url>", "First frame image URL").option("--output <path>", "Copy generated video to this path").option("-v, --verbose", "Verbose logging").action(
|
|
1257
|
+
async (opts) => {
|
|
1258
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1259
|
+
const apiKey = requireMinimaxKey();
|
|
1260
|
+
const model = opts.model ?? TASK_DEFAULTS.video;
|
|
1261
|
+
const duration = Number.parseInt(opts.duration, 10);
|
|
1262
|
+
if (!VIDEO_DURATIONS.includes(duration)) {
|
|
1263
|
+
throw new ValidationError(`--duration must be one of: ${VIDEO_DURATIONS.join(", ")}`);
|
|
1264
|
+
}
|
|
1265
|
+
const resolution = opts.resolution.toUpperCase();
|
|
1266
|
+
if (!VIDEO_RESOLUTIONS.includes(resolution)) {
|
|
1267
|
+
throw new ValidationError(`--resolution must be one of: ${VIDEO_RESOLUTIONS.join(", ")}`);
|
|
1268
|
+
}
|
|
1269
|
+
if (opts.firstFrame && !opts.firstFrame.startsWith("http")) {
|
|
1270
|
+
throw new ValidationError("--first-frame must be an http(s) URL");
|
|
1271
|
+
}
|
|
1272
|
+
logger.info("Video generation is async and may take several minutes...");
|
|
1273
|
+
const result = await generateMinimaxVideo({
|
|
1274
|
+
apiKey,
|
|
1275
|
+
prompt: opts.prompt,
|
|
1276
|
+
model,
|
|
1277
|
+
duration,
|
|
1278
|
+
resolution,
|
|
1279
|
+
firstFrame: opts.firstFrame,
|
|
1280
|
+
output: opts.output,
|
|
1281
|
+
logger
|
|
1282
|
+
});
|
|
1283
|
+
if (result.status === "error") {
|
|
1284
|
+
logger.error(result.error ?? "Unknown error");
|
|
1285
|
+
process.exit(1);
|
|
1286
|
+
}
|
|
1287
|
+
console.log(`
|
|
1288
|
+
Generated video: ${result.generatedVideo}`);
|
|
1289
|
+
if (result.generationTime)
|
|
1290
|
+
console.log(`Generation time: ${result.generationTime.toFixed(1)}s`);
|
|
1291
|
+
if (result.fileSizeMb) console.log(`File size: ${result.fileSizeMb.toFixed(2)} MB`);
|
|
1292
|
+
}
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/providers/minimax/generators/image.ts
|
|
1297
|
+
init_errors();
|
|
1298
|
+
init_http_client();
|
|
1299
|
+
import fs14 from "fs";
|
|
1300
|
+
import path12 from "path";
|
|
1301
|
+
async function generateMinimaxImage(opts) {
|
|
1302
|
+
const {
|
|
1303
|
+
apiKey,
|
|
1304
|
+
prompt,
|
|
1305
|
+
model = "image-01",
|
|
1306
|
+
aspectRatio = "1:1",
|
|
1307
|
+
numImages = 1,
|
|
1308
|
+
output,
|
|
1309
|
+
logger
|
|
1310
|
+
} = opts;
|
|
1311
|
+
const payload = {
|
|
1312
|
+
model,
|
|
1313
|
+
prompt,
|
|
1314
|
+
aspect_ratio: aspectRatio,
|
|
1315
|
+
n: Math.min(numImages, 9),
|
|
1316
|
+
response_format: "url",
|
|
1317
|
+
prompt_optimizer: true
|
|
1318
|
+
};
|
|
1319
|
+
logger?.debug(`Generating ${numImages} image(s) with ${model}`);
|
|
1320
|
+
let resp;
|
|
1321
|
+
try {
|
|
1322
|
+
resp = await apiPost("image_generation", payload, apiKey, { logger });
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
1325
|
+
}
|
|
1326
|
+
const imageUrls = resp.data?.image_urls ?? [];
|
|
1327
|
+
if (imageUrls.length === 0) {
|
|
1328
|
+
return { status: "error", error: "No images in response" };
|
|
1329
|
+
}
|
|
1330
|
+
const outDir = getOutputDir();
|
|
1331
|
+
const savedFiles = [];
|
|
1332
|
+
for (let i = 0; i < imageUrls.length; i++) {
|
|
1333
|
+
const url = imageUrls[i];
|
|
1334
|
+
if (!url) continue;
|
|
1335
|
+
try {
|
|
1336
|
+
const bytes = await fetchBytes(url);
|
|
1337
|
+
const fname = `minimax_image_${Date.now()}_${i}.png`;
|
|
1338
|
+
const dest = path12.join(outDir, fname);
|
|
1339
|
+
fs14.writeFileSync(dest, bytes);
|
|
1340
|
+
savedFiles.push(dest);
|
|
1341
|
+
logger?.success(`Saved: ${dest}`);
|
|
1342
|
+
} catch (e) {
|
|
1343
|
+
throw new ProviderError(
|
|
1344
|
+
`Failed to download image: ${e instanceof Error ? e.message : String(e)}`,
|
|
1345
|
+
"minimax"
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (output && savedFiles[0]) {
|
|
1350
|
+
fs14.mkdirSync(path12.dirname(path12.resolve(output)), { recursive: true });
|
|
1351
|
+
fs14.copyFileSync(savedFiles[0], output);
|
|
1352
|
+
}
|
|
1353
|
+
return { status: "success", generatedImages: savedFiles, model };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// src/providers/minimax/commands/generate.ts
|
|
1357
|
+
function registerMinimaxGenerateCommand(parent) {
|
|
1358
|
+
parent.command("generate").description("Generate images with MiniMax").requiredOption("--prompt <text>", "Image generation prompt").option("--model <id>", `Model (${[...MINIMAX_IMAGE_MODELS].join("|")})`).option("--aspect-ratio <ratio>", "Aspect ratio (e.g. 1:1, 16:9)", "1:1").option("--num-images <n>", "Number of images (1-9)", "1").option("--output <path>", "Copy first image to this path").option("-v, --verbose", "Verbose logging").action(
|
|
1359
|
+
async (opts) => {
|
|
1360
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1361
|
+
const apiKey = requireMinimaxKey();
|
|
1362
|
+
const model = opts.model ?? TASK_DEFAULTS.image;
|
|
1363
|
+
const numImages = Math.min(Math.max(Number.parseInt(opts.numImages, 10) || 1, 1), 9);
|
|
1364
|
+
const result = await generateMinimaxImage({
|
|
1365
|
+
apiKey,
|
|
1366
|
+
prompt: opts.prompt,
|
|
1367
|
+
model,
|
|
1368
|
+
aspectRatio: opts.aspectRatio,
|
|
1369
|
+
numImages,
|
|
1370
|
+
output: opts.output,
|
|
1371
|
+
logger
|
|
1372
|
+
});
|
|
1373
|
+
if (result.status === "error") {
|
|
1374
|
+
logger.error(result.error ?? "Unknown error");
|
|
1375
|
+
process.exit(1);
|
|
1376
|
+
}
|
|
1377
|
+
console.log(`
|
|
1378
|
+
Generated ${result.generatedImages?.length ?? 0} image(s):`);
|
|
1379
|
+
for (const f of result.generatedImages ?? []) console.log(` ${f}`);
|
|
1380
|
+
}
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// src/providers/minimax/commands/index.ts
|
|
1385
|
+
function registerMinimaxCommands(program2) {
|
|
1386
|
+
const minimax = program2.command("minimax").description("MiniMax generation: images, video, speech, and music");
|
|
1387
|
+
registerMinimaxGenerateCommand(minimax);
|
|
1388
|
+
registerMinimaxGenerateVideoCommand(minimax);
|
|
1389
|
+
registerMinimaxGenerateSpeechCommand(minimax);
|
|
1390
|
+
registerMinimaxGenerateMusicCommand(minimax);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/providers/openrouter/client.ts
|
|
1394
|
+
import fs15 from "fs";
|
|
1395
|
+
import path13 from "path";
|
|
1396
|
+
init_errors();
|
|
1397
|
+
init_http_client();
|
|
1398
|
+
var OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
1399
|
+
var DEFAULT_OPENROUTER_MODEL = "google/gemini-3.1-flash-image-preview";
|
|
1400
|
+
function requireOpenRouterKey() {
|
|
1401
|
+
const key = resolveKey("OPENROUTER_API_KEY");
|
|
1402
|
+
if (!key) {
|
|
1403
|
+
throw new ConfigError(
|
|
1404
|
+
"OPENROUTER_API_KEY is not set. Get one at https://openrouter.ai/settings/keys"
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
return key;
|
|
1408
|
+
}
|
|
1409
|
+
function buildHeaders(apiKey) {
|
|
1410
|
+
const headers = {
|
|
1411
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1412
|
+
"Content-Type": "application/json"
|
|
1413
|
+
};
|
|
1414
|
+
const referer = resolveKey("OPENROUTER_SITE_URL");
|
|
1415
|
+
const title = resolveKey("OPENROUTER_APP_NAME") ?? "multix";
|
|
1416
|
+
if (referer) headers["HTTP-Referer"] = referer;
|
|
1417
|
+
if (title) headers["X-Title"] = title;
|
|
1418
|
+
return headers;
|
|
1419
|
+
}
|
|
1420
|
+
function buildPayload(opts) {
|
|
1421
|
+
const payload = {
|
|
1422
|
+
messages: [{ role: "user", content: opts.prompt }],
|
|
1423
|
+
modalities: opts.model.includes("gemini") ? ["image", "text"] : ["image"],
|
|
1424
|
+
image_config: { aspect_ratio: opts.aspectRatio }
|
|
1425
|
+
};
|
|
1426
|
+
if (opts.imageSize) {
|
|
1427
|
+
payload.image_config.image_size = opts.imageSize;
|
|
1428
|
+
}
|
|
1429
|
+
if (opts.fallbackModels.length > 0) {
|
|
1430
|
+
payload.models = [opts.model, ...opts.fallbackModels];
|
|
1431
|
+
} else {
|
|
1432
|
+
payload.model = opts.model;
|
|
1433
|
+
}
|
|
1434
|
+
return payload;
|
|
1435
|
+
}
|
|
1436
|
+
async function generateOpenRouterImage(opts) {
|
|
1437
|
+
const { prompt, model, aspectRatio = "1:1", imageSize, numImages = 1, output, logger } = opts;
|
|
1438
|
+
const apiKey = requireOpenRouterKey();
|
|
1439
|
+
const fallbackModels = (resolveKey("OPENROUTER_FALLBACK_MODELS") ?? "").split(",").map((m) => m.trim()).filter(Boolean);
|
|
1440
|
+
const imageUrls = [];
|
|
1441
|
+
let usedModel = model;
|
|
1442
|
+
for (let i = 0; i < Math.max(1, numImages); i++) {
|
|
1443
|
+
const payload = buildPayload({ prompt, model, aspectRatio, imageSize, fallbackModels });
|
|
1444
|
+
logger?.debug(
|
|
1445
|
+
`OpenRouter model: ${model}${fallbackModels.length ? ` (fallbacks: ${fallbackModels.join(", ")})` : ""}`
|
|
1446
|
+
);
|
|
1447
|
+
let data;
|
|
1448
|
+
try {
|
|
1449
|
+
data = await httpJson({
|
|
1450
|
+
url: OPENROUTER_API_URL,
|
|
1451
|
+
method: "POST",
|
|
1452
|
+
headers: buildHeaders(apiKey),
|
|
1453
|
+
body: payload,
|
|
1454
|
+
timeoutMs: 24e4
|
|
1455
|
+
});
|
|
1456
|
+
} catch (e) {
|
|
1457
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
1458
|
+
}
|
|
1459
|
+
const choices = data.choices ?? [];
|
|
1460
|
+
if (choices.length === 0) {
|
|
1461
|
+
return { status: "error", error: `No choices in response: ${JSON.stringify(data)}` };
|
|
1462
|
+
}
|
|
1463
|
+
const messageImages = choices[0]?.message?.images ?? [];
|
|
1464
|
+
if (messageImages.length === 0) {
|
|
1465
|
+
return {
|
|
1466
|
+
status: "error",
|
|
1467
|
+
error: `No images in response \u2014 model '${model}' may not support image generation`
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
for (const img of messageImages) {
|
|
1471
|
+
const url = img.image_url?.url;
|
|
1472
|
+
if (url) imageUrls.push(url);
|
|
1473
|
+
}
|
|
1474
|
+
usedModel = data.model ?? usedModel;
|
|
1475
|
+
if (imageUrls.length >= numImages) break;
|
|
1476
|
+
}
|
|
1477
|
+
const outDir = getOutputDir();
|
|
1478
|
+
const savedFiles = [];
|
|
1479
|
+
for (let i = 0; i < Math.min(imageUrls.length, Math.max(1, numImages)); i++) {
|
|
1480
|
+
const url = imageUrls[i];
|
|
1481
|
+
if (!url) continue;
|
|
1482
|
+
try {
|
|
1483
|
+
const bytes = await fetchBytes(url);
|
|
1484
|
+
const dest = path13.join(outDir, `openrouter_image_${Date.now()}_${i}.png`);
|
|
1485
|
+
fs15.writeFileSync(dest, bytes);
|
|
1486
|
+
savedFiles.push(dest);
|
|
1487
|
+
logger?.success(`Saved: ${dest}`);
|
|
1488
|
+
} catch (e) {
|
|
1489
|
+
throw new ProviderError(
|
|
1490
|
+
`Failed to save image: ${e instanceof Error ? e.message : String(e)}`,
|
|
1491
|
+
"openrouter"
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (output && savedFiles[0]) {
|
|
1496
|
+
fs15.mkdirSync(path13.dirname(path13.resolve(output)), { recursive: true });
|
|
1497
|
+
fs15.writeFileSync(output, fs15.readFileSync(savedFiles[0]));
|
|
1498
|
+
}
|
|
1499
|
+
return { status: "success", generatedImages: savedFiles, model: usedModel };
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// src/providers/openrouter/commands/generate.ts
|
|
1503
|
+
function registerOpenRouterGenerateCommand(parent) {
|
|
1504
|
+
parent.command("generate").description("Generate images via OpenRouter (uses chat completions API)").requiredOption("--prompt <text>", "Image generation prompt").option("--model <id>", "OpenRouter model id (e.g. google/gemini-3.1-flash-image-preview)").option("--aspect-ratio <ratio>", "Aspect ratio (e.g. 1:1, 16:9)", "1:1").option("--image-size <size>", "Image size hint (e.g. 1K, 2K)").option("--num-images <n>", "Number of images to generate", "1").option("--output <path>", "Copy first image to this path").option("-v, --verbose", "Verbose logging").action(
|
|
1505
|
+
async (opts) => {
|
|
1506
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1507
|
+
const model = opts.model ?? resolveKey("OPENROUTER_IMAGE_MODEL") ?? DEFAULT_OPENROUTER_MODEL;
|
|
1508
|
+
const numImages = Math.max(1, Number.parseInt(opts.numImages, 10) || 1);
|
|
1509
|
+
logger.debug(`Generating ${numImages} image(s) via OpenRouter: ${model}`);
|
|
1510
|
+
const result = await generateOpenRouterImage({
|
|
1511
|
+
prompt: opts.prompt,
|
|
1512
|
+
model,
|
|
1513
|
+
aspectRatio: opts.aspectRatio,
|
|
1514
|
+
imageSize: opts.imageSize,
|
|
1515
|
+
numImages,
|
|
1516
|
+
output: opts.output,
|
|
1517
|
+
logger
|
|
1518
|
+
});
|
|
1519
|
+
if (result.status === "error") {
|
|
1520
|
+
logger.error(result.error ?? "Unknown error");
|
|
1521
|
+
process.exit(1);
|
|
1522
|
+
}
|
|
1523
|
+
console.log(`
|
|
1524
|
+
Generated ${result.generatedImages?.length ?? 0} image(s):`);
|
|
1525
|
+
for (const f of result.generatedImages ?? []) console.log(` ${f}`);
|
|
1526
|
+
if (result.model) console.log(`Model used: ${result.model}`);
|
|
1527
|
+
}
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// src/providers/openrouter/commands/index.ts
|
|
1532
|
+
function registerOpenRouterCommands(program2) {
|
|
1533
|
+
const openrouter = program2.command("openrouter").description("OpenRouter image generation via chat completions API");
|
|
1534
|
+
registerOpenRouterGenerateCommand(openrouter);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// src/commands/check-helpers/binary-check.ts
|
|
1538
|
+
import { execa } from "execa";
|
|
1539
|
+
async function checkBinary(binary) {
|
|
1540
|
+
try {
|
|
1541
|
+
const result = await execa(binary, ["--version"], { reject: true, all: true });
|
|
1542
|
+
const version = (result.stdout || result.stderr || "").split("\n")[0]?.trim();
|
|
1543
|
+
return { available: true, version, binary };
|
|
1544
|
+
} catch (e) {
|
|
1545
|
+
const err2 = e;
|
|
1546
|
+
if (err2.code === "ENOENT") return { available: false, binary };
|
|
1547
|
+
const output = (err2.stderr ?? err2.all ?? "").toLowerCase();
|
|
1548
|
+
if (output.includes("not recognized") || output.includes("not found") || output.includes("no such file")) {
|
|
1549
|
+
return { available: false, binary };
|
|
1550
|
+
}
|
|
1551
|
+
if (err2.exitCode !== void 0) return { available: true, binary };
|
|
1552
|
+
return { available: false, binary };
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/commands/check-helpers/gemini-ping.ts
|
|
1557
|
+
init_errors();
|
|
1558
|
+
init_http_client();
|
|
1559
|
+
var MODELS_URL = "https://generativelanguage.googleapis.com/v1beta/models";
|
|
1560
|
+
async function pingGemini(apiKey) {
|
|
1561
|
+
try {
|
|
1562
|
+
const resp = await httpJson({
|
|
1563
|
+
url: `${MODELS_URL}?key=${encodeURIComponent(apiKey)}`,
|
|
1564
|
+
timeoutMs: 1e4
|
|
1565
|
+
});
|
|
1566
|
+
const modelCount = Array.isArray(resp.models) ? resp.models.length : 0;
|
|
1567
|
+
return { status: "success", modelCount };
|
|
1568
|
+
} catch (e) {
|
|
1569
|
+
if (e instanceof HttpError) {
|
|
1570
|
+
if (e.status === 400 || e.status === 401 || e.status === 403) {
|
|
1571
|
+
return { status: "auth_error", error: `HTTP ${e.status}: ${e.snippet}` };
|
|
1572
|
+
}
|
|
1573
|
+
return { status: "network_error", error: `HTTP ${e.status}: ${e.snippet}` };
|
|
1574
|
+
}
|
|
1575
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1576
|
+
return { status: "network_error", error: msg };
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// src/commands/check-helpers/setup-hints.ts
|
|
1581
|
+
var SETUP_HINTS = `
|
|
1582
|
+
To configure multix, set at least one provider API key:
|
|
1583
|
+
|
|
1584
|
+
Provider URLs:
|
|
1585
|
+
Gemini / AI Studio : https://aistudio.google.com/apikey
|
|
1586
|
+
OpenRouter : https://openrouter.ai/settings/keys
|
|
1587
|
+
MiniMax : https://platform.minimax.io/user-center/basic-information/interface-key
|
|
1588
|
+
|
|
1589
|
+
Option A \u2014 user global config (recommended):
|
|
1590
|
+
echo 'GEMINI_API_KEY=your-key' >> ~/.multix/.env
|
|
1591
|
+
|
|
1592
|
+
Option B \u2014 project .env:
|
|
1593
|
+
echo 'GEMINI_API_KEY=your-key' >> .env
|
|
1594
|
+
|
|
1595
|
+
Option C \u2014 shell environment:
|
|
1596
|
+
export GEMINI_API_KEY=your-key
|
|
1597
|
+
export OPENROUTER_API_KEY=your-openrouter-key # optional
|
|
1598
|
+
export MINIMAX_API_KEY=your-minimax-key # optional
|
|
1599
|
+
|
|
1600
|
+
After configuring, verify with:
|
|
1601
|
+
multix check
|
|
1602
|
+
`.trim();
|
|
1603
|
+
var FFMPEG_HINT = "ffmpeg not found \u2014 media optimize/split/batch commands unavailable.\n Linux: sudo apt-get install ffmpeg\n macOS: brew install ffmpeg\n Windows: https://ffmpeg.org/download.html";
|
|
1604
|
+
var MAGICK_HINT = "ImageMagick (magick) not found \u2014 image optimization unavailable.\n Linux: sudo apt-get install imagemagick\n macOS: brew install imagemagick\n Windows: https://imagemagick.org/script/download.php";
|
|
1605
|
+
|
|
1606
|
+
// src/commands/check.ts
|
|
1607
|
+
function registerCheckCommand(program2) {
|
|
1608
|
+
program2.command("check").description("Validate multix setup: tooling, API keys, and live Gemini connectivity").option("-v, --verbose", "Verbose output").action(async (opts) => {
|
|
1609
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1610
|
+
let exitCode = 0;
|
|
1611
|
+
logger.header("Tooling");
|
|
1612
|
+
const ffmpegStatus = await checkBinary("ffmpeg");
|
|
1613
|
+
const magickStatus = await checkBinary("magick");
|
|
1614
|
+
if (ffmpegStatus.available) {
|
|
1615
|
+
logger.success(`ffmpeg found${ffmpegStatus.version ? ` \u2014 ${ffmpegStatus.version}` : ""}`);
|
|
1616
|
+
} else {
|
|
1617
|
+
logger.warn("ffmpeg not found");
|
|
1618
|
+
if (opts.verbose) console.log(` ${FFMPEG_HINT}`);
|
|
1619
|
+
}
|
|
1620
|
+
if (magickStatus.available) {
|
|
1621
|
+
logger.success(`magick found${magickStatus.version ? ` \u2014 ${magickStatus.version}` : ""}`);
|
|
1622
|
+
} else {
|
|
1623
|
+
logger.warn("magick (ImageMagick) not found");
|
|
1624
|
+
if (opts.verbose) console.log(` ${MAGICK_HINT}`);
|
|
1625
|
+
}
|
|
1626
|
+
logger.header("API Keys");
|
|
1627
|
+
const geminiKey = resolveKey("GEMINI_API_KEY");
|
|
1628
|
+
const openrouterKey = resolveKey("OPENROUTER_API_KEY");
|
|
1629
|
+
const minimaxKey = resolveKey("MINIMAX_API_KEY");
|
|
1630
|
+
if (geminiKey) {
|
|
1631
|
+
logger.success(`GEMINI_API_KEY found \u2014 ${redact(geminiKey)}`);
|
|
1632
|
+
} else {
|
|
1633
|
+
logger.warn("GEMINI_API_KEY not set (Gemini analyze/transcribe/doc features unavailable)");
|
|
1634
|
+
}
|
|
1635
|
+
if (openrouterKey) {
|
|
1636
|
+
logger.success(`OPENROUTER_API_KEY found \u2014 ${redact(openrouterKey)}`);
|
|
1637
|
+
} else {
|
|
1638
|
+
logger.info("OPENROUTER_API_KEY not set (optional)");
|
|
1639
|
+
}
|
|
1640
|
+
if (minimaxKey) {
|
|
1641
|
+
logger.success(`MINIMAX_API_KEY found \u2014 ${redact(minimaxKey)}`);
|
|
1642
|
+
} else {
|
|
1643
|
+
logger.info("MINIMAX_API_KEY not set (optional)");
|
|
1644
|
+
}
|
|
1645
|
+
if (!geminiKey && !openrouterKey && !minimaxKey) {
|
|
1646
|
+
logger.header("No Provider Keys Configured");
|
|
1647
|
+
console.log(SETUP_HINTS);
|
|
1648
|
+
process.exit(1);
|
|
1649
|
+
}
|
|
1650
|
+
if (geminiKey) {
|
|
1651
|
+
logger.header("Gemini Connectivity");
|
|
1652
|
+
logger.info("Pinging Gemini models endpoint...");
|
|
1653
|
+
const ping = await pingGemini(geminiKey);
|
|
1654
|
+
if (ping.status === "success") {
|
|
1655
|
+
logger.success(`Gemini API reachable \u2014 ${ping.modelCount ?? 0} models available`);
|
|
1656
|
+
} else if (ping.status === "auth_error") {
|
|
1657
|
+
logger.error(`Gemini auth failed \u2014 check GEMINI_API_KEY: ${ping.error ?? ""}`);
|
|
1658
|
+
exitCode = 1;
|
|
1659
|
+
} else {
|
|
1660
|
+
logger.warn(`Gemini network error (API may be down): ${ping.error ?? ""}`);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
logger.header("Summary");
|
|
1664
|
+
if (exitCode === 0) {
|
|
1665
|
+
logger.success("multix is ready to use");
|
|
1666
|
+
if (geminiKey) {
|
|
1667
|
+
console.log(
|
|
1668
|
+
" Full Gemini multimodal setup (analyze, transcribe, generate, doc convert)"
|
|
1669
|
+
);
|
|
1670
|
+
} else {
|
|
1671
|
+
console.log(" Image generation via OpenRouter/MiniMax available");
|
|
1672
|
+
console.log(" Add GEMINI_API_KEY for analysis, transcription, and doc conversion");
|
|
1673
|
+
}
|
|
1674
|
+
} else {
|
|
1675
|
+
logger.error("One or more checks failed \u2014 see above");
|
|
1676
|
+
}
|
|
1677
|
+
process.exit(exitCode);
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// src/commands/doc/convert.ts
|
|
1682
|
+
init_errors();
|
|
1683
|
+
import fs16 from "fs";
|
|
1684
|
+
import path14 from "path";
|
|
1685
|
+
|
|
1686
|
+
// src/commands/doc/default-prompt.ts
|
|
1687
|
+
var DEFAULT_CONVERT_PROMPT = `Convert this document to clean, well-formatted Markdown.
|
|
1688
|
+
|
|
1689
|
+
Requirements:
|
|
1690
|
+
- Preserve all content, structure, and formatting
|
|
1691
|
+
- Convert tables to markdown table format
|
|
1692
|
+
- Maintain heading hierarchy (# ## ### etc)
|
|
1693
|
+
- Preserve lists, code blocks, and quotes
|
|
1694
|
+
- Extract text from images if present
|
|
1695
|
+
- Keep formatting consistent and readable
|
|
1696
|
+
|
|
1697
|
+
Output only the markdown content without any preamble or explanation.`;
|
|
1698
|
+
|
|
1699
|
+
// src/commands/doc/convert.ts
|
|
1700
|
+
async function convertDocuments(opts) {
|
|
1701
|
+
const { inputs, output, autoName = false, verbose, logger } = opts;
|
|
1702
|
+
const model = opts.model ?? DOC_MODEL_DEFAULT;
|
|
1703
|
+
const prompt = opts.prompt ?? DEFAULT_CONVERT_PROMPT;
|
|
1704
|
+
for (const f of inputs) {
|
|
1705
|
+
if (!fs16.existsSync(f)) throw new ValidationError(`Input file not found: ${f}`);
|
|
1706
|
+
}
|
|
1707
|
+
let outputPath;
|
|
1708
|
+
if (output) {
|
|
1709
|
+
outputPath = path14.resolve(output);
|
|
1710
|
+
} else if (autoName && inputs.length === 1 && inputs[0]) {
|
|
1711
|
+
const base = path14.basename(inputs[0], path14.extname(inputs[0]));
|
|
1712
|
+
outputPath = path14.join(getOutputDir(), `${base}-extraction.md`);
|
|
1713
|
+
} else {
|
|
1714
|
+
outputPath = path14.join(getOutputDir(), "document-extraction.md");
|
|
1715
|
+
}
|
|
1716
|
+
fs16.mkdirSync(path14.dirname(outputPath), { recursive: true });
|
|
1717
|
+
const results = [];
|
|
1718
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
1719
|
+
const filePath = inputs[i];
|
|
1720
|
+
logger?.debug(`[${i + 1}/${inputs.length}] Converting: ${filePath}`);
|
|
1721
|
+
let markdown;
|
|
1722
|
+
let errorMsg;
|
|
1723
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1724
|
+
try {
|
|
1725
|
+
const useApi = shouldUseFileApi(filePath);
|
|
1726
|
+
let contentParts;
|
|
1727
|
+
if (useApi) {
|
|
1728
|
+
logger?.debug("File >20MB \u2014 using Files API");
|
|
1729
|
+
const ref = await uploadFile(filePath, { logger });
|
|
1730
|
+
contentParts = [
|
|
1731
|
+
{ text: prompt },
|
|
1732
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
1733
|
+
];
|
|
1734
|
+
} else {
|
|
1735
|
+
contentParts = [{ text: prompt }, inlinePart(filePath)];
|
|
1736
|
+
}
|
|
1737
|
+
const resp = await generateContent({
|
|
1738
|
+
model,
|
|
1739
|
+
contents: [{ parts: contentParts }]
|
|
1740
|
+
});
|
|
1741
|
+
markdown = extractText(resp);
|
|
1742
|
+
break;
|
|
1743
|
+
} catch (e) {
|
|
1744
|
+
errorMsg = e instanceof Error ? e.message : String(e);
|
|
1745
|
+
if (attempt < 2) {
|
|
1746
|
+
const wait = 2 ** attempt * 1e3;
|
|
1747
|
+
logger?.debug(`Retry ${attempt + 1} after ${wait / 1e3}s: ${errorMsg}`);
|
|
1748
|
+
await sleep4(wait);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
results.push({
|
|
1753
|
+
file: filePath,
|
|
1754
|
+
status: markdown !== void 0 ? "success" : "error",
|
|
1755
|
+
markdown,
|
|
1756
|
+
error: errorMsg
|
|
1757
|
+
});
|
|
1758
|
+
logger?.debug(`Status: ${results[results.length - 1]?.status}`);
|
|
1759
|
+
}
|
|
1760
|
+
const lines = ["# Document Extraction Results\n"];
|
|
1761
|
+
lines.push(`Converted ${inputs.length} document(s) to markdown.
|
|
1762
|
+
|
|
1763
|
+
---
|
|
1764
|
+
`);
|
|
1765
|
+
for (const r of results) {
|
|
1766
|
+
lines.push(`
|
|
1767
|
+
## ${path14.basename(r.file)}
|
|
1768
|
+
`);
|
|
1769
|
+
if (r.status === "success" && r.markdown) {
|
|
1770
|
+
lines.push(`${r.markdown}
|
|
1771
|
+
`);
|
|
1772
|
+
} else if (r.status === "success") {
|
|
1773
|
+
lines.push("**Note**: Conversion succeeded but no content was returned.\n");
|
|
1774
|
+
} else {
|
|
1775
|
+
lines.push(`**Error**: ${r.error ?? "Unknown error"}
|
|
1776
|
+
`);
|
|
1777
|
+
}
|
|
1778
|
+
lines.push("\n---\n");
|
|
1779
|
+
}
|
|
1780
|
+
fs16.writeFileSync(outputPath, lines.join("\n"), "utf8");
|
|
1781
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
1782
|
+
console.log(`
|
|
1783
|
+
${"=".repeat(50)}`);
|
|
1784
|
+
console.log(`Converted: ${results.length} file(s)`);
|
|
1785
|
+
console.log(`Success: ${succeeded}`);
|
|
1786
|
+
console.log(`Failed: ${results.length - succeeded}`);
|
|
1787
|
+
console.log(`Output saved to: ${outputPath}`);
|
|
1788
|
+
if (verbose) {
|
|
1789
|
+
for (const r of results) {
|
|
1790
|
+
console.log(` [${r.status}] ${r.file}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
const failed = results.filter((r) => r.status === "error").length;
|
|
1794
|
+
if (failed > 0) process.exit(1);
|
|
1795
|
+
}
|
|
1796
|
+
function sleep4(ms) {
|
|
1797
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// src/commands/doc/index.ts
|
|
1801
|
+
function registerDocCommands(program2) {
|
|
1802
|
+
const doc = program2.command("doc").description("Document operations: convert PDFs, images, Office docs to Markdown via Gemini");
|
|
1803
|
+
doc.command("convert").description("Convert documents to Markdown using Gemini Files API").requiredOption(
|
|
1804
|
+
"--input <files...>",
|
|
1805
|
+
"Input file(s) to convert",
|
|
1806
|
+
(val, prev) => [...prev, val],
|
|
1807
|
+
[]
|
|
1808
|
+
).alias("-i").option("--output <path>", "Output markdown file path").option("--auto-name", "Derive output filename from input basename (single file only)").option("--model <id>", `Gemini model to use (default: ${DOC_MODEL_DEFAULT})`).option("--prompt <text>", "Custom conversion prompt").option("-v, --verbose", "Verbose logging").action(
|
|
1809
|
+
async (opts) => {
|
|
1810
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1811
|
+
await convertDocuments({
|
|
1812
|
+
inputs: opts.input,
|
|
1813
|
+
output: opts.output,
|
|
1814
|
+
autoName: opts.autoName,
|
|
1815
|
+
model: opts.model,
|
|
1816
|
+
prompt: opts.prompt,
|
|
1817
|
+
verbose: opts.verbose,
|
|
1818
|
+
logger
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// src/commands/media/index.ts
|
|
1825
|
+
init_errors();
|
|
1826
|
+
import fs19 from "fs";
|
|
1827
|
+
import path17 from "path";
|
|
1828
|
+
|
|
1829
|
+
// src/commands/media/batch.ts
|
|
1830
|
+
import fs17 from "fs";
|
|
1831
|
+
import path15 from "path";
|
|
1832
|
+
|
|
1833
|
+
// src/commands/media/detect-type.ts
|
|
1834
|
+
var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".mpg", ".mpeg"]);
|
|
1835
|
+
var AUDIO_EXTS = /* @__PURE__ */ new Set([".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".aiff"]);
|
|
1836
|
+
var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".gif", ".bmp"]);
|
|
1837
|
+
function extToKind(ext) {
|
|
1838
|
+
const lower = ext.toLowerCase();
|
|
1839
|
+
if (VIDEO_EXTS.has(lower)) return "video";
|
|
1840
|
+
if (AUDIO_EXTS.has(lower)) return "audio";
|
|
1841
|
+
if (IMAGE_EXTS.has(lower)) return "image";
|
|
1842
|
+
return void 0;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// src/commands/media/optimize-audio.ts
|
|
1846
|
+
init_errors();
|
|
1847
|
+
|
|
1848
|
+
// src/commands/media/ffmpeg-runner.ts
|
|
1849
|
+
init_errors();
|
|
1850
|
+
import { execa as execa2 } from "execa";
|
|
1851
|
+
async function assertFfmpeg() {
|
|
1852
|
+
try {
|
|
1853
|
+
await execa2("ffmpeg", ["-version"], { reject: true });
|
|
1854
|
+
} catch (e) {
|
|
1855
|
+
if (e.code === "ENOENT") {
|
|
1856
|
+
throw new ValidationError(
|
|
1857
|
+
"ffmpeg not found on PATH.\n Linux: sudo apt-get install ffmpeg\n macOS: brew install ffmpeg\n Windows: https://ffmpeg.org/download.html"
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
async function runFfmpeg(args, opts = {}) {
|
|
1863
|
+
const { verbose, logger } = opts;
|
|
1864
|
+
logger?.debug(`ffmpeg ${args.join(" ")}`);
|
|
1865
|
+
try {
|
|
1866
|
+
const result = await execa2("ffmpeg", args, { reject: true, all: true });
|
|
1867
|
+
if (verbose && result.stderr) logger?.debug(result.stderr);
|
|
1868
|
+
} catch (e) {
|
|
1869
|
+
const err2 = e;
|
|
1870
|
+
const stderr = (err2.stderr ?? err2.all ?? "").toString().slice(0, 1e3);
|
|
1871
|
+
throw new ProviderError(`ffmpeg failed: ${stderr}`, "ffmpeg");
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
async function getMediaInfo(filePath) {
|
|
1875
|
+
try {
|
|
1876
|
+
const result = await execa2(
|
|
1877
|
+
"ffprobe",
|
|
1878
|
+
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
|
1879
|
+
{ reject: true }
|
|
1880
|
+
);
|
|
1881
|
+
const data = JSON.parse(result.stdout);
|
|
1882
|
+
const fmt = data.format ?? {};
|
|
1883
|
+
const info = {
|
|
1884
|
+
sizeByes: Number.parseInt(fmt.size ?? "0", 10),
|
|
1885
|
+
duration: Number.parseFloat(fmt.duration ?? "0"),
|
|
1886
|
+
bitRate: Number.parseInt(fmt.bit_rate ?? "0", 10)
|
|
1887
|
+
};
|
|
1888
|
+
for (const stream of data.streams ?? []) {
|
|
1889
|
+
if (stream.codec_type === "video") {
|
|
1890
|
+
info.width = stream.width;
|
|
1891
|
+
info.height = stream.height;
|
|
1892
|
+
if (stream.r_frame_rate) {
|
|
1893
|
+
const [num, den] = stream.r_frame_rate.split("/").map(Number);
|
|
1894
|
+
info.fps = den ? (num ?? 0) / (den ?? 1) : num;
|
|
1895
|
+
}
|
|
1896
|
+
} else if (stream.codec_type === "audio") {
|
|
1897
|
+
info.sampleRate = Number.parseInt(stream.sample_rate ?? "0", 10);
|
|
1898
|
+
info.channels = stream.channels;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
return info;
|
|
1902
|
+
} catch {
|
|
1903
|
+
return {};
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/commands/media/optimize-audio.ts
|
|
1908
|
+
async function optimizeAudio(opts) {
|
|
1909
|
+
await assertFfmpeg();
|
|
1910
|
+
const { input, output, bitrate = "64k", sampleRate = 16e3, verbose, logger } = opts;
|
|
1911
|
+
const info = await getMediaInfo(input);
|
|
1912
|
+
if (!info.duration) throw new ValidationError(`Cannot read media info from: ${input}`);
|
|
1913
|
+
if (verbose && logger) {
|
|
1914
|
+
logger.info(`Input: ${input}`);
|
|
1915
|
+
logger.info(` Size: ${((info.sizeByes ?? 0) / 1024 / 1024).toFixed(2)} MB`);
|
|
1916
|
+
logger.info(` Duration: ${(info.duration ?? 0).toFixed(2)}s`);
|
|
1917
|
+
}
|
|
1918
|
+
const args = [
|
|
1919
|
+
"-i",
|
|
1920
|
+
input,
|
|
1921
|
+
"-y",
|
|
1922
|
+
"-c:a",
|
|
1923
|
+
"aac",
|
|
1924
|
+
"-b:a",
|
|
1925
|
+
bitrate,
|
|
1926
|
+
"-ar",
|
|
1927
|
+
String(sampleRate),
|
|
1928
|
+
"-ac",
|
|
1929
|
+
"1",
|
|
1930
|
+
// mono — Gemini uses mono
|
|
1931
|
+
output
|
|
1932
|
+
];
|
|
1933
|
+
await runFfmpeg(args, { verbose, logger });
|
|
1934
|
+
logger?.success(`Optimized: ${output}`);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// src/commands/media/magick-runner.ts
|
|
1938
|
+
init_errors();
|
|
1939
|
+
import { execa as execa3 } from "execa";
|
|
1940
|
+
async function assertMagick() {
|
|
1941
|
+
try {
|
|
1942
|
+
await execa3("magick", ["-version"], { reject: true });
|
|
1943
|
+
} catch (e) {
|
|
1944
|
+
if (e.code === "ENOENT") {
|
|
1945
|
+
throw new ValidationError(
|
|
1946
|
+
"ImageMagick (magick) not found on PATH.\n Linux: sudo apt-get install imagemagick\n macOS: brew install imagemagick\n Windows: https://imagemagick.org/script/download.php"
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
async function runMagick(args, opts = {}) {
|
|
1952
|
+
const { verbose, logger } = opts;
|
|
1953
|
+
logger?.debug(`magick ${args.join(" ")}`);
|
|
1954
|
+
try {
|
|
1955
|
+
const result = await execa3("magick", args, { reject: true, all: true });
|
|
1956
|
+
if (verbose && result.stderr) logger?.debug(result.stderr);
|
|
1957
|
+
} catch (e) {
|
|
1958
|
+
const err2 = e;
|
|
1959
|
+
const stderr = (err2.stderr ?? err2.all ?? "").toString().slice(0, 1e3);
|
|
1960
|
+
throw new ProviderError(`magick failed: ${stderr}`, "imagemagick");
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// src/commands/media/optimize-image.ts
|
|
1965
|
+
async function optimizeImage(opts) {
|
|
1966
|
+
await assertMagick();
|
|
1967
|
+
const { input, output, maxWidth = 1920, quality = 85, verbose, logger } = opts;
|
|
1968
|
+
const args = [input, "-resize", `${maxWidth}x>`, "-quality", String(quality), output];
|
|
1969
|
+
await runMagick(args, { verbose, logger });
|
|
1970
|
+
logger?.success(`Optimized: ${output}`);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// src/commands/media/optimize-video.ts
|
|
1974
|
+
init_errors();
|
|
1975
|
+
async function optimizeVideo(opts) {
|
|
1976
|
+
await assertFfmpeg();
|
|
1977
|
+
const {
|
|
1978
|
+
input,
|
|
1979
|
+
output,
|
|
1980
|
+
targetSizeMb,
|
|
1981
|
+
quality = 23,
|
|
1982
|
+
resolution,
|
|
1983
|
+
maxWidth = 1920,
|
|
1984
|
+
verbose,
|
|
1985
|
+
logger
|
|
1986
|
+
} = opts;
|
|
1987
|
+
const info = await getMediaInfo(input);
|
|
1988
|
+
if (!info.duration) throw new ValidationError(`Cannot read media info from: ${input}`);
|
|
1989
|
+
if (verbose && logger) {
|
|
1990
|
+
logger.info(`Input: ${input}`);
|
|
1991
|
+
logger.info(` Size: ${((info.sizeByes ?? 0) / 1024 / 1024).toFixed(2)} MB`);
|
|
1992
|
+
logger.info(` Duration: ${(info.duration ?? 0).toFixed(2)}s`);
|
|
1993
|
+
if (info.width) logger.info(` Resolution: ${info.width}x${info.height}`);
|
|
1994
|
+
logger.info(` Bitrate: ${((info.bitRate ?? 0) / 1e3).toFixed(0)} kbps`);
|
|
1995
|
+
}
|
|
1996
|
+
const args = ["-i", input, "-y"];
|
|
1997
|
+
args.push("-c:v", "libx264", "-crf", String(quality));
|
|
1998
|
+
if (resolution) {
|
|
1999
|
+
args.push("-vf", `scale=${resolution}`);
|
|
2000
|
+
} else if ((info.width ?? 0) > maxWidth) {
|
|
2001
|
+
args.push("-vf", "scale=1920:-2");
|
|
2002
|
+
}
|
|
2003
|
+
args.push("-c:a", "aac", "-b:a", "128k", "-ac", "2");
|
|
2004
|
+
if (targetSizeMb && info.duration) {
|
|
2005
|
+
const targetBits = targetSizeMb * 8 * 1024 * 1024;
|
|
2006
|
+
const targetBitrate = Math.floor(targetBits / info.duration);
|
|
2007
|
+
const videoBitrate = Math.max(targetBitrate - 128e3, 5e5);
|
|
2008
|
+
args.push("-b:v", String(videoBitrate));
|
|
2009
|
+
}
|
|
2010
|
+
args.push(output);
|
|
2011
|
+
await runFfmpeg(args, { verbose, logger });
|
|
2012
|
+
logger?.success(`Optimized: ${output}`);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// src/commands/media/batch.ts
|
|
2016
|
+
var ALL_EXTS = /* @__PURE__ */ new Set([...VIDEO_EXTS, ...AUDIO_EXTS, ...IMAGE_EXTS]);
|
|
2017
|
+
async function batchOptimize(opts) {
|
|
2018
|
+
const {
|
|
2019
|
+
inputDir,
|
|
2020
|
+
outputDir,
|
|
2021
|
+
quality = 85,
|
|
2022
|
+
maxWidth = 1920,
|
|
2023
|
+
bitrate = "64k",
|
|
2024
|
+
verbose,
|
|
2025
|
+
logger
|
|
2026
|
+
} = opts;
|
|
2027
|
+
fs17.mkdirSync(outputDir, { recursive: true });
|
|
2028
|
+
const entries = fs17.readdirSync(inputDir);
|
|
2029
|
+
const mediaFiles = entries.filter((f) => {
|
|
2030
|
+
const ext = path15.extname(f).toLowerCase();
|
|
2031
|
+
return ALL_EXTS.has(ext);
|
|
2032
|
+
});
|
|
2033
|
+
if (mediaFiles.length === 0) {
|
|
2034
|
+
logger?.warn(`No supported media files found in ${inputDir}`);
|
|
2035
|
+
return { total: 0, succeeded: 0, failed: 0, errors: [] };
|
|
2036
|
+
}
|
|
2037
|
+
logger?.info(`Found ${mediaFiles.length} files to process`);
|
|
2038
|
+
let succeeded = 0;
|
|
2039
|
+
const errors = [];
|
|
2040
|
+
for (const file of mediaFiles) {
|
|
2041
|
+
const inputPath = path15.join(inputDir, file);
|
|
2042
|
+
const outputPath = path15.join(outputDir, file);
|
|
2043
|
+
const ext = path15.extname(file).toLowerCase();
|
|
2044
|
+
const kind = extToKind(ext);
|
|
2045
|
+
logger?.debug(`Processing: ${file}`);
|
|
2046
|
+
try {
|
|
2047
|
+
if (kind === "video") {
|
|
2048
|
+
await optimizeVideo({
|
|
2049
|
+
input: inputPath,
|
|
2050
|
+
output: outputPath,
|
|
2051
|
+
quality,
|
|
2052
|
+
maxWidth,
|
|
2053
|
+
verbose,
|
|
2054
|
+
logger
|
|
2055
|
+
});
|
|
2056
|
+
} else if (kind === "audio") {
|
|
2057
|
+
await optimizeAudio({ input: inputPath, output: outputPath, bitrate, verbose, logger });
|
|
2058
|
+
} else if (kind === "image") {
|
|
2059
|
+
await optimizeImage({
|
|
2060
|
+
input: inputPath,
|
|
2061
|
+
output: outputPath,
|
|
2062
|
+
maxWidth,
|
|
2063
|
+
quality,
|
|
2064
|
+
verbose,
|
|
2065
|
+
logger
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
succeeded++;
|
|
2069
|
+
} catch (e) {
|
|
2070
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2071
|
+
logger?.error(`Failed: ${file} \u2014 ${msg}`);
|
|
2072
|
+
errors.push({ file, error: msg });
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
return { total: mediaFiles.length, succeeded, failed: errors.length, errors };
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// src/commands/media/split-video.ts
|
|
2079
|
+
init_errors();
|
|
2080
|
+
import fs18 from "fs";
|
|
2081
|
+
import path16 from "path";
|
|
2082
|
+
async function splitVideo(opts) {
|
|
2083
|
+
await assertFfmpeg();
|
|
2084
|
+
const { input, outputDir, chunkDuration = 3600, verbose, logger } = opts;
|
|
2085
|
+
const info = await getMediaInfo(input);
|
|
2086
|
+
if (!info.duration) throw new ValidationError(`Cannot read media info from: ${input}`);
|
|
2087
|
+
const numChunks = Math.ceil(info.duration / chunkDuration);
|
|
2088
|
+
if (numChunks <= 1) {
|
|
2089
|
+
logger?.info("Video is short enough \u2014 no splitting needed");
|
|
2090
|
+
return [input];
|
|
2091
|
+
}
|
|
2092
|
+
fs18.mkdirSync(outputDir, { recursive: true });
|
|
2093
|
+
const ext = path16.extname(input) || ".mp4";
|
|
2094
|
+
const baseName = path16.basename(input, ext);
|
|
2095
|
+
const outputPattern = path16.join(outputDir, `${baseName}_chunk_%03d${ext}`);
|
|
2096
|
+
logger?.debug(`Splitting into ~${numChunks} chunks of ${chunkDuration}s each`);
|
|
2097
|
+
const args = [
|
|
2098
|
+
"-i",
|
|
2099
|
+
input,
|
|
2100
|
+
"-y",
|
|
2101
|
+
"-f",
|
|
2102
|
+
"segment",
|
|
2103
|
+
"-segment_time",
|
|
2104
|
+
String(chunkDuration),
|
|
2105
|
+
"-c",
|
|
2106
|
+
"copy",
|
|
2107
|
+
"-reset_timestamps",
|
|
2108
|
+
"1",
|
|
2109
|
+
outputPattern
|
|
2110
|
+
];
|
|
2111
|
+
await runFfmpeg(args, { verbose, logger });
|
|
2112
|
+
const files = [];
|
|
2113
|
+
for (let i = 0; i < numChunks + 1; i++) {
|
|
2114
|
+
const fname = path16.join(outputDir, `${baseName}_chunk_${String(i).padStart(3, "0")}${ext}`);
|
|
2115
|
+
if (fs18.existsSync(fname)) files.push(fname);
|
|
2116
|
+
}
|
|
2117
|
+
logger?.success(`Created ${files.length} chunks in ${outputDir}`);
|
|
2118
|
+
return files;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// src/commands/media/index.ts
|
|
2122
|
+
function registerMediaCommands(program2) {
|
|
2123
|
+
const media = program2.command("media").description(
|
|
2124
|
+
"Media optimization: compress video/audio/images, split long videos, batch process"
|
|
2125
|
+
);
|
|
2126
|
+
media.command("optimize").description("Optimize a single media file (video, audio, or image)").requiredOption("--input <file>", "Input media file").requiredOption("--output <file>", "Output file path").option("--target-size <MB>", "Target file size in MB").option("--quality <n>", "Quality: video CRF (0-51) or image (1-100)", "85").option("--max-width <px>", "Max image/video width in pixels", "1920").option("--bitrate <rate>", "Audio bitrate (e.g. 64k)", "64k").option("--resolution <WxH>", "Force video resolution (e.g. 1920x1080)").option("-v, --verbose", "Verbose logging").action(
|
|
2127
|
+
async (opts) => {
|
|
2128
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
2129
|
+
if (!fs19.existsSync(opts.input)) throw new ValidationError(`Input not found: ${opts.input}`);
|
|
2130
|
+
fs19.mkdirSync(path17.dirname(path17.resolve(opts.output)), { recursive: true });
|
|
2131
|
+
const ext = path17.extname(opts.input).toLowerCase();
|
|
2132
|
+
const kind = extToKind(ext);
|
|
2133
|
+
if (!kind) throw new ValidationError(`Unsupported file type: ${ext}`);
|
|
2134
|
+
const quality = Number.parseInt(opts.quality, 10);
|
|
2135
|
+
const maxWidth = Number.parseInt(opts.maxWidth, 10);
|
|
2136
|
+
const targetSizeMb = opts.targetSize ? Number.parseInt(opts.targetSize, 10) : void 0;
|
|
2137
|
+
if (kind === "video") {
|
|
2138
|
+
await optimizeVideo({
|
|
2139
|
+
input: opts.input,
|
|
2140
|
+
output: opts.output,
|
|
2141
|
+
targetSizeMb,
|
|
2142
|
+
quality,
|
|
2143
|
+
maxWidth,
|
|
2144
|
+
resolution: opts.resolution,
|
|
2145
|
+
verbose: opts.verbose,
|
|
2146
|
+
logger
|
|
2147
|
+
});
|
|
2148
|
+
} else if (kind === "audio") {
|
|
2149
|
+
await optimizeAudio({
|
|
2150
|
+
input: opts.input,
|
|
2151
|
+
output: opts.output,
|
|
2152
|
+
bitrate: opts.bitrate,
|
|
2153
|
+
verbose: opts.verbose,
|
|
2154
|
+
logger
|
|
2155
|
+
});
|
|
2156
|
+
} else {
|
|
2157
|
+
await optimizeImage({
|
|
2158
|
+
input: opts.input,
|
|
2159
|
+
output: opts.output,
|
|
2160
|
+
maxWidth,
|
|
2161
|
+
quality,
|
|
2162
|
+
verbose: opts.verbose,
|
|
2163
|
+
logger
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
);
|
|
2168
|
+
media.command("split").description("Split a long video into fixed-duration chunks").requiredOption("--input <file>", "Input video file").option("--output-dir <dir>", "Output directory for chunks", "./chunks").option("--chunk-duration <sec>", "Chunk duration in seconds", "3600").option("-v, --verbose", "Verbose logging").action(
|
|
2169
|
+
async (opts) => {
|
|
2170
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
2171
|
+
if (!fs19.existsSync(opts.input)) throw new ValidationError(`Input not found: ${opts.input}`);
|
|
2172
|
+
const chunks = await splitVideo({
|
|
2173
|
+
input: opts.input,
|
|
2174
|
+
outputDir: opts.outputDir,
|
|
2175
|
+
chunkDuration: Number.parseInt(opts.chunkDuration, 10),
|
|
2176
|
+
verbose: opts.verbose,
|
|
2177
|
+
logger
|
|
2178
|
+
});
|
|
2179
|
+
console.log(`
|
|
2180
|
+
Created ${chunks.length} chunk(s) in ${opts.outputDir}`);
|
|
2181
|
+
for (const c2 of chunks) console.log(` ${c2}`);
|
|
2182
|
+
}
|
|
2183
|
+
);
|
|
2184
|
+
media.command("batch").description("Batch optimize all media files in a directory").requiredOption("--input-dir <dir>", "Input directory").requiredOption("--output-dir <dir>", "Output directory").option("--quality <n>", "Quality setting", "85").option("--max-width <px>", "Max image/video width", "1920").option("--bitrate <rate>", "Audio bitrate", "64k").option("-v, --verbose", "Verbose logging").action(
|
|
2185
|
+
async (opts) => {
|
|
2186
|
+
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
2187
|
+
if (!fs19.existsSync(opts.inputDir))
|
|
2188
|
+
throw new ValidationError(`Input dir not found: ${opts.inputDir}`);
|
|
2189
|
+
const result = await batchOptimize({
|
|
2190
|
+
inputDir: opts.inputDir,
|
|
2191
|
+
outputDir: opts.outputDir,
|
|
2192
|
+
quality: Number.parseInt(opts.quality, 10),
|
|
2193
|
+
maxWidth: Number.parseInt(opts.maxWidth, 10),
|
|
2194
|
+
bitrate: opts.bitrate,
|
|
2195
|
+
verbose: opts.verbose,
|
|
2196
|
+
logger
|
|
2197
|
+
});
|
|
2198
|
+
console.log(`
|
|
2199
|
+
Processed: ${result.succeeded}/${result.total} files`);
|
|
2200
|
+
if (result.failed > 0) {
|
|
2201
|
+
console.log(`Failed: ${result.failed}`);
|
|
2202
|
+
process.exit(1);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// src/commands/index.ts
|
|
2209
|
+
function registerCommands(program2) {
|
|
2210
|
+
registerCheckCommand(program2);
|
|
2211
|
+
registerGeminiCommands(program2);
|
|
2212
|
+
registerMinimaxCommands(program2);
|
|
2213
|
+
registerOpenRouterCommands(program2);
|
|
2214
|
+
registerMediaCommands(program2);
|
|
2215
|
+
registerDocCommands(program2);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// src/core/index.ts
|
|
2219
|
+
init_errors();
|
|
2220
|
+
init_http_client();
|
|
2221
|
+
|
|
2222
|
+
// src/cli.ts
|
|
2223
|
+
loadEnv();
|
|
2224
|
+
var require2 = createRequire(import.meta.url);
|
|
2225
|
+
var pkg = require2("../package.json");
|
|
2226
|
+
var program = new Command().name("multix").description(
|
|
2227
|
+
"AI multimodal CLI \u2014 Gemini \xB7 MiniMax \xB7 OpenRouter \xB7 ffmpeg \xB7 ImageMagick \xB7 doc-to-md"
|
|
2228
|
+
).version(pkg.version);
|
|
2229
|
+
registerCommands(program);
|
|
2230
|
+
program.parseAsync(process.argv).catch((err2) => {
|
|
2231
|
+
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
2232
|
+
console.error(`Error: ${msg}`);
|
|
2233
|
+
process.exit(1);
|
|
2234
|
+
});
|