hyper-animator-codex 0.6.0 → 0.7.1

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.
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import {
7
+ readMinimaxRuntimeConfig,
8
+ redactMinimaxConfig,
9
+ } from "./minimax_runtime_config.mjs";
10
+
11
+ const DEFAULT_TTS_ENDPOINT = "https://api.minimaxi.com/v1/t2a_v2";
12
+ const DEFAULT_VOICE_ENDPOINT = "https://api.minimaxi.com/v1/get_voice";
13
+ const DEFAULT_TTS_MODEL = "speech-2.8-hd";
14
+ const DEFAULT_VOICE_ID = "XiaoR_001";
15
+ const EMOTIONS = new Set(["happy", "sad", "angry", "fearful", "disgusted", "surprised", "calm", "fluent", "whipser"]);
16
+ const VOICE_TYPES = new Set(["all", "system", "voice_cloning", "voice_generation"]);
17
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
18
+ const skillRoot = dirname(scriptDir);
19
+
20
+ function requireValue(args, index, flag) {
21
+ const value = args[index + 1];
22
+ if (!value || value.startsWith("--")) {
23
+ throw new Error(`${flag} requires a value`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ function parseArgs(args) {
29
+ const parsed = {
30
+ list_voices: false,
31
+ voice_type: "all",
32
+ narration_json: undefined,
33
+ request_json: undefined,
34
+ text: undefined,
35
+ voice_id: undefined,
36
+ emotion: undefined,
37
+ language: undefined,
38
+ output_dir: undefined,
39
+ model: undefined,
40
+ audio_format: undefined,
41
+ sample_rate: undefined,
42
+ bitrate: undefined,
43
+ channel: undefined,
44
+ speed: undefined,
45
+ volume: undefined,
46
+ pitch: undefined,
47
+ subtitle_enable: undefined,
48
+ config_path: undefined,
49
+ dry_run: false,
50
+ };
51
+
52
+ for (let index = 0; index < args.length; index += 1) {
53
+ const arg = args[index];
54
+ if (arg === "--list-voices") {
55
+ parsed.list_voices = true;
56
+ } else if (arg === "--voice-type") {
57
+ parsed.voice_type = requireValue(args, index, arg);
58
+ index += 1;
59
+ } else if (arg === "--narration-json") {
60
+ parsed.narration_json = requireValue(args, index, arg);
61
+ index += 1;
62
+ } else if (arg === "--request-json") {
63
+ parsed.request_json = requireValue(args, index, arg);
64
+ index += 1;
65
+ } else if (arg === "--text") {
66
+ parsed.text = requireValue(args, index, arg);
67
+ index += 1;
68
+ } else if (arg === "--voice-id") {
69
+ parsed.voice_id = requireValue(args, index, arg);
70
+ index += 1;
71
+ } else if (arg === "--emotion") {
72
+ parsed.emotion = requireValue(args, index, arg);
73
+ index += 1;
74
+ } else if (arg === "--language") {
75
+ parsed.language = requireValue(args, index, arg);
76
+ index += 1;
77
+ } else if (arg === "--output-dir") {
78
+ parsed.output_dir = requireValue(args, index, arg);
79
+ index += 1;
80
+ } else if (arg === "--model") {
81
+ parsed.model = requireValue(args, index, arg);
82
+ index += 1;
83
+ } else if (arg === "--format") {
84
+ parsed.audio_format = requireValue(args, index, arg);
85
+ index += 1;
86
+ } else if (arg === "--sample-rate") {
87
+ parsed.sample_rate = Number.parseInt(requireValue(args, index, arg), 10);
88
+ index += 1;
89
+ } else if (arg === "--bitrate") {
90
+ parsed.bitrate = Number.parseInt(requireValue(args, index, arg), 10);
91
+ index += 1;
92
+ } else if (arg === "--channel") {
93
+ parsed.channel = Number.parseInt(requireValue(args, index, arg), 10);
94
+ index += 1;
95
+ } else if (arg === "--speed") {
96
+ parsed.speed = Number.parseFloat(requireValue(args, index, arg));
97
+ index += 1;
98
+ } else if (arg === "--volume") {
99
+ parsed.volume = Number.parseFloat(requireValue(args, index, arg));
100
+ index += 1;
101
+ } else if (arg === "--pitch") {
102
+ parsed.pitch = Number.parseInt(requireValue(args, index, arg), 10);
103
+ index += 1;
104
+ } else if (arg === "--subtitle-enable") {
105
+ parsed.subtitle_enable = true;
106
+ } else if (arg === "--config") {
107
+ parsed.config_path = requireValue(args, index, arg);
108
+ index += 1;
109
+ } else if (arg === "--dry-run") {
110
+ parsed.dry_run = true;
111
+ } else if (arg === "--help" || arg === "-h") {
112
+ parsed.help = true;
113
+ } else {
114
+ throw new Error(`Unknown option: ${arg}`);
115
+ }
116
+ }
117
+
118
+ return parsed;
119
+ }
120
+
121
+ function printHelp() {
122
+ console.log(`Usage:
123
+ node scripts/generate_minimax_tts.mjs --narration-json <file> [options]
124
+ node scripts/generate_minimax_tts.mjs --list-voices [--voice-type all]
125
+
126
+ Options:
127
+ --list-voices Query MiniMax available voices
128
+ --voice-type <type> all, system, voice_cloning, or voice_generation
129
+ --narration-json <file> Narration JSON with scenes[].narration
130
+ --request-json <file> Structured TTS request options
131
+ --text <text> Direct text input
132
+ --voice-id <id> MiniMax voice_id, default from narration or XiaoR_001
133
+ --emotion <label> happy, sad, angry, fearful, disgusted, surprised, calm, fluent, whipser
134
+ --language <code> zh, en, or MiniMax language_boost value
135
+ --output-dir <dir> Directory for generated audio and metadata
136
+ --model <model> MiniMax speech model, default speech-2.8-hd
137
+ --format <mp3|wav|pcm> Audio encoding format, default mp3
138
+ --sample-rate <rate> Default 32000
139
+ --bitrate <bits> Default 128000
140
+ --channel <count> Default 1
141
+ --speed <value> Default 1
142
+ --volume <value> Default 1
143
+ --pitch <value> Default 0
144
+ --subtitle-enable Ask MiniMax to include subtitle data when available
145
+ --config <file> Explicit MiniMax config JSON
146
+ --dry-run Print redacted request without contacting MiniMax
147
+ `);
148
+ }
149
+
150
+ function printJson(payload) {
151
+ console.log(JSON.stringify(payload, null, 2));
152
+ }
153
+
154
+ function minimaxError(message, fields = {}) {
155
+ const error = new Error(message);
156
+ Object.assign(error, fields);
157
+ return error;
158
+ }
159
+
160
+ function makeErrorEnvelope(error, attempts = 0) {
161
+ return {
162
+ ok: false,
163
+ provider: "minimax",
164
+ error: {
165
+ kind: error.kind || "validation",
166
+ message: error.message,
167
+ retryable: Boolean(error.retryable),
168
+ http_status: error.http_status ?? null,
169
+ status_code: error.status_code ?? null,
170
+ },
171
+ attempts,
172
+ };
173
+ }
174
+
175
+ async function readJsonObject(path, label) {
176
+ if (!path) {
177
+ return {};
178
+ }
179
+
180
+ const parsed = JSON.parse(await readFile(path, "utf8"));
181
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
182
+ throw minimaxError(`${label} must contain a JSON object: ${path}`, { kind: "validation" });
183
+ }
184
+ return parsed;
185
+ }
186
+
187
+ function validateNarration(narration) {
188
+ if (!narration || typeof narration !== "object" || !Array.isArray(narration.scenes)) {
189
+ throw minimaxError("narration JSON must include scenes[]", { kind: "validation" });
190
+ }
191
+
192
+ narration.scenes.forEach((scene, index) => {
193
+ if (typeof scene?.narration !== "string" || !scene.narration.trim()) {
194
+ throw minimaxError(`scenes[${index}].narration is required`, { kind: "validation" });
195
+ }
196
+ });
197
+ }
198
+
199
+ function languageBoost(value) {
200
+ const language = typeof value === "string" ? value.trim() : "";
201
+ if (!language || language === "zh" || language === "zh-CN") {
202
+ return "Chinese";
203
+ }
204
+ if (language === "en" || language === "en-US") {
205
+ return "English";
206
+ }
207
+ return language;
208
+ }
209
+
210
+ function mergeOptions({ requestJson, narration, cliOptions }) {
211
+ const narrationText = narration?.scenes?.map((scene) => scene.narration.trim()).filter(Boolean).join("\n");
212
+
213
+ return {
214
+ text: cliOptions.text ?? requestJson.text ?? narrationText,
215
+ voice_id: cliOptions.voice_id ?? requestJson.voice_id ?? narration?.voice ?? DEFAULT_VOICE_ID,
216
+ emotion: cliOptions.emotion ?? requestJson.emotion ?? narration?.emotion ?? "calm",
217
+ language: cliOptions.language ?? requestJson.language ?? narration?.language ?? "zh",
218
+ output_dir: cliOptions.output_dir ?? requestJson.output_dir ?? join(process.cwd(), "hyper-animator-output", "voice"),
219
+ model: cliOptions.model ?? requestJson.model ?? DEFAULT_TTS_MODEL,
220
+ audio_format: cliOptions.audio_format ?? requestJson.audio_format ?? "mp3",
221
+ sample_rate: cliOptions.sample_rate ?? requestJson.sample_rate ?? 32000,
222
+ bitrate: cliOptions.bitrate ?? requestJson.bitrate ?? 128000,
223
+ channel: cliOptions.channel ?? requestJson.channel ?? 1,
224
+ speed: cliOptions.speed ?? requestJson.speed ?? 1,
225
+ volume: cliOptions.volume ?? requestJson.volume ?? 1,
226
+ pitch: cliOptions.pitch ?? requestJson.pitch ?? 0,
227
+ subtitle_enable: cliOptions.subtitle_enable ?? Boolean(requestJson.subtitle_enable),
228
+ };
229
+ }
230
+
231
+ function validateOptions(options) {
232
+ if (!options.text || !options.text.trim()) {
233
+ throw minimaxError("--text or --narration-json is required", { kind: "validation" });
234
+ }
235
+ if (options.text.length >= 10000) {
236
+ throw minimaxError("MiniMax TTS text must be fewer than 10000 characters", { kind: "validation" });
237
+ }
238
+ if (!options.voice_id || !options.voice_id.trim()) {
239
+ throw minimaxError("voice_id is required", { kind: "validation" });
240
+ }
241
+ if (!EMOTIONS.has(options.emotion)) {
242
+ throw minimaxError(`Unsupported emotion: ${options.emotion}`, { kind: "validation" });
243
+ }
244
+ if (!["mp3", "wav", "pcm"].includes(options.audio_format)) {
245
+ throw minimaxError("--format must be mp3, wav, or pcm", { kind: "validation" });
246
+ }
247
+ if (![16000, 24000, 32000, 44100].includes(options.sample_rate)) {
248
+ throw minimaxError("--sample-rate must be 16000, 24000, 32000, or 44100", { kind: "validation" });
249
+ }
250
+ if (![32000, 64000, 128000, 256000].includes(options.bitrate)) {
251
+ throw minimaxError("--bitrate must be 32000, 64000, 128000, or 256000", { kind: "validation" });
252
+ }
253
+ if (![1, 2].includes(options.channel)) {
254
+ throw minimaxError("--channel must be 1 or 2", { kind: "validation" });
255
+ }
256
+ }
257
+
258
+ function buildTtsRequest(options) {
259
+ return {
260
+ model: options.model,
261
+ text: options.text,
262
+ stream: false,
263
+ output_format: "hex",
264
+ voice_setting: {
265
+ voice_id: options.voice_id,
266
+ speed: options.speed,
267
+ vol: options.volume,
268
+ pitch: options.pitch,
269
+ emotion: options.emotion,
270
+ },
271
+ audio_setting: {
272
+ sample_rate: options.sample_rate,
273
+ bitrate: options.bitrate,
274
+ format: options.audio_format,
275
+ channel: options.channel,
276
+ },
277
+ language_boost: languageBoost(options.language),
278
+ subtitle_enable: options.subtitle_enable,
279
+ };
280
+ }
281
+
282
+ function endpointFromEnv(env = process.env) {
283
+ return {
284
+ tts: env.MINIMAX_TTS_ENDPOINT || DEFAULT_TTS_ENDPOINT,
285
+ voice: env.MINIMAX_VOICE_ENDPOINT || DEFAULT_VOICE_ENDPOINT,
286
+ };
287
+ }
288
+
289
+ function timeoutFromEnv(env = process.env) {
290
+ const value = Number.parseInt(env.MINIMAX_REQUEST_TIMEOUT_MS || "60000", 10);
291
+ return Number.isFinite(value) && value > 0 ? value : 60000;
292
+ }
293
+
294
+ function classifyHttpStatus(status) {
295
+ if (status === 401 || status === 403) {
296
+ return { kind: "auth", retryable: false };
297
+ }
298
+ if (status === 429) {
299
+ return { kind: "rate_limit", retryable: true };
300
+ }
301
+ if (status >= 500) {
302
+ return { kind: "server", retryable: true };
303
+ }
304
+ return { kind: "response", retryable: false };
305
+ }
306
+
307
+ async function fetchWithTimeout(url, options, timeoutMs) {
308
+ if (!URL.canParse(url)) {
309
+ throw minimaxError(`MiniMax endpoint is not a valid URL: ${url}`, { kind: "validation" });
310
+ }
311
+
312
+ const controller = new AbortController();
313
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
314
+ try {
315
+ return await fetch(url, { ...options, signal: controller.signal });
316
+ } catch (error) {
317
+ if (error?.name === "AbortError") {
318
+ throw minimaxError("MiniMax request timed out", { kind: "timeout", retryable: true });
319
+ }
320
+ if (typeof error?.cause?.code === "string") {
321
+ throw minimaxError(`MiniMax network error: ${error.message}`, { kind: "network", retryable: true });
322
+ }
323
+ throw minimaxError(`MiniMax request setup failed: ${error.message}`, { kind: "response" });
324
+ } finally {
325
+ clearTimeout(timeout);
326
+ }
327
+ }
328
+
329
+ async function postMiniMax(config, endpoint, request, timeoutMs) {
330
+ const response = await fetchWithTimeout(endpoint, {
331
+ method: "POST",
332
+ headers: {
333
+ "Content-Type": "application/json",
334
+ Authorization: `Bearer ${config.api_key}`,
335
+ },
336
+ body: JSON.stringify(request),
337
+ }, timeoutMs);
338
+
339
+ const text = await response.text();
340
+ let json = null;
341
+
342
+ if (text) {
343
+ try {
344
+ json = JSON.parse(text);
345
+ } catch {
346
+ json = null;
347
+ }
348
+ }
349
+
350
+ if (!response.ok) {
351
+ const message = json?.base_resp?.status_msg || `HTTP ${response.status}`;
352
+ throw minimaxError(`MiniMax request failed: ${message}`, {
353
+ ...classifyHttpStatus(response.status),
354
+ http_status: response.status,
355
+ status_code: json?.base_resp?.status_code ?? null,
356
+ });
357
+ }
358
+
359
+ if (!json) {
360
+ throw minimaxError(`MiniMax returned non-JSON response with HTTP ${response.status}`, {
361
+ kind: "response",
362
+ http_status: response.status,
363
+ });
364
+ }
365
+
366
+ const statusCode = json.base_resp?.status_code;
367
+ if (statusCode !== undefined && statusCode !== 0) {
368
+ const message = json.base_resp?.status_msg || "unknown MiniMax error";
369
+ throw minimaxError(`MiniMax request failed with status_code ${statusCode}: ${message}`, {
370
+ kind: "provider",
371
+ http_status: response.status,
372
+ status_code: statusCode,
373
+ });
374
+ }
375
+
376
+ return json;
377
+ }
378
+
379
+ function decodeHexAudio(value) {
380
+ if (typeof value !== "string" || !/^[0-9a-fA-F]+$/.test(value) || value.length % 2 !== 0) {
381
+ throw minimaxError("MiniMax response audio is not valid hex data", { kind: "response" });
382
+ }
383
+ return Buffer.from(value, "hex");
384
+ }
385
+
386
+ function safeFileStem(value) {
387
+ const stem = value
388
+ .toLowerCase()
389
+ .replace(/[^a-z0-9]+/g, "-")
390
+ .replace(/^-+|-+$/g, "")
391
+ .slice(0, 48);
392
+ return stem || "minimax-tts";
393
+ }
394
+
395
+ async function writeTtsOutput({ responseJson, request, outputDir, endpoint }) {
396
+ if (responseJson.data?.status !== 2) {
397
+ throw minimaxError(`MiniMax TTS generation is not complete; data.status is ${responseJson.data?.status}`, { kind: "provider" });
398
+ }
399
+ if (!responseJson.data?.audio) {
400
+ throw minimaxError("MiniMax response did not include data.audio", { kind: "provider" });
401
+ }
402
+
403
+ await mkdir(outputDir, { recursive: true });
404
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
405
+ const extension = request.audio_setting.format === "pcm" ? "pcm" : request.audio_setting.format;
406
+ const stem = `${safeFileStem(request.voice_setting.voice_id)}-${timestamp}`;
407
+ const audioPath = join(outputDir, `${stem}.${extension}`);
408
+ const metadataPath = join(outputDir, `${stem}.minimax-tts.json`);
409
+
410
+ await writeFile(audioPath, decodeHexAudio(responseJson.data.audio));
411
+ await writeFile(metadataPath, `${JSON.stringify({
412
+ provider: "minimax",
413
+ endpoint,
414
+ request,
415
+ response: {
416
+ base_resp: responseJson.base_resp,
417
+ data: { status: responseJson.data.status },
418
+ extra_info: responseJson.extra_info,
419
+ trace_id: responseJson.trace_id,
420
+ },
421
+ audio_path: audioPath,
422
+ }, null, 2)}\n`, "utf8");
423
+
424
+ return { audioPath, metadataPath };
425
+ }
426
+
427
+ function flattenVoices(json) {
428
+ const order = ["voice_cloning", "voice_generation", "system_voice", "music_generation"];
429
+ return order.flatMap((type) => (Array.isArray(json[type]) ? json[type] : []).map((voice) => ({
430
+ type,
431
+ voice_id: voice.voice_id,
432
+ voice_name: voice.voice_name,
433
+ description: voice.description,
434
+ created_time: voice.created_time,
435
+ })));
436
+ }
437
+
438
+ async function readRuntime(options) {
439
+ let runtime;
440
+ try {
441
+ runtime = await readMinimaxRuntimeConfig({
442
+ skillRoot,
443
+ env: process.env,
444
+ configPath: options.config_path,
445
+ });
446
+ } catch (error) {
447
+ throw Object.assign(error, { kind: "config", retryable: false });
448
+ }
449
+
450
+ if (!runtime) {
451
+ throw minimaxError("MiniMax config not found. Run hyper-animator-codex install with --minimax-api-key and --minimax-group-id, or set MINIMAX_API_KEY and MINIMAX_GROUP_ID.", {
452
+ kind: "config",
453
+ retryable: false,
454
+ });
455
+ }
456
+
457
+ return runtime;
458
+ }
459
+
460
+ async function listVoices(options, runtime, endpoints, timeoutMs) {
461
+ if (!VOICE_TYPES.has(options.voice_type)) {
462
+ throw minimaxError(`Unsupported voice_type: ${options.voice_type}`, { kind: "validation" });
463
+ }
464
+
465
+ const request = { voice_type: options.voice_type };
466
+ const responseJson = await postMiniMax(runtime.config, endpoints.voice, request, timeoutMs);
467
+
468
+ return {
469
+ ok: true,
470
+ provider: "minimax",
471
+ endpoint: endpoints.voice,
472
+ voice_type: options.voice_type,
473
+ config: {
474
+ source: runtime.source,
475
+ path: runtime.configPath,
476
+ redacted: redactMinimaxConfig(runtime.config),
477
+ },
478
+ voices: flattenVoices(responseJson),
479
+ };
480
+ }
481
+
482
+ async function main() {
483
+ try {
484
+ const cliOptions = parseArgs(process.argv.slice(2));
485
+ const endpoints = endpointFromEnv();
486
+ const timeoutMs = timeoutFromEnv();
487
+
488
+ if (cliOptions.help) {
489
+ printHelp();
490
+ return;
491
+ }
492
+
493
+ const runtime = await readRuntime(cliOptions);
494
+
495
+ if (cliOptions.list_voices) {
496
+ printJson(await listVoices(cliOptions, runtime, endpoints, timeoutMs));
497
+ return;
498
+ }
499
+
500
+ const requestJson = await readJsonObject(cliOptions.request_json, "--request-json");
501
+ const narration = cliOptions.narration_json
502
+ ? await readJsonObject(cliOptions.narration_json, "--narration-json")
503
+ : null;
504
+ if (narration) {
505
+ validateNarration(narration);
506
+ }
507
+
508
+ const options = mergeOptions({ requestJson, narration, cliOptions });
509
+ validateOptions(options);
510
+ const request = buildTtsRequest(options);
511
+ const redactedConfig = redactMinimaxConfig(runtime.config);
512
+
513
+ if (options.dry_run || cliOptions.dry_run) {
514
+ printJson({
515
+ ok: true,
516
+ dry_run: true,
517
+ provider: "minimax",
518
+ endpoint: endpoints.tts,
519
+ config: {
520
+ source: runtime.source,
521
+ path: runtime.configPath,
522
+ redacted: redactedConfig,
523
+ },
524
+ request_path: cliOptions.request_json,
525
+ narration_path: cliOptions.narration_json,
526
+ request,
527
+ output_dir: options.output_dir,
528
+ });
529
+ return;
530
+ }
531
+
532
+ const responseJson = await postMiniMax(runtime.config, endpoints.tts, request, timeoutMs);
533
+ const output = await writeTtsOutput({
534
+ responseJson,
535
+ request,
536
+ outputDir: options.output_dir,
537
+ endpoint: endpoints.tts,
538
+ });
539
+
540
+ printJson({
541
+ ok: true,
542
+ provider: "minimax",
543
+ model: request.model,
544
+ voice_id: request.voice_setting.voice_id,
545
+ emotion: request.voice_setting.emotion,
546
+ output_path: output.audioPath,
547
+ metadata_path: output.metadataPath,
548
+ extra_info: responseJson.extra_info,
549
+ });
550
+ } catch (error) {
551
+ printJson(makeErrorEnvelope(error, error.attempts ?? 0));
552
+ process.exitCode = 1;
553
+ }
554
+ }
555
+
556
+ main();