summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/renderer')
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts14
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts173
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts7
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts6
4 files changed, 175 insertions, 25 deletions
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index 9842320e..a2fb0170 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -1,9 +1,9 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { resolveStickerId } from "@/lib/stickers";
import { VisualNode, type VisualNodeParams } from "./visual-node";
export interface StickerNodeParams extends VisualNodeParams {
- iconName: string;
- color?: string;
+ stickerId: string;
}
export class StickerNode extends VisualNode<StickerNodeParams> {
@@ -18,15 +18,15 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
private async load() {
const image = new Image();
this.image = image;
- const color = this.params.color
- ? `&color=${encodeURIComponent(this.params.color)}`
- : "";
- const url = `https://api.iconify.design/${this.params.iconName}.svg?width=200&height=200${color}`;
+ const url = resolveStickerId({
+ stickerId: this.params.stickerId,
+ options: { width: 200, height: 200 },
+ });
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () =>
- reject(new Error(`Failed to load sticker: ${this.params.iconName}`));
+ reject(new Error(`Failed to load sticker: ${this.params.stickerId}`));
image.src = url;
});
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 121ba834..b7e36d64 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,7 +1,10 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
-import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
+import {
+ DEFAULT_LINE_HEIGHT,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
function scaleFontSize({
fontSize,
@@ -13,6 +16,107 @@ function scaleFontSize({
return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
}
+function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
+ return `"${fontFamily.replace(/"/g, '\\"')}"`;
+}
+
+function getMetricAscent({
+ metrics,
+ fallback,
+}: {
+ metrics: TextMetrics;
+ fallback: number;
+}): number {
+ return metrics.actualBoundingBoxAscent ?? fallback * 0.8;
+}
+
+function getMetricDescent({
+ metrics,
+ fallback,
+}: {
+ metrics: TextMetrics;
+ fallback: number;
+}): number {
+ return metrics.actualBoundingBoxDescent ?? fallback * 0.2;
+}
+
+interface TextBlockMeasurement {
+ visualCenterOffset: number;
+ height: number;
+ maxWidth: number;
+}
+
+function measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize,
+}: {
+ lineMetrics: TextMetrics[];
+ lineHeightPx: number;
+ fallbackFontSize: number;
+}): TextBlockMeasurement {
+ let top = Number.POSITIVE_INFINITY;
+ let bottom = Number.NEGATIVE_INFINITY;
+ let maxWidth = 0;
+
+ for (let i = 0; i < lineMetrics.length; i++) {
+ const metrics = lineMetrics[i];
+ const y = i * lineHeightPx;
+ top = Math.min(
+ top,
+ y - getMetricAscent({ metrics, fallback: fallbackFontSize }),
+ );
+ bottom = Math.max(
+ bottom,
+ y + getMetricDescent({ metrics, fallback: fallbackFontSize }),
+ );
+ maxWidth = Math.max(maxWidth, metrics.width);
+ }
+
+ const height = bottom - top;
+ const visualCenterOffset = (top + bottom) / 2;
+
+ return { visualCenterOffset, height, maxWidth };
+}
+
+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 * 0.07);
+ const ascent = getMetricAscent({ metrics, fallback: scaledFontSize });
+ const descent = getMetricDescent({ metrics, fallback: 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) * 0.35;
+ ctx.fillRect(xStart, strikeY, lineWidth, thickness);
+ }
+}
+
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };
canvasHeight: number;
@@ -38,6 +142,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
const y = this.params.transform.position.y + this.params.canvasCenter.y;
renderer.context.translate(x, y);
+ renderer.context.scale(
+ this.params.transform.scale,
+ this.params.transform.scale,
+ );
if (this.params.transform.rotate) {
renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
}
@@ -48,39 +156,72 @@ export class TextNode extends BaseNode<TextNodeParams> {
fontSize: this.params.fontSize,
canvasHeight: this.params.canvasHeight,
});
- renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${this.params.fontFamily}`;
+ const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
+ renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
renderer.context.textAlign = this.params.textAlign;
- renderer.context.textBaseline = this.params.textBaseline || "middle";
renderer.context.fillStyle = this.params.color;
+ const letterSpacing = this.params.letterSpacing ?? 0;
+ const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ if ("letterSpacing" in renderer.context) {
+ (
+ renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
+ ).letterSpacing = `${letterSpacing}px`;
+ }
+
+ const lines = this.params.content.split("\n");
+ const lineHeightPx = scaledFontSize * lineHeight;
+ const baseline = this.params.textBaseline ?? "middle";
+
+ renderer.context.textBaseline = baseline;
+ const lineMetrics = lines.map((line) => renderer.context.measureText(line));
+ const lineCount = lines.length;
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize: scaledFontSize,
+ });
+
const prevAlpha = renderer.context.globalAlpha;
+ renderer.context.globalCompositeOperation = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
renderer.context.globalAlpha = this.params.opacity;
- if (this.params.backgroundColor) {
- const metrics = renderer.context.measureText(this.params.content);
- const ascent = metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8;
- const descent = metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2;
- const textW = metrics.width;
- const textH = ascent + descent;
+ if (this.params.backgroundColor && lineCount > 0) {
const padX = 8;
const padY = 4;
-
renderer.context.fillStyle = this.params.backgroundColor;
- let bgLeft = -textW / 2;
+ let bgLeft = -block.maxWidth / 2;
if (renderer.context.textAlign === "left") bgLeft = 0;
- if (renderer.context.textAlign === "right") bgLeft = -textW;
+ if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth;
renderer.context.fillRect(
bgLeft - padX,
- -textH / 2 - padY,
- textW + padX * 2,
- textH + padY * 2,
+ -block.height / 2 - padY,
+ block.maxWidth + padX * 2,
+ block.height + padY * 2,
);
renderer.context.fillStyle = this.params.color;
}
- renderer.context.fillText(this.params.content, 0, 0);
+ for (let i = 0; i < lineCount; i++) {
+ const y = i * lineHeightPx - block.visualCenterOffset;
+ renderer.context.fillText(lines[i], 0, y);
+ drawTextDecoration({
+ ctx: renderer.context,
+ textDecoration: this.params.textDecoration ?? "none",
+ lineWidth: lineMetrics[i].width,
+ lineY: y,
+ metrics: lineMetrics[i],
+ scaledFontSize,
+ textAlign: this.params.textAlign,
+ });
+ }
renderer.context.globalAlpha = prevAlpha;
renderer.context.restore();
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 9c6c88ca..7179369e 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,5 +1,6 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
+import type { BlendMode } from "@/types/rendering";
import type { Transform } from "@/types/timeline";
const VISUAL_EPSILON = 1 / 1000;
@@ -11,6 +12,7 @@ export interface VisualNodeParams {
trimEnd: number;
transform: Transform;
opacity: number;
+ blendMode?: BlendMode;
}
export abstract class VisualNode<
@@ -51,6 +53,11 @@ export abstract class VisualNode<
const x = renderer.width / 2 + transform.position.x - scaledWidth / 2;
const y = renderer.height / 2 + transform.position.y - scaledHeight / 2;
+ renderer.context.globalCompositeOperation = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
renderer.context.globalAlpha = opacity;
if (transform.rotate !== 0) {
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index d1b8217e..ee7f3d6c 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -66,6 +66,7 @@ export function buildScene(params: BuildSceneParams) {
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
+ blendMode: element.blendMode,
}),
);
}
@@ -79,6 +80,7 @@ export function buildScene(params: BuildSceneParams) {
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
+ blendMode: element.blendMode,
}),
);
}
@@ -98,14 +100,14 @@ export function buildScene(params: BuildSceneParams) {
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
- iconName: element.iconName,
+ stickerId: element.stickerId,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
- color: element.color,
+ blendMode: element.blendMode,
}),
);
}