import type { CanvasRenderer } from "../canvas-renderer"; import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/lib/timeline"; import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, } from "@/constants/text-constants"; import { getMetricAscent, getMetricDescent, getTextBackgroundRect, setCanvasLetterSpacing, } from "@/lib/text/layout"; import { measureTextElement } from "@/lib/text/measure-element"; import { getElementLocalTime, resolveColorAtTime, resolveOpacityAtTime, resolveTransformAtTime, } from "@/lib/animation"; import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; import { webglEffectRenderer } from "../webgl/webgl-effect-renderer"; import { clamp } from "@/utils/math"; const TEXT_DECORATION_THICKNESS_RATIO = 0.07; const STRIKETHROUGH_VERTICAL_RATIO = 0.35; function drawTextDecoration({ ctx, textDecoration, lineWidth, lineY, metrics, scaledFontSize, textAlign, }: { ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; textDecoration: string; lineWidth: number; lineY: number; metrics: TextMetrics; scaledFontSize: number; textAlign: CanvasTextAlign; }): void { if (textDecoration === "none" || !textDecoration) return; const thickness = Math.max( 1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO, ); const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize, }); let xStart = -lineWidth / 2; if (textAlign === "left") xStart = 0; if (textAlign === "right") xStart = -lineWidth; if (textDecoration === "underline") { const underlineY = lineY + descent + thickness; ctx.fillRect(xStart, underlineY, lineWidth, thickness); } if (textDecoration === "line-through") { const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO; ctx.fillRect(xStart, strikeY, lineWidth, thickness); } } export type TextNodeParams = TextElement & { canvasCenter: { x: number; y: number }; canvasHeight: number; textBaseline?: CanvasTextBaseline; }; export class TextNode extends BaseNode { isInRange({ time }: { time: number }) { return ( time >= this.params.startTime && time < this.params.startTime + this.params.duration ); } async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { if (!this.isInRange({ time })) { return; } const localTime = getElementLocalTime({ timelineTime: time, elementStartTime: this.params.startTime, elementDuration: this.params.duration, }); const transform = resolveTransformAtTime({ baseTransform: this.params.transform, animations: this.params.animations, localTime, }); const opacity = resolveOpacityAtTime({ baseOpacity: this.params.opacity, animations: this.params.animations, localTime, }); const x = transform.position.x + this.params.canvasCenter.x; const y = transform.position.y + this.params.canvasCenter.y; const baseline = this.params.textBaseline ?? "middle"; const blendMode = ( this.params.blendMode && this.params.blendMode !== "normal" ? this.params.blendMode : "source-over" ) as GlobalCompositeOperation; const { scaledFontSize, fontString, letterSpacing, lineHeightPx, lines, lineMetrics, block, fontSizeRatio, resolvedBackground, } = measureTextElement({ element: this.params, canvasHeight: this.params.canvasHeight, localTime, ctx: renderer.context, }); const lineCount = lines.length; const textColor = resolveColorAtTime({ baseColor: this.params.color, animations: this.params.animations, propertyPath: "color", localTime, }); const resolvedBackgroundWithColor = { ...resolvedBackground, color: resolveColorAtTime({ baseColor: this.params.background.color, animations: this.params.animations, propertyPath: "background.color", localTime, }), }; const drawContent = ( ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, ) => { ctx.font = fontString; ctx.textAlign = this.params.textAlign; ctx.textBaseline = baseline; ctx.fillStyle = textColor; setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); if ( this.params.background.enabled && this.params.background.color && this.params.background.color !== "transparent" && lineCount > 0 ) { const backgroundRect = getTextBackgroundRect({ textAlign: this.params.textAlign, block, background: resolvedBackgroundWithColor, fontSizeRatio, }); if (backgroundRect) { const p = clamp({ value: resolvedBackgroundWithColor.cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, }) / 100; const radius = (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p; ctx.fillStyle = resolvedBackgroundWithColor.color; ctx.beginPath(); ctx.roundRect( backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius, ); ctx.fill(); ctx.fillStyle = textColor; } } for (let i = 0; i < lineCount; i++) { const lineY = i * lineHeightPx - block.visualCenterOffset; ctx.fillText(lines[i], 0, lineY); drawTextDecoration({ ctx, textDecoration: this.params.textDecoration ?? "none", lineWidth: lineMetrics[i].width, lineY, metrics: lineMetrics[i], scaledFontSize, textAlign: this.params.textAlign, }); } }; const applyTransform = ( ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, ) => { ctx.translate(x, y); ctx.scale(transform.scaleX, transform.scaleY); if (transform.rotate) { ctx.rotate((transform.rotate * Math.PI) / 180); } }; const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? []; if (enabledEffects.length === 0) { renderer.context.save(); applyTransform(renderer.context); renderer.context.globalCompositeOperation = blendMode; renderer.context.globalAlpha = opacity; drawContent(renderer.context); renderer.context.restore(); return; } // Effects path: render text to a same-size offscreen canvas so the blur // can spread into the surrounding transparent area without hard clipping. const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height, }); const offscreenCtx = offscreen.getContext( "2d", ) as OffscreenCanvasRenderingContext2D | null; if (!offscreenCtx) { renderer.context.save(); applyTransform(renderer.context); renderer.context.globalCompositeOperation = blendMode; renderer.context.globalAlpha = opacity; drawContent(renderer.context); renderer.context.restore(); return; } offscreenCtx.save(); applyTransform(offscreenCtx); drawContent(offscreenCtx); offscreenCtx.restore(); let currentSource: CanvasImageSource = offscreen; for (const effect of enabledEffects) { const resolvedParams = resolveEffectParamsAtTime({ effect, animations: this.params.animations, localTime, }); const definition = effectsRegistry.get(effect.type); const passes = resolveEffectPasses({ definition, effectParams: resolvedParams, width: renderer.width, height: renderer.height, }); currentSource = webglEffectRenderer.applyEffect({ source: currentSource, width: renderer.width, height: renderer.height, passes, }); } renderer.context.save(); renderer.context.globalCompositeOperation = blendMode; renderer.context.globalAlpha = opacity; renderer.context.drawImage(currentSource, 0, 0); renderer.context.restore(); } }