Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
183 lines
6.3 KiB
TypeScript
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"),
|
|
};
|