Download lib/caption-renderer.ts from d3evil4/scrape: direct link, hf CLI and curl.
- Browser
- Download file 5.24 kB
-
https://huggingface.co/spaces/d3evil4/scrape/resolve/main/lib/caption-renderer.ts
- Command line
-
hf download hf://spaces/d3evil4/scrape/lib/caption-renderer.ts
-
curl -L -o caption-renderer.ts https://huggingface.co/spaces/d3evil4/scrape/resolve/main/lib/caption-renderer.ts
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, | |
| ); | |
| } | |
| } | |