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"), };