hyper-animator-codex 0.5.0 → 0.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyper-animator-codex",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Install the Hyper Animator Codex skill for Codex.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@ Turn a natural-language animation or video brief into a validated HyperFrames HT
20
20
  - `generate_new_hyperframes_html` when the user asks to write HTML, create a new effect, customize style, match a brand, use complex animation, or when component snippets are only paste placeholders.
21
21
  - `assemble_existing_catalog_items` only when the user asks to use existing catalog items or quickly compose installed blocks/components.
22
22
  7. Clarify audio: ask whether to add animation/transition sound effects and whether to add background music.
23
- 8. If background music is requested or undecided, read `references/minimax-music-workflow.md` and prefer MiniMax generation when configured. If MiniMax is unavailable or declined, ask for a local audio path or continue without BGM.
23
+ 8. If background music is requested or undecided, read `references/minimax-music-workflow.md` and use `scripts/generate_minimax_music.mjs` for every MiniMax request. Do not construct MiniMax HTTP calls directly. If MiniMax is unavailable or declined, ask for a local audio path or continue without BGM.
24
24
  9. If background music is used, read `references/beat-sync-workflow.md`, generate or obtain the audio, and run `scripts/analyze_music_beats.py` when the file is available.
25
25
  10. Ask the second clarification round with candidate context: visual direction, motion rhythm, generation mode, audio choices, music prompt/model when MiniMax is used, and beat-sync assumptions when background music is present.
26
26
  11. Write or assemble HTML. When beat-sync is enabled, align major reveals, cuts, transitions, camera moves, and visual accents to the beat map instead of arbitrary timestamps.
@@ -2,6 +2,10 @@
2
2
 
3
3
  Use this when the user wants generated background music or asks whether Hyper Animator Codex can create music for an animation.
4
4
 
5
+ ## Script-Only Rule
6
+
7
+ Do not hand-assemble MiniMax HTTP requests in agent reasoning or shell commands. Always call `scripts/generate_minimax_music.mjs`. The script owns request validation, retry policy, error normalization, audio writing, metadata writing, and secret redaction.
8
+
5
9
  ## MiniMax Preference
6
10
 
7
11
  Prefer MiniMax before other background-music sources when `config/minimax.json` exists in the installed skill or `MINIMAX_API_KEY` and `MINIMAX_GROUP_ID` are present in the environment.
@@ -60,6 +64,13 @@ node scripts/generate_minimax_music.mjs \
60
64
 
61
65
  Use `--dry-run` before a real call when checking config, request shape, or model choice.
62
66
 
67
+ To generate a request JSON file for a scripted call:
68
+
69
+ ```bash
70
+ node scripts/generate_minimax_music.mjs \
71
+ --request-json hyper-animator-output/music/minimax-request.json
72
+ ```
73
+
63
74
  ## Beat Sync
64
75
 
65
76
  After music generation succeeds, analyze the generated audio:
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { mkdir, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -10,7 +10,7 @@ import {
10
10
  validateMinimaxConfig,
11
11
  } from "./minimax_runtime_config.mjs";
12
12
 
13
- const ENDPOINT = "https://api.minimaxi.com/v1/music_generation";
13
+ const DEFAULT_ENDPOINT = "https://api.minimaxi.com/v1/music_generation";
14
14
  const scriptDir = dirname(fileURLToPath(import.meta.url));
15
15
  const skillRoot = dirname(scriptDir);
16
16
 
@@ -28,15 +28,16 @@ function parseArgs(args) {
28
28
  const parsed = {
29
29
  prompt: undefined,
30
30
  lyrics: undefined,
31
- lyrics_optimizer: false,
32
- is_instrumental: true,
33
- output_dir: join(process.cwd(), "hyper-animator-output", "music"),
34
- output_format: "hex",
35
- audio_format: "mp3",
36
- sample_rate: 44100,
37
- bitrate: 256000,
31
+ lyrics_optimizer: undefined,
32
+ is_instrumental: undefined,
33
+ output_dir: undefined,
34
+ output_format: undefined,
35
+ audio_format: undefined,
36
+ sample_rate: undefined,
37
+ bitrate: undefined,
38
38
  model: undefined,
39
39
  config_path: undefined,
40
+ request_json: undefined,
40
41
  dry_run: false,
41
42
  };
42
43
 
@@ -76,6 +77,9 @@ function parseArgs(args) {
76
77
  } else if (arg === "--config") {
77
78
  parsed.config_path = requireValue(args, index, arg);
78
79
  index += 1;
80
+ } else if (arg === "--request-json") {
81
+ parsed.request_json = requireValue(args, index, arg);
82
+ index += 1;
79
83
  } else if (arg === "--dry-run") {
80
84
  parsed.dry_run = true;
81
85
  } else if (arg === "--help" || arg === "-h") {
@@ -105,41 +109,108 @@ Options:
105
109
  --bitrate <bits> 32000, 64000, 128000, or 256000; default 256000
106
110
  --model <model> music-2.6 or music-2.6-free
107
111
  --config <file> Explicit MiniMax config JSON
112
+ --request-json <file> Read request options from a JSON file
108
113
  --dry-run Print redacted request without contacting MiniMax
109
114
  `);
110
115
  }
111
116
 
117
+ async function readRequestJson(path) {
118
+ if (!path) {
119
+ return {};
120
+ }
121
+
122
+ const parsed = JSON.parse(await readFile(path, "utf8"));
123
+
124
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
125
+ throw Object.assign(new Error(`--request-json must contain a JSON object: ${path}`), {
126
+ kind: "validation",
127
+ });
128
+ }
129
+
130
+ return parsed;
131
+ }
132
+
133
+ function mergeOptions(jsonOptions, cliOptions) {
134
+ return {
135
+ ...cliOptions,
136
+ prompt: cliOptions.prompt ?? jsonOptions.prompt,
137
+ lyrics: cliOptions.lyrics ?? jsonOptions.lyrics,
138
+ lyrics_optimizer: cliOptions.lyrics_optimizer ?? Boolean(jsonOptions.lyrics_optimizer),
139
+ is_instrumental: cliOptions.is_instrumental ?? jsonOptions.is_instrumental ?? true,
140
+ output_dir: cliOptions.output_dir ?? jsonOptions.output_dir ?? join(process.cwd(), "hyper-animator-output", "music"),
141
+ output_format: cliOptions.output_format ?? jsonOptions.output_format ?? "hex",
142
+ audio_format: cliOptions.audio_format ?? jsonOptions.audio_format ?? "mp3",
143
+ sample_rate: cliOptions.sample_rate ?? jsonOptions.sample_rate ?? 44100,
144
+ bitrate: cliOptions.bitrate ?? jsonOptions.bitrate ?? 256000,
145
+ model: cliOptions.model ?? jsonOptions.model,
146
+ };
147
+ }
148
+
149
+ function endpointFromEnv(env = process.env) {
150
+ return env.MINIMAX_MUSIC_ENDPOINT || DEFAULT_ENDPOINT;
151
+ }
152
+
153
+ function timeoutFromEnv(env = process.env) {
154
+ const value = Number.parseInt(env.MINIMAX_REQUEST_TIMEOUT_MS || "60000", 10);
155
+ return Number.isFinite(value) && value > 0 ? value : 60000;
156
+ }
157
+
158
+ function makeErrorEnvelope(error, attempts = 0) {
159
+ return {
160
+ ok: false,
161
+ provider: "minimax",
162
+ error: {
163
+ kind: error.kind || "validation",
164
+ message: error.message,
165
+ retryable: Boolean(error.retryable),
166
+ http_status: error.http_status ?? null,
167
+ status_code: error.status_code ?? null,
168
+ },
169
+ attempts,
170
+ };
171
+ }
172
+
173
+ function minimaxError(message, fields = {}) {
174
+ const error = new Error(message);
175
+ Object.assign(error, fields);
176
+ return error;
177
+ }
178
+
179
+ function printJson(payload) {
180
+ console.log(JSON.stringify(payload, null, 2));
181
+ }
182
+
112
183
  function validateOptions(options) {
113
184
  if (!options.prompt || options.prompt.trim().length === 0) {
114
- throw new Error("--prompt is required");
185
+ throw Object.assign(new Error("--prompt is required"), { kind: "validation" });
115
186
  }
116
187
 
117
188
  if (options.prompt.length > 2000) {
118
- throw new Error("--prompt must be 2000 characters or fewer");
189
+ throw Object.assign(new Error("--prompt must be 2000 characters or fewer"), { kind: "validation" });
119
190
  }
120
191
 
121
192
  if (!["hex", "url"].includes(options.output_format)) {
122
- throw new Error("--output-format must be hex or url");
193
+ throw Object.assign(new Error("--output-format must be hex or url"), { kind: "validation" });
123
194
  }
124
195
 
125
196
  if (!["mp3", "wav", "pcm"].includes(options.audio_format)) {
126
- throw new Error("--format must be mp3, wav, or pcm");
197
+ throw Object.assign(new Error("--format must be mp3, wav, or pcm"), { kind: "validation" });
127
198
  }
128
199
 
129
200
  if (![16000, 24000, 32000, 44100].includes(options.sample_rate)) {
130
- throw new Error("--sample-rate must be 16000, 24000, 32000, or 44100");
201
+ throw Object.assign(new Error("--sample-rate must be 16000, 24000, 32000, or 44100"), { kind: "validation" });
131
202
  }
132
203
 
133
204
  if (![32000, 64000, 128000, 256000].includes(options.bitrate)) {
134
- throw new Error("--bitrate must be 32000, 64000, 128000, or 256000");
205
+ throw Object.assign(new Error("--bitrate must be 32000, 64000, 128000, or 256000"), { kind: "validation" });
135
206
  }
136
207
 
137
208
  if (!options.is_instrumental && !options.lyrics && !options.lyrics_optimizer) {
138
- throw new Error("Vocal MiniMax generation requires --lyrics or --lyrics-optimizer");
209
+ throw Object.assign(new Error("Vocal MiniMax generation requires --lyrics or --lyrics-optimizer"), { kind: "validation" });
139
210
  }
140
211
 
141
212
  if (options.lyrics && options.lyrics.length > 3500) {
142
- throw new Error("--lyrics must be 3500 characters or fewer");
213
+ throw Object.assign(new Error("--lyrics must be 3500 characters or fewer"), { kind: "validation" });
143
214
  }
144
215
  }
145
216
 
@@ -202,48 +273,148 @@ function decodeHexAudio(value) {
202
273
  return Buffer.from(value, "hex");
203
274
  }
204
275
 
205
- async function callMiniMax(config, request) {
206
- const response = await fetch(ENDPOINT, {
276
+ function classifyHttpStatus(status) {
277
+ if (status === 401 || status === 403) {
278
+ return { kind: "auth", retryable: false };
279
+ }
280
+
281
+ if (status === 429) {
282
+ return { kind: "rate_limit", retryable: true };
283
+ }
284
+
285
+ if (status >= 500) {
286
+ return { kind: "server", retryable: true };
287
+ }
288
+
289
+ return { kind: "response", retryable: false };
290
+ }
291
+
292
+ async function fetchWithTimeout(url, options, timeoutMs) {
293
+ if (!URL.canParse(url)) {
294
+ throw minimaxError(`MiniMax endpoint is not a valid URL: ${url}`, {
295
+ kind: "validation",
296
+ retryable: false,
297
+ });
298
+ }
299
+
300
+ const controller = new AbortController();
301
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
302
+
303
+ try {
304
+ return await fetch(url, { ...options, signal: controller.signal });
305
+ } catch (error) {
306
+ if (error?.name === "AbortError") {
307
+ throw minimaxError("MiniMax request timed out", { kind: "timeout", retryable: true });
308
+ }
309
+
310
+ if (typeof error?.cause?.code === "string") {
311
+ throw minimaxError(`MiniMax network error: ${error.message}`, { kind: "network", retryable: true });
312
+ }
313
+
314
+ throw minimaxError(`MiniMax request setup failed: ${error.message}`, {
315
+ kind: "response",
316
+ retryable: false,
317
+ });
318
+ } finally {
319
+ clearTimeout(timeout);
320
+ }
321
+ }
322
+
323
+ async function callMiniMax(config, request, { endpoint, timeoutMs }) {
324
+ const response = await fetchWithTimeout(endpoint, {
207
325
  method: "POST",
208
326
  headers: {
209
327
  "Content-Type": "application/json",
210
328
  Authorization: `Bearer ${config.api_key}`,
211
329
  },
212
330
  body: JSON.stringify(request),
213
- });
331
+ }, timeoutMs);
214
332
 
215
333
  const text = await response.text();
216
- let json;
334
+ let json = null;
335
+
336
+ if (!response.ok) {
337
+ if (text) {
338
+ try {
339
+ json = JSON.parse(text);
340
+ } catch {
341
+ json = null;
342
+ }
343
+ }
344
+
345
+ const message = json?.base_resp?.status_msg || `HTTP ${response.status}`;
346
+ throw minimaxError(`MiniMax request failed: ${message}`, {
347
+ ...classifyHttpStatus(response.status),
348
+ http_status: response.status,
349
+ status_code: json?.base_resp?.status_code ?? null,
350
+ });
351
+ }
217
352
 
218
353
  try {
219
354
  json = JSON.parse(text);
220
355
  } catch (error) {
221
- throw new Error(`MiniMax returned non-JSON response with HTTP ${response.status}`);
222
- }
223
-
224
- if (!response.ok) {
225
- const message = json.base_resp?.status_msg || `HTTP ${response.status}`;
226
- throw new Error(`MiniMax request failed: ${message}`);
356
+ throw minimaxError(`MiniMax returned non-JSON response with HTTP ${response.status}`, {
357
+ kind: "response",
358
+ retryable: false,
359
+ http_status: response.status,
360
+ });
227
361
  }
228
362
 
229
363
  const statusCode = json.base_resp?.status_code;
230
364
  if (statusCode !== 0) {
231
365
  const message = json.base_resp?.status_msg || "unknown MiniMax error";
232
- throw new Error(`MiniMax request failed with status_code ${statusCode}: ${message}`);
366
+ throw minimaxError(`MiniMax request failed with status_code ${statusCode}: ${message}`, {
367
+ kind: "provider",
368
+ retryable: false,
369
+ http_status: response.status,
370
+ status_code: statusCode,
371
+ });
233
372
  }
234
373
 
235
374
  if (json.data?.status !== 2) {
236
- throw new Error(`MiniMax generation is not complete; data.status is ${json.data?.status}`);
375
+ throw minimaxError(`MiniMax generation is not complete; data.status is ${json.data?.status}`, {
376
+ kind: "provider",
377
+ retryable: false,
378
+ http_status: response.status,
379
+ status_code: statusCode,
380
+ });
237
381
  }
238
382
 
239
383
  if (!json.data?.audio) {
240
- throw new Error("MiniMax response did not include data.audio");
384
+ throw minimaxError("MiniMax response did not include data.audio", {
385
+ kind: "provider",
386
+ retryable: false,
387
+ http_status: response.status,
388
+ status_code: statusCode,
389
+ });
241
390
  }
242
391
 
243
392
  return json;
244
393
  }
245
394
 
246
- async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
395
+ async function callMiniMaxWithRetries(config, request, { endpoint, timeoutMs, maxRetries = 2 }) {
396
+ let attempts = 0;
397
+
398
+ while (attempts <= maxRetries) {
399
+ attempts += 1;
400
+
401
+ try {
402
+ return {
403
+ responseJson: await callMiniMax(config, request, { endpoint, timeoutMs }),
404
+ attempts,
405
+ };
406
+ } catch (error) {
407
+ if (!error.retryable || attempts > maxRetries) {
408
+ error.attempts = attempts;
409
+ throw error;
410
+ }
411
+ }
412
+ }
413
+
414
+ throw minimaxError("MiniMax request failed", { kind: "network", retryable: true, attempts });
415
+ }
416
+
417
+ async function writeAudioOutput({ responseJson, request, outputDir, prompt, endpoint }) {
247
418
  await mkdir(outputDir, { recursive: true });
248
419
 
249
420
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
@@ -261,7 +432,7 @@ async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
261
432
  metadataPath,
262
433
  `${JSON.stringify({
263
434
  provider: "minimax",
264
- endpoint: ENDPOINT,
435
+ endpoint,
265
436
  request,
266
437
  response: {
267
438
  base_resp: responseJson.base_resp,
@@ -283,64 +454,90 @@ async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
283
454
  }
284
455
 
285
456
  async function main() {
286
- const options = parseArgs(process.argv.slice(2));
457
+ try {
458
+ const cliOptions = parseArgs(process.argv.slice(2));
459
+ const endpoint = endpointFromEnv();
460
+ const timeoutMs = timeoutFromEnv();
287
461
 
288
- if (options.help) {
289
- printHelp();
290
- return;
291
- }
462
+ if (cliOptions.help) {
463
+ printHelp();
464
+ return;
465
+ }
292
466
 
293
- validateOptions(options);
467
+ const requestJson = await readRequestJson(cliOptions.request_json);
468
+ const options = mergeOptions(requestJson, cliOptions);
294
469
 
295
- const runtime = await readMinimaxRuntimeConfig({
296
- skillRoot,
297
- env: process.env,
298
- configPath: options.config_path,
299
- });
470
+ validateOptions(options);
300
471
 
301
- if (!runtime) {
302
- throw new Error("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.");
303
- }
472
+ let runtime;
473
+ try {
474
+ runtime = await readMinimaxRuntimeConfig({
475
+ skillRoot,
476
+ env: process.env,
477
+ configPath: options.config_path,
478
+ });
479
+ } catch (error) {
480
+ throw Object.assign(error, { kind: "config", retryable: false });
481
+ }
482
+
483
+ if (!runtime) {
484
+ throw Object.assign(new Error("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."), {
485
+ kind: "config",
486
+ retryable: false,
487
+ });
488
+ }
489
+
490
+ let request;
491
+ try {
492
+ request = buildRequest(options, runtime.config);
493
+ } catch (error) {
494
+ throw Object.assign(error, { kind: "config", retryable: false });
495
+ }
496
+ const redactedConfig = redactMinimaxConfig(runtime.config);
497
+
498
+ if (options.dry_run) {
499
+ printJson({
500
+ ok: true,
501
+ dry_run: true,
502
+ provider: "minimax",
503
+ endpoint,
504
+ config: {
505
+ source: runtime.source,
506
+ path: runtime.configPath,
507
+ redacted: redactedConfig,
508
+ },
509
+ request_path: cliOptions.request_json,
510
+ request,
511
+ output_dir: options.output_dir,
512
+ });
513
+ return;
514
+ }
304
515
 
305
- const request = buildRequest(options, runtime.config);
306
- const redactedConfig = redactMinimaxConfig(runtime.config);
516
+ const { responseJson, attempts } = await callMiniMaxWithRetries(runtime.config, request, {
517
+ endpoint,
518
+ timeoutMs,
519
+ });
520
+ const output = await writeAudioOutput({
521
+ responseJson,
522
+ request,
523
+ outputDir: options.output_dir,
524
+ prompt: options.prompt,
525
+ endpoint,
526
+ });
307
527
 
308
- if (options.dry_run) {
309
- console.log(JSON.stringify({
528
+ printJson({
310
529
  ok: true,
311
- dry_run: true,
312
530
  provider: "minimax",
313
- endpoint: ENDPOINT,
314
- config: {
315
- source: runtime.source,
316
- path: runtime.configPath,
317
- redacted: redactedConfig,
318
- },
319
- request,
320
- output_dir: options.output_dir,
321
- }, null, 2));
322
- return;
531
+ model: request.model,
532
+ attempts,
533
+ output_path: output.audioPath,
534
+ metadata_path: output.metadataPath,
535
+ beat_analysis_command: `python3 scripts/analyze_music_beats.py ${output.audioPath} -o ${join(options.output_dir, "beat-map.json")} --fps 60 --pretty`,
536
+ });
537
+ } catch (error) {
538
+ printJson(makeErrorEnvelope(error, error.attempts ?? 0));
539
+ process.exitCode = 1;
323
540
  }
324
-
325
- const responseJson = await callMiniMax(runtime.config, request);
326
- const output = await writeAudioOutput({
327
- responseJson,
328
- request,
329
- outputDir: options.output_dir,
330
- prompt: options.prompt,
331
- });
332
-
333
- console.log(JSON.stringify({
334
- ok: true,
335
- provider: "minimax",
336
- model: request.model,
337
- output_path: output.audioPath,
338
- metadata_path: output.metadataPath,
339
- beat_analysis_command: `python3 scripts/analyze_music_beats.py ${output.audioPath} -o ${join(options.output_dir, "beat-map.json")} --fps 60 --pretty`,
340
- }, null, 2));
341
541
  }
342
542
 
343
- main().catch((error) => {
344
- console.error(`Error: ${error.message}`);
345
- process.exitCode = 1;
346
- });
543
+ main();