返回 html-video
minimax.ts
根目录 / packages / core / src / minimax.ts
1 /**
2 * @html-video/core — MiniMax audio provider.
3 *
4 * MiniMax exposes speech (`/t2a_v2`) and music (`/music_generation`) under the
5 * same host, the same Bearer key, and the same response shape — both wrap the
6 * payload in a `base_resp` envelope and return the audio as a hex string in
7 * `data.audio`. So one provider + one key covers both narration and music.
8 *
9 * The request/parse pattern is ported from open-design's `renderMinimaxTTS`
10 * (apps/daemon/src/media.ts): fetch → Bearer → check `base_resp.status_code`
11 * (an HTTP 200 can still be a logical failure) → `Buffer.from(hex, 'hex')`.
12 *
13 * Credentials are read from the environment so the studio works without any
14 * config file; a missing key yields `null` from {@link resolveMinimaxCredentials}
15 * and callers report it gracefully instead of throwing.
16 */
17
18 import { HtmlVideoError } from './errors.js';
19
20 /** Default base URL. The old `api.minimaxi.chat` host is RETIRED server-side
21 * (issue #4). MiniMax now has two region-bound endpoints — international
22 * `api.minimax.io` and China `api.minimaxi.com` — and a key only authenticates
23 * against its own region. We default to international; override via
24 * OD_MINIMAX_BASE_URL / MINIMAX_BASE_URL (or the Studio Settings UI). */
25 const MINIMAX_DEFAULT_BASE_URL = 'https://api.minimax.io/v1';
26
27 /** Hard ceiling for a single MiniMax request. Music generation is slow but a
28 * request that hasn't returned in 2 minutes is hung, not slow. */
29 const MINIMAX_REQUEST_TIMEOUT_MS = 120_000;
30 /** Fast turbo speech tier (same default open-design ships). */
31 const MINIMAX_TTS_MODEL = 'speech-02-turbo';
32 /**
33 * Music model. We use music-1.5, NOT the newer music-2.6 family: 2.6's
34 * synchronous music_generation call never returns for our key (verified: 180s
35 * with no response), whereas music-1.5 returns audio synchronously in ~50s.
36 * Trade-off: 1.5 has no `is_instrumental` flag and REQUIRES a `lyrics` field,
37 * so for instrumental soundtracks we pass a minimal humming placeholder.
38 */
39 const MINIMAX_MUSIC_MODEL = 'music-1.5';
40
41 export interface MinimaxCredentials {
42 apiKey: string;
43 baseUrl: string;
44 }
45
46 export interface MinimaxAudioResult {
47 /** Decoded audio bytes (MP3). */
48 bytes: Buffer;
49 /** File extension to store under. */
50 ext: '.mp3';
51 /** Human-readable note of what was produced (provider · model · size). */
52 providerNote: string;
53 /** Reported duration in seconds, if the API surfaced it. */
54 durationSec?: number;
55 }
56
57 /**
58 * Resolve MiniMax credentials from the environment. Returns `null` (not throw)
59 * when no key is set, so the studio can show a friendly "configure your key"
60 * message instead of a 500.
61 *
62 * Key precedence: OD_MINIMAX_API_KEY → MINIMAX_API_KEY
63 * Base precedence: OD_MINIMAX_BASE_URL → MINIMAX_BASE_URL → default
64 */
65 export function resolveMinimaxCredentials(
66 env: NodeJS.ProcessEnv = process.env,
67 ): MinimaxCredentials | null {
68 const apiKey = (env.OD_MINIMAX_API_KEY || env.MINIMAX_API_KEY || '').trim();
69 if (!apiKey) return null;
70 const baseUrl = (env.OD_MINIMAX_BASE_URL || env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL)
71 .trim()
72 .replace(/\/$/, '');
73 return { apiKey, baseUrl };
74 }
75
76 /**
77 * Shared POST + decode for both MiniMax audio endpoints. Throws
78 * HtmlVideoError('render-failed', …) on transport / API / decode failure.
79 */
80 async function postAndDecode(
81 endpoint: string,
82 body: unknown,
83 creds: MinimaxCredentials,
84 label: string,
85 signal?: AbortSignal,
86 ): Promise<{ bytes: Buffer; extraInfo: Record<string, unknown> }> {
87 // MiniMax generation (esp. music) can take tens of seconds, but it must NOT
88 // hang forever — an unbounded fetch leaves the studio's SSE stream stuck on
89 // "generating…" with no failure event, which reads to the user as "the button
90 // does nothing". Cap it; if the caller passed its own signal, respect that.
91 const timeoutSignal = AbortSignal.timeout(MINIMAX_REQUEST_TIMEOUT_MS);
92 const effectiveSignal = signal
93 ? (AbortSignal.any ? AbortSignal.any([signal, timeoutSignal]) : signal)
94 : timeoutSignal;
95 let resp: Response;
96 try {
97 resp = await fetch(`${creds.baseUrl}/${endpoint}`, {
98 method: 'POST',
99 headers: {
100 authorization: `Bearer ${creds.apiKey}`,
101 'content-type': 'application/json',
102 },
103 body: JSON.stringify(body),
104 signal: effectiveSignal,
105 });
106 } catch (e) {
107 const isTimeout = e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError');
108 const msg = e instanceof Error ? e.message : String(e);
109 throw new HtmlVideoError(
110 'render-failed',
111 isTimeout
112 ? `minimax ${label} timed out after ${Math.round(MINIMAX_REQUEST_TIMEOUT_MS / 1000)}s (the API did not respond — try again, or check OD_MINIMAX_BASE_URL)`
113 : `minimax ${label} request failed: ${msg} (check the API region — international is api.minimax.io, China is api.minimaxi.com; a key only works against its own region)`,
114 true,
115 );
116 }
117
118 const respText = await resp.text();
119 if (!resp.ok) {
120 throw new HtmlVideoError(
121 'render-failed',
122 `minimax ${label} ${resp.status}: ${truncate(respText, 240)}`,
123 resp.status >= 500,
124 );
125 }
126
127 let data: {
128 base_resp?: { status_code?: number; status_msg?: string };
129 data?: { audio?: unknown };
130 extra_info?: Record<string, unknown>;
131 };
132 try {
133 data = JSON.parse(respText);
134 } catch {
135 throw new HtmlVideoError('render-failed', `minimax ${label} non-JSON: ${truncate(respText, 200)}`);
136 }
137
138 // MiniMax wraps every response in base_resp; an HTTP 200 can still be a
139 // logical failure (auth / params), surfaced via a non-zero status_code.
140 if (data.base_resp && data.base_resp.status_code !== 0) {
141 const code = data.base_resp.status_code;
142 const hint = code === 1004 || code === 1008 ? ' (auth / insufficient balance — check the API key)' : '';
143 throw new HtmlVideoError(
144 'render-failed',
145 `minimax ${label} api error ${code}: ${data.base_resp.status_msg || 'unknown'}${hint}`,
146 );
147 }
148
149 const hex = data.data?.audio;
150 if (typeof hex !== 'string' || !hex) {
151 throw new HtmlVideoError('render-failed', `minimax ${label} response missing data.audio`);
152 }
153 const bytes = Buffer.from(hex, 'hex');
154 if (bytes.length === 0) {
155 throw new HtmlVideoError('render-failed', `minimax ${label} decoded zero bytes`);
156 }
157 return { bytes, extraInfo: data.extra_info ?? {} };
158 }
159
160 /**
161 * Generate spoken narration via MiniMax TTS (`/t2a_v2`).
162 * Defaults to a neutral Mandarin male voice that reads both zh + en well.
163 */
164 export async function generateTts(opts: {
165 text: string;
166 voiceId?: string;
167 languageBoost?: string;
168 speed?: number;
169 vol?: number;
170 pitch?: number;
171 creds: MinimaxCredentials;
172 signal?: AbortSignal;
173 }): Promise<MinimaxAudioResult> {
174 const text = (opts.text || '').trim();
175 if (!text) {
176 throw new HtmlVideoError('invalid-input', 'narration text is empty');
177 }
178 const voiceId = (opts.voiceId || '').trim() || 'male-qn-qingse';
179 const languageBoost = (opts.languageBoost || '').trim();
180
181 const body = {
182 model: MINIMAX_TTS_MODEL,
183 text,
184 stream: false,
185 ...(languageBoost ? { language_boost: languageBoost } : {}),
186 voice_setting: {
187 voice_id: voiceId,
188 speed: opts.speed ?? 1.0,
189 vol: opts.vol ?? 1.0,
190 pitch: opts.pitch ?? 0,
191 },
192 audio_setting: { sample_rate: 32000, format: 'mp3' },
193 };
194
195 const { bytes, extraInfo } = await postAndDecode('t2a_v2', body, opts.creds, 'tts', opts.signal);
196 const audioLen = typeof extraInfo.audio_length === 'number' ? extraInfo.audio_length : undefined;
197 const durationSec = audioLen ? Math.round(audioLen / 100) / 10 : undefined;
198 return {
199 bytes,
200 ext: '.mp3',
201 providerNote: `minimax/${MINIMAX_TTS_MODEL} · ${voiceId} · ${durationSec ?? '?'}s · ${bytes.length} bytes`,
202 durationSec,
203 };
204 }
205
206 /**
207 * Generate background music via MiniMax (`/music_generation`).
208 * Instrumental-only by default (a video soundtrack rarely wants vocals).
209 */
210 export async function generateMusic(opts: {
211 prompt: string;
212 instrumental?: boolean;
213 creds: MinimaxCredentials;
214 signal?: AbortSignal;
215 }): Promise<MinimaxAudioResult> {
216 const prompt = (opts.prompt || '').trim();
217 if (!prompt) {
218 throw new HtmlVideoError('invalid-input', 'music prompt is empty');
219 }
220
221 const instrumental = opts.instrumental ?? true;
222 // music-1.5 requires a non-empty `lyrics` field and has no is_instrumental
223 // flag. For an instrumental soundtrack we feed a minimal hummed placeholder
224 // so the model produces a melody without foregrounded vocals; otherwise let
225 // the prompt double as a loose lyrical brief.
226 const lyrics = instrumental ? '[Intro]\nooh ooh\n[Hook]\nla la la' : prompt;
227 const body = {
228 model: MINIMAX_MUSIC_MODEL,
229 prompt,
230 lyrics,
231 audio_setting: { sample_rate: 44100, bitrate: 256000, format: 'mp3' },
232 output_format: 'hex',
233 };
234
235 const { bytes } = await postAndDecode('music_generation', body, opts.creds, 'music', opts.signal);
236 return {
237 bytes,
238 ext: '.mp3',
239 providerNote: `minimax/${MINIMAX_MUSIC_MODEL} · ${instrumental ? 'instrumental' : 'with-vocals'} · ${bytes.length} bytes`,
240 };
241 }
242
243 function truncate(s: string, n: number): string {
244 return s.length > n ? `${s.slice(0, n)}…` : s;
245 }
246
246 lines TYPESCRIPT