| 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 |