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

## 生成前确认模型与预算

这个服务端 Node.js 示例使用公开的 `minimax-h3` 任务契约，不是 MiniMax 上游接口。运行会创建一个视频并产生用量。先确认模型出现在 `GET /v1/media-models` 中，按其 `request_schema` 校验**完整**输入，再核对[账号模型价格](/account/pricing)。[MiniMax H3 指南](/zh/docs/models/minimax-h3)和[模型页面](/zh/model/minimax/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` 指定下载目录。已有文件不会被覆盖；若部分文件已经下载，请使用新目录或只下载缺失文件，不重新生成。

```javascript
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 做成服务端下载入口。下载失败不是重新创建计费视频任务的理由。

验收时检查运动、构图和主体一致性。通过[批量预算指南](/zh/docs/media/batch-budget)核对任务费用，或返回[媒体学习路径](/zh/docs/media)选择输入与工作流。
