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