Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
/*
|
||
* 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()}`);
|
||
}
|