Personalized video
One video per customer from your data.
One video per customer: a greeting with their name, a card with their plan and usage stats, background music and a credits line. The data comes from a CSV export of your customer table.
npm install @miraiclip/core @miraiclip/renderer @miraiclip/templates
npm install @miraiclip/server-export # only for server rendering1. Build the base composition
| Time | Clip | Content |
|---|---|---|
| 0–3 s | hello (text) | Hi {{name}}, here's your year |
| 3–8 s | stats (html) | Data card: name, plan, videos made, hours saved |
| 8–10 s | credits (text) | Credit line for the music |
| 0–10 s | bed (audio) | Stock music, 1 s fade-out |
// build-template.ts (Node)
import { writeFileSync } from "node:fs";
import { createProject, creditsFor, licenseReport } from "@miraiclip/core";
import { defineTemplate } from "@miraiclip/templates";
const CARD = `
<div style="width:1200px;height:640px;box-sizing:border-box;padding:64px;border-radius:32px;
background:#0f172a;color:#fff;font-family:sans-serif;display:flex;flex-direction:column;gap:32px">
<div style="font-size:40px;opacity:.7">{{name}} · {{plan}} plan</div>
<div style="display:flex;gap:96px">
<div><div style="font-size:120px;font-weight:700">{{videos}}</div><div style="font-size:36px">videos made</div></div>
<div><div style="font-size:120px;font-weight:700">{{hours}}</div><div style="font-size:36px">hours saved</div></div>
</div>
</div>`;
const project = createProject({ width: 1920, height: 1080, fps: 30 });
// Stock music with its license (importAudio from @miraiclip/audio-sources sets these for you)
project.dispatch({
type: "asset/add",
payload: {
id: "music", kind: "audio", src: "media/calm-piano.mp3", durationUs: 30_000_000,
name: "Calm Piano",
source: { provider: "freesound", id: "123456", url: "https://freesound.org/s/123456/" },
license: { id: "CC-BY-4.0", commercial: true, attributionRequired: true },
attribution: "“Calm Piano” by Jane Doe (CC BY 4.0)",
},
});
project.dispatch({ type: "track/add", payload: { id: "titles", kind: "video" } });
project.dispatch({ type: "track/add", payload: { id: "sound", kind: "audio" } });
project.dispatch({
type: "clip/add",
payload: {
kind: "text", id: "hello", trackId: "titles", startUs: 0, durationUs: 3_000_000,
text: "Hi {{name}}, here's your year", fontFamily: "sans-serif", fontSizePx: 88, color: "#ffffff",
},
});
project.dispatch({
type: "clip/add",
payload: {
kind: "html", id: "stats", trackId: "titles", startUs: 3_000_000, durationUs: 5_000_000,
template: CARD, widthPx: 1200, heightPx: 640,
params: { name: "{{name}}", plan: "{{plan}}", videos: "{{videos}}", hours: "{{hours}}" },
},
});
project.dispatch({
type: "clip/add",
payload: {
kind: "audio", id: "bed", trackId: "sound", assetId: "music",
startUs: 0, durationUs: 10_000_000, fadeOutUs: 1_000_000,
},
});
// End card with the credit lines the licenses ask for
const credits = creditsFor(project.toJSON());
project.dispatch({
type: "clip/add",
payload: {
kind: "text", id: "credits", trackId: "titles", startUs: 8_000_000, durationUs: 2_000_000,
text: `Music: ${credits.join("\n")}`, fontFamily: "sans-serif", fontSizePx: 36, color: "#cbd5e1",
},
});
const issues = licenseReport(project.toJSON(), { commercial: true });
if (issues.length > 0) throw new Error(issues.map((i) => i.message).join("\n"));
const template = defineTemplate({
name: "year-in-review",
doc: project.toJSON(),
fields: [
{ name: "name", type: "text", maxLength: 40 },
{ name: "plan", type: "text", enum: ["Starter", "Pro", "Business"] },
{ name: "videos", type: "number", integer: true, min: 0 },
{ name: "hours", type: "number", min: 0 },
],
});
writeFileSync("year-in-review.json", JSON.stringify(template, null, 2));Stock audio and credits
Check the license before you render for customers. licenseReport(doc, { commercial: true }) reports used assets that are non-commercial (non-commercial), came from a provider without a recorded license (unknown-license), or require attribution but have no credit line (missing-attribution). creditsFor(doc) returns the credit lines to show; here it returns ["“Calm Piano” by Jane Doe (CC BY 4.0)"], which goes into the credits clip. More in Audio.
2. Declare the fields
| Field | Type | Rule | Binds to |
|---|---|---|---|
name | text | maxLength: 40 | hello text and stats param |
plan | text | enum: Starter, Pro, Business | stats param |
videos | number | integer, min: 0 | stats param |
hours | number | min: 0 | stats param |
A param whose whole value is one placeholder ("{{videos}}") gets the typed value: videos: 214, a number. Inside longer text ("Hi {{name}}, …") the value is inserted as a string. Numbers print as-is (1204, 37.5). For 1,204 or 37.5 h, format in your data prep and make the field text. See Defining templates.
3. The data
id,name,plan,videos,hours
c_1001,Ada,Pro,214,37.5
c_1002,Grace,Starter,12,2
c_1003,Linus,Enterprise,8,1.5
c_1004,Margaret,Business,1204.5,310rowsFromCsv converts each declared field's cells to its type. Columns that aren't fields, like id, stay strings; split them off before hydrating, because every key in a row must be a field.
// customers.ts (Node)
import { readFileSync } from "node:fs";
import { parseTemplate, rowsFromCsv } from "@miraiclip/templates";
export const template = parseTemplate(JSON.parse(readFileSync("year-in-review.json", "utf8")));
// rowsFromCsv coerces declared fields (videos, hours) to numbers; other columns stay strings
export const customers = rowsFromCsv(template, readFileSync("customers.csv", "utf8")).map(({ id, ...data }) => ({
id: String(id),
data,
}));
// [{ id: "c_1001", data: { name: "Ada", plan: "Pro", videos: 214, hours: 37.5 } }, …]4. Hydrate and validate
// check.ts (Node)
import { tryHydrate } from "@miraiclip/templates";
import { customers, template } from "./customers";
for (const { id, data } of customers) {
const result = tryHydrate(template, data);
if (!result.ok) console.error(id, result.issues);
}Output for the CSV above:
c_1003 [ { path: 'data.plan', message: 'must be one of: Starter, Pro, Business' } ]
c_1004 [ { path: 'data.videos', message: 'expected an integer' } ]Fix the data, or let those rows fail and render the rest. More in Data & validation.
5. Preview one customer
import { createProject } from "@miraiclip/core";
import { renderProjectStill } from "@miraiclip/renderer";
import { hydrate, parseTemplate } from "@miraiclip/templates";
const template = parseTemplate(await (await fetch("/templates/year-in-review.json")).json());
const project = createProject(hydrate(template, { name: "Ada", plan: "Pro", videos: 214, hours: 37.5 }));
const png = await renderProjectStill(project, { timeUs: 5_000_000, width: 960, height: 540 });
document.querySelector("img")!.src = URL.createObjectURL(png);timeUs: 5_000_000 lands on the data card. To hear the music, mount a player instead: Preview.
6. Render every customer
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/year-in-review.json")).json());
const customers: { id: string; data: TemplateData }[] = await (await fetch("/api/year-in-review")).json();
for (const { id, data } of customers) {
const project = createProject(hydrate(template, data));
download(await exportProject(project, { format: "mp4", quality: "high" }), `${id}.mp4`);
}/api/year-in-review stands for your backend returning the rows from step 3. download is the helper from Export UI.
On a server
// render.ts (Node)
import { renderTemplateBatch } from "@miraiclip/templates/render";
import { customers, template } from "./customers";
const { failed } = await renderTemplateBatch(template, customers.map((c) => c.data), {
out: (_data, index) => `out/${customers[index].id}.mp4`,
format: "mp4",
quality: "high",
concurrency: 2,
onProgress: ({ rowsDone, totalRows, row }) =>
console.log(`${rowsDone}/${totalRows}`, customers[row.index].id, row.ok ? "ok" : row.error),
});
if (failed.length > 0) process.exitCode = 1;Invalid rows are reported with row.issues and row.error (invalid data: data.plan: must be one of: …); the other rows still render. See Batch rendering.
From the CLI
The CLI passes every CSV column to the template, so the id column would fail every row as an unknown field. Drop it and name files by row:
npx miraiclip-templates render year-in-review.json \
--data customers-no-id.csv --out "out/{index}-{name}.mp4" --quality highOutput naming
| Approach | File |
|---|---|
out function with your customer id | out/c_1001.mp4 |
"out/{index}-{name}.mp4" | out/0-Ada.mp4 |
Two customers with the same name map to the same {name} path; include {index} or an id. Rules: Output paths.
Variations
| To | Do this |
|---|---|
| Show a customer photo | An image asset used as <img src="asset:avatar"> in the card, and an asset field for it |
| Format numbers | Format in data prep (Intl.NumberFormat) and declare the field as text |
| Change the accent per plan | A color field set from the plan in data prep |
| Generated voiceover per customer | Generate audio per row with @miraiclip/audio-sources, store it, and pass its URL to an asset field as { src, durationUs }. See Audio |
| Your own music | A plain upload (no source, license or attribution) adds no credit line and no license issue |
| Drop the credits clip | Only when creditsFor(doc) is empty |