#!/usr/bin/env node
/**
* render-caption — generate an animated caption / lower-third / CTA as a
* transparent animated SVG (domotion-svg). Render it to alpha video with the
* svg-to-video bin for compositing.
*
* Usage:
* render-caption --text "Your own private Kanban + List" --out cap.svg
* render-caption --style cta --text "Watch the full demo →" --text "{{URL}}" --out cta.svg
*
* Options:
* --text Caption line. Repeat for multiple lines.
* --style pill (default) | plain | cta
* --position
lower-third (default) | center | upper-third
* --duration Total seconds incl. fade in/out (default 2.0)
* --fps Frame rate to align to (default 24)
* --width/--height Canvas (default 1920x1080)
* --accent Accent color (default #ff5a00)
* --icon SVG icon to chip on the left (pill/cta)
* --font Font family (default Avenir/Helvetica)
* --size Primary text size (style-dependent default)
* --out Output .svg (required)
*
* The pure caption/argument logic lives in ./caption-format.mjs (unit-tested);
* this file owns the headless-Chromium rendering pipeline.
*/
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { chromium } from "@playwright/test";
import { captureElementTree, embedRemoteImages, elementTreeToSvgInner, generateAnimatedSvg, optimizeSvg } from "domotion-svg";
import { buildPage, buildSpecs, parseArgs } from "./caption-format.mjs";
const HELP = [
"render-caption — generate an animated caption / lower-third / CTA as a",
"transparent animated SVG (domotion-svg).",
"",
"Usage:",
' render-caption --text "Your caption" --out cap.svg',
' render-caption --style cta --text "Watch the full demo →" --text "{{URL}}" --out cta.svg',
"",
"Options:",
" --text Caption line. Repeat for multiple lines.",
" --style pill (default) | plain | cta",
" --position lower-third (default) | center | upper-third",
" --duration Total seconds incl. fade in/out (default 2.0)",
" --fps Frame rate to align to (default 24)",
" --width/--height Canvas (default 1920x1080)",
" --accent Accent color (default #ff5a00)",
" --icon SVG icon to chip on the left (pill/cta)",
" --font Font family (default Avenir/Helvetica)",
" --size Primary text size (style-dependent default)",
" --out Output .svg (required)",
].join("\n");
async function main() {
const o = parseArgs(process.argv.slice(2), HELP);
const specs = buildSpecs(o);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: o.width, height: o.height } });
const pg = await ctx.newPage();
const cache = new Map();
const frames = [];
for (let i = 0; i < specs.length; i++) {
const s = specs[i];
const key = `${s.op}_${s.ty}`;
if (!cache.has(key)) {
const tmp = `${process.env.TMPDIR || "/tmp"}/render-caption-${process.pid}-${i}.html`;
writeFileSync(tmp, buildPage(o, s.op, s.ty));
await pg.goto(`file://${tmp}`);
await pg.waitForTimeout(80);
const tree = await captureElementTree(pg, "body", { x: 0, y: 0, width: o.width, height: o.height });
await embedRemoteImages(tree);
cache.set(key, elementTreeToSvgInner(tree, o.width, o.height, `c${i}-`));
}
frames.push({ svgContent: cache.get(key), duration: s.dur, transition: { type: "cut", duration: 0 } });
}
await browser.close();
let svg = generateAnimatedSvg({ width: o.width, height: o.height, frames });
svg = optimizeSvg(svg);
writeFileSync(o.out, svg);
console.log(`wrote ${o.out} (${(svg.length / 1024).toFixed(1)} KB, ${o.duration}s @ ${o.fps}fps, style=${o.style}, pos=${o.position})`);
}
// Only run the browser pipeline when invoked directly (not when imported in tests).
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((e) => { console.error(e?.stack || e); process.exit(1); });
}