hyper-animator-codex 0.1.0 → 0.3.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.
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import {
7
+ DEFAULT_MINIMAX_MODEL,
8
+ readMinimaxRuntimeConfig,
9
+ redactMinimaxConfig,
10
+ validateMinimaxConfig,
11
+ } from "./minimax_runtime_config.mjs";
12
+
13
+ const ENDPOINT = "https://api.minimaxi.com/v1/music_generation";
14
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
15
+ const skillRoot = dirname(scriptDir);
16
+
17
+ function requireValue(args, index, flag) {
18
+ const value = args[index + 1];
19
+
20
+ if (!value || value.startsWith("--")) {
21
+ throw new Error(`${flag} requires a value`);
22
+ }
23
+
24
+ return value;
25
+ }
26
+
27
+ function parseArgs(args) {
28
+ const parsed = {
29
+ prompt: undefined,
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,
38
+ model: undefined,
39
+ config_path: undefined,
40
+ dry_run: false,
41
+ };
42
+
43
+ for (let index = 0; index < args.length; index += 1) {
44
+ const arg = args[index];
45
+
46
+ if (arg === "--prompt") {
47
+ parsed.prompt = requireValue(args, index, arg);
48
+ index += 1;
49
+ } else if (arg === "--lyrics") {
50
+ parsed.lyrics = requireValue(args, index, arg);
51
+ index += 1;
52
+ } else if (arg === "--lyrics-optimizer") {
53
+ parsed.lyrics_optimizer = true;
54
+ } else if (arg === "--instrumental") {
55
+ parsed.is_instrumental = true;
56
+ } else if (arg === "--vocal") {
57
+ parsed.is_instrumental = false;
58
+ } else if (arg === "--output-dir") {
59
+ parsed.output_dir = requireValue(args, index, arg);
60
+ index += 1;
61
+ } else if (arg === "--output-format") {
62
+ parsed.output_format = requireValue(args, index, arg);
63
+ index += 1;
64
+ } else if (arg === "--format") {
65
+ parsed.audio_format = requireValue(args, index, arg);
66
+ index += 1;
67
+ } else if (arg === "--sample-rate") {
68
+ parsed.sample_rate = Number.parseInt(requireValue(args, index, arg), 10);
69
+ index += 1;
70
+ } else if (arg === "--bitrate") {
71
+ parsed.bitrate = Number.parseInt(requireValue(args, index, arg), 10);
72
+ index += 1;
73
+ } else if (arg === "--model") {
74
+ parsed.model = requireValue(args, index, arg);
75
+ index += 1;
76
+ } else if (arg === "--config") {
77
+ parsed.config_path = requireValue(args, index, arg);
78
+ index += 1;
79
+ } else if (arg === "--dry-run") {
80
+ parsed.dry_run = true;
81
+ } else if (arg === "--help" || arg === "-h") {
82
+ parsed.help = true;
83
+ } else {
84
+ throw new Error(`Unknown option: ${arg}`);
85
+ }
86
+ }
87
+
88
+ return parsed;
89
+ }
90
+
91
+ function printHelp() {
92
+ console.log(`Usage:
93
+ node scripts/generate_minimax_music.mjs --prompt <text> [options]
94
+
95
+ Options:
96
+ --prompt <text> Music style, mood, and scene prompt
97
+ --lyrics <text> Lyrics for vocal generation
98
+ --lyrics-optimizer Let MiniMax generate lyrics from prompt for vocal generation
99
+ --instrumental Generate instrumental music, default
100
+ --vocal Generate vocal music; requires --lyrics or --lyrics-optimizer
101
+ --output-dir <dir> Directory for generated audio and metadata
102
+ --output-format <hex|url> MiniMax response format, default hex
103
+ --format <mp3|wav|pcm> Audio encoding format, default mp3
104
+ --sample-rate <rate> 16000, 24000, 32000, or 44100; default 44100
105
+ --bitrate <bits> 32000, 64000, 128000, or 256000; default 256000
106
+ --model <model> music-2.6 or music-2.6-free
107
+ --config <file> Explicit MiniMax config JSON
108
+ --dry-run Print redacted request without contacting MiniMax
109
+ `);
110
+ }
111
+
112
+ function validateOptions(options) {
113
+ if (!options.prompt || options.prompt.trim().length === 0) {
114
+ throw new Error("--prompt is required");
115
+ }
116
+
117
+ if (options.prompt.length > 2000) {
118
+ throw new Error("--prompt must be 2000 characters or fewer");
119
+ }
120
+
121
+ if (!["hex", "url"].includes(options.output_format)) {
122
+ throw new Error("--output-format must be hex or url");
123
+ }
124
+
125
+ if (!["mp3", "wav", "pcm"].includes(options.audio_format)) {
126
+ throw new Error("--format must be mp3, wav, or pcm");
127
+ }
128
+
129
+ if (![16000, 24000, 32000, 44100].includes(options.sample_rate)) {
130
+ throw new Error("--sample-rate must be 16000, 24000, 32000, or 44100");
131
+ }
132
+
133
+ if (![32000, 64000, 128000, 256000].includes(options.bitrate)) {
134
+ throw new Error("--bitrate must be 32000, 64000, 128000, or 256000");
135
+ }
136
+
137
+ if (!options.is_instrumental && !options.lyrics && !options.lyrics_optimizer) {
138
+ throw new Error("Vocal MiniMax generation requires --lyrics or --lyrics-optimizer");
139
+ }
140
+
141
+ if (options.lyrics && options.lyrics.length > 3500) {
142
+ throw new Error("--lyrics must be 3500 characters or fewer");
143
+ }
144
+ }
145
+
146
+ function buildRequest(options, config) {
147
+ const model = options.model || config.model || DEFAULT_MINIMAX_MODEL;
148
+ const validatedConfig = validateMinimaxConfig({
149
+ ...config,
150
+ model,
151
+ });
152
+
153
+ const request = {
154
+ model: validatedConfig.model,
155
+ prompt: options.prompt,
156
+ stream: false,
157
+ output_format: options.output_format,
158
+ audio_setting: {
159
+ sample_rate: options.sample_rate,
160
+ bitrate: options.bitrate,
161
+ format: options.audio_format,
162
+ },
163
+ is_instrumental: options.is_instrumental,
164
+ };
165
+
166
+ if (options.lyrics) {
167
+ request.lyrics = options.lyrics;
168
+ }
169
+
170
+ if (options.lyrics_optimizer) {
171
+ request.lyrics_optimizer = true;
172
+ }
173
+
174
+ return request;
175
+ }
176
+
177
+ function safeFileStem(prompt) {
178
+ const stem = prompt
179
+ .toLowerCase()
180
+ .replace(/[^a-z0-9]+/g, "-")
181
+ .replace(/^-+|-+$/g, "")
182
+ .slice(0, 48);
183
+
184
+ return stem || "minimax-music";
185
+ }
186
+
187
+ async function downloadUrl(url) {
188
+ const response = await fetch(url);
189
+
190
+ if (!response.ok) {
191
+ throw new Error(`MiniMax audio URL download failed with HTTP ${response.status}`);
192
+ }
193
+
194
+ return Buffer.from(await response.arrayBuffer());
195
+ }
196
+
197
+ function decodeHexAudio(value) {
198
+ if (!/^[0-9a-fA-F]+$/.test(value) || value.length % 2 !== 0) {
199
+ throw new Error("MiniMax response audio is not valid hex data");
200
+ }
201
+
202
+ return Buffer.from(value, "hex");
203
+ }
204
+
205
+ async function callMiniMax(config, request) {
206
+ const response = await fetch(ENDPOINT, {
207
+ method: "POST",
208
+ headers: {
209
+ "Content-Type": "application/json",
210
+ Authorization: `Bearer ${config.api_key}`,
211
+ },
212
+ body: JSON.stringify(request),
213
+ });
214
+
215
+ const text = await response.text();
216
+ let json;
217
+
218
+ try {
219
+ json = JSON.parse(text);
220
+ } 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}`);
227
+ }
228
+
229
+ const statusCode = json.base_resp?.status_code;
230
+ if (statusCode !== 0) {
231
+ const message = json.base_resp?.status_msg || "unknown MiniMax error";
232
+ throw new Error(`MiniMax request failed with status_code ${statusCode}: ${message}`);
233
+ }
234
+
235
+ if (json.data?.status !== 2) {
236
+ throw new Error(`MiniMax generation is not complete; data.status is ${json.data?.status}`);
237
+ }
238
+
239
+ if (!json.data?.audio) {
240
+ throw new Error("MiniMax response did not include data.audio");
241
+ }
242
+
243
+ return json;
244
+ }
245
+
246
+ async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
247
+ await mkdir(outputDir, { recursive: true });
248
+
249
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
250
+ const stem = `${safeFileStem(prompt)}-${timestamp}`;
251
+ const extension = request.audio_setting.format === "pcm" ? "pcm" : request.audio_setting.format;
252
+ const audioPath = join(outputDir, `${stem}.${extension}`);
253
+ const metadataPath = join(outputDir, `${stem}.minimax.json`);
254
+ const audioValue = responseJson.data.audio;
255
+ const audioBuffer = request.output_format === "url"
256
+ ? await downloadUrl(audioValue)
257
+ : decodeHexAudio(audioValue);
258
+
259
+ await writeFile(audioPath, audioBuffer);
260
+ await writeFile(
261
+ metadataPath,
262
+ `${JSON.stringify({
263
+ provider: "minimax",
264
+ endpoint: ENDPOINT,
265
+ request,
266
+ response: {
267
+ base_resp: responseJson.base_resp,
268
+ data: {
269
+ status: responseJson.data.status,
270
+ },
271
+ extra_info: responseJson.extra_info,
272
+ trace_id: responseJson.trace_id,
273
+ },
274
+ audio_path: audioPath,
275
+ }, null, 2)}\n`,
276
+ "utf8",
277
+ );
278
+
279
+ return {
280
+ audioPath,
281
+ metadataPath,
282
+ };
283
+ }
284
+
285
+ async function main() {
286
+ const options = parseArgs(process.argv.slice(2));
287
+
288
+ if (options.help) {
289
+ printHelp();
290
+ return;
291
+ }
292
+
293
+ validateOptions(options);
294
+
295
+ const runtime = await readMinimaxRuntimeConfig({
296
+ skillRoot,
297
+ env: process.env,
298
+ configPath: options.config_path,
299
+ });
300
+
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
+ }
304
+
305
+ const request = buildRequest(options, runtime.config);
306
+ const redactedConfig = redactMinimaxConfig(runtime.config);
307
+
308
+ if (options.dry_run) {
309
+ console.log(JSON.stringify({
310
+ ok: true,
311
+ dry_run: true,
312
+ 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;
323
+ }
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
+ }
342
+
343
+ main().catch((error) => {
344
+ console.error(`Error: ${error.message}`);
345
+ process.exitCode = 1;
346
+ });
@@ -0,0 +1,113 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ export const DEFAULT_MINIMAX_MODEL = "music-2.6-free";
5
+ export const TEXT_MUSIC_MODELS = new Set(["music-2.6", "music-2.6-free"]);
6
+
7
+ async function pathExists(path) {
8
+ try {
9
+ await stat(path);
10
+ return true;
11
+ } catch (error) {
12
+ if (error && error.code === "ENOENT") {
13
+ return false;
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ function cleanString(value) {
20
+ if (typeof value !== "string") {
21
+ return undefined;
22
+ }
23
+
24
+ const trimmed = value.trim();
25
+ return trimmed.length > 0 ? trimmed : undefined;
26
+ }
27
+
28
+ function normalize(input = {}) {
29
+ return {
30
+ api_key: cleanString(input.api_key),
31
+ group_id: cleanString(input.group_id),
32
+ model: cleanString(input.model),
33
+ };
34
+ }
35
+
36
+ function hasValues(input = {}) {
37
+ return Boolean(cleanString(input.api_key) || cleanString(input.group_id) || cleanString(input.model));
38
+ }
39
+
40
+ export function redactMinimaxConfig(config = {}) {
41
+ const normalized = normalize(config);
42
+
43
+ return {
44
+ api_key: normalized.api_key ? "[redacted]" : undefined,
45
+ group_id: normalized.group_id,
46
+ model: normalized.model || DEFAULT_MINIMAX_MODEL,
47
+ };
48
+ }
49
+
50
+ export function validateMinimaxConfig(config = {}) {
51
+ const normalized = normalize(config);
52
+
53
+ if (!normalized.api_key) {
54
+ throw new Error("MiniMax api_key is required");
55
+ }
56
+
57
+ if (!normalized.group_id) {
58
+ throw new Error("MiniMax group_id is required");
59
+ }
60
+
61
+ const model = normalized.model || DEFAULT_MINIMAX_MODEL;
62
+
63
+ if (!TEXT_MUSIC_MODELS.has(model)) {
64
+ throw new Error(`Unsupported MiniMax text music model: ${model}. Use music-2.6 or music-2.6-free.`);
65
+ }
66
+
67
+ return {
68
+ api_key: normalized.api_key,
69
+ group_id: normalized.group_id,
70
+ model,
71
+ };
72
+ }
73
+
74
+ async function readJson(path) {
75
+ const raw = await readFile(path, "utf8");
76
+ const parsed = JSON.parse(raw);
77
+
78
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
79
+ throw new Error(`MiniMax config file must be a JSON object: ${path}`);
80
+ }
81
+
82
+ return parsed;
83
+ }
84
+
85
+ export async function readMinimaxRuntimeConfig({ skillRoot, env = process.env, configPath } = {}) {
86
+ const explicitConfigPath = cleanString(configPath);
87
+ const defaultConfigPath = skillRoot ? join(skillRoot, "config", "minimax.json") : undefined;
88
+ const filePath = explicitConfigPath || defaultConfigPath;
89
+
90
+ if (filePath && await pathExists(filePath)) {
91
+ return {
92
+ source: "file",
93
+ configPath: filePath,
94
+ config: validateMinimaxConfig(await readJson(filePath)),
95
+ };
96
+ }
97
+
98
+ const envConfig = normalize({
99
+ api_key: env.MINIMAX_API_KEY,
100
+ group_id: env.MINIMAX_GROUP_ID,
101
+ model: env.MINIMAX_MODEL,
102
+ });
103
+
104
+ if (!hasValues(envConfig)) {
105
+ return null;
106
+ }
107
+
108
+ return {
109
+ source: "environment",
110
+ configPath: null,
111
+ config: validateMinimaxConfig(envConfig),
112
+ };
113
+ }
@@ -0,0 +1,13 @@
1
+ # Vendored music-beat-detector
2
+
3
+ Trimmed source from `git@github.com:realpkuasule/music-beat-detector.git`, commit `29b081fbe3bb38f0fa8cb569fa3150d7cfdb18cb`.
4
+
5
+ Only `beat_detector/` is vendored. Sample media, ffmpeg archives, development contracts, and upstream tests are intentionally omitted from the npm package.
6
+
7
+ Optional runtime dependencies:
8
+
9
+ ```bash
10
+ python3 -m pip install librosa pydub numpy click
11
+ ```
12
+
13
+ MP3/M4A/AAC/WMA files require system `ffmpeg`; WAV/FLAC/OGG are preferred for fewer dependencies.
@@ -0,0 +1,33 @@
1
+ """Music Beat Detector - 音乐自动打点工具"""
2
+
3
+ from .analyzer import analyze, AnalysisResult, MetaInfo
4
+ from .beat import Beat, BeatResult, EnergyLevel
5
+ from .structure import Segment, EnergyPeak, SilenceRegion, StructureResult
6
+ from .errors import BeatDetectorError, FileNotFoundError, UnsupportedFormatError, AnalysisError, FFmpegRequiredError
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ # 主要 API
12
+ 'analyze',
13
+ 'AnalysisResult',
14
+ 'MetaInfo',
15
+
16
+ # 节拍检测
17
+ 'Beat',
18
+ 'BeatResult',
19
+ 'EnergyLevel',
20
+
21
+ # 结构检测
22
+ 'StructureResult',
23
+ 'Segment',
24
+ 'EnergyPeak',
25
+ 'SilenceRegion',
26
+
27
+ # 异常
28
+ 'BeatDetectorError',
29
+ 'FileNotFoundError',
30
+ 'UnsupportedFormatError',
31
+ 'FFmpegRequiredError',
32
+ 'AnalysisError',
33
+ ]
@@ -0,0 +1,129 @@
1
+ """分析器模块"""
2
+ from dataclasses import dataclass, field, asdict
3
+ from typing import List, Callable, Optional, Any
4
+ import json
5
+ import logging
6
+ import numpy as np
7
+
8
+ from .beat import Beat, BeatResult, detect_beats
9
+ from .structure import StructureResult, detect_structure
10
+ from .utils import load_audio, setup_logging
11
+ from .errors import (
12
+ FileNotFoundError as BeatDetectorFileNotFoundError,
13
+ FFmpegRequiredError,
14
+ UnsupportedFormatError
15
+ )
16
+
17
+
18
+ @dataclass
19
+ class MetaInfo:
20
+ """元信息"""
21
+ file: str
22
+ duration_ms: int
23
+ sample_rate: int
24
+ bpm: float
25
+ time_signature: str = "4/4"
26
+
27
+
28
+ @dataclass
29
+ class AnalysisResult:
30
+ """完整分析结果"""
31
+ meta: MetaInfo
32
+ beats: List[Beat] = field(default_factory=list)
33
+ structure: Optional[StructureResult] = None
34
+
35
+ def to_dict(self) -> dict:
36
+ """转换为字典"""
37
+ result = {
38
+ 'meta': asdict(self.meta),
39
+ 'beats': [
40
+ {
41
+ 'time_ms': beat.time_ms,
42
+ 'frame': beat.frame,
43
+ 'beat_in_bar': beat.beat_in_bar,
44
+ 'energy_level': beat.energy_level.value
45
+ }
46
+ for beat in self.beats
47
+ ],
48
+ 'structure': {
49
+ 'segments': [asdict(seg) for seg in self.structure.segments],
50
+ 'energy_peaks': [asdict(peak) for peak in self.structure.energy_peaks],
51
+ 'silence_regions': [asdict(region) for region in self.structure.silence_regions],
52
+ }
53
+ }
54
+ return result
55
+
56
+ def to_json(self, pretty: bool = False) -> str:
57
+ """导出为 JSON"""
58
+ indent = 2 if pretty else None
59
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
60
+
61
+ def save(self, path: str, pretty: bool = False) -> None:
62
+ """保存到文件"""
63
+ with open(path, 'w', encoding='utf-8') as f:
64
+ f.write(self.to_json(pretty=pretty))
65
+
66
+
67
+ def analyze(
68
+ file_path: str,
69
+ fps: int = 30,
70
+ log_level: str = "info",
71
+ on_progress: Optional[Callable[[float], None]] = None
72
+ ) -> AnalysisResult:
73
+ """分析音频文件"""
74
+
75
+ # 设置日志
76
+ setup_logging(log_level)
77
+ logger = logging.getLogger(__name__)
78
+
79
+ # 进度辅助函数
80
+ def report_progress(stage_progress: float):
81
+ if on_progress:
82
+ on_progress(stage_progress)
83
+
84
+ # 加载音频
85
+ logger.info(f"Loading audio file: {file_path}")
86
+ try:
87
+ y, sr = load_audio(file_path)
88
+ except (BeatDetectorFileNotFoundError, FFmpegRequiredError, UnsupportedFormatError):
89
+ raise
90
+ except Exception as e:
91
+ raise BeatDetectorFileNotFoundError(file_path)
92
+
93
+ report_progress(10.0)
94
+
95
+ # 计算时长
96
+ duration_ms = int(len(y) / sr * 1000)
97
+ logger.info(f"Audio duration: {duration_ms/1000:.1f}s, sample rate: {sr}Hz")
98
+
99
+ # 节拍检测
100
+ logger.info("Detecting beats...")
101
+ beat_result = detect_beats(y, sr, fps=fps, on_progress=lambda p: report_progress(10 + p * 0.45))
102
+ logger.info(f"Detected BPM: {beat_result.bpm:.1f}")
103
+
104
+ report_progress(55.0)
105
+
106
+ # 结构检测
107
+ logger.info("Analyzing structure...")
108
+ structure_result = detect_structure(y, sr, fps=fps, on_progress=lambda p: report_progress(55 + p * 0.45))
109
+ logger.info(f"Found {len(structure_result.segments)} segments")
110
+
111
+ report_progress(100.0)
112
+
113
+ # 构建结果
114
+ meta = MetaInfo(
115
+ file=file_path,
116
+ duration_ms=duration_ms,
117
+ sample_rate=sr,
118
+ bpm=beat_result.bpm,
119
+ time_signature=beat_result.time_signature
120
+ )
121
+
122
+ result = AnalysisResult(
123
+ meta=meta,
124
+ beats=beat_result.beats,
125
+ structure=structure_result
126
+ )
127
+
128
+ logger.info("Analysis complete")
129
+ return result