Catalog to product video
Product feeds into on-brand video ads.
A product feed (title, price, image URL, brand color) becomes one short video per product. The template is a product card; each row swaps the photo, the text and the color.
npm install @miraiclip/core @miraiclip/renderer @miraiclip/templates
npm install @miraiclip/server-export # only for server rendering1. Build the base composition
The card is an html clip. It draws the product photo with src="asset:product", so CSS (object-fit: contain) fits any photo into the same box.
// build-template.ts (Node)
import { writeFileSync } from "node:fs";
import { createProject } from "@miraiclip/core";
import { defineTemplate } from "@miraiclip/templates";
const CARD = `
<div style="width:1080px;height:1080px;box-sizing:border-box;padding:80px;background:{{brand}};
display:flex;flex-direction:column;align-items:center;gap:40px;font-family:sans-serif;color:#fff">
<img src="asset:product" style="width:920px;height:640px;object-fit:contain">
<div style="font-size:64px;font-weight:700;text-align:center">{{title}}</div>
<div style="font-size:56px">{{price}}</div>
</div>`;
const project = createProject({ width: 1080, height: 1080, fps: 30 });
project.dispatch({ type: "asset/add", payload: { id: "product", kind: "image", src: "media/placeholder.png" } });
project.dispatch({ type: "track/add", payload: { id: "card", kind: "video" } });
project.dispatch({
type: "clip/add",
payload: {
kind: "html", id: "card", trackId: "card", startUs: 0, durationUs: 5_000_000,
template: CARD,
params: { brand: "{{brand}}", title: "{{title}}", price: "{{price}}" },
},
});
const template = defineTemplate({
name: "product-card",
category: "catalog",
doc: project.toJSON(),
fields: [
{ name: "title", type: "text", maxLength: 60 },
{ name: "price", type: "text", description: "Formatted price, e.g. $129.00" },
{ name: "brand", type: "color", default: "#111827" },
{ name: "image", type: "asset", assetId: "product", description: "Product photo URL or path" },
],
});
writeFileSync("product-card.json", JSON.stringify(template, null, 2));An image clip would also work, but it draws the image at its own pixel size, so photos of different sizes would show at different sizes. The html clip keeps the layout fixed.
2. Declare the fields
| Field | Type | Rule | Binds to |
|---|---|---|---|
title | text | maxLength: 60 | card html param |
price | text | already formatted | card html param |
brand | color | default #111827 | card html param (background) |
image | asset | required | replaces the product asset's src |
Fields are required unless they have a default or required: false. Param values are HTML-escaped, so a title like Tom & Jerry renders as text. See Defining templates.
3. Prepare the data
Templates have no formatters: a value is inserted as given. Format prices (currency, separators, locale) before hydrating, and keep ids like the SKU outside the row, since every key in a row must be a declared field.
[
{ "sku": "LMP-01", "title": "Brass desk lamp", "price": 129, "currency": "USD", "image": "https://cdn.example.com/lmp-01.jpg", "brandColor": "#1f6feb" },
{ "sku": "MUG-07", "title": "Stoneware mug", "price": 18.5, "currency": "EUR", "image": "https://cdn.example.com/mug-07.jpg", "brandColor": "#c2410c" },
{ "sku": "CHR-12", "title": "Oak lounge chair", "price": 1299, "currency": "USD", "image": "media/chr-12.jpg", "brandColor": "#15803d" }
]// items.ts (Node)
import { readFileSync } from "node:fs";
import type { TemplateData } from "@miraiclip/templates";
interface Product {
sku: string;
title: string;
price: number;
currency: string;
image: string;
brandColor: string;
}
const products: Product[] = JSON.parse(readFileSync("products.json", "utf8"));
export const items = products.map((p) => ({
sku: p.sku,
data: {
title: p.title,
price: new Intl.NumberFormat("en-US", { style: "currency", currency: p.currency }).format(p.price),
image: p.image,
brand: p.brandColor,
} satisfies TemplateData,
}));
// [{ sku: "LMP-01", data: { title: "Brass desk lamp", price: "$129.00", image: "https://cdn.example.com/lmp-01.jpg", brand: "#1f6feb" } },
// { sku: "MUG-07", data: { …, price: "€18.50", … } },
// { sku: "CHR-12", data: { …, price: "$1,299.00", image: "media/chr-12.jpg", … } }]The same rows as CSV, for the CLI. Quote cells that contain commas; an empty cell uses the default.
title,price,brand,image
Brass desk lamp,$129.00,#1f6feb,https://cdn.example.com/lmp-01.jpg
Stoneware mug,€18.50,,https://cdn.example.com/mug-07.jpg
Oak lounge chair,"$1,299.00",#15803d,media/chr-12.jpg4. Hydrate and validate
// check.ts (Node)
import { readFileSync } from "node:fs";
import { parseTemplate, tryHydrate } from "@miraiclip/templates";
import { items } from "./items";
const template = parseTemplate(JSON.parse(readFileSync("product-card.json", "utf8")));
for (const { sku, data } of items) {
const result = tryHydrate(template, data);
if (!result.ok) console.error(sku, result.issues);
}A row without an image fails with { path: "data.image", message: "required field is missing" }. Validation checks the values, not the media: a broken image URL fails at render time, in that row only.
5. Preview one product
import { createProject } from "@miraiclip/core";
import { renderProjectStill } from "@miraiclip/renderer";
import { hydrate, parseTemplate } from "@miraiclip/templates";
const template = parseTemplate(await (await fetch("/templates/product-card.json")).json());
const project = createProject(
hydrate(template, {
title: "Brass desk lamp",
price: "$129.00",
image: "https://cdn.example.com/lmp-01.jpg",
}),
);
const png = await renderProjectStill(project, { timeUs: 1_000_000, width: 540, height: 540 });
document.querySelector("img")!.src = URL.createObjectURL(png);The page fetches the image, so a remote image host must allow cross-origin requests (CORS). For playback, see Preview.
6. Render the catalog
In the browser
import { createProject } from "@miraiclip/core";
import { exportProject } from "@miraiclip/renderer";
import { hydrate, parseTemplate, type TemplateData } from "@miraiclip/templates";
declare function download(bytes: Uint8Array, filename: string): void; // see Export UI
const template = parseTemplate(await (await fetch("/templates/product-card.json")).json());
const items: { sku: string; data: TemplateData }[] = await (await fetch("/api/catalog-items")).json();
for (const { sku, data } of items) {
const project = createProject(hydrate(template, data));
download(await exportProject(project, { format: "mp4" }), `${sku}.mp4`);
}/api/catalog-items stands for your backend returning the rows from step 3. download is the helper from Export UI.
On a server
// render.ts (Node)
import { readFileSync } from "node:fs";
import { parseTemplate } from "@miraiclip/templates";
import { renderTemplateBatch } from "@miraiclip/templates/render";
import { items } from "./items";
const template = parseTemplate(JSON.parse(readFileSync("product-card.json", "utf8")));
const { rows, failed } = await renderTemplateBatch(template, items.map((item) => item.data), {
out: (_data, index) => `out/${items[index].sku}.mp4`,
format: "mp4",
concurrency: 2,
});
console.log(`${rows.length - failed.length}/${rows.length} rendered`);
for (const row of failed) console.error(items[row.index].sku, row.error);| Image value | Resolved from |
|---|---|
https://… or data: URL | Fetched as-is |
A relative path (media/chr-12.jpg) | assetsDir (default: the working directory) |
Every asset in the document must resolve, including ones a row doesn't swap. See Batch rendering.
From the CLI
npx miraiclip-templates render product-card.json --data products.csv --out "out/{index}.mp4" --concurrency 2The CLI names files from row values or {index}. To name by SKU, use renderTemplateBatch with an out function as above. Naming rules: Output paths.
Variations
| To | Do this |
|---|---|
| Show a sale price | Add salePrice (text) and format it in step 3 |
| Animate the card | Add keyframes or an animation preset to the card clip before defineTemplate. See Keyframes |
| Add a brand logo | A second asset field and an <img src="asset:logo"> in the card |
| Product video instead of a photo | A video asset and clip; pass { src, durationUs } per row. For media shorter than the clip, run fitClipsToMedia on the hydrated document before exporting (renderTemplateBatch hydrates internally and doesn't apply it). See Asset fields |
| Square and vertical | One template per size, as in Ad variants |
| Localized prices | Pass the locale to Intl.NumberFormat per market |