用 Node.js 调用 Makernice API
用 Node.js 18 及以上自带的 fetch 调用 Makernice,不用装任何包:先报精确价格,再创建生成、轮询到完成,最后下载文件。把示例存成 quickstart.mjs,用 node 运行即可。
开始之前
- 在控制台创建 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);
});代码做了什么
- 报价:
POST /v1/generations/quote返回精确金额(micro-USD 整数字符串),锁定 12 分钟,这一步不扣费。 - 创建:
POST /v1/generations带上quote_id、同一份输入和Idempotency-Key请求头。用同一个键重试,拿回的是同一个生成,不会重复下单。 - 等待:轮询
GET /v1/generations/{id}直到succeeded、failed或canceled;或者传callback_url,完成后我们把结果 POST 到你的服务器。 - 下载:每个
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 模式下都能编译通过。
生成失败要付钱吗?
不用。创建时冻结金额,失败或取消就全额释放;生成完成后被内容审核拦截的仍会扣费。详见失败不扣费。