跳转到内容
NewRouters/开发者文档
接入指南

Node.js 视频任务:提交、轮询与保存

只提交一次视频任务,使用原任务 ID 恢复轮询,处理失败与本地超时,并在不转发 API Key 的前提下保存结果文件。

这个服务端 Node.js 示例使用公开的 minimax-h3 任务契约,不是 MiniMax 上游接口。运行会创建一个视频并产生用量。先确认模型出现在 GET /v1/media-models 中,按其 request_schema 校验完整输入,再核对账号模型价格。MiniMax H3 指南和模型页面提供当前契约与计算器。

示例选择 5 秒、768P、16:9。这些是请求参数,不是固定像素、速度或质量保证。其他模型可能使用不同字段、枚举和组合约束,不能只替换模型 ID 就假设输入兼容。

使用 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}`);
}

pending 与 processing 是非终态;succeeded 返回产物,failed 返回错误。始终用同一个任务 ID 查询。示例中的 30 秒提交超时、10 分钟轮询预算、3 秒间隔与 100 MiB 下载上限都是客户端策略,不是平台限制或完成时间承诺。

POST 发生超时或断线时,是否已提交可能不确定,应停止并检查任务历史,不盲目重提。如果已收到 ID 但本地记录保存失败,保留打印的 ID,用 TASK_ID 恢复。轮询超时不会取消生成,也不证明不会扣费。示例遇到只读查询错误会停止;确认错误原因后可恢复原 ID 的查询。

公开响应中的 result.assets 是 URL 字符串数组,仅在 succeeded 后下载。脚本流式写入、限制大小、不转发 API 凭据、拒绝跳转,并在下载失败时删除本次部分写入的文件。未知内容类型使用 .bin,使用前检查实际格式。生成资源链接不等于永久归档,请保存有权保留的文件。

生产下载器应限制为部署配置中可信的媒体域名,并落实出站网络策略;不要将任意用户 URL 做成服务端下载入口。下载失败不是重新创建计费视频任务的理由。

验收时检查运动、构图和主体一致性。通过批量预算指南核对任务费用,或返回媒体学习路径选择输入与工作流。