summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer/nodes/text-node.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/renderer/nodes/text-node.ts')
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts356
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();
}