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 rendering1. 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| Format | Size | Footage |
|---|---|---|
9x16 | 1080 × 1920 | media/hero-9x16.mp4 |
1x1 | 1080 × 1080 | media/hero-1x1.mp4 |
16x9 | 1920 × 1080 | media/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
| Field | Type | Rule | Binds to |
|---|---|---|---|
headline | text | maxLength: 32 | copy html param |
cta | text | enum of three labels | copy html param |
accent | color | default #ff5a1f | copy 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,#2563eb4. 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
out | Row | File |
|---|---|---|
"out/{index}.mp4" | first row | out/0.mp4 |
"out/{index}-{cta}.mp4" | cta: "Get 20% off", third row | out/2-Get-20%-off.mp4 |
(data, index) => … | any | whatever the function returns |
| Rule | Detail |
|---|---|
{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 characters | Whitespace and / \ : * ? " < > | become - |
| Same name twice | The later row overwrites the file. Include {index} or a unique id |
| Folders | Created as needed |
More in Output paths.
Variations
| To | Do this |
|---|---|
| Add a format (4:5, 1080 × 1350) | Add an entry to FORMATS and cut a source for it |
| Swap footage per variant | Add { name: "hero", type: "asset", assetId: "hero" }; pass { src, durationUs } per row |
| Localize | Add rows with translated headline and cta; widen the enum |
| Review drafts quickly | quality: "draft" and a smaller width / height |
| Export without real Chrome | format: "webm" |
| Let an LLM write the copy | toFieldToolDefinition(template) gives a tool whose input is a row. See As an AI tool |