| 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); |
| } |
|
|
| |
| 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, |
| ) { |
| |
| |
| if (currentMs < caption.startMs || currentMs >= caption.endMs) return; |
|
|
| const text = style.uppercase ? caption.text.toUpperCase() : caption.text; |
| |
| |
| const px = videoW / 1080; |
| const fontSize = style.fontSize * px; |
| const x = videoW / 2; |
| const y = getAnchorY(layout, videoH); |
|
|
| |
| 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 { |
| |
| 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; |
| |
| const lines = wrapText(ctx, text, videoW * 0.86); |
| const lineHeight = fontSize * 1.15; |
| const firstLineY = -((lines.length - 1) * lineHeight) / 2; |
|
|
| |
| |
| 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, |
| ); |
| } |
| } |
|
|