Makernice

用 Next.js 接入 Makernice API

在 Next.js App Router 应用里加两个路由:一个在服务端报价并创建生成,API Key 不会到浏览器;另一个接收带签名的 webhook,拿到完成的生成。两段都是纯 TypeScript,不需要装包。

Makernice 团队 ·

开始之前

  • 在控制台创建 API Key,设成 MAKERNICE_API_KEY。
  • 把控制台设置里的 webhook 签名密钥设成 MAKERNICE_WEBHOOK_SECRET。
  • 把 APP_URL 设成应用的公网 https 地址,创建路由用它拼 callback_url。

两个路由

app/api/videos/route.ts
// app/api/videos/route.ts: runs on the server, so MAKERNICE_API_KEY never reaches the browser.
import { randomUUID } from "node:crypto";

const API = "https://api.makernice.com/v1";
const HEADERS = { Authorization: `Bearer ${process.env.MAKERNICE_API_KEY}`, "Content-Type": "application/json" };

class ApiError extends Error {
  status: number;
  body: unknown;
  constructor(status: number, body: unknown) {
    super(`Makernice API ${status}`);
    this.status = status;
    this.body = body;
  }
}

async function post(path: string, body: unknown, headers: Record<string, string> = {}) {
  const r = await fetch(API + path, { signal: AbortSignal.timeout(30_000),
    method: "POST",
    headers: { ...HEADERS, ...headers },
    body: JSON.stringify(body),
  });
  const data = await r.json();
  if (!r.ok) throw new ApiError(r.status, data);
  return data;
}

export async function POST(req: Request) {
  const { prompt } = (await req.json()) as { prompt?: unknown };
  if (typeof prompt !== "string" || prompt.trim() === "") {
    return Response.json({ error: "prompt is required" }, { status: 400 });
  }
  // Same job as the quickstart; swap model, duration or resolution as needed
  const input = { model: "seedance-2.5", mode: "t2v", prompt, duration: 5, resolution: "720p" };
  try {
    // 1. Quote: the exact amount in micro-USD, locked for 12 minutes. Nothing is charged yet
    const quote = await post("/generations/quote", input);
    // 2. Create. callback_url: we POST the finished generation to the webhook route below.
    //    If the browser may retry this request, take the Idempotency-Key from it instead of a new UUID
    const gen = await post(
      "/generations",
      { quote_id: quote.id, input, callback_url: `${process.env.APP_URL}/api/makernice-webhook` },
      { "Idempotency-Key": randomUUID() },
    );
    return Response.json({ id: gen.id, status: gen.status, amount: quote.amount });
  } catch (err) {
    // 402 insufficient_balance, 422 invalid_request, 429 rate_limited… pass the API error through
    if (err instanceof ApiError) return Response.json(err.body, { status: err.status });
    throw err;
  }
}
app/api/makernice-webhook/route.ts
// app/api/makernice-webhook/route.ts: receives generation.succeeded / failed / canceled.
import { createHmac, timingSafeEqual } from "node:crypto";

export const runtime = "nodejs"; // node:crypto needs the Node.js runtime, not Edge

// Deliveries can repeat (retries, timeouts on your side). Keep seen event ids in your database in production.
const seen = new Set<string>();

// rawBody: the body exactly as received. Don't re-serialize parsed JSON.
function verifyWebhook(rawBody: Buffer, signatureHeader: string, secret: string, toleranceSeconds = 300): boolean {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

export async function POST(req: Request) {
  const raw = Buffer.from(await req.arrayBuffer());
  const signature = req.headers.get("makernice-signature") ?? "";
  if (!verifyWebhook(raw, signature, process.env.MAKERNICE_WEBHOOK_SECRET ?? "")) {
    return new Response("invalid signature", { status: 401 });
  }
  const event = JSON.parse(raw.toString("utf8")) as {
    id: string;
    type: string;
    data: { id: string; status: string; outputs?: { url: string; state: string }[] };
  };
  if (seen.has(event.id)) return new Response(null, { status: 200 });
  seen.add(event.id);

  if (event.type === "generation.succeeded") {
    // outputs[].url is absolute here. Fetch it with your API key: it answers 302 with a signed file URL.
    // A generation can arrive again with a new event id (an output released after review); the latest one wins.
    console.log("ready", event.data.id, event.data.outputs?.map((o) => o.url));
  }
  // Any 2xx counts as delivered. Answer fast and do heavy work afterwards.
  return new Response(null, { status: 200 });
}

代码做了什么

  1. 浏览器把 { "prompt": "…" } POST 到 /api/videos,提示词为空时路由返回 400。
  2. 报价:POST /v1/generations/quote 返回精确金额(micro-USD 整数字符串),锁定 12 分钟,这一步不扣费。
  3. 创建:POST /v1/generations 带上 quote_id、同一份输入和 Idempotency-Key 请求头。用同一个键重试,拿回的是同一个生成,不会重复下单。
  4. 等待:创建时带了 callback_url,生成变成 succeeded、failed 或 canceled 时,我们把它 POST 到 /api/makernice-webhook。
  5. 402 insufficient_balance、429 rate_limited 等 API 错误按原状态码返回给浏览器。

webhook 要注意的地方

  • 先对原始请求体验证 Makernice-Signature,再解析 JSON。签名密钥在控制台设置里,callback_url 的推送用同一个密钥。
  • callback_url 必须是公网 https 地址,本地开发时 localhost 收不到推送:可以先用轮询,或用隧道把开发服务器暴露出去。
  • 尽快返回任意 2xx,重活放到响应之后做。推送失败会在 1 分钟、5 分钟、30 分钟、2 小时、6 小时、12 小时 后重试(共 7 次),同一事件可能收到多次:按事件 id 去重(与 Makernice-Event-Id 请求头相同)。
  • webhook 路由设了 runtime = "nodejs":它用到 node:crypto,Edge 运行时没有。请求体用 arrayBuffer() 读取,签名按收到的原始字节校验。

常见问题

Pages Router 能用吗?

能。把同样的逻辑放进 pages/api/ 下的 API 路由;webhook 路由要关掉 body parser(export const config = { api: { bodyParser: false } }),才能读到原始请求体去验签。

浏览器怎么知道视频好了?

webhook 到达时把结果存下来(比如按 /api/videos 返回的生成 id 存进数据库),再让页面轮询你自己的接口,或用你现有的实时推送通知页面。

生成失败要付钱吗?

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

相关链接