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