hyper-animator-codex 0.6.0 → 0.7.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/README.md +37 -3
- package/package.json +1 -1
- package/skills/hyper-animator-codex/SKILL.md +23 -18
- package/skills/hyper-animator-codex/references/narration-audio-workflow.md +110 -0
- package/skills/hyper-animator-codex/scripts/detect_project_files.mjs +178 -0
- package/skills/hyper-animator-codex/scripts/generate_minimax_tts.mjs +556 -0
- package/skills/hyper-animator-codex/scripts/generate_subtitles.mjs +181 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Install the `hyper-animator-codex` Codex skill from npm.
|
|
4
4
|
|
|
5
|
-
The skill guides Codex through a HyperFrames animation workflow: natural-language brief, clarification questions, catalog-map intent matching, style and motion selection, HTML animation authoring, validation, user approval, and render handoff.
|
|
5
|
+
The skill guides Codex through a HyperFrames animation workflow: natural-language brief, outline/narration detection, clarification questions, catalog-map intent matching, style and motion selection, HTML animation authoring, validation, user approval, and render handoff.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -52,9 +52,9 @@ node skills/hyper-animator-codex/scripts/git_checkpoint.mjs \
|
|
|
52
52
|
|
|
53
53
|
Existing Git repositories with uncommitted changes are protected: the helper stops instead of mixing user edits into automatic commits. Large video and audio files above 10 MB are skipped by default and recorded in a manifest under `hyper-animator-output/git-checkpoints/`.
|
|
54
54
|
|
|
55
|
-
## MiniMax
|
|
55
|
+
## MiniMax Audio Configuration
|
|
56
56
|
|
|
57
|
-
MiniMax is the preferred generated-background-music provider. Configure it during install so the copied Codex skill can generate music without asking for credentials later:
|
|
57
|
+
MiniMax is the preferred generated-background-music provider and the required narration TTS provider. Configure it during install so the copied Codex skill can generate music and voiceover without asking for credentials later:
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
60
|
npx hyper-animator-codex install --force \
|
|
@@ -104,10 +104,44 @@ When rendering video, hide preview controls with any one of:
|
|
|
104
104
|
<html data-render-mode="video">
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Narration And Subtitles
|
|
108
|
+
|
|
109
|
+
Detect outline and narration files in a project directory:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
node skills/hyper-animator-codex/scripts/detect_project_files.mjs --dir . --pretty
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
List MiniMax voices, cloned voices first:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
node skills/hyper-animator-codex/scripts/generate_minimax_tts.mjs --list-voices --voice-type all
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Generate narration audio from `narration.json`:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
node skills/hyper-animator-codex/scripts/generate_minimax_tts.mjs \
|
|
125
|
+
--narration-json narration.json \
|
|
126
|
+
--output-dir hyper-animator-output/voice
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Generate timed subtitle JSON and SRT:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
node skills/hyper-animator-codex/scripts/generate_subtitles.mjs \
|
|
133
|
+
--narration narration.json \
|
|
134
|
+
--output-dir hyper-animator-output/subtitles \
|
|
135
|
+
--style clean
|
|
136
|
+
```
|
|
137
|
+
|
|
107
138
|
## Contents
|
|
108
139
|
|
|
109
140
|
- `skills/hyper-animator-codex/SKILL.md`: Codex skill instructions.
|
|
110
141
|
- `skills/hyper-animator-codex/references/`: HyperFrames catalog map, workflow guide, pseudocode, and request examples.
|
|
142
|
+
- `skills/hyper-animator-codex/scripts/detect_project_files.mjs`: top-level outline and narration detector for first-run/restart workflows.
|
|
143
|
+
- `skills/hyper-animator-codex/scripts/generate_minimax_tts.mjs`: MiniMax voice listing and narration TTS helper.
|
|
144
|
+
- `skills/hyper-animator-codex/scripts/generate_subtitles.mjs`: timed subtitle JSON/SRT generator.
|
|
111
145
|
- `skills/hyper-animator-codex/scripts/git_checkpoint.mjs`: Git initialization and stage-commit helper for `/hyper-animator` runs.
|
|
112
146
|
- `skills/hyper-animator-codex/scripts/validate_hyperframes_html.py`: static pre-render HTML quality gate.
|
|
113
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hyper-animator-codex
|
|
3
|
-
description: Use when a user asks Codex to create, plan, author, customize, validate, preview, or render a HyperFrames, HTML, or GSAP animation/video from natural-language requirements, including product demos, code demos, data videos, podcast captions, social shorts, catalog assembly, new HyperFrames HTML, sound effects, background music, rhythm, BPM, or beat-synced transitions.
|
|
3
|
+
description: Use when a user asks Codex to create, plan, author, customize, validate, preview, or render a HyperFrames, HTML, or GSAP animation/video from natural-language requirements, including product demos, code demos, data videos, podcast captions, social shorts, catalog assembly, new HyperFrames HTML, outline/narration detection, voiceover, subtitles, sound effects, background music, rhythm, BPM, final mixing, or beat-synced transitions.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Hyper Animator Codex
|
|
@@ -12,23 +12,25 @@ Turn a natural-language animation or video brief into a validated HyperFrames HT
|
|
|
12
12
|
## Required Flow
|
|
13
13
|
|
|
14
14
|
1. Read `references/git-checkpoint-workflow.md` and run `scripts/git_checkpoint.mjs --phase init --message "chore: initialize hyper animator workspace" --allow-empty` before requirement capture. If the helper reports an existing dirty repository or linked worktree, stop and ask the user to resolve it.
|
|
15
|
-
2.
|
|
16
|
-
3.
|
|
17
|
-
4.
|
|
18
|
-
5.
|
|
19
|
-
6.
|
|
15
|
+
2. When starting in a new directory, restarting, or handling outline/narration/voiceover/subtitle/full-mix work, read `references/narration-audio-workflow.md` and run `scripts/detect_project_files.mjs --dir . --pretty` before visual planning.
|
|
16
|
+
3. Capture the raw request and extract an initial intent profile: purpose, format, duration, content inputs, style tags, motion tags, and needed roles.
|
|
17
|
+
4. If purpose, format, or core content is unclear, ask the first clarification round before choosing catalog items.
|
|
18
|
+
5. Read `references/hyperframes-catalog-map.json` and score candidates by keyword, intent domain, format, role, style, motion, and constraints.
|
|
19
|
+
6. Pick candidate blocks/components for main scene, caption, effects, transitions, and outro.
|
|
20
|
+
7. Decide generation mode:
|
|
20
21
|
- `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
22
|
- `assemble_existing_catalog_items` only when the user asks to use existing catalog items or quickly compose installed blocks/components.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
8. Clarify audio: ask whether to use narration, subtitles, animation/transition sound effects, and background music.
|
|
24
|
+
9. If narration is used, use `scripts/generate_minimax_tts.mjs` for voice listing and TTS. Do not construct MiniMax TTS HTTP calls directly. If subtitles are requested, run `scripts/generate_subtitles.mjs`.
|
|
25
|
+
10. If background music is requested or undecided, read `references/minimax-music-workflow.md` and use `scripts/generate_minimax_music.mjs` for every MiniMax music request. Do not construct MiniMax music HTTP calls directly. If MiniMax is unavailable or declined, ask for a local audio path or continue without BGM.
|
|
26
|
+
11. 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.
|
|
27
|
+
12. Ask the second clarification round with candidate context: visual direction, motion rhythm, generation mode, narration/subtitle/audio choices, music prompt/model when MiniMax is used, and beat-sync assumptions when background music is present.
|
|
28
|
+
13. Write or assemble HTML. When beat-sync is enabled, align major reveals, cuts, transitions, camera moves, narration beats, subtitle cues, and visual accents to the beat map instead of arbitrary timestamps.
|
|
29
|
+
14. After each file-writing stage, run `scripts/git_checkpoint.mjs` with the phase-specific allowlist from `references/git-checkpoint-workflow.md`. Do not create, switch, or recommend Git branches or worktrees.
|
|
30
|
+
15. Run pre-render quality gates on the base HTML.
|
|
31
|
+
16. Read `references/preview-controls-workflow.md`, run `scripts/inject_preview_controls.mjs` to create a preview HTML copy, and run `scripts/validate_hyperframes_html.py preview.html --preview-controls`.
|
|
32
|
+
17. Show a concise plan summary and preview path or HTML file to the user. Ask for confirmation before video render.
|
|
33
|
+
18. Render only after user confirmation. Prefer rendering the base HTML; if rendering the preview copy, use `?render=1`, `?preview=0`, or `<html data-render-mode="video">` so preview controls are hidden. Then report output path and any caveats.
|
|
32
34
|
|
|
33
35
|
## Interactive Questions
|
|
34
36
|
|
|
@@ -36,14 +38,15 @@ Prefer Codex interactive question tools such as `AskUserQuestion` or `request_us
|
|
|
36
38
|
|
|
37
39
|
Use two rounds:
|
|
38
40
|
|
|
39
|
-
- Round 1: purpose, format, duration, platform, required content, brand assets, sound effects, background music, and whether video render is expected in this turn.
|
|
40
|
-
- Round 2: after catalog scoring, ask about style, motion, candidate selection, generation mode, and beat-sync assumptions when background music is present.
|
|
41
|
+
- Round 1: purpose, format, duration, platform, required content, outline/narration status, narration/subtitles, brand assets, sound effects, background music, and whether video render is expected in this turn.
|
|
42
|
+
- Round 2: after catalog scoring, ask about style, motion, candidate selection, generation mode, voice/emotion/subtitle style, and beat-sync assumptions when background music is present.
|
|
41
43
|
|
|
42
44
|
Do not ask everything upfront when the brief is already specific. Ask only for missing or decision-changing information.
|
|
43
45
|
|
|
44
46
|
## References
|
|
45
47
|
|
|
46
48
|
- Read `references/git-checkpoint-workflow.md` before any `/hyper-animator` run writes files or initializes output.
|
|
49
|
+
- Read `references/narration-audio-workflow.md` when first-run detection, restart, outline, narration, voiceover, subtitle, BGM timing, or final audio/video mixing is mentioned.
|
|
47
50
|
- Read `references/hyperframes-intent-workflow.md` for the full AskUserQuestion workflow, generation-mode rules, scoring model, and render confirmation requirements.
|
|
48
51
|
- Read `references/hyperframes-catalog-map.json` whenever selecting catalog candidates or determining visual references.
|
|
49
52
|
- Read `references/hyperframes-agent-pseudocode.ts` when implementing the end-to-end loop or when the correct sequence is ambiguous.
|
|
@@ -94,7 +97,9 @@ If the validator fails, fix the HTML before asking the user to approve render.
|
|
|
94
97
|
Before rendering, summarize:
|
|
95
98
|
|
|
96
99
|
- generation mode;
|
|
100
|
+
- detected outline/narration paths, ambiguity status, and any outline/narration generation assumptions;
|
|
97
101
|
- selected or referenced catalog items;
|
|
102
|
+
- narration usage, selected voice, emotion, voice audio path, subtitle style, subtitle JSON/SRT paths, and voice/BGM/SFX volume values when applicable;
|
|
98
103
|
- sound effects and background music choices;
|
|
99
104
|
- MiniMax provider status, model, generated audio path, metadata path, and redacted config source when MiniMax is used;
|
|
100
105
|
- beat map path, BPM, duration, and timing assumptions when beat-sync is enabled;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Narration Audio Workflow
|
|
2
|
+
|
|
3
|
+
Use this when a `/hyper-animator` run starts in a new directory, the user asks to restart, or the brief mentions outline, narration, voiceover, subtitles, BGM, beat sync, sound effects, or final audio/video mixing.
|
|
4
|
+
|
|
5
|
+
## Start Or Restart
|
|
6
|
+
|
|
7
|
+
Run file detection before visual planning:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
node scripts/detect_project_files.mjs --dir . --pretty
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
|
|
15
|
+
- scan only the current directory top level;
|
|
16
|
+
- trust fixed names first: `outline.*` and `narration.*`;
|
|
17
|
+
- if multiple candidates are returned, ask the user to choose;
|
|
18
|
+
- if only outline exists, ask whether to generate narration from it;
|
|
19
|
+
- if only narration exists, ask whether to generate an outline from it;
|
|
20
|
+
- if narration is not used, do not generate voice audio or subtitles.
|
|
21
|
+
|
|
22
|
+
Codex writes or edits outline/narration content. Scripts only validate, convert, call MiniMax, and write artifacts.
|
|
23
|
+
|
|
24
|
+
## Narration JSON
|
|
25
|
+
|
|
26
|
+
Use this shape:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"voice": "XiaoR_001",
|
|
31
|
+
"emotion": "calm",
|
|
32
|
+
"language": "zh",
|
|
33
|
+
"style": "casual",
|
|
34
|
+
"scenes": [
|
|
35
|
+
{
|
|
36
|
+
"scene": 1,
|
|
37
|
+
"title": "Scene title",
|
|
38
|
+
"sync_hint": "Visual sync hint",
|
|
39
|
+
"narration": "Spoken text",
|
|
40
|
+
"duration_estimate": 8
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Before generating narration, ask language and style. Default language is `zh`.
|
|
47
|
+
|
|
48
|
+
## MiniMax Voice
|
|
49
|
+
|
|
50
|
+
Do not hand-write MiniMax TTS HTTP calls. Use:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
node scripts/generate_minimax_tts.mjs --list-voices --voice-type all
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Prefer cloned voices, then generated voices, then system voices. Recommend three candidates by content tone and ask the user to choose. If listing fails, continue with `XiaoR_001` after telling the user.
|
|
57
|
+
|
|
58
|
+
Emotion labels:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
happy, sad, angry, fearful, disgusted, surprised, calm, fluent, whipser
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Generate narration audio:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
node scripts/generate_minimax_tts.mjs \
|
|
68
|
+
--narration-json narration.json \
|
|
69
|
+
--output-dir hyper-animator-output/voice
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Use `--dry-run` before a real call when checking request shape or credentials.
|
|
73
|
+
|
|
74
|
+
## Subtitles
|
|
75
|
+
|
|
76
|
+
Generate subtitles only when narration is used and the user wants subtitles:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
node scripts/generate_subtitles.mjs \
|
|
80
|
+
--narration narration.json \
|
|
81
|
+
--output-dir hyper-animator-output/subtitles \
|
|
82
|
+
--style clean
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Subtitle rules:
|
|
86
|
+
|
|
87
|
+
- short phrase cues;
|
|
88
|
+
- no trailing punctuation;
|
|
89
|
+
- one visible cue at a time;
|
|
90
|
+
- JSON and SRT outputs include timecodes.
|
|
91
|
+
|
|
92
|
+
## BGM, Beat, And Schedule
|
|
93
|
+
|
|
94
|
+
BGM is the timing source when present:
|
|
95
|
+
|
|
96
|
+
1. decide BGM generation, local import, or skip;
|
|
97
|
+
2. if configured, generate BGM with `scripts/generate_minimax_music.mjs`;
|
|
98
|
+
3. analyze the music with `scripts/analyze_music_beats.py`;
|
|
99
|
+
4. schedule transitions, animation, narration, and subtitles against the beat map;
|
|
100
|
+
5. generate HTML;
|
|
101
|
+
6. preview and get user confirmation;
|
|
102
|
+
7. render and mix final video/audio.
|
|
103
|
+
|
|
104
|
+
Default mix:
|
|
105
|
+
|
|
106
|
+
- voice: `1.0`;
|
|
107
|
+
- BGM: `0.3`;
|
|
108
|
+
- SFX: `0.3`.
|
|
109
|
+
|
|
110
|
+
If the user gives volume commands, adjust `voice`, `bgm`, and `sfx` independently in the final mix command or render handoff.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { resolve, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const OUTLINE_NAMES = new Set(["outline.md", "outline.json", "outline.txt"]);
|
|
6
|
+
const NARRATION_NAMES = new Set(["narration.json", "narration.md", "narration.txt"]);
|
|
7
|
+
const TEXT_EXTENSIONS = new Set([".md", ".txt"]);
|
|
8
|
+
|
|
9
|
+
function requireValue(args, index, flag) {
|
|
10
|
+
const value = args[index + 1];
|
|
11
|
+
if (!value || value.startsWith("--")) {
|
|
12
|
+
throw new Error(`${flag} requires a value`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(args) {
|
|
18
|
+
const parsed = { dir: process.cwd(), pretty: false };
|
|
19
|
+
|
|
20
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
21
|
+
const arg = args[index];
|
|
22
|
+
if (arg === "--dir") {
|
|
23
|
+
parsed.dir = requireValue(args, index, arg);
|
|
24
|
+
index += 1;
|
|
25
|
+
} else if (arg === "--pretty") {
|
|
26
|
+
parsed.pretty = true;
|
|
27
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
28
|
+
parsed.help = true;
|
|
29
|
+
} else {
|
|
30
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extensionOf(name) {
|
|
38
|
+
const dot = name.lastIndexOf(".");
|
|
39
|
+
return dot === -1 ? "" : name.slice(dot).toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readTopLevelFiles(root) {
|
|
43
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
44
|
+
return entries
|
|
45
|
+
.filter((entry) => entry.isFile())
|
|
46
|
+
.map((entry) => ({
|
|
47
|
+
name: entry.name,
|
|
48
|
+
lowerName: entry.name.toLowerCase(),
|
|
49
|
+
path: join(root, entry.name),
|
|
50
|
+
extension: extensionOf(entry.name),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function classifyJson(file) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(await readFile(file.path, "utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.scenes)) {
|
|
63
|
+
const hasNarration = parsed.scenes.some((scene) => typeof scene?.narration === "string" && scene.narration.trim());
|
|
64
|
+
if (hasNarration) {
|
|
65
|
+
return { type: "narration", reason: "json-scenes-narration" };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function classifyText(file) {
|
|
73
|
+
if (!TEXT_EXTENSIONS.has(file.extension)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const text = await readFile(file.path, "utf8");
|
|
78
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
79
|
+
const outlineLines = lines.filter((line) => /^#{1,4}\s+\S/.test(line) || /^[-*]\s+\S/.test(line) || /^\d+[.)]\s+\S/.test(line));
|
|
80
|
+
|
|
81
|
+
if (outlineLines.length >= 2 || (lines.length >= 3 && outlineLines.length >= 1)) {
|
|
82
|
+
return { type: "outline", reason: "outline-text-structure" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pickSingle(candidates) {
|
|
89
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function detectProjectFiles(rootDir) {
|
|
93
|
+
const root = resolve(rootDir);
|
|
94
|
+
const rootStat = await stat(root);
|
|
95
|
+
if (!rootStat.isDirectory()) {
|
|
96
|
+
throw new Error(`--dir must be a directory: ${root}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const files = await readTopLevelFiles(root);
|
|
100
|
+
const fixedOutlines = files
|
|
101
|
+
.filter((file) => OUTLINE_NAMES.has(file.lowerName))
|
|
102
|
+
.map((file) => ({ path: file.path, name: file.name, reason: "fixed-name" }));
|
|
103
|
+
const fixedNarrations = files
|
|
104
|
+
.filter((file) => NARRATION_NAMES.has(file.lowerName))
|
|
105
|
+
.map((file) => ({ path: file.path, name: file.name, reason: "fixed-name" }));
|
|
106
|
+
|
|
107
|
+
const candidates = {
|
|
108
|
+
outline: [...fixedOutlines],
|
|
109
|
+
narration: [...fixedNarrations],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (fixedOutlines.length === 0 || fixedNarrations.length === 0) {
|
|
113
|
+
for (const file of files) {
|
|
114
|
+
if (OUTLINE_NAMES.has(file.lowerName) || NARRATION_NAMES.has(file.lowerName)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const classification = file.extension === ".json"
|
|
119
|
+
? await classifyJson(file)
|
|
120
|
+
: await classifyText(file);
|
|
121
|
+
|
|
122
|
+
if (!classification) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (classification.type === "outline" && fixedOutlines.length === 0) {
|
|
127
|
+
candidates.outline.push({ path: file.path, name: file.name, reason: classification.reason });
|
|
128
|
+
} else if (classification.type === "narration" && fixedNarrations.length === 0) {
|
|
129
|
+
candidates.narration.push({ path: file.path, name: file.name, reason: classification.reason });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const outline = pickSingle(candidates.outline);
|
|
135
|
+
const narration = pickSingle(candidates.narration);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
root,
|
|
140
|
+
outline,
|
|
141
|
+
narration,
|
|
142
|
+
candidates,
|
|
143
|
+
ambiguous: candidates.outline.length > 1 || candidates.narration.length > 1,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function printHelp() {
|
|
148
|
+
console.log(`Usage:
|
|
149
|
+
node scripts/detect_project_files.mjs [--dir <path>] [--pretty]
|
|
150
|
+
|
|
151
|
+
Options:
|
|
152
|
+
--dir <path> Project directory to scan, default cwd
|
|
153
|
+
--pretty Pretty-print JSON output
|
|
154
|
+
`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function main() {
|
|
158
|
+
try {
|
|
159
|
+
const options = parseArgs(process.argv.slice(2));
|
|
160
|
+
if (options.help) {
|
|
161
|
+
printHelp();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const result = await detectProjectFiles(options.dir);
|
|
166
|
+
console.log(JSON.stringify(result, null, options.pretty ? 2 : 0));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.log(JSON.stringify({
|
|
169
|
+
ok: false,
|
|
170
|
+
error: {
|
|
171
|
+
message: error.message,
|
|
172
|
+
},
|
|
173
|
+
}, null, 2));
|
|
174
|
+
process.exitCode = 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
main();
|
|
@@ -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();
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const TRAILING_PUNCTUATION = /[,。!?;:,.!?;:、]+$/u;
|
|
6
|
+
|
|
7
|
+
function requireValue(args, index, flag) {
|
|
8
|
+
const value = args[index + 1];
|
|
9
|
+
if (!value || value.startsWith("--")) {
|
|
10
|
+
throw new Error(`${flag} requires a value`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseArgs(args) {
|
|
16
|
+
const parsed = {
|
|
17
|
+
narration: undefined,
|
|
18
|
+
output_dir: join(process.cwd(), "hyper-animator-output", "subtitles"),
|
|
19
|
+
style: "default",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
23
|
+
const arg = args[index];
|
|
24
|
+
if (arg === "--narration") {
|
|
25
|
+
parsed.narration = requireValue(args, index, arg);
|
|
26
|
+
index += 1;
|
|
27
|
+
} else if (arg === "--output-dir") {
|
|
28
|
+
parsed.output_dir = requireValue(args, index, arg);
|
|
29
|
+
index += 1;
|
|
30
|
+
} else if (arg === "--style") {
|
|
31
|
+
parsed.style = requireValue(args, index, arg);
|
|
32
|
+
index += 1;
|
|
33
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
34
|
+
parsed.help = true;
|
|
35
|
+
} else {
|
|
36
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateNarration(narration) {
|
|
44
|
+
if (!narration || typeof narration !== "object" || !Array.isArray(narration.scenes)) {
|
|
45
|
+
throw new Error("narration JSON must include scenes[]");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
narration.scenes.forEach((scene, index) => {
|
|
49
|
+
if (typeof scene?.narration !== "string" || !scene.narration.trim()) {
|
|
50
|
+
throw new Error(`scenes[${index}].narration is required`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (scene.duration_estimate !== undefined && (!Number.isFinite(Number(scene.duration_estimate)) || Number(scene.duration_estimate) <= 0)) {
|
|
54
|
+
throw new Error(`scenes[${index}].duration_estimate must be a positive number`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cleanCueText(text) {
|
|
60
|
+
return text.trim().replace(TRAILING_PUNCTUATION, "").trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function splitNarration(text) {
|
|
64
|
+
return text
|
|
65
|
+
.split(/[\r\n]+|(?<=[,。!?;:,.!?;:、])/u)
|
|
66
|
+
.map(cleanCueText)
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function roundTime(value) {
|
|
71
|
+
return Number(value.toFixed(3));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildCues(narration) {
|
|
75
|
+
const cues = [];
|
|
76
|
+
let cursor = 0;
|
|
77
|
+
|
|
78
|
+
narration.scenes.forEach((scene, sceneIndex) => {
|
|
79
|
+
const parts = splitNarration(scene.narration);
|
|
80
|
+
const duration = Number(scene.duration_estimate) || Math.max(parts.length * 2, 2);
|
|
81
|
+
const cueDuration = duration / Math.max(parts.length, 1);
|
|
82
|
+
|
|
83
|
+
parts.forEach((text) => {
|
|
84
|
+
const start = cursor;
|
|
85
|
+
const end = cursor + cueDuration;
|
|
86
|
+
cues.push({
|
|
87
|
+
index: cues.length + 1,
|
|
88
|
+
scene: scene.scene ?? sceneIndex + 1,
|
|
89
|
+
start: roundTime(start),
|
|
90
|
+
end: roundTime(end),
|
|
91
|
+
text,
|
|
92
|
+
});
|
|
93
|
+
cursor = end;
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return cues;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatSrtTime(seconds) {
|
|
101
|
+
const totalMs = Math.round(seconds * 1000);
|
|
102
|
+
const ms = totalMs % 1000;
|
|
103
|
+
const totalSeconds = Math.floor(totalMs / 1000);
|
|
104
|
+
const sec = totalSeconds % 60;
|
|
105
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
106
|
+
const min = totalMinutes % 60;
|
|
107
|
+
const hour = Math.floor(totalMinutes / 60);
|
|
108
|
+
|
|
109
|
+
return `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderSrt(cues) {
|
|
113
|
+
return `${cues.map((cue) => [
|
|
114
|
+
cue.index,
|
|
115
|
+
`${formatSrtTime(cue.start)} --> ${formatSrtTime(cue.end)}`,
|
|
116
|
+
cue.text,
|
|
117
|
+
].join("\n")).join("\n\n")}\n`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function generateSubtitles(options) {
|
|
121
|
+
if (!options.narration) {
|
|
122
|
+
throw new Error("--narration is required");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const narration = JSON.parse(await readFile(options.narration, "utf8"));
|
|
126
|
+
validateNarration(narration);
|
|
127
|
+
|
|
128
|
+
const cues = buildCues(narration);
|
|
129
|
+
await mkdir(options.output_dir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
const jsonPath = join(options.output_dir, "subtitles.json");
|
|
132
|
+
const srtPath = join(options.output_dir, "subtitles.srt");
|
|
133
|
+
const payload = {
|
|
134
|
+
ok: true,
|
|
135
|
+
style: options.style,
|
|
136
|
+
source: options.narration,
|
|
137
|
+
cues,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
await writeFile(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
141
|
+
await writeFile(srtPath, renderSrt(cues), "utf8");
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...payload,
|
|
145
|
+
json_path: jsonPath,
|
|
146
|
+
srt_path: srtPath,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function printHelp() {
|
|
151
|
+
console.log(`Usage:
|
|
152
|
+
node scripts/generate_subtitles.mjs --narration <file> [options]
|
|
153
|
+
|
|
154
|
+
Options:
|
|
155
|
+
--narration <file> Narration JSON file
|
|
156
|
+
--output-dir <dir> Output directory, default hyper-animator-output/subtitles
|
|
157
|
+
--style <name> Subtitle style label for downstream HTML/render steps
|
|
158
|
+
`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function main() {
|
|
162
|
+
try {
|
|
163
|
+
const options = parseArgs(process.argv.slice(2));
|
|
164
|
+
if (options.help) {
|
|
165
|
+
printHelp();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(JSON.stringify(await generateSubtitles(options), null, 2));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.log(JSON.stringify({
|
|
172
|
+
ok: false,
|
|
173
|
+
error: {
|
|
174
|
+
message: error.message,
|
|
175
|
+
},
|
|
176
|
+
}, null, 2));
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
main();
|