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:
parent
7ccc96ac11
commit
b5cef21f44
10
packages/adapters/src/media-motion-renderer.ts
Normal file
10
packages/adapters/src/media-motion-renderer.ts
Normal 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";
|
||||||
56
packages/adapters/src/media-motion/easing.ts
Normal file
56
packages/adapters/src/media-motion/easing.ts
Normal 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;
|
||||||
|
}
|
||||||
308
packages/adapters/src/media-motion/index.ts
Normal file
308
packages/adapters/src/media-motion/index.ts
Normal 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()}`);
|
||||||
|
}
|
||||||
441
packages/adapters/src/media-motion/media-motion.test.ts
Normal file
441
packages/adapters/src/media-motion/media-motion.test.ts
Normal 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("<script>alert("x")");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
305
packages/adapters/src/media-motion/template.ts
Normal file
305
packages/adapters/src/media-motion/template.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
182
packages/adapters/src/media-motion/templates/captions.ts
Normal file
182
packages/adapters/src/media-motion/templates/captions.ts
Normal 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"),
|
||||||
|
};
|
||||||
153
packages/adapters/src/media-motion/templates/end-cards.ts
Normal file
153
packages/adapters/src/media-motion/templates/end-cards.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
309
packages/adapters/src/media-motion/templates/intros.ts
Normal file
309
packages/adapters/src/media-motion/templates/intros.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
245
packages/adapters/src/media-motion/templates/lower-thirds.ts
Normal file
245
packages/adapters/src/media-motion/templates/lower-thirds.ts
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
106
packages/adapters/src/media-motion/templates/outros.ts
Normal file
106
packages/adapters/src/media-motion/templates/outros.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user