生成前确认模型与预算
Section titled “生成前确认模型与预算”这个服务端 Node.js 示例使用公开的 minimax-h3 任务契约,不是 MiniMax 上游接口。运行会创建一个视频并产生用量。先确认模型出现在 GET /v1/media-models 中,按其 request_schema 校验完整输入,再核对账号模型价格。MiniMax H3 指南和模型页面提供当前契约与计算器。
示例选择 5 秒、768P、16:9。这些是请求参数,不是固定像素、速度或质量保证。其他模型可能使用不同字段、枚举和组合约束,不能只替换模型 ID 就假设输入兼容。
运行或恢复同一个任务
Section titled “运行或恢复同一个任务”使用 Node.js 24 或更新版本。在服务端环境变量中设置 API_KEY,不要写入浏览器代码或源码仓库。将脚本保存为 video-task.mjs,执行 node video-task.mjs。脚本取得任务 ID 后先保存本地记录,再开始轮询;HTTP 202 表示已受理,不是最终视频。
恢复时将 TASK_ID 设置为原 ID,再次运行脚本,会跳过提交步骤。可通过 OUTPUT_DIR 指定下载目录。已有文件不会被覆盖;若部分文件已经下载,请使用新目录或只下载缺失文件,不重新生成。
import { mkdir, open, rm, writeFile } from 'node:fs/promises';import { Readable, Transform } from 'node:stream';import { pipeline } from 'node:stream/promises';import { setTimeout as delay } from 'node:timers/promises';import { join } from 'node:path';
const origin = 'https://newrouters.com';const key = process.env.API_KEY;if (!key) throw new Error('Set API_KEY on your server');const headers = { Authorization: `Bearer ${key}` };const outputDir = process.env.OUTPUT_DIR || 'video-output';let taskId = process.env.TASK_ID;
if (!taskId) { // One submission only. Never automatically retry an uncertain POST. const response = await fetch(`${origin}/v1/tasks`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'minimax-h3', input: { prompt: 'A paper boat floating on calm water, fixed camera.', seconds: 5, resolution: '768P', aspect_ratio: '16:9', }, }), signal: AbortSignal.timeout(30000), }); if (response.status !== 202) throw new Error(`Submit HTTP ${response.status}; inspect before retrying`); const accepted = await response.json(); if (typeof accepted.id !== 'string' || !/^task_[0-9A-HJKMNP-TV-Z]{26}$/.test(accepted.id)) throw new Error('Missing task ID; investigate before resubmitting'); taskId = accepted.id; console.log(`TASK_ID=${taskId}`); await writeFile(`task-${taskId}.json`, JSON.stringify({ id: taskId }), { flag: 'wx', mode: 0o600 });}if (!/^task_[0-9A-HJKMNP-TV-Z]{26}$/.test(taskId)) throw new Error('Invalid TASK_ID');
// Ten minutes is this client's polling budget, not a service completion promise.const deadline = Date.now() + 10 * 60 * 1000;let completed;while (Date.now() < deadline) { const response = await fetch(`${origin}/v1/tasks/${encodeURIComponent(taskId)}`, { headers, signal: AbortSignal.timeout(Math.max(1, Math.min(15000, deadline - Date.now()))), }); if (!response.ok) throw new Error(`Poll HTTP ${response.status}; resume with TASK_ID=${taskId}`); const task = await response.json(); if (task.status === 'failed') throw new Error(`Task failed: ${task.error?.code || 'unknown'}; inspect ${taskId}`); if (task.status === 'succeeded') { completed = task; break; } if (!['pending', 'processing'].includes(task.status)) throw new Error(`Unexpected state; inspect ${taskId}`); await delay(Math.min(3000, Math.max(0, deadline - Date.now())));}if (!completed) throw new Error(`Polling deadline reached; resume with TASK_ID=${taskId}, do not resubmit`);const assets = completed.result?.assets;if (!Array.isArray(assets) || !assets.length || assets.some(url => typeof url !== 'string')) { throw new Error(`No valid result assets; inspect ${taskId}`);}await mkdir(outputDir, { recursive: true });for (const [index, rawUrl] of assets.entries()) { const url = new URL(rawUrl); if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Unexpected asset URL'); // No API authorization header is forwarded to the asset host. const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(60000) }); if (!response.ok || !response.body) throw new Error(`Asset HTTP ${response.status}; retry download, not generation`); const type = response.headers.get('content-type')?.split(';')[0]; const extension = type === 'video/mp4' ? 'mp4' : type === 'video/webm' ? 'webm' : 'bin'; const path = join(outputDir, `${taskId}-${index}.${extension}`); const file = await open(path, 'wx', 0o600); // Never overwrite an existing output. let bytes = 0; const limit = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; callback(bytes > 100 * 1024 * 1024 ? new Error('Asset exceeds local 100 MiB limit') : null, chunk); } }); try { await pipeline(Readable.fromWeb(response.body), limit, file.createWriteStream()); } catch (error) { await file.close(); await rm(path, { force: true }); throw error; } console.log(`Saved ${path}`);}正确理解超时
Section titled “正确理解超时”pending 与 processing 是非终态;succeeded 返回产物,failed 返回错误。始终用同一个任务 ID 查询。示例中的 30 秒提交超时、10 分钟轮询预算、3 秒间隔与 100 MiB 下载上限都是客户端策略,不是平台限制或完成时间承诺。
POST 发生超时或断线时,是否已提交可能不确定,应停止并检查任务历史,不盲目重提。如果已收到 ID 但本地记录保存失败,保留打印的 ID,用 TASK_ID 恢复。轮询超时不会取消生成,也不证明不会扣费。示例遇到只读查询错误会停止;确认错误原因后可恢复原 ID 的查询。
安全保存结果
Section titled “安全保存结果”公开响应中的 result.assets 是 URL 字符串数组,仅在 succeeded 后下载。脚本流式写入、限制大小、不转发 API 凭据、拒绝跳转,并在下载失败时删除本次部分写入的文件。未知内容类型使用 .bin,使用前检查实际格式。生成资源链接不等于永久归档,请保存有权保留的文件。
生产下载器应限制为部署配置中可信的媒体域名,并落实出站网络策略;不要将任意用户 URL 做成服务端下载入口。下载失败不是重新创建计费视频任务的理由。