scrape / lib /caption-renderer.ts
kjbytes's picture
push code
ef4c36f
Raw
History Blame Contribute Delete
5.24 kB
import type { CanvasRenderingContext2D } from "canvas";
import type {
AnimationSettings,
Caption,
CaptionLayout,
CaptionStyle,
} from "@/types";
function easeOutCubic(x: number): number {
return 1 - Math.pow(1 - x, 3);
}
// gentle single overshoot (~2%) that settles — unlike elastic, which rubber-bands
function easeOutBack(x: number): number {
const c1 = 0.7;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
}
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string[] {
const words = text.split(/\s+/).filter(Boolean);
if (!words.length) return [];
const lines: string[] = [];
let line = words[0];
for (const word of words.slice(1)) {
const candidate = `${line} ${word}`;
if (ctx.measureText(candidate).width <= maxWidth) {
line = candidate;
} else {
lines.push(line);
line = word;
}
}
lines.push(line);
return lines;
}
function getAnchorY(
layout: CaptionLayout,
videoH: number,
): number {
let y = videoH * 0.85;
if (layout.position === "top") y = videoH * 0.15;
if (layout.position === "center") y = videoH * 0.5;
return y + layout.yOffset;
}
export function renderCaptionWord(
ctx: CanvasRenderingContext2D,
caption: Caption,
currentMs: number,
style: CaptionStyle,
layout: CaptionLayout,
animation: AnimationSettings,
videoW: number,
videoH: number,
) {
// ponytail: strict [start, end) — cues are contiguous, so any grace window
// double-draws the outgoing phrase under the incoming one
if (currentMs < caption.startMs || currentMs >= caption.endMs) return;
const text = style.uppercase ? caption.text.toUpperCase() : caption.text;
// ponytail: scale off WIDTH, not height. On a 9:16 frame videoH/1080 = 1.78, which
// silently inflated every caption by 78% — that's why the text looked huge.
const px = videoW / 1080;
const fontSize = style.fontSize * px;
const x = videoW / 2;
const y = getAnchorY(layout, videoH);
// One entrance, then hold dead still — no perpetual wobble, no elastic slam.
const elapsed = currentMs - caption.startMs;
const animDuration = 240 / animation.speed;
const progress = Math.min(1, elapsed / animDuration);
let scale = 1;
let opacity = 1;
let rise = 0;
if (animation.style === "pop") {
const eased = easeOutBack(progress);
scale = 0.92 + 0.08 * eased;
opacity = easeOutCubic(progress);
} else {
// "bounce" and "fade" both get the same calm fade + rise
const eased = easeOutCubic(progress);
opacity = eased;
scale = 0.97 + 0.03 * eased;
rise = (1 - eased) * 18 * px;
}
ctx.save();
ctx.translate(x, y + rise);
ctx.scale(scale, scale);
ctx.globalAlpha = opacity;
ctx.font = `900 ${fontSize}px "${style.fontFamily}", "Arial Black", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (style.shadow) {
ctx.shadowColor = style.shadow.color;
ctx.shadowBlur = style.shadow.blur * px;
ctx.shadowOffsetX = style.shadow.offsetX;
ctx.shadowOffsetY = style.shadow.offsetY;
}
const strokeWidth = style.strokeWidth * px;
ctx.strokeStyle = style.strokeColor;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = "round";
ctx.miterLimit = 2;
// ponytail: multi-word cues need wrapping — a 6-word phrase overruns 1080px on one line
const lines = wrapText(ctx, text, videoW * 0.86);
const lineHeight = fontSize * 1.15;
const firstLineY = -((lines.length - 1) * lineHeight) / 2;
// ponytail: a plate behind the text is what makes captions readable over a light
// README — stroke alone disappears on white
if (style.backdrop) {
const widest = Math.max(...lines.map((l) => ctx.measureText(l).width));
const padX = 28 * px;
const padY = 18 * px;
const boxW = widest + padX * 2;
const boxH = lines.length * lineHeight + padY * 2 - (lineHeight - fontSize);
const radius = 18 * px;
ctx.save();
ctx.shadowColor = "transparent";
ctx.fillStyle = style.backdrop;
ctx.beginPath();
ctx.roundRect(-boxW / 2, firstLineY - fontSize / 2 - padY, boxW, boxH, radius);
ctx.fill();
ctx.restore();
}
lines.forEach((line, index) => {
ctx.strokeText(line, 0, firstLineY + index * lineHeight);
});
ctx.shadowColor = "transparent";
ctx.fillStyle = style.fillColor;
lines.forEach((line, index) => {
ctx.fillText(line, 0, firstLineY + index * lineHeight);
});
if (style.highlightColor) {
ctx.fillStyle = style.highlightColor;
ctx.globalAlpha = opacity * 0.3;
lines.forEach((line, index) => {
ctx.fillText(line, 0, firstLineY + index * lineHeight - 2 * px);
});
}
ctx.restore();
}
export function renderFrameCaptions(
ctx: CanvasRenderingContext2D,
captions: Caption[],
currentMs: number,
style: CaptionStyle,
layout: CaptionLayout,
animation: AnimationSettings,
videoW: number,
videoH: number,
) {
const visible = captions.filter(
(c) => c.startMs <= currentMs && currentMs < c.endMs,
);
for (const caption of visible) {
renderCaptionWord(
ctx,
caption,
currentMs,
style,
layout,
animation,
videoW,
videoH,
);
}
}