diff options
Diffstat (limited to 'apps/web/src/services/renderer/nodes/text-node.ts')
| -rw-r--r-- | apps/web/src/services/renderer/nodes/text-node.ts | 356 |
1 files changed, 98 insertions, 258 deletions
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 643e538a..fc503991 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,289 +1,129 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/lib/timeline"; +import type { EffectPass } from "@/lib/effects/types"; +import type { Transform } from "@/lib/rendering"; import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, } from "@/constants/text-constants"; import { - getMetricAscent, - getMetricDescent, + drawTextDecoration, 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 { gpuRenderer } from "../gpu-renderer"; +import type { MeasuredTextElement } from "@/lib/text/measure-element"; 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<TextNodeParams> { - isInRange({ time }: { time: number }) { - return ( - time >= this.params.startTime && - time < this.params.startTime + this.params.duration - ); - } +export interface ResolvedTextNodeState { + transform: Transform; + opacity: number; + textColor: string; + backgroundColor: string; + effectPasses: EffectPass[][]; + measuredText: MeasuredTextElement; +} - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - if (!this.isInRange({ time })) { - return; - } +export class TextNode extends BaseNode<TextNodeParams, ResolvedTextNodeState> {} - 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, - }); +export function renderTextToContext({ + node, + ctx, +}: { + node: TextNode; + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}): void { + const resolved = node.resolved; + if (!resolved) { + return; + } + + const x = resolved.transform.position.x + node.params.canvasCenter.x; + const y = resolved.transform.position.y + node.params.canvasCenter.y; + const baseline = node.params.textBaseline ?? "middle"; + const { + scaledFontSize, + fontString, + letterSpacing, + lineHeightPx, + lines, + lineMetrics, + block, + fontSizeRatio, + resolvedBackground, + } = resolved.measuredText; + const lineCount = lines.length; + const resolvedBackgroundWithColor = { + ...resolvedBackground, + color: resolved.backgroundColor, + }; - const x = transform.position.x + this.params.canvasCenter.x; - const y = transform.position.y + this.params.canvasCenter.y; + ctx.save(); + ctx.translate(x, y); + ctx.scale(resolved.transform.scaleX, resolved.transform.scaleY); + if (resolved.transform.rotate) { + ctx.rotate((resolved.transform.rotate * Math.PI) / 180); + } - const baseline = this.params.textBaseline ?? "middle"; - const blendMode = ( - this.params.blendMode && this.params.blendMode !== "normal" - ? this.params.blendMode - : "source-over" - ) as GlobalCompositeOperation; + ctx.font = fontString; + ctx.textAlign = node.params.textAlign; + ctx.textBaseline = baseline; + ctx.fillStyle = resolved.textColor; + setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); - const { - scaledFontSize, - fontString, - letterSpacing, - lineHeightPx, - lines, - lineMetrics, + if ( + node.params.background.enabled && + node.params.background.color && + node.params.background.color !== "transparent" && + lineCount > 0 + ) { + const backgroundRect = getTextBackgroundRect({ + textAlign: node.params.textAlign, block, + background: resolvedBackgroundWithColor, 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; + 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 = resolved.textColor; } + } - // 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, + for (let index = 0; index < lineCount; index++) { + const lineY = index * lineHeightPx - block.visualCenterOffset; + ctx.fillText(lines[index], 0, lineY); + drawTextDecoration({ + ctx, + textDecoration: node.params.textDecoration ?? "none", + lineWidth: lineMetrics[index].width, + lineY, + metrics: lineMetrics[index], + scaledFontSize, + textAlign: node.params.textAlign, }); - 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 = gpuRenderer.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(); } + + ctx.restore(); } |
