Miraiclip SDK

Docker

Build the render worker image, run it, and send it render jobs.

The render worker is one Node file and a Dockerfile. The image installs Google Chrome, so it exports both MP4 (H.264 + AAC) and WebM. Chromium builds can't encode H.264; see The browser.

The same files are in the SDK repository under examples/render-worker.

Files

package.json
{
  "name": "miraiclip-render-worker",
  "private": true,
  "type": "module",
  "dependencies": {
    "@miraiclip/core": "^0.6.0",
    "@miraiclip/server-export": "^0.5.0"
  }
}
server.mjs
import { createServer } from "node:http";
import { Readable } from "node:stream";
import { createRenderJobHandler } from "@miraiclip/server-export";

const handler = createRenderJobHandler({
  token: process.env.RENDER_TOKEN, // checked against "Authorization: Bearer <token>"
  browser: {
    swiftshader: !process.env.GPU, // software WebGL, unless the machine has a GPU
    args: ["--disable-dev-shm-usage"], // containers often have a tiny /dev/shm
  },
});

const port = Number(process.env.PORT ?? 8080);
createServer(async (req, res) => {
  if (req.method === "GET" && req.url === "/healthz") return void res.end("ok");
  const request = new Request(`http://localhost${req.url}`, {
    method: req.method,
    headers: req.headers,
    body: req.method === "POST" ? Readable.toWeb(req) : undefined,
    duplex: "half",
  });
  const response = await handler(request);
  res.writeHead(response.status, Object.fromEntries(response.headers));
  res.end(Buffer.from(await response.arrayBuffer()));
}).listen(port, () => console.log(`render worker on :${port}`));

process.on("SIGTERM", async () => {
  await handler.close();
  process.exit(0);
});
Dockerfile
FROM node:22-bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends wget ca-certificates gnupg fonts-noto fonts-noto-color-emoji \
 && wget -q -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
 && apt-get install -y --no-install-recommends /tmp/chrome.deb \
 && rm -rf /tmp/chrome.deb /var/lib/apt/lists/*
WORKDIR /app
COPY package.json server.mjs ./
RUN npm install --omit=dev
ENV PORT=8080
EXPOSE 8080
CMD ["node", "server.mjs"]

The worker finds the installed Chrome on its own. The Noto fonts cover most scripts and emoji in text and captions; add more font packages if your projects need them.

x86-64 only

Google Chrome for Linux ships for x86-64 only. Build with --platform linux/amd64 (required on Apple silicon Macs), and pick x86-64 machines in the cloud, not Arm (Graviton, Ampere).

Build and run

docker build --platform linux/amd64 -t miraiclip-render-worker .

docker run --rm -p 8080:8080 \
  -e RENDER_TOKEN=change-me \
  --cpus 2 --memory 4g \
  miraiclip-render-worker
curl http://localhost:8080/healthz
# ok

Environment variables

VariableDefaultWhat it does
PORT8080Port the server listens on
RENDER_TOKENnoneReject jobs without Authorization: Bearer <token>. Set it on every public deployment
GPUunsetSet to any value on machines with a GPU to use it for WebGL instead of software rendering
MIRAICLIP_BROWSERthe installed ChromePath to another Chrome or Chromium binary

Send a render job

A job is the project document (what project.toJSON() returns) plus export options. The response body is the encoded file.

render.mjs
import { readFile, writeFile } from "node:fs/promises";

const doc = JSON.parse(await readFile("project.json", "utf8"));

const res = await fetch("http://localhost:8080/", {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${process.env.RENDER_TOKEN}` },
  body: JSON.stringify({
    id: "final", // letters, digits, ".", "_" and "-"
    kind: "video",
    doc,
    options: { format: "mp4", quality: "high" },
  }),
});
if (!res.ok) throw new Error(`render failed (${res.status}): ${await res.text()}`);
await writeFile("final.mp4", Buffer.from(await res.arrayBuffer()));

One job renders the whole document, picture and sound, in a single pass. options takes format, quality, fps, width, height and range, with the same meaning as in exportProjectFile.

StatusMeaning
200The file: video/mp4 or video/webm
400Not a valid job, or the output frame is larger than maxPixels (4K by default)
401Missing or wrong token
405Not a POST
500The export failed. The body is { "error": "…" }, for example an asset URL that didn't load

Asset src values must be https: URLs the container can reach. A path on your laptop isn't inside the container.

Many workers

renderDistributed splits one export into chunks, sends them to workers in parallel with httpRunner, and joins the results with ffmpeg. The machine that runs renderDistributed needs ffmpeg; the workers don't.

compose.yaml
services:
  worker:
    build: .
    platform: linux/amd64
    environment:
      RENDER_TOKEN: change-me
    ports:
      - "8081-8084:8080"
    deploy:
      replicas: 4
      resources:
        limits: { cpus: "2", memory: 4g }
docker compose up --build
distributed.mjs
import { readFile } from "node:fs/promises";
import { httpRunner, renderDistributed } from "@miraiclip/server-export";

const doc = JSON.parse(await readFile("project.json", "utf8"));

const runner = httpRunner({
  endpoints: ["http://localhost:8081", "http://localhost:8082", "http://localhost:8083", "http://localhost:8084"],
  headers: { authorization: `Bearer ${process.env.RENDER_TOKEN}` },
  concurrency: 4, // one job per worker: a worker renders one job at a time
});

await renderDistributed(doc, {
  format: "mp4",
  out: "final.mp4",
  runner,
  onProgress: (p) => console.log(p.phase, p.jobsDone, "/", p.jobsTotal),
});

In the cloud, point endpoints at one load-balanced URL instead of a list. See AWS and Google Cloud.

Production checklist

  • Token. Set RENDER_TOKEN, and keep it in your platform's secret store rather than in the image.
  • One job per container. A worker renders one job at a time and queues the rest. Match the platform's per-instance concurrency to 1, and httpRunner's concurrency to the number of workers.
  • Timeouts. A job sends nothing back until the file is done. Every proxy and load balancer in front of the worker needs a timeout longer than your longest job.
  • Disk and memory. Each job writes its output to the temp folder, then holds it in memory while sending it. Leave room for your largest output in both, on top of Chrome: 4 GB of memory covers 1080p, give 4K jobs 8 GB.
  • Shutdown. On SIGTERM the worker closes Chrome and exits at once, so a job in flight fails. httpRunner retries failed jobs (network errors and 5xx) on the next worker; your own callers should retry too.
  • Updates. Rebuild the image to pick up new Chrome and SDK releases. Pin @miraiclip/* versions in package.json if you need the same output across rebuilds.

On this page