Miraiclip SDK
Use cases

Ad variants

Many versions of one ad for testing.

One ad, rendered as every combination of headline, call to action and accent color, in 9:16, 1:1 and 16:9. You build the ad once per format as a template, then render a row per variant.

npm install @miraiclip/core @miraiclip/renderer @miraiclip/templates
npm install @miraiclip/server-export # only for server rendering

1. Build the base composition

Templates don't reflow. Hydration fills in content (text, html params, asset sources) and never changes the frame size or positions. So each aspect ratio is its own template: the same fields, a layout per format.

// build-templates.ts (Node)
import { mkdirSync, writeFileSync } from "node:fs";
import { createProject } from "@miraiclip/core";
import { defineTemplate, type TemplateField } from "@miraiclip/templates";

const FORMATS = {
  "9x16": { width: 1080, height: 1920, headlinePx: 110, ctaPx: 48, bottomPx: 320 },
  "1x1": { width: 1080, height: 1080, headlinePx: 88, ctaPx: 40, bottomPx: 120 },
  "16x9": { width: 1920, height: 1080, headlinePx: 96, ctaPx: 40, bottomPx: 120 },
};
type Format = keyof typeof FORMATS;

const OVERLAY = `
<div style="width:{{w}}px;height:{{h}}px;box-sizing:border-box;padding-bottom:{{bottom}}px;
  display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:40px;font-family:sans-serif">
  <h1 style="margin:0;max-width:90%;text-align:center;color:#fff;font-size:{{headlineSize}}px">{{headline}}</h1>
  <div style="padding:24px 56px;border-radius:999px;background:{{accent}};color:#fff;font-size:{{ctaSize}}px;font-weight:700">{{cta}}</div>
</div>`;

const fields: TemplateField[] = [
  { name: "headline", type: "text", maxLength: 32 },
  { name: "cta", type: "text", enum: ["Shop now", "Learn more", "Get 20% off"] },
  { name: "accent", type: "color", default: "#ff5a1f" },
];

function buildAd(format: Format) {
  const f = FORMATS[format];
  const project = createProject({ width: f.width, height: f.height, fps: 30 });

  project.dispatch({
    type: "asset/add",
    payload: { id: "hero", kind: "video", src: `media/hero-${format}.mp4`, durationUs: 6_000_000 },
  });
  project.dispatch({ type: "track/add", payload: { id: "footage", kind: "video" } });
  project.dispatch({ type: "track/add", payload: { id: "copy", kind: "video" } }); // on top
  project.dispatch({
    type: "clip/add",
    payload: { kind: "video", id: "hero", trackId: "footage", assetId: "hero", startUs: 0, durationUs: 6_000_000 },
  });
  project.dispatch({
    type: "clip/add",
    payload: {
      kind: "html", id: "copy", trackId: "copy", startUs: 0, durationUs: 6_000_000,
      template: OVERLAY,
      params: {
        // layout: fixed per format
        w: f.width, h: f.height, bottom: f.bottomPx, headlineSize: f.headlinePx, ctaSize: f.ctaPx,
        // content: template fields
        headline: "{{headline}}", cta: "{{cta}}", accent: "{{accent}}",
      },
    },
  });

  return defineTemplate({ name: `ad-${format}`, category: "ads", doc: project.toJSON(), fields });
}

mkdirSync("templates", { recursive: true });
for (const format of Object.keys(FORMATS) as Format[]) {
  writeFileSync(`templates/ad-${format}.json`, JSON.stringify(buildAd(format), null, 2));
}
npx tsx build-templates.ts   # templates/ad-9x16.json, ad-1x1.json, ad-16x9.json
FormatSizeFootage
9x161080 × 1920media/hero-9x16.mp4
1x11080 × 1080media/hero-1x1.mp4
16x91920 × 1080media/hero-16x9.mp4

Video is fitted to the frame with its aspect ratio kept, so a 16:9 source in a 9:16 frame is letterboxed. Cut one source per format.

2. Declare the fields

FieldTypeRuleBinds to
headlinetextmaxLength: 32copy html param
ctatextenum of three labelscopy html param
accentcolordefault #ff5a1fcopy html param (button background)

The {{w}}, {{headlineSize}} and other layout placeholders in OVERLAY belong to the html clip's own params. Only {{name}} placeholders inside param values are template fields. More in Defining templates.

3. The variant grid

Every combination of the lists below: 2 × 2 × 2 = 8 variants. The id stays outside data, because every key in a row must be a declared field.

// variants.ts
import type { TemplateData } from "@miraiclip/templates";

const headlines = ["Summer sale", "Up to 50% off"];
const ctas = ["Shop now", "Get 20% off"];
const accents = ["#ff5a1f", "#2563eb"];

export const variants = headlines.flatMap((headline, h) =>
  ctas.flatMap((cta, c) =>
    accents.map((accent, a) => ({
      id: `h${h}-c${c}-a${a}`,
      data: { headline, cta, accent } satisfies TemplateData,
    })),
  ),
);
// [{ id: "h0-c0-a0", data: { headline: "Summer sale", cta: "Shop now", accent: "#ff5a1f" } }, …]

For the CLI, the same rows as CSV (an empty cell uses the field's default):

headline,cta,accent
Summer sale,Shop now,#ff5a1f
Summer sale,Shop now,#2563eb
Summer sale,Get 20% off,
Up to 50% off,Shop now,#2563eb

4. Hydrate and validate

Check every variant against every format before rendering anything:

// check.ts (Node)
import { readFileSync } from "node:fs";
import { parseTemplate, tryHydrate } from "@miraiclip/templates";
import { variants } from "./variants";

for (const format of ["9x16", "1x1", "16x9"]) {
  const template = parseTemplate(JSON.parse(readFileSync(`templates/ad-${format}.json`, "utf8")));
  for (const { id, data } of variants) {
    const result = tryHydrate(template, data);
    if (!result.ok) console.error(format, id, result.issues);
  }
}

A row that breaks the rules fails with every issue listed:

import { tryHydrate, type Template } from "@miraiclip/templates";

declare const template: Template;

tryHydrate(template, { headline: "Our biggest summer sale of the year", cta: "Buy now" });
// { ok: false, issues: [
//   { path: "data.headline", message: "longer than maxLength 32" },
//   { path: "data.cta", message: "must be one of: Shop now, Learn more, Get 20% off" } ] }

More in Data & validation.

5. Preview one variant

In the browser, render a single frame to check the layout:

import { createProject } from "@miraiclip/core";
import { renderProjectStill } from "@miraiclip/renderer";
import { hydrate, parseTemplate } from "@miraiclip/templates";

const template = parseTemplate(await (await fetch("/templates/ad-9x16.json")).json());
const project = createProject(hydrate(template, { headline: "Summer sale", cta: "Shop now" }));

const png = await renderProjectStill(project, { timeUs: 3_000_000, width: 540, height: 960 });
document.querySelector("img")!.src = URL.createObjectURL(png);

For playback, mount a player on the hydrated project: Preview.

6. Render every variant

In the browser

Each hydrated document is an ordinary project. Export them one after another:

import { createProject } from "@miraiclip/core";
import { exportProject } from "@miraiclip/renderer";
import { hydrate, parseTemplate } from "@miraiclip/templates";
import { variants } from "./variants";

declare function download(bytes: Uint8Array, filename: string): void; // see Export UI

for (const format of ["9x16", "1x1", "16x9"]) {
  const template = parseTemplate(await (await fetch(`/templates/ad-${format}.json`)).json());
  for (const { id, data } of variants) {
    const project = createProject(hydrate(template, data));
    const mp4 = await exportProject(project, { format: "mp4" });
    download(mp4, `ad-${format}-${id}.mp4`);
  }
}

download is the helper from Export UI. Asset paths like media/hero-9x16.mp4 resolve against the page URL.

On a server

renderTemplateBatch keeps headless Chrome warm across rows. A failed row is reported and doesn't stop the others.

// render.ts (Node)
import { readFileSync } from "node:fs";
import { parseTemplate } from "@miraiclip/templates";
import { renderTemplateBatch } from "@miraiclip/templates/render";
import { variants } from "./variants";

for (const format of ["9x16", "1x1", "16x9"]) {
  const template = parseTemplate(JSON.parse(readFileSync(`templates/ad-${format}.json`, "utf8")));

  const { failed } = await renderTemplateBatch(template, variants.map((v) => v.data), {
    out: (_data, index) => `out/${format}/${variants[index].id}.mp4`,
    format: "mp4",
    assetsDir: ".", // asset src paths are relative to the project root
    concurrency: 2,
    onProgress: ({ rowsDone, totalRows }) => console.log(`${format} ${rowsDone}/${totalRows}`),
  });

  for (const row of failed) console.error(format, variants[row.index].id, row.error);
}

MP4 needs a real Chrome on the machine; Chromium alone exports WebM. See Batch rendering.

From the CLI

npx miraiclip-templates render templates/ad-9x16.json \
  --data variants.csv --out "out/9x16/{index}-{cta}.mp4" --assets-dir . --concurrency 2

--assets-dir defaults to the template file's folder; pass . when asset paths are relative to the project root. All flags: CLI.

Output naming

outRowFile
"out/{index}.mp4"first rowout/0.mp4
"out/{index}-{cta}.mp4"cta: "Get 20% off", third rowout/2-Get-20%-off.mp4
(data, index) => …anywhatever the function returns
RuleDetail
{index}The row's position in rows, from 0
{field}The value in the row. A field left to its default stays as the literal {field}
Unsafe charactersWhitespace and / \ : * ? " < > | become -
Same name twiceThe later row overwrites the file. Include {index} or a unique id
FoldersCreated as needed

More in Output paths.

Variations

ToDo this
Add a format (4:5, 1080 × 1350)Add an entry to FORMATS and cut a source for it
Swap footage per variantAdd { name: "hero", type: "asset", assetId: "hero" }; pass { src, durationUs } per row
LocalizeAdd rows with translated headline and cta; widen the enum
Review drafts quicklyquality: "draft" and a smaller width / height
Export without real Chromeformat: "webm"
Let an LLM write the copytoFieldToolDefinition(template) gives a tool whose input is a row. See As an AI tool

On this page