feat(media): resvg motion graphics renderer with brand-kit templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
Claude 2026-10-03 17:47:24 +00:00
parent 7ccc96ac11
commit b5cef21f44
No known key found for this signature in database
10 changed files with 2115 additions and 0 deletions

View File

@ -0,0 +1,10 @@
export type {
MotionFrameContext,
MotionTemplate,
ResvgMotionGraphicsRendererOptions,
} from "./media-motion/index.js";
export {
MOTION_TEMPLATES,
MotionTemplateError,
ResvgMotionGraphicsRenderer,
} from "./media-motion/index.js";

View File

@ -0,0 +1,56 @@
/** Easing and timing helpers shared by every motion template. All pure. */
export function clamp(value: number, min = 0, max = 1): number {
return Math.min(max, Math.max(min, value));
}
export function lerp(from: number, to: number, amount: number): number {
return from + (to - from) * amount;
}
/** Linear progress 0..1 of `t` through the window [start, end]. */
export function progress(t: number, start: number, end: number): number {
if (end <= start) return t >= end ? 1 : 0;
return clamp((t - start) / (end - start));
}
export function easeInCubic(x: number): number {
return x * x * x;
}
export function easeOutCubic(x: number): number {
return 1 - (1 - x) ** 3;
}
export function easeInOutCubic(x: number): number {
return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
}
export function easeOutExpo(x: number): number {
return x >= 1 ? 1 : 1 - 2 ** (-10 * x);
}
/** Overshoots slightly before settling; for pop-in reveals. */
export function easeOutBack(x: number): number {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2;
}
/**
* Opacity envelope for a clip of `durationMs`: fades in over `inMs` and out
* over the last `outMs`.
*/
export function fadeEnvelope(t: number, durationMs: number, inMs: number, outMs: number): number {
const fadeIn = inMs > 0 ? easeOutCubic(progress(t, 0, inMs)) : 1;
const fadeOut = outMs > 0 ? 1 - easeInCubic(progress(t, durationMs - outMs, durationMs)) : 1;
return clamp(Math.min(fadeIn, fadeOut));
}
/** Deterministic pseudo-random value in [0, 1) for integer seeds. */
export function hash01(a: number, b = 0): number {
let h = Math.imul(a ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(b + 0x632be5ab, 0xc2b2ae35);
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);
h = Math.imul(h ^ (h >>> 12), 0x297a2d39);
return ((h ^ (h >>> 15)) >>> 0) / 4294967296;
}

View File

@ -0,0 +1,308 @@
/*
* Motion graphics renderer for Media Studio (intros, outros, lower thirds,
* caption previews, end cards).
*
* Technology decision: no Remotion and no headless Chromium. Remotion needs a
* paid company license above a team size and Chromium is a heavy, hard-to-
* sandbox runtime. Instead every template is a pure TypeScript function that
* returns an SVG document for a point in time. Frames are rasterized with
* @resvg/resvg-js (MPL-2.0; resvg itself is MPL-2.0, used unmodified as a
* library, commercially usable) and piped as PNGs into ffmpeg (LGPL/GPL
* depending on the build, invoked as a separate process with an argv array),
* which encodes a QuickTime Animation (qtrle, argb) MOV with alpha. Nothing
* here needs a network or a hosted service.
*/
import type { ChildProcess } from "node:child_process";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import { readFile, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { MotionGraphicsRenderer, MotionRenderRequest } from "@rakazo/adapter-kit";
import type { BrandKit, MotionTemplateInfo, MotionTemplateRef } from "@rakazo/contracts";
import { BrandKitSchema, MotionTemplateRefSchema } from "@rakazo/contracts";
import { assertInside } from "@rakazo/media/node";
import type { RenderedImage, ResvgRenderOptions } from "@resvg/resvg-js";
import { renderAsync } from "@resvg/resvg-js";
import type { MotionFrameContext, MotionTemplate } from "./template.js";
import { brandFonts, MotionTemplateError, validateDuration, validateParams } from "./template.js";
import { captionBoldSocial, captionClean, captionKaraoke } from "./templates/captions.js";
import { endCardCta, endCardMinimal } from "./templates/end-cards.js";
import {
introElegantFade,
introGlitch,
introKineticType,
introLogoReveal,
} from "./templates/intros.js";
import { lowerThirdBar, lowerThirdClean, lowerThirdPill } from "./templates/lower-thirds.js";
import { outroLogo, outroSubscribe } from "./templates/outros.js";
export type { MotionFrameContext, MotionTemplate } from "./template.js";
export { MotionTemplateError } from "./template.js";
export const MOTION_TEMPLATES: readonly MotionTemplate[] = [
introLogoReveal,
introKineticType,
introGlitch,
introElegantFade,
outroLogo,
outroSubscribe,
lowerThirdClean,
lowerThirdBar,
lowerThirdPill,
captionClean,
captionKaraoke,
captionBoldSocial,
endCardCta,
endCardMinimal,
];
export function motionTemplate(id: string): MotionTemplate {
const template = MOTION_TEMPLATES.find((entry) => entry.info.id === id);
if (!template) throw new MotionTemplateError("unknown_template", `unknown template "${id}"`);
return template;
}
export const MIN_FRAME_SIDE = 16;
export const MAX_FRAME_SIDE = 3840;
export const MAX_FPS = 60;
/** Default cap on width × height × frames: ten seconds of 1080p60. */
export const DEFAULT_MAX_PIXEL_FRAMES = 1920 * 1080 * 60 * 10;
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".ttc"]);
/**
* Read a logo as a data URI. Only PNG and SVG are accepted, and an SVG may not
* declare entities or reference anything outside itself.
*/
export async function loadLogoDataUri(file: string): Promise<string> {
const extension = path.extname(file).toLowerCase();
if (extension !== ".png" && extension !== ".svg")
throw new MotionTemplateError("invalid_asset", "logos must be PNG or SVG");
const info = await stat(file);
if (!info.isFile() || info.size > MAX_LOGO_BYTES)
throw new MotionTemplateError("invalid_asset", "logo is not a file or is too large");
const bytes = await readFile(file);
if (extension === ".png") {
if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE))
throw new MotionTemplateError("invalid_asset", "logo is not a PNG");
return `data:image/png;base64,${bytes.toString("base64")}`;
}
const text = bytes.toString("utf8");
if (!/<svg[\s>]/i.test(text))
throw new MotionTemplateError("invalid_asset", "logo is not an SVG");
if (/<!DOCTYPE|<!ENTITY/i.test(text) || /\bhref\s*=\s*["']\s*(?!#|data:)/i.test(text))
throw new MotionTemplateError("invalid_asset", "SVG logos must be self-contained");
return `data:image/svg+xml;base64,${bytes.toString("base64")}`;
}
/** Validate a template reference and assemble the pure frame context. */
export function buildFrameContext(input: {
template: MotionTemplateRef;
kit: BrandKit;
width: number;
height: number;
logos: string[];
}): { template: MotionTemplate; ctx: MotionFrameContext } {
const ref = MotionTemplateRefSchema.parse(input.template);
const kit = BrandKitSchema.parse(input.kit);
const template = motionTemplate(ref.templateId);
validateDuration(template.info, ref.durationMs);
for (const side of [input.width, input.height]) {
if (!Number.isInteger(side) || side < MIN_FRAME_SIDE || side > MAX_FRAME_SIDE)
throw new MotionTemplateError(
"invalid_size",
`frame sides must be ${MIN_FRAME_SIDE}..${MAX_FRAME_SIDE} px`,
);
}
return {
template,
ctx: {
width: input.width,
height: input.height,
durationMs: ref.durationMs,
kit,
params: validateParams(template.info, ref.params),
logos: input.logos,
fonts: brandFonts(kit),
},
};
}
export interface RasterizeOptions {
fontFiles: string[];
defaultFontFamily?: string | null;
/** Fall back to system fonts for missing families and glyphs. */
loadSystemFonts: boolean;
}
export function rasterizeFrame(svg: string, options: RasterizeOptions): Promise<RenderedImage> {
const resvgOptions: ResvgRenderOptions = {
font: {
loadSystemFonts: options.loadSystemFonts,
fontFiles: options.fontFiles,
...(options.defaultFontFamily ? { defaultFontFamily: options.defaultFontFamily } : {}),
},
logLevel: "off",
};
return renderAsync(svg, resvgOptions);
}
export function frameCount(durationMs: number, fps: number): number {
return Math.max(1, Math.round((durationMs * fps) / 1000));
}
function abortError(): Error {
const error = new Error("motion render aborted");
error.name = "AbortError";
return error;
}
export interface ResvgMotionGraphicsRendererOptions {
ffmpegPath?: string;
/** When set, every asset path and the output path must lie inside it. */
root?: string;
maxPixelFrames?: number;
}
export class ResvgMotionGraphicsRenderer implements MotionGraphicsRenderer {
readonly id = "resvg";
private readonly ffmpegPath: string;
private readonly maxPixelFrames: number;
constructor(private readonly options: ResvgMotionGraphicsRendererOptions = {}) {
this.ffmpegPath = options.ffmpegPath ?? "ffmpeg";
this.maxPixelFrames = options.maxPixelFrames ?? DEFAULT_MAX_PIXEL_FRAMES;
}
templates(): MotionTemplateInfo[] {
return MOTION_TEMPLATES.map((template) => structuredClone(template.info));
}
async render(request: MotionRenderRequest): Promise<{ path: string; hasAlpha: boolean }> {
const { signal } = request;
if (signal?.aborted) throw abortError();
if (!Number.isFinite(request.fps) || request.fps < 1 || request.fps > MAX_FPS)
throw new MotionTemplateError("invalid_size", `fps must be 1..${MAX_FPS}`);
if (!path.isAbsolute(request.outPath) || path.extname(request.outPath).toLowerCase() !== ".mov")
throw new MotionTemplateError("invalid_asset", "outPath must be an absolute .mov path");
const outPath = await this.checkedPath(request.outPath);
const fontFiles: string[] = [];
for (const font of request.assets.fonts) {
const file = await this.checkedPath(font.path);
if (!FONT_EXTENSIONS.has(path.extname(file).toLowerCase()) || !(await stat(file)).isFile())
throw new MotionTemplateError("invalid_asset", "fonts must be TTF, OTF, or TTC files");
fontFiles.push(file);
}
const logos: string[] = [];
for (const logo of request.assets.logoPaths) {
logos.push(await loadLogoDataUri(await this.checkedPath(logo)));
}
const { template, ctx } = buildFrameContext({
template: request.template,
kit: request.brandKit,
width: request.width,
height: request.height,
logos,
});
const frames = frameCount(ctx.durationMs, request.fps);
if (ctx.width * ctx.height * frames > this.maxPixelFrames)
throw new MotionTemplateError("invalid_size", "motion clip exceeds the pixel budget");
const raster: RasterizeOptions = {
fontFiles,
defaultFontFamily: ctx.fonts.body,
loadSystemFonts: true,
};
const partial = path.join(
path.dirname(outPath),
`.${path.basename(outPath, ".mov")}.${randomUUID()}.partial.mov`,
);
const child = spawn(
this.ffmpegPath,
[
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-f",
"image2pipe",
"-framerate",
String(request.fps),
"-c:v",
"png",
"-i",
"pipe:0",
"-an",
"-c:v",
"qtrle",
"-pix_fmt",
"argb",
"-f",
"mov",
partial,
],
{ stdio: ["pipe", "ignore", "pipe"], shell: false },
);
const exited = waitForExit(child);
const kill = () => child.kill("SIGKILL");
signal?.addEventListener("abort", kill, { once: true });
try {
const stdin = child.stdin!;
stdin.on("error", () => {});
for (let index = 0; index < frames; index += 1) {
if (signal?.aborted) throw abortError();
const svg = template.frame((index * 1000) / request.fps, ctx);
const png = (await rasterizeFrame(svg, raster)).asPng();
if (signal?.aborted) throw abortError();
if (!stdin.write(png)) {
const result = await Promise.race([once(stdin, "drain").then(() => null), exited]);
if (result) throw ffmpegError(result);
}
}
stdin.end();
const result = await exited;
if (signal?.aborted) throw abortError();
if (result.code !== 0) throw ffmpegError(result);
await rename(partial, outPath);
return { path: outPath, hasAlpha: true };
} catch (error) {
kill();
await exited;
await rm(partial, { force: true });
throw signal?.aborted ? abortError() : error;
} finally {
signal?.removeEventListener("abort", kill);
}
}
private async checkedPath(file: string): Promise<string> {
if (!path.isAbsolute(file) || file.includes("\0"))
throw new MotionTemplateError("invalid_asset", "asset paths must be absolute");
return this.options.root ? assertInside(this.options.root, file) : path.resolve(file);
}
}
interface ExitResult {
code: number | null;
stderr: string;
}
function waitForExit(child: ChildProcess): Promise<ExitResult> {
let stderr = "";
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderr = (stderr + chunk).slice(-4000);
});
return new Promise((resolve) => {
child.once("error", (error) => resolve({ code: -1, stderr: error.message }));
child.once("close", (code) => resolve({ code, stderr }));
});
}
function ffmpegError(result: ExitResult): Error {
return new Error(`ffmpeg failed (${result.code}): ${result.stderr.trim()}`);
}

View File

@ -0,0 +1,441 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { BrandKit, MotionTemplateRef } from "@rakazo/contracts";
import { MotionTemplateInfoSchema } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MotionTemplate } from "./index.js";
import {
buildFrameContext,
loadLogoDataUri,
MOTION_TEMPLATES,
MotionTemplateError,
motionTemplate,
ResvgMotionGraphicsRenderer,
rasterizeFrame,
} from "./index.js";
import { GLITCH_BURST_INTERVAL_MS } from "./templates/intros.js";
const DEJAVU_DIR = "/usr/share/fonts/truetype/dejavu";
const DEJAVU = ["DejaVuSans.ttf", "DejaVuSans-Bold.ttf", "DejaVuSerif.ttf"].map((name) =>
path.join(DEJAVU_DIR, name),
);
const hasDejaVu = DEJAVU.every((file) => existsSync(file));
const hasFfmpeg = (() => {
try {
execFileSync("ffmpeg", ["-hide_banner", "-encoders"], { stdio: "pipe" });
execFileSync("ffprobe", ["-version"], { stdio: "pipe" });
return true;
} catch {
return false;
}
})();
const LOGO_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">' +
'<rect x="10" y="10" width="80" height="80" rx="20" fill="#ff8800"/>' +
'<circle cx="50" cy="50" r="22" fill="#ffffff"/></svg>';
const kit: BrandKit = {
name: "Test Brand",
colors: {
primary: "#2255cc",
secondary: "#9999aa",
accent: "#ff8800",
text: "#ffffff",
background: "#101820",
},
fonts: [{ family: "DejaVu Sans", artifactId: "font-1", role: "heading" }],
logoArtifactIds: ["logo-1"],
captionPresets: [],
musicBeds: [],
};
/** Parameters that exercise every template with all its text slots filled. */
const SAMPLE_PARAMS: Record<string, MotionTemplateRef["params"]> = {
intro_logo_reveal: { tagline: "Make it simple" },
intro_kinetic_type: { title: "Bold ideas move fast", tagline: "Since 2026" },
intro_glitch: { tagline: "Energy" },
intro_elegant_fade: { tagline: "Crafted slowly" },
outro_logo: { message: "Thanks" },
outro_subscribe: { cta: "Subscribe", handle: "@example" },
lt_clean: { title: "Alex Example", subtitle: "Host" },
lt_bar: { title: "Alex Example", subtitle: "Host", barColor: "accent" },
lt_pill: { title: "Alex Example", subtitle: "Host" },
caption_clean: { text: "A caption in the clean style" },
caption_karaoke: { text: "A caption in the karaoke style" },
caption_bold_social: { text: "Bold social caption" },
endcard_cta: { headline: "Get the full video", cta: "Watch now", price: "Only 9 today" },
endcard_minimal: { url: "example.com" },
};
let dir: string;
let logoPath: string;
let logo: string;
beforeAll(async () => {
dir = await mkdtemp(path.join(tmpdir(), "rakazo-motion-"));
logoPath = path.join(dir, "logo.svg");
await writeFile(logoPath, LOGO_SVG);
logo = await loadLogoDataUri(logoPath);
});
afterAll(async () => {
await rm(dir, { recursive: true, force: true });
});
function context(
template: MotionTemplate,
overrides: {
params?: MotionTemplateRef["params"];
durationMs?: number;
size?: [number, number];
} = {},
) {
const [width, height] = overrides.size ?? [640, 360];
return buildFrameContext({
template: {
templateId: template.info.id,
params: overrides.params ?? SAMPLE_PARAMS[template.info.id] ?? {},
durationMs: overrides.durationMs ?? template.info.minDurationMs,
},
kit,
width,
height,
logos: [logo],
}).ctx;
}
const raster = { fontFiles: DEJAVU, defaultFontFamily: "DejaVu Sans", loadSystemFonts: false };
/** Mean relative luminance of an RGBA frame composited over a solid backdrop. */
function meanLuminance(pixels: Buffer, backdrop: 0 | 1): number {
let sum = 0;
const count = pixels.length / 4;
for (let index = 0; index < pixels.length; index += 4) {
const alpha = pixels[index + 3]! / 255;
const channel = (offset: number) => {
const value = (pixels[index + offset]! / 255) * alpha + backdrop * (1 - alpha);
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
sum += 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
}
return sum / count;
}
/**
* Count brightness reversals of at least 10% relative luminance (the WCAG 2.3.1
* flash threshold) per one-second window and return the worst window. A flash
* is a pair of opposing changes, so a steady fade counts as none; counting each
* reversal on its own is stricter than WCAG's three flashes per second.
*/
function maxReversalsPerSecond(series: number[], fps: number): number {
const transitions: number[] = [];
let direction = 0;
let extreme = series[0] ?? 0;
series.forEach((value, index) => {
const delta = value - extreme;
if (direction !== 0 && Math.sign(delta) === direction) {
extreme = value;
} else if (Math.abs(delta) >= 0.1) {
if (direction !== 0) transitions.push(index / fps);
direction = Math.sign(delta);
extreme = value;
}
});
let worst = 0;
for (const start of transitions) {
worst = Math.max(worst, transitions.filter((at) => at >= start && at < start + 1).length);
}
return worst;
}
describe("motion templates", () => {
it("ships the required set with valid, unique descriptors", () => {
const ids = MOTION_TEMPLATES.map((template) => template.info.id);
expect(new Set(ids).size).toBe(ids.length);
for (const template of MOTION_TEMPLATES) MotionTemplateInfoSchema.parse(template.info);
const count = (kind: string) =>
MOTION_TEMPLATES.filter((template) => template.info.kind === kind).length;
expect([
count("intro"),
count("outro"),
count("lower_third"),
count("caption"),
count("end_card"),
]).toEqual([4, 2, 3, 3, 2]);
});
it.each(MOTION_TEMPLATES.map((template) => [template.info.id, template] as const))(
"%s renders its first, middle and last frame",
async (_id, template) => {
const ctx = context(template);
for (const t of [0, ctx.durationMs / 2, ctx.durationMs]) {
const svg = template.frame(t, ctx);
expect(svg.startsWith("<svg")).toBe(true);
expect(svg).not.toMatch(/NaN|undefined|Infinity/);
const image = await rasterizeFrame(svg, { fontFiles: [], loadSystemFonts: true });
expect([image.width, image.height]).toEqual([640, 360]);
}
},
);
it("takes every color from the brand kit", () => {
const allowed = new Set(Object.values(kit.colors).map((color) => color.toLowerCase()));
for (const template of MOTION_TEMPLATES) {
const ctx = context(template);
const svg = template.frame(ctx.durationMs / 2, ctx);
const colors = [...svg.matchAll(/(?:fill|stroke)="([^"]+)"/g)].map((match) => match[1]!);
for (const color of colors) {
if (color === "none" || color.startsWith("url(")) continue;
expect(allowed.has(color.toLowerCase()), `${template.info.id} uses ${color}`).toBe(true);
}
}
});
it.skipIf(!hasDejaVu)("renders deterministic middle frames", async () => {
const hashes: Record<string, string> = {};
for (const template of MOTION_TEMPLATES) {
const ctx = context(template);
const image = await rasterizeFrame(template.frame(ctx.durationMs / 2, ctx), raster);
hashes[template.info.id] = createHash("sha256")
.update(image.pixels)
.digest("hex")
.slice(0, 16);
}
expect(hashes).toMatchInlineSnapshot(`
{
"caption_bold_social": "4a132766b59cdf12",
"caption_clean": "6194d5224d8e97ec",
"caption_karaoke": "f2db9bc313e4ccea",
"endcard_cta": "943fa754c9a30803",
"endcard_minimal": "be2b9f833895bc36",
"intro_elegant_fade": "8f8a98930994243f",
"intro_glitch": "35074c7ad30d26bb",
"intro_kinetic_type": "52129b6c4f5581e3",
"intro_logo_reveal": "2baf101db044941b",
"lt_bar": "a13184996d2ffc2e",
"lt_clean": "ca6a4239283e111e",
"lt_pill": "30a7342e49735d69",
"outro_logo": "84b1cd23213f8a2e",
"outro_subscribe": "18733abc1b0f1296",
}
`);
});
it("never changes full-frame brightness more than three times a second in the glitch intro", async () => {
const template = motionTemplate("intro_glitch");
const ctx = context(template, { durationMs: 4000, size: [160, 90] });
const fps = 25;
const dark: number[] = [];
const light: number[] = [];
for (let index = 0; index <= (ctx.durationMs * fps) / 1000; index += 1) {
const image = await rasterizeFrame(template.frame((index * 1000) / fps, ctx), {
fontFiles: [],
loadSystemFonts: true,
});
dark.push(meanLuminance(image.pixels, 0));
light.push(meanLuminance(image.pixels, 1));
}
expect(1000 / GLITCH_BURST_INTERVAL_MS).toBeLessThanOrEqual(3);
expect(maxReversalsPerSecond(dark, fps)).toBeLessThanOrEqual(3);
expect(maxReversalsPerSecond(light, fps)).toBeLessThanOrEqual(3);
});
it("detects a frame that does flash too fast", () => {
const flashing = Array.from({ length: 50 }, (_, index) => (index % 5 < 2 ? 0.9 : 0.05));
expect(maxReversalsPerSecond(flashing, 50)).toBeGreaterThan(3);
});
});
describe("motion params and escaping", () => {
const lowerThird = motionTemplate("lt_clean");
it.each([
[{ title: "x", extra: "y" }, "unknown_param"],
[{ title: 3 }, "invalid_param"],
[{ title: "a\u0007b" }, "invalid_param"],
[{ subtitle: "only" }, "missing_param"],
] as const)("rejects %j", (params, code) => {
expect(() => context(lowerThird, { params })).toThrow(
expect.objectContaining({ code }) as unknown as Error,
);
});
it("rejects oversized strings, unknown colors, durations, and frame sizes", () => {
expect(() => context(lowerThird, { params: { title: "x".repeat(201) } })).toThrow();
expect(() =>
context(motionTemplate("lt_bar"), { params: { title: "x", barColor: "#ff0000" } }),
).toThrow(MotionTemplateError);
expect(() => context(lowerThird, { durationMs: 60_000 })).toThrow(MotionTemplateError);
expect(() => context(lowerThird, { size: [8000, 360] })).toThrow(MotionTemplateError);
expect(() =>
buildFrameContext({
template: { templateId: "nope", params: {}, durationMs: 2000 },
kit,
width: 640,
height: 360,
logos: [],
}),
).toThrow(MotionTemplateError);
});
it("escapes text and keeps hostile input out of the markup", async () => {
const hostile = `<script>alert("x")</script> & 'quotes' ]]> <!--`;
for (const template of MOTION_TEMPLATES) {
const params = Object.fromEntries(
template.info.params
.filter((param) => param.type === "string")
.map((param) => [param.name, hostile]),
);
const ctx = context(template, { params });
const svg = template.frame(ctx.durationMs / 2, ctx);
expect(svg).not.toContain("<script");
expect(svg).not.toContain("<!--");
await rasterizeFrame(svg, { fontFiles: [], loadSystemFonts: true });
}
const ctx = context(lowerThird, { params: { title: hostile } });
expect(lowerThird.frame(1000, ctx)).toContain("&lt;script&gt;alert(&quot;x&quot;)");
});
it("shortens very long text instead of overflowing", () => {
const long = "W".repeat(200);
const ctx = context(lowerThird, { params: { title: long } });
const svg = lowerThird.frame(1000, ctx);
expect(svg).toContain("…");
expect(svg).not.toContain(long);
});
it("strips CSS-breaking characters from font families", () => {
const ctx = buildFrameContext({
template: { templateId: "lt_clean", params: { title: "x" }, durationMs: 2000 },
kit: {
...kit,
fonts: [{ family: `Evil", url(x); "`, artifactId: "font-1", role: "heading" }],
},
width: 640,
height: 360,
logos: [],
}).ctx;
expect(ctx.fonts.heading).toBe("Evil urlx");
});
it("accepts only self-contained PNG and SVG logos", async () => {
const jpg = path.join(dir, "logo.jpg");
const fakePng = path.join(dir, "fake.png");
const linked = path.join(dir, "linked.svg");
await writeFile(jpg, "x");
await writeFile(fakePng, "not a png");
await writeFile(
linked,
'<svg xmlns="http://www.w3.org/2000/svg"><image href="file:///etc/passwd"/></svg>',
);
for (const file of [jpg, fakePng, linked]) {
await expect(loadLogoDataUri(file)).rejects.toBeInstanceOf(MotionTemplateError);
}
expect(logo.startsWith("data:image/svg+xml;base64,")).toBe(true);
});
});
describe("ResvgMotionGraphicsRenderer", () => {
const request = (
outPath: string,
overrides: Partial<Parameters<ResvgMotionGraphicsRenderer["render"]>[0]> = {},
) => ({
template: { templateId: "intro_logo_reveal", params: { tagline: "Hello" }, durationMs: 1500 },
brandKit: kit,
assets: {
fonts: hasDejaVu ? [{ family: "DejaVu Sans", path: DEJAVU[0]! }] : [],
logoPaths: [logoPath],
},
width: 640,
height: 360,
fps: 25,
outPath,
...overrides,
});
it("lists its templates", () => {
expect(new ResvgMotionGraphicsRenderer().templates()).toHaveLength(MOTION_TEMPLATES.length);
});
it("refuses clips over the pixel budget before starting ffmpeg", async () => {
const renderer = new ResvgMotionGraphicsRenderer({
maxPixelFrames: 640 * 360 * 10,
ffmpegPath: "/nonexistent",
});
await expect(renderer.render(request(path.join(dir, "big.mov")))).rejects.toMatchObject({
code: "invalid_size",
});
});
it("refuses paths outside its root and non-MOV outputs", async () => {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-motion-root-"));
try {
const renderer = new ResvgMotionGraphicsRenderer({ root, ffmpegPath: "/nonexistent" });
await expect(renderer.render(request(path.join(root, "out.mov")))).rejects.toThrow(/outside/);
await expect(renderer.render(request(path.join(root, "out.mp4")))).rejects.toMatchObject({
code: "invalid_asset",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it.skipIf(!hasFfmpeg)("renders a MOV with alpha of the requested length", async () => {
const outPath = path.join(dir, "intro.mov");
const result = await new ResvgMotionGraphicsRenderer().render(request(outPath));
expect(result).toEqual({ path: outPath, hasAlpha: true });
const probe = JSON.parse(
execFileSync("ffprobe", [
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name,pix_fmt,width,height:format=duration",
"-of",
"json",
outPath,
]).toString(),
) as {
streams: Array<{ codec_name: string; pix_fmt: string; width: number; height: number }>;
format: { duration: string };
};
const stream = probe.streams[0]!;
expect(["qtrle", "prores"]).toContain(stream.codec_name);
expect(stream.pix_fmt).toMatch(/^(argb|rgba|bgra|yuva)/);
expect([stream.width, stream.height]).toEqual([640, 360]);
expect(Math.abs(Number(probe.format.duration) - 1.5)).toBeLessThanOrEqual(1 / 25);
expect((await readdir(dir)).filter((name) => name.includes("partial"))).toEqual([]);
});
it.skipIf(!hasFfmpeg)("cleans up when aborted mid-render", async () => {
const outDir = await mkdtemp(path.join(tmpdir(), "rakazo-motion-abort-"));
try {
const controller = new AbortController();
const outPath = path.join(outDir, "intro.mov");
const render = new ResvgMotionGraphicsRenderer().render(
request(outPath, {
template: { templateId: "intro_glitch", params: {}, durationMs: 4000 },
width: 1920,
height: 1080,
fps: 30,
signal: controller.signal,
}),
);
// Abort once ffmpeg has started writing its partial output.
for (let tries = 0; tries < 200 && (await readdir(outDir)).length === 0; tries += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
controller.abort();
await expect(render).rejects.toMatchObject({ name: "AbortError" });
expect(await readdir(outDir)).toEqual([]);
} finally {
await rm(outDir, { recursive: true, force: true });
}
});
});

View File

@ -0,0 +1,305 @@
import type { BrandKit, MotionTemplateInfo, MotionTemplateRef } from "@rakazo/contracts";
/*
* Shared building blocks for motion templates. A template is a pure function
* from (time, context) to a complete SVG document; everything visual comes from
* the brand kit in the context, and every piece of text passes through
* escapeXml before it reaches markup.
*/
export type BrandColorRole = keyof BrandKit["colors"];
export const BRAND_COLOR_ROLES: readonly BrandColorRole[] = [
"primary",
"secondary",
"accent",
"text",
"background",
];
export type MotionParamValue = string | number | boolean;
export interface MotionFrameContext {
width: number;
height: number;
durationMs: number;
kit: BrandKit;
/** Validated against the template's parameter list. */
params: Readonly<Record<string, MotionParamValue>>;
/** Logo data URIs in brand-kit order. */
logos: readonly string[];
/** Sanitized font families by role, already falling back across roles. */
fonts: { heading: string | null; body: string | null; caption: string | null };
}
export interface MotionTemplate {
info: MotionTemplateInfo;
frame(t: number, ctx: MotionFrameContext): string;
}
export class MotionTemplateError extends Error {
constructor(
readonly code:
| "unknown_template"
| "unknown_param"
| "invalid_param"
| "missing_param"
| "invalid_duration"
| "invalid_size"
| "invalid_asset",
message: string,
) {
super(message);
this.name = "MotionTemplateError";
}
}
export const MAX_PARAM_STRING_LENGTH = 200;
const MAX_PARAM_NUMBER = 1_000_000;
// XML 1.0 cannot carry these at all, escaped or not.
// biome-ignore lint/suspicious/noControlCharactersInRegex: the point is to find them.
const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/;
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
/** Check a template reference's params against its declared list. */
export function validateParams(
info: MotionTemplateInfo,
params: MotionTemplateRef["params"],
): Record<string, MotionParamValue> {
const declared = new Map(info.params.map((param) => [param.name, param]));
const result: Record<string, MotionParamValue> = {};
for (const [name, value] of Object.entries(params)) {
const spec = declared.get(name);
if (!spec) throw new MotionTemplateError("unknown_param", `${info.id} has no param "${name}"`);
if (spec.type === "string" || spec.type === "color") {
if (typeof value !== "string")
throw new MotionTemplateError("invalid_param", `${name} must be a string`);
if (value.length > MAX_PARAM_STRING_LENGTH)
throw new MotionTemplateError("invalid_param", `${name} is too long`);
if (XML_FORBIDDEN.test(value) || LONE_SURROGATE.test(value))
throw new MotionTemplateError("invalid_param", `${name} contains unsupported characters`);
if (spec.type === "color" && !BRAND_COLOR_ROLES.includes(value as BrandColorRole))
throw new MotionTemplateError(
"invalid_param",
`${name} must name a brand color: ${BRAND_COLOR_ROLES.join(", ")}`,
);
const trimmed = value.trim();
if (trimmed === "") continue;
result[name] = trimmed;
} else if (spec.type === "number") {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
Math.abs(value) > MAX_PARAM_NUMBER
)
throw new MotionTemplateError("invalid_param", `${name} must be a finite number`);
result[name] = value;
} else {
if (typeof value !== "boolean")
throw new MotionTemplateError("invalid_param", `${name} must be a boolean`);
result[name] = value;
}
}
for (const spec of info.params) {
if (spec.required && !(spec.name in result))
throw new MotionTemplateError("missing_param", `${info.id} needs "${spec.name}"`);
}
return result;
}
export function validateDuration(info: MotionTemplateInfo, durationMs: number): void {
if (
!Number.isInteger(durationMs) ||
durationMs < info.minDurationMs ||
durationMs > info.maxDurationMs
)
throw new MotionTemplateError(
"invalid_duration",
`${info.id} runs ${info.minDurationMs}..${info.maxDurationMs} ms`,
);
}
/** Font family names reach a CSS list inside an attribute; keep them plain. */
export function sanitizeFontFamily(family: string): string | null {
const clean = family
.replace(/[^\p{L}\p{N} _-]/gu, "")
.replace(/\s+/g, " ")
.trim();
return clean === "" ? null : clean.slice(0, 80);
}
export function brandFonts(kit: BrandKit): MotionFrameContext["fonts"] {
const byRole = (role: "heading" | "body" | "caption") => {
const font = kit.fonts.find((entry) => entry.role === role);
return font ? sanitizeFontFamily(font.family) : null;
};
const heading = byRole("heading");
const body = byRole("body");
const caption = byRole("caption");
return {
heading: heading ?? body ?? caption,
body: body ?? heading ?? caption,
caption: caption ?? body ?? heading,
};
}
export function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** Round for stable, compact markup. */
export function num(value: number): string {
const rounded = Math.round(value * 100) / 100;
return Object.is(rounded, -0) ? "0" : String(rounded);
}
type Attrs = Record<string, string | number | undefined>;
function attrs(values: Attrs): string {
return Object.entries(values)
.filter((entry): entry is [string, string | number] => entry[1] !== undefined)
.map(([key, value]) => ` ${key}="${typeof value === "number" ? num(value) : escapeXml(value)}"`)
.join("");
}
export function el(name: string, values: Attrs, children = ""): string {
return children === ""
? `<${name}${attrs(values)}/>`
: `<${name}${attrs(values)}>${children}</${name}>`;
}
export function fontStack(family: string | null, generic: "sans-serif" | "serif"): string {
return family ? `${family}, ${generic}` : generic;
}
/** Rough advance width per character relative to font size; deliberately generous. */
const CHAR_WIDTH = 0.6;
/**
* Pick a font size so `text` fits `maxWidth` on one line, shrinking down to
* `minSize`; anything still too wide is cut with an ellipsis.
*/
export function fitText(
text: string,
maxWidth: number,
size: number,
minSize: number,
letterSpacingEm = 0,
): { text: string; size: number } {
const chars = [...text];
const per = CHAR_WIDTH + letterSpacingEm;
const fitted = maxWidth / Math.max(1, chars.length * per);
if (fitted >= size) return { text, size };
if (fitted >= minSize) return { text, size: fitted };
const keep = Math.max(1, Math.floor(maxWidth / (minSize * per)) - 1);
return { text: `${chars.slice(0, keep).join("").trimEnd()}…`, size: minSize };
}
export function textEl(content: string, values: Attrs): string {
return el("text", values, escapeXml(content) || " ");
}
export function stringParam(ctx: MotionFrameContext, name: string): string | null {
const value = ctx.params[name];
return typeof value === "string" ? value : null;
}
export function colorParam(
ctx: MotionFrameContext,
name: string,
fallback: BrandColorRole,
): string {
const role = stringParam(ctx, name);
return ctx.kit.colors[
role && BRAND_COLOR_ROLES.includes(role as BrandColorRole) ? (role as BrandColorRole) : fallback
];
}
export function logoEl(
ctx: MotionFrameContext,
box: { x: number; y: number; w: number; h: number },
extra: Attrs = {},
): string {
const href = ctx.logos[0];
if (!href) return "";
return el("image", {
href,
x: box.x,
y: box.y,
width: box.w,
height: box.h,
preserveAspectRatio: "xMidYMid meet",
...extra,
});
}
export function svgDocument(ctx: MotionFrameContext, body: string, defs = ""): string {
const { width, height } = ctx;
return (
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"` +
` width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
(defs ? `<defs>${defs}</defs>` : "") +
body +
"</svg>"
);
}
export function fullBackground(ctx: MotionFrameContext, opacity = 1): string {
return el("rect", {
x: 0,
y: 0,
width: ctx.width,
height: ctx.height,
fill: ctx.kit.colors.background,
opacity: opacity < 1 ? opacity : undefined,
});
}
/** A filled, centered call-to-action button in the primary brand color. */
export function ctaButton(
ctx: MotionFrameContext,
label: string,
cx: number,
cy: number,
scale = 1,
opacity = 1,
): string {
const u = unit(ctx);
const fitted = fitText(label, ctx.width * 0.7, u * 0.07, u * 0.035);
const width = Math.min(ctx.width * 0.8, [...fitted.text].length * fitted.size * 0.6 + u * 0.16);
const height = fitted.size * 1.9;
return el(
"g",
{
transform: `translate(${num(cx)} ${num(cy)}) scale(${num(Math.max(scale, 0.001))})`,
opacity: opacity < 1 ? opacity : undefined,
},
el("rect", {
x: -width / 2,
y: -height / 2,
width,
height,
rx: height / 2,
fill: ctx.kit.colors.primary,
}) +
textEl(fitted.text, {
x: 0,
y: 0,
"text-anchor": "middle",
"dominant-baseline": "central",
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-weight": 700,
"font-size": fitted.size,
fill: ctx.kit.colors.background,
}),
);
}
/** Short side of the frame; templates scale type and spacing from it. */
export function unit(ctx: MotionFrameContext): number {
return Math.min(ctx.width, ctx.height);
}

View File

@ -0,0 +1,182 @@
import type { CaptionStylePreset } from "@rakazo/contracts";
import { easeInCubic, easeOutBack, easeOutCubic, progress } from "../easing.js";
import type { MotionFrameContext, MotionTemplate } from "../template.js";
import {
el,
escapeXml,
fontStack,
num,
sanitizeFontFamily,
stringParam,
svgDocument,
} from "../template.js";
/*
* Preview cards for caption presets (brand-kit editor, caption picker). The
* burned-in captions of a render come from the compiler's ASS subtitles; these
* only need to look like them.
*/
const CAPTION = { kind: "caption", minDurationMs: 1000, maxDurationMs: 10000 } as const;
const CAPTION_PARAMS = [
{ name: "text", type: "string", required: true },
{ name: "presetId", type: "string", required: false },
] as const;
const MAX_LINES = 2;
interface CaptionStyle {
family: string | null;
size: number;
text: string;
highlight: string;
background: string | null;
uppercase: boolean;
}
function captionStyle(
ctx: MotionFrameContext,
template: CaptionStylePreset["template"],
): CaptionStyle {
const presetId = stringParam(ctx, "presetId");
const presets = ctx.kit.captionPresets;
const preset =
(presetId ? presets.find((entry) => entry.id === presetId) : undefined) ??
presets.find((entry) => entry.template === template);
const { colors } = ctx.kit;
return {
family: preset ? sanitizeFontFamily(preset.fontFamily) : ctx.fonts.caption,
size: (ctx.height * (preset?.sizePct ?? (template === "bold_social" ? 7 : 5.5))) / 100,
text: preset?.textColor ?? colors.text,
highlight: preset?.highlightColor ?? colors.accent,
background: preset
? (preset.backgroundColor ?? null)
: template === "clean"
? colors.background
: null,
uppercase: preset?.uppercase ?? template === "bold_social",
};
}
/** Greedy word wrap into at most MAX_LINES lines that fit `maxWidth`. */
function wrapWords(words: string[], size: number, maxWidth: number): string[][] {
const maxChars = Math.max(4, Math.floor(maxWidth / (size * 0.6)));
const lines: string[][] = [[]];
let length = 0;
for (const raw of words) {
const word = [...raw].length > maxChars ? `${[...raw].slice(0, maxChars - 1).join("")}…` : raw;
const current = lines[lines.length - 1]!;
const added = (current.length > 0 ? 1 : 0) + [...word].length;
if (current.length > 0 && length + added > maxChars) {
if (lines.length === MAX_LINES) {
current[current.length - 1] = `${current[current.length - 1]}…`;
return lines;
}
lines.push([word]);
length = [...word].length;
} else {
current.push(word);
length += added;
}
}
return lines;
}
function captionFrame(
t: number,
ctx: MotionFrameContext,
template: CaptionStylePreset["template"],
): string {
const style = captionStyle(ctx, template);
const raw = stringParam(ctx, "text") ?? "";
const words = (style.uppercase ? raw.toUpperCase() : raw).split(/\s+/).filter(Boolean);
const lines = wrapWords(words, style.size, ctx.width * 0.84);
const enter = progress(t, 0, Math.min(250, ctx.durationMs * 0.2));
const leave = easeInCubic(
progress(t, ctx.durationMs - Math.min(200, ctx.durationMs * 0.15), ctx.durationMs),
);
const total = lines.reduce((sum, line) => sum + line.length, 0);
// Karaoke and bold styles move a highlight through the words across the hold.
const active =
template === "clean"
? -1
: Math.min(
total - 1,
Math.floor(progress(t, ctx.durationMs * 0.1, ctx.durationMs * 0.9) * total),
);
const lineHeight = style.size * 1.25;
const baseY = ctx.height * 0.84 - (lines.length - 1) * lineHeight;
let wordIndex = 0;
const rendered = lines.map((line, row) => {
const spans = line.map((word, column) => {
const index = wordIndex++;
const fill =
template === "karaoke"
? index <= active
? style.highlight
: style.text
: template === "bold_social" && index === active
? style.highlight
: style.text;
return el("tspan", { fill }, escapeXml(column < line.length - 1 ? `${word} ` : word));
});
return el(
"text",
{
x: ctx.width / 2,
y: baseY + row * lineHeight,
"text-anchor": "middle",
"font-family": fontStack(style.family, "sans-serif"),
"font-weight": template === "bold_social" ? 900 : 600,
"font-size": style.size,
stroke: template === "bold_social" ? ctx.kit.colors.background : undefined,
"stroke-width": template === "bold_social" ? style.size * 0.12 : undefined,
"stroke-linejoin": template === "bold_social" ? "round" : undefined,
"paint-order": template === "bold_social" ? "stroke" : undefined,
},
spans.join("") || " ",
);
});
const longest = Math.max(1, ...lines.map((line) => [...line.join(" ")].length));
const plate = style.background
? el("rect", {
x: ctx.width / 2 - (longest * style.size * 0.6) / 2 - style.size * 0.5,
y: baseY - style.size * 1.05,
width: longest * style.size * 0.6 + style.size,
height: lines.length * lineHeight + style.size * 0.45,
rx: style.size * 0.25,
fill: style.background,
opacity: 0.75,
})
: "";
const scale = template === "bold_social" ? (enter <= 0 ? 0.001 : easeOutBack(enter)) : 1;
const originY = ctx.height * 0.84;
return svgDocument(
ctx,
el(
"g",
{
opacity: (template === "bold_social" ? 1 : easeOutCubic(enter)) * (1 - leave),
transform:
scale === 1
? undefined
: `translate(${num(ctx.width / 2)} ${num(originY)}) scale(${num(scale)}) translate(${num(-ctx.width / 2)} ${num(-originY)})`,
},
plate + rendered.join(""),
),
);
}
export const captionClean: MotionTemplate = {
info: { id: "caption_clean", name: "Clean", ...CAPTION, params: [...CAPTION_PARAMS] },
frame: (t, ctx) => captionFrame(t, ctx, "clean"),
};
export const captionKaraoke: MotionTemplate = {
info: { id: "caption_karaoke", name: "Karaoke", ...CAPTION, params: [...CAPTION_PARAMS] },
frame: (t, ctx) => captionFrame(t, ctx, "karaoke"),
};
export const captionBoldSocial: MotionTemplate = {
info: { id: "caption_bold_social", name: "Bold social", ...CAPTION, params: [...CAPTION_PARAMS] },
frame: (t, ctx) => captionFrame(t, ctx, "bold_social"),
};

View File

@ -0,0 +1,153 @@
import { easeOutBack, easeOutCubic, progress } from "../easing.js";
import type { MotionFrameContext, MotionTemplate } from "../template.js";
import {
ctaButton,
fitText,
fontStack,
fullBackground,
logoEl,
stringParam,
svgDocument,
textEl,
unit,
} from "../template.js";
const END_CARD = { kind: "end_card", minDurationMs: 3000, maxDurationMs: 10000 } as const;
/** Staggered rise-in for the n-th element of a card. */
function rise(t: number, ctx: MotionFrameContext, index: number): number {
const start = ctx.durationMs * (0.05 + index * 0.08);
return easeOutCubic(progress(t, start, start + ctx.durationMs * 0.18));
}
export const endCardCta: MotionTemplate = {
info: {
id: "endcard_cta",
name: "Call to action",
...END_CARD,
params: [
{ name: "headline", type: "string", required: true },
{ name: "cta", type: "string", required: true },
{ name: "price", type: "string", required: false },
],
},
frame(t, ctx) {
const u = unit(ctx);
const cx = ctx.width / 2;
const hasLogo = ctx.logos.length > 0;
const logoSize = u * 0.16;
const top = ctx.height * (hasLogo ? 0.2 : 0.3);
const logoAppear = rise(t, ctx, 0);
const logo = logoEl(
ctx,
{ x: cx - logoSize / 2, y: top, w: logoSize, h: logoSize },
{ opacity: logoAppear },
);
const headlineY = top + (hasLogo ? logoSize + u * 0.12 : u * 0.05);
const headline = fitText(
stringParam(ctx, "headline") ?? "",
ctx.width * 0.84,
u * 0.09,
u * 0.04,
);
const headlineAppear = rise(t, ctx, 1);
let y = headlineY;
let body =
fullBackground(ctx) +
logo +
textEl(headline.text, {
x: cx,
y: y + (1 - headlineAppear) * u * 0.04,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 800,
"font-size": headline.size,
fill: ctx.kit.colors.text,
opacity: headlineAppear,
});
const price = stringParam(ctx, "price");
if (price) {
const fitted = fitText(price, ctx.width * 0.7, u * 0.055, u * 0.03);
const appear = rise(t, ctx, 2);
y += u * 0.1;
body += textEl(fitted.text, {
x: cx,
y: y + (1 - appear) * u * 0.03,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-weight": 600,
"font-size": fitted.size,
fill: ctx.kit.colors.accent,
opacity: appear,
});
}
const pop = progress(t, ctx.durationMs * 0.3, ctx.durationMs * 0.45);
body += ctaButton(
ctx,
stringParam(ctx, "cta") ?? "",
cx,
y + u * 0.17,
pop <= 0 ? 0 : easeOutBack(pop),
);
return svgDocument(ctx, body);
},
};
export const endCardMinimal: MotionTemplate = {
info: {
id: "endcard_minimal",
name: "Minimal",
...END_CARD,
params: [
{ name: "headline", type: "string", required: false },
{ name: "url", type: "string", required: false },
],
},
frame(t, ctx) {
const u = unit(ctx);
const cx = ctx.width / 2;
const size = u * 0.22;
const cy = ctx.height * 0.4;
const appear = rise(t, ctx, 0);
const logo = logoEl(
ctx,
{ x: cx - size / 2, y: cy - size / 2, w: size, h: size },
{ opacity: appear },
);
const headline = fitText(
stringParam(ctx, "headline") ?? ctx.kit.name,
ctx.width * 0.8,
u * 0.075,
u * 0.035,
);
const headlineY = ctx.logos.length > 0 ? cy + size / 2 + u * 0.1 : cy + u * 0.04;
const headlineAppear = rise(t, ctx, 1);
let body =
fullBackground(ctx) +
logo +
textEl(headline.text, {
x: cx,
y: headlineY,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 600,
"font-size": headline.size,
fill: ctx.kit.colors.text,
opacity: headlineAppear,
});
const url = stringParam(ctx, "url");
if (url) {
const fitted = fitText(url, ctx.width * 0.8, u * 0.045, u * 0.025);
body += textEl(fitted.text, {
x: cx,
y: headlineY + u * 0.09,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-size": fitted.size,
fill: ctx.kit.colors.secondary,
opacity: rise(t, ctx, 2),
});
}
return svgDocument(ctx, body);
},
};

View File

@ -0,0 +1,309 @@
import {
clamp,
easeInOutCubic,
easeOutBack,
easeOutCubic,
easeOutExpo,
fadeEnvelope,
hash01,
lerp,
progress,
} from "../easing.js";
import type { MotionFrameContext, MotionTemplate } from "../template.js";
import {
el,
escapeXml,
fitText,
fontStack,
fullBackground,
logoEl,
num,
stringParam,
svgDocument,
textEl,
unit,
} from "../template.js";
const INTRO_PARAMS = [
{ name: "title", type: "string", required: false },
{ name: "tagline", type: "string", required: false },
] as const;
function title(ctx: MotionFrameContext): string {
return stringParam(ctx, "title") ?? ctx.kit.name;
}
/** Fades the whole intro, background included, so it hands over to the video. */
function outro(t: number, ctx: MotionFrameContext): number {
return fadeEnvelope(t, ctx.durationMs, 0, Math.min(400, ctx.durationMs * 0.15));
}
function tagline(ctx: MotionFrameContext, t: number, y: number, startPct: number): string {
const text = stringParam(ctx, "tagline");
if (!text) return "";
const appear = easeOutCubic(
progress(t, ctx.durationMs * startPct, ctx.durationMs * (startPct + 0.2)),
);
const fitted = fitText(text, ctx.width * 0.8, unit(ctx) * 0.055, unit(ctx) * 0.03);
return textEl(fitted.text, {
x: ctx.width / 2,
y: y + (1 - appear) * unit(ctx) * 0.03,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-size": fitted.size,
fill: ctx.kit.colors.text,
opacity: appear,
});
}
export const introLogoReveal: MotionTemplate = {
info: {
id: "intro_logo_reveal",
kind: "intro",
name: "Logo reveal",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const cx = ctx.width / 2;
const cy = ctx.height * 0.46;
const d = ctx.durationMs;
const circle = easeOutExpo(progress(t, 0, d * 0.3));
const pop = progress(t, d * 0.18, d * 0.48);
const scale = pop <= 0 ? 0 : easeOutBack(pop);
const radius = u * 0.24;
let mark: string;
if (ctx.logos.length > 0) {
const size = radius * 1.3 * scale;
mark = logoEl(ctx, { x: cx - size / 2, y: cy - size / 2, w: size, h: size });
} else {
const fitted = fitText(title(ctx), ctx.width * 0.8, u * 0.14, u * 0.05);
mark = textEl(fitted.text, {
x: cx,
y: cy,
"text-anchor": "middle",
"dominant-baseline": "central",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 700,
"font-size": fitted.size * Math.max(scale, 0.001),
fill: ctx.kit.colors.text,
});
}
const ring = el("circle", {
cx,
cy,
r: Math.max(radius * circle, 0.01),
fill: ctx.logos.length > 0 ? ctx.kit.colors.primary : "none",
stroke: ctx.kit.colors.accent,
"stroke-width": u * 0.01,
opacity: ctx.logos.length > 0 ? 1 : 0.9,
});
const body = fullBackground(ctx) + ring + mark + tagline(ctx, t, cy + radius + u * 0.12, 0.42);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body));
},
};
export const introKineticType: MotionTemplate = {
info: {
id: "intro_kinetic_type",
kind: "intro",
name: "Kinetic type",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const fitted = fitText(title(ctx), ctx.width * 0.86, u * 0.16, u * 0.05);
const words = fitted.text.split(/\s+/).filter(Boolean).slice(0, 12);
const stagger = (d * 0.35) / Math.max(words.length, 1);
const baseY = ctx.height * 0.48;
let previousOffset = 0;
const spans = words.map((word, index) => {
const appear = easeOutExpo(progress(t, index * stagger, index * stagger + d * 0.25));
const offset = (1 - appear) * u * 0.18;
const dy = offset - previousOffset;
previousOffset = offset;
const last = index === words.length - 1 && words.length > 1;
return el(
"tspan",
{
dy,
fill: last ? ctx.kit.colors.primary : ctx.kit.colors.text,
"fill-opacity": clamp(appear * 1.4),
},
escapeXml(index === words.length - 1 ? word : `${word} `),
);
});
const heading = el(
"text",
{
x: ctx.width / 2,
y: baseY,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 800,
"font-size": fitted.size,
},
spans.join("") || " ",
);
const barGrow = easeInOutCubic(progress(t, d * 0.3, d * 0.6));
const barWidth = ctx.width * 0.3 * barGrow;
const bar = el("rect", {
x: ctx.width / 2 - barWidth / 2,
y: baseY + fitted.size * 0.3,
width: barWidth,
height: u * 0.012,
fill: ctx.kit.colors.accent,
});
const body = fullBackground(ctx) + heading + bar + tagline(ctx, t, baseY + u * 0.16, 0.45);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body));
},
};
/**
* Glitch bursts are scheduled at a fixed 2 Hz. Photosensitivity guidance
* (WCAG 2.3.1) allows at most three flashes per second; the template never
* changes full-frame brightness, and the bursts stay below that rate even if
* they were counted as flashes.
*/
export const GLITCH_BURST_INTERVAL_MS = 500;
const GLITCH_BURST_MS = 120;
const GLITCH_SLICES = 6;
export const introGlitch: MotionTemplate = {
info: {
id: "intro_glitch",
kind: "intro",
name: "Glitch",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const fitted = fitText(title(ctx).toUpperCase(), ctx.width * 0.84, u * 0.15, u * 0.05);
const cy = ctx.height * 0.47;
const burstIndex = Math.floor((t - 200) / GLITCH_BURST_INTERVAL_MS);
const inBurst =
t >= 200 && t - 200 - burstIndex * GLITCH_BURST_INTERVAL_MS < GLITCH_BURST_MS && t < d * 0.85;
const reveal = easeOutCubic(progress(t, 0, d * 0.25));
const textAttrs = {
x: ctx.width / 2,
y: cy,
"text-anchor": "middle",
"dominant-baseline": "central",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 900,
"font-size": fitted.size,
};
const bandTop = cy - fitted.size * 0.7;
const bandHeight = fitted.size * 1.4;
const sliceHeight = bandHeight / GLITCH_SLICES;
const defs = [
el(
"clipPath",
{ id: "reveal" },
el("rect", { x: 0, y: 0, width: ctx.width * reveal, height: ctx.height }),
),
...Array.from({ length: GLITCH_SLICES }, (_, index) =>
el(
"clipPath",
{ id: `slice${index}` },
el("rect", {
x: 0,
y: bandTop + index * sliceHeight,
width: ctx.width,
height: sliceHeight,
}),
),
),
].join("");
let title_: string;
if (inBurst) {
const split = u * 0.012;
const ghosts =
textEl(fitted.text, {
...textAttrs,
x: ctx.width / 2 - split,
fill: ctx.kit.colors.primary,
opacity: 0.8,
}) +
textEl(fitted.text, {
...textAttrs,
x: ctx.width / 2 + split,
fill: ctx.kit.colors.accent,
opacity: 0.8,
});
const slices = Array.from({ length: GLITCH_SLICES }, (_, index) => {
const shift = (hash01(burstIndex, index) - 0.5) * u * 0.08;
return el(
"g",
{ "clip-path": `url(#slice${index})`, transform: `translate(${num(shift)} 0)` },
textEl(fitted.text, { ...textAttrs, fill: ctx.kit.colors.text }),
);
}).join("");
title_ = ghosts + slices;
} else {
title_ = textEl(fitted.text, { ...textAttrs, fill: ctx.kit.colors.text });
}
const scan = el("rect", {
x: 0,
y: lerp(bandTop, bandTop + bandHeight, (t % 900) / 900),
width: ctx.width * reveal,
height: u * 0.004,
fill: ctx.kit.colors.accent,
opacity: 0.6,
});
const body =
fullBackground(ctx) +
el("g", { "clip-path": "url(#reveal)" }, title_ + scan) +
tagline(ctx, t, cy + fitted.size * 0.7 + u * 0.08, 0.4);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body), defs);
},
};
export const introElegantFade: MotionTemplate = {
info: {
id: "intro_elegant_fade",
kind: "intro",
name: "Elegant fade",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const cy = ctx.height * 0.46;
const line = easeInOutCubic(progress(t, 0, d * 0.4));
const appear = easeOutCubic(progress(t, d * 0.15, d * 0.6));
const spacing = lerp(0.4, 0.15, appear);
const fitted = fitText(title(ctx), ctx.width * 0.8, u * 0.11, u * 0.04, spacing);
const heading = textEl(fitted.text, {
x: ctx.width / 2,
y: cy - u * 0.03 + (1 - appear) * u * 0.02,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.heading, "serif"),
"font-weight": 400,
"font-size": fitted.size,
"letter-spacing": `${num(spacing)}em`,
fill: ctx.kit.colors.text,
opacity: appear,
});
const lineWidth = ctx.width * 0.4 * line;
const rule = el("rect", {
x: ctx.width / 2 - lineWidth / 2,
y: cy + u * 0.02,
width: lineWidth,
height: Math.max(1, u * 0.003),
fill: ctx.kit.colors.accent,
});
const body = fullBackground(ctx) + heading + rule + tagline(ctx, t, cy + u * 0.12, 0.5);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body));
},
};

View File

@ -0,0 +1,245 @@
import { easeInCubic, easeOutBack, easeOutCubic, progress } from "../easing.js";
import type { MotionFrameContext, MotionTemplate } from "../template.js";
import {
colorParam,
el,
fitText,
fontStack,
num,
stringParam,
svgDocument,
textEl,
unit,
} from "../template.js";
const LOWER_THIRD = {
kind: "lower_third",
minDurationMs: 2000,
maxDurationMs: 10000,
} as const;
const TEXT_PARAMS = [
{ name: "title", type: "string", required: true },
{ name: "subtitle", type: "string", required: false },
] as const;
/** 0 → 1 while entering, 1 while held, 1 → 0 while leaving. */
function presence(t: number, ctx: MotionFrameContext): { enter: number; leave: number } {
return {
enter: progress(t, 0, Math.min(600, ctx.durationMs * 0.25)),
leave: easeInCubic(
progress(t, ctx.durationMs - Math.min(400, ctx.durationMs * 0.2), ctx.durationMs),
),
};
}
function lines(ctx: MotionFrameContext, maxWidth: number) {
const u = unit(ctx);
const title = fitText(stringParam(ctx, "title") ?? "", maxWidth, u * 0.06, u * 0.03);
const subtitleText = stringParam(ctx, "subtitle");
const subtitle = subtitleText ? fitText(subtitleText, maxWidth, u * 0.04, u * 0.025) : null;
return { title, subtitle };
}
function textWidth(text: string, size: number): number {
return [...text].length * size * 0.6;
}
export const lowerThirdClean: MotionTemplate = {
info: { id: "lt_clean", name: "Clean", ...LOWER_THIRD, params: [...TEXT_PARAMS] },
frame(t, ctx) {
const u = unit(ctx);
const { enter, leave } = presence(t, ctx);
const x = ctx.width * 0.07;
const y = ctx.height * 0.8;
const { title, subtitle } = lines(ctx, ctx.width * 0.6);
const appear = easeOutCubic(enter) * (1 - leave);
const slide = (1 - easeOutCubic(enter)) * -u * 0.05;
const accentHeight = (title.size + (subtitle ? subtitle.size * 1.4 : 0)) * easeOutCubic(enter);
const plateWidth =
Math.max(
textWidth(title.text, title.size),
subtitle ? textWidth(subtitle.text, subtitle.size) : 0,
) +
u * 0.08;
const plateHeight = title.size * 1.5 + (subtitle ? subtitle.size * 1.5 : 0);
const body =
el("rect", {
x: x - u * 0.02,
y: y - title.size * 1.15,
width: plateWidth,
height: plateHeight,
rx: u * 0.008,
fill: ctx.kit.colors.background,
opacity: 0.55,
}) +
el("rect", {
x: x - u * 0.02,
y: y - title.size * 1.15,
width: u * 0.008,
height: accentHeight + title.size * 0.5,
fill: ctx.kit.colors.accent,
}) +
textEl(title.text, {
x: x + u * 0.01,
y,
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 700,
"font-size": title.size,
fill: ctx.kit.colors.text,
}) +
(subtitle
? textEl(subtitle.text, {
x: x + u * 0.01,
y: y + subtitle.size * 1.4,
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-size": subtitle.size,
fill: ctx.kit.colors.text,
})
: "");
return svgDocument(
ctx,
el("g", { opacity: appear, transform: `translate(${num(slide)} 0)` }, body),
);
},
};
export const lowerThirdBar: MotionTemplate = {
info: {
id: "lt_bar",
name: "Bar",
...LOWER_THIRD,
params: [...TEXT_PARAMS, { name: "barColor", type: "color", required: false }],
},
frame(t, ctx) {
const u = unit(ctx);
const { enter, leave } = presence(t, ctx);
const x = ctx.width * 0.06;
const y = ctx.height * 0.76;
const { title, subtitle } = lines(ctx, ctx.width * 0.6);
const sweep = easeOutCubic(enter) * (1 - leave);
const barWidth = (textWidth(title.text, title.size) + u * 0.08) * sweep;
const barHeight = title.size * 1.6;
const subWidth = subtitle ? (textWidth(subtitle.text, subtitle.size) + u * 0.06) * sweep : 0;
const textOpacity =
easeOutCubic(progress(t, ctx.durationMs * 0.08, ctx.durationMs * 0.2)) * (1 - leave);
const body =
el("clipPath", { id: "bar" }, el("rect", { x, y, width: barWidth, height: barHeight })) +
el("rect", {
x,
y,
width: barWidth,
height: barHeight,
fill: colorParam(ctx, "barColor", "primary"),
}) +
textEl(title.text, {
x: x + u * 0.04,
y: y + barHeight / 2,
"dominant-baseline": "central",
"clip-path": "url(#bar)",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 800,
"font-size": title.size,
fill: ctx.kit.colors.background,
opacity: textOpacity,
}) +
(subtitle
? el("rect", {
x,
y: y + barHeight,
width: subWidth,
height: subtitle.size * 1.6,
fill: ctx.kit.colors.background,
}) +
textEl(subtitle.text, {
x: x + u * 0.03,
y: y + barHeight + subtitle.size * 0.8,
"dominant-baseline": "central",
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-size": subtitle.size,
fill: ctx.kit.colors.text,
opacity: textOpacity,
})
: "");
return svgDocument(ctx, body);
},
};
export const lowerThirdPill: MotionTemplate = {
info: { id: "lt_pill", name: "Pill", ...LOWER_THIRD, params: [...TEXT_PARAMS] },
frame(t, ctx) {
const u = unit(ctx);
const { enter, leave } = presence(t, ctx);
const { title, subtitle } = lines(ctx, ctx.width * 0.55);
const height = subtitle ? title.size * 1.3 + subtitle.size * 1.6 : title.size * 2;
const dot = height * 0.62;
const width =
dot +
u * 0.07 +
Math.max(
textWidth(title.text, title.size),
subtitle ? textWidth(subtitle.text, subtitle.size) : 0,
);
const x = ctx.width * 0.06;
const y = ctx.height * 0.88 - height;
const scale = enter <= 0 ? 0.001 : easeOutBack(enter);
const textX = x + height / 2 + dot / 2 + u * 0.03;
const logo = ctx.logos[0];
const badge = logo
? el("image", {
href: logo,
x: x + height / 2 - dot / 2,
y: y + height / 2 - dot / 2,
width: dot,
height: dot,
preserveAspectRatio: "xMidYMid meet",
})
: el("circle", {
cx: x + height / 2,
cy: y + height / 2,
r: dot / 2,
fill: ctx.kit.colors.accent,
});
const body =
el("rect", {
x,
y,
width,
height,
rx: height / 2,
fill: ctx.kit.colors.background,
}) +
badge +
textEl(title.text, {
x: textX,
y: subtitle ? y + title.size * 1.15 : y + height / 2,
"dominant-baseline": subtitle ? undefined : "central",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 700,
"font-size": title.size,
fill: ctx.kit.colors.text,
}) +
(subtitle
? textEl(subtitle.text, {
x: textX,
y: y + title.size * 1.15 + subtitle.size * 1.3,
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-size": subtitle.size,
fill: ctx.kit.colors.secondary,
})
: "");
const originX = x;
const originY = y + height / 2;
return svgDocument(
ctx,
el(
"g",
{
opacity: 1 - leave,
transform: `translate(${num(originX)} ${num(originY)}) scale(${num(scale)}) translate(${num(-originX)} ${num(-originY)})`,
},
body,
),
);
},
};

View File

@ -0,0 +1,106 @@
import { easeOutBack, easeOutCubic, progress } from "../easing.js";
import type { MotionTemplate } from "../template.js";
import {
ctaButton,
el,
fitText,
fontStack,
fullBackground,
logoEl,
stringParam,
svgDocument,
textEl,
unit,
} from "../template.js";
export const outroLogo: MotionTemplate = {
info: {
id: "outro_logo",
kind: "outro",
name: "Logo outro",
minDurationMs: 1500,
maxDurationMs: 6000,
params: [{ name: "message", type: "string", required: false }],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const cx = ctx.width / 2;
const cy = ctx.height * 0.44;
const background = easeOutCubic(progress(t, 0, Math.min(500, d * 0.25)));
const pop = progress(t, d * 0.1, d * 0.4);
const scale = pop <= 0 ? 0 : easeOutBack(pop);
const size = u * 0.3 * scale;
const mark =
ctx.logos.length > 0
? logoEl(ctx, { x: cx - size / 2, y: cy - size / 2, w: size, h: size })
: el("circle", { cx, cy, r: Math.max(size / 2, 0.01), fill: ctx.kit.colors.primary });
const message = stringParam(ctx, "message") ?? ctx.kit.name;
const appear = easeOutCubic(progress(t, d * 0.3, d * 0.55));
const fitted = fitText(message, ctx.width * 0.8, u * 0.07, u * 0.035);
const text = textEl(fitted.text, {
x: cx,
y: cy + u * 0.26 + (1 - appear) * u * 0.03,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 600,
"font-size": fitted.size,
fill: ctx.kit.colors.text,
opacity: appear,
});
return svgDocument(ctx, fullBackground(ctx, background) + mark + text);
},
};
export const outroSubscribe: MotionTemplate = {
info: {
id: "outro_subscribe",
kind: "outro",
name: "Call to action outro",
minDurationMs: 2000,
maxDurationMs: 8000,
params: [
{ name: "cta", type: "string", required: true },
{ name: "handle", type: "string", required: false },
],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const cx = ctx.width / 2;
const background = easeOutCubic(progress(t, 0, Math.min(500, d * 0.2)));
const logoAppear = easeOutCubic(progress(t, d * 0.05, d * 0.3));
const size = u * 0.18;
const logo = logoEl(
ctx,
{ x: cx - size / 2, y: ctx.height * 0.18, w: size, h: size },
{ opacity: logoAppear },
);
const pop = progress(t, d * 0.15, d * 0.4);
// A gentle 1 Hz breathing pulse once the button has landed; no brightness change.
const pulse = pop >= 1 ? 1 + 0.03 * Math.sin(((t - d * 0.4) / 1000) * Math.PI * 2) : 1;
const button = ctaButton(
ctx,
stringParam(ctx, "cta") ?? "",
cx,
ctx.height * 0.55,
(pop <= 0 ? 0 : easeOutBack(pop)) * pulse,
);
const handle = stringParam(ctx, "handle");
let handleText = "";
if (handle) {
const appear = easeOutCubic(progress(t, d * 0.35, d * 0.6));
const fitted = fitText(handle, ctx.width * 0.8, u * 0.05, u * 0.03);
handleText = textEl(fitted.text, {
x: cx,
y: ctx.height * 0.55 + u * 0.16,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-size": fitted.size,
fill: ctx.kit.colors.text,
opacity: appear,
});
}
return svgDocument(ctx, fullBackground(ctx, background) + logo + button + handleText);
},
};