/* * 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 { 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 (!/]/i.test(text)) throw new MotionTemplateError("invalid_asset", "logo is not an SVG"); if (/ 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 { 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 { 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 { 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()}`); }