2026-10-03 17:47:24 +00:00

309 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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()}`);
}