Makernice

用 Node.js 调用 Makernice API

用 Node.js 18 及以上自带的 fetch 调用 Makernice,不用装任何包:先报精确价格,再创建生成、轮询到完成,最后下载文件。把示例存成 quickstart.mjs,用 node 运行即可。

Makernice 团队 ·

开始之前

  • 在控制台创建 API Key,设成环境变量 MAKERNICE_API_KEY。
  • Node.js 18 及以上,不需要安装依赖。

完整示例

quickstart.mjs
// Node.js 18+ (built-in fetch, no packages). Save as quickstart.mjs and run: node quickstart.mjs
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";

const API = "https://api.makernice.com/v1";
const ORIGIN = "https://api.makernice.com";
const HEADERS = { Authorization: `Bearer ${process.env.MAKERNICE_API_KEY}` };
const TIMEOUT_MS = 30_000;
const POLL_INTERVAL_MS = 5_000;
const MAX_WAIT_MS = 30 * 60 * 1000;
const FINAL = new Set(["succeeded", "failed", "canceled"]);

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// micro-USD integer string -> dollars, no float rounding
const usd = (micro) => `${BigInt(micro) / 1_000_000n}.${(BigInt(micro) % 1_000_000n).toString().padStart(6, "0")}`;

class Stop extends Error {}

async function call(method, path, { body, headers } = {}) {
  // Waits out 429 (rate_limited, concurrency_limit_reached) using Retry-After; throws on any other error.
  for (let attempt = 0; attempt < 5; attempt++) {
    const r = await fetch(API + path, { signal: AbortSignal.timeout(TIMEOUT_MS),
      method,
      headers: { ...HEADERS, ...(body ? { "Content-Type": "application/json" } : {}), ...headers },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (r.status === 429) {
      await sleep(Number(r.headers.get("Retry-After") ?? "5") * 1000);
      continue;
    }
    if (!r.ok) throw new Stop(`${method} ${path} -> ${r.status}: ${await r.text()}`);
    return r.json();
  }
  throw new Stop(`${method} ${path}: still rate limited`);
}

async function main() {
  const job = {
    "model": "seedance-2.5",
    "mode": "t2v",
    "prompt": "A slow dolly shot through a greenhouse",
    "duration": 5,
    "resolution": "720p",
  };

  // 1. Quote: the exact amount, locked for 12 minutes
  const quote = await call("POST", "/generations/quote", { body: job });
  console.log(`Quoted $${usd(quote.amount)} (price list v${quote.price_version}), valid until ${quote.expires_at}`);

  // 2. Create: same input + quote_id. A retry with the same Idempotency-Key returns the same generation
  const idempotencyKey = randomUUID();
  let gen = await call("POST", "/generations", { body: { quote_id: quote.id, input: job }, headers: { "Idempotency-Key": idempotencyKey } });

  // 3. Poll: queued -> generating -> moderating -> succeeded | failed | canceled (checking: keep polling)
  const deadline = Date.now() + MAX_WAIT_MS;
  while (!FINAL.has(gen.status)) {
    if (Date.now() > deadline) throw new Stop(`${gen.id} is still ${gen.status}; poll it again later`);
    await sleep(POLL_INTERVAL_MS);
    gen = await call("GET", `/generations/${gen.id}`);
  }

  if (gen.status !== "succeeded") {
    // billing is "released": the hold went back to your balance
    throw new Stop(`${gen.status}, billing ${gen.billing}: ${JSON.stringify(gen.error)}`);
  }

  // 4. Download: outputs[].url is relative to the API origin and answers 302 with a signed file URL.
  //    Follow it yourself so your API key is never sent to the file host.
  const [output] = gen.outputs;
  if (output.state === "blocked") throw new Stop("Output blocked by moderation (the run is charged); contact support for a manual review");
  const hop = await fetch(ORIGIN + output.url, { headers: HEADERS, redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS) });
  const location = hop.headers.get("Location");
  if (hop.status < 300 || hop.status > 399 || !location) throw new Stop(`GET ${output.url} -> ${hop.status}`);
  const file = await fetch(new URL(location, ORIGIN + output.url), { signal: AbortSignal.timeout(120_000) });
  if (!file.ok) throw new Stop(`download -> ${file.status}`);
  await writeFile("output.mp4", Buffer.from(await file.arrayBuffer()));
  console.log(`Saved output.mp4, charged $${usd(gen.amount)}`);
}

main().catch((err) => {
  console.error(err instanceof Stop ? err.message : err);
  process.exit(1);
});

代码做了什么

  1. 报价:POST /v1/generations/quote 返回精确金额(micro-USD 整数字符串),锁定 12 分钟,这一步不扣费。
  2. 创建:POST /v1/generations 带上 quote_id、同一份输入和 Idempotency-Key 请求头。用同一个键重试,拿回的是同一个生成,不会重复下单。
  3. 等待:轮询 GET /v1/generations/{id} 直到 succeeded、failed 或 canceled;或者传 callback_url,完成后我们把结果 POST 到你的服务器。
  4. 下载:每个 outputs[].url 返回 302,跳到带签名的文件地址。

Node.js 要注意的地方

  • 金额是 micro-USD 整数字符串,示例用 BigInt 换算,不会有舍入误差。
  • 每个请求都用 AbortSignal.timeout 设了超时,429 会按 Retry-After 等待后重试。
  • 下载用 redirect: "manual" 自己跟 302,API Key 不会发给文件主机。不同 Node 版本的 fetch 在跨主机跳转时是否去掉 Authorization 不一致。

常见问题

有官方 SDK 吗?

没有。REST API 只有四次调用,本页代码就是完整的客户端。想自动生成客户端,可以用 /openapi.json 的 OpenAPI 描述。

能在浏览器里直接调用吗?

不要这样做:API Key 会暴露给每个访客。请从你的服务器或 Serverless 函数调用。Next.js 接入演示了把 Key 留在服务端的路由写法。

支持 TypeScript 吗?

支持。带类型的同一组调用见 Next.js 接入,那里的两个路由在 strict 模式下都能编译通过。

生成失败要付钱吗?

不用。创建时冻结金额,失败或取消就全额释放;生成完成后被内容审核拦截的仍会扣费。详见失败不扣费。

相关链接