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

183 lines
6.3 KiB
TypeScript

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