diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:41:33 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:08:29 +0200 |
| commit | 7d4e166678c35d48b22b8d1d05639f981a956a8b (patch) | |
| tree | eb596d4f4609053524224169c247db24052db353 /apps/web/src/lib | |
| parent | cad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (diff) | |
| download | opencut-7d4e166678c35d48b22b8d1d05639f981a956a8b.tar.gz | |
fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
Diffstat (limited to 'apps/web/src/lib')
| -rw-r--r-- | apps/web/src/lib/background/constants.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/lib/text/layout.ts | 51 | ||||
| -rw-r--r-- | apps/web/src/lib/text/measure-element.ts | 289 |
3 files changed, 210 insertions, 136 deletions
diff --git a/apps/web/src/lib/background/constants.ts b/apps/web/src/lib/background/constants.ts index 654154a5..b682c260 100644 --- a/apps/web/src/lib/background/constants.ts +++ b/apps/web/src/lib/background/constants.ts @@ -2,9 +2,9 @@ export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{ label: string; value: number; }> = [ - { label: "Light", value: 10 }, - { label: "Medium", value: 50 }, - { label: "Heavy", value: 100 }, + { label: "Light", value: 100 }, + { label: "Medium", value: 200 }, + { label: "Heavy", value: 500 }, ] as const; export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts index 75fa7f09..a3badfdc 100644 --- a/apps/web/src/lib/text/layout.ts +++ b/apps/web/src/lib/text/layout.ts @@ -14,15 +14,18 @@ export interface TextBlockMeasurement { maxWidth: number; } -type CanvasContext = +export type TextCanvasContext = | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +const TEXT_DECORATION_THICKNESS_RATIO = 0.07; +const STRIKETHROUGH_VERTICAL_RATIO = 0.35; + export function setCanvasLetterSpacing({ ctx, letterSpacingPx, }: { - ctx: CanvasContext; + ctx: TextCanvasContext; letterSpacingPx: number; }): void { if ("letterSpacing" in ctx) { @@ -177,3 +180,47 @@ export function getTextVisualRect({ height: bottom - top, }; } + +export function drawTextDecoration({ + ctx, + textDecoration, + lineWidth, + lineY, + metrics, + scaledFontSize, + textAlign, +}: { + ctx: TextCanvasContext; + 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); + } +} diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts index 77efe1b8..99b8a7ba 100644 --- a/apps/web/src/lib/text/measure-element.ts +++ b/apps/web/src/lib/text/measure-element.ts @@ -1,131 +1,158 @@ -import {
- CORNER_RADIUS_MIN,
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-import { resolveNumberAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type { TextBackground, TextElement } from "@/lib/timeline";
-import {
- measureTextBlock,
- setCanvasLetterSpacing,
- getTextVisualRect,
- type TextBlockMeasurement,
-} from "./layout";
-
-export interface ResolvedTextBackground extends TextBackground {
- paddingX: number;
- paddingY: number;
- offsetX: number;
- offsetY: number;
- cornerRadius: number;
-}
-
-export interface MeasuredTextElement {
- scaledFontSize: number;
- fontString: string;
- letterSpacing: number;
- lineHeightPx: number;
- lines: string[];
- lineMetrics: TextMetrics[];
- block: TextBlockMeasurement;
- fontSizeRatio: number;
- resolvedBackground: ResolvedTextBackground;
- visualRect: { left: number; top: number; width: number; height: number };
-}
-
-/**
- * Shared text measurement used by both the renderer and preview bounds.
- * Accepts the canvas context to measure on so callers can reuse an existing
- * context (e.g. the renderer's) rather than creating a throwaway canvas.
- * The context state is preserved via save/restore.
- */
-export function measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
-}: {
- element: TextElement;
- canvasHeight: number;
- localTime: number;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
-}): MeasuredTextElement {
- const scaledFontSize =
- element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
- const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- const letterSpacing = element.letterSpacing ?? 0;
- const lineHeightPx =
- scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
- const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
- const lines = element.content.split("\n");
-
- ctx.save();
- ctx.font = fontString;
- ctx.textBaseline = "middle";
- setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
- const lineMetrics = lines.map((line) => ctx.measureText(line));
- ctx.restore();
-
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- });
-
- const bg = element.background;
- const resolvedBackground: ResolvedTextBackground = {
- ...bg,
- paddingX: resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- }),
- paddingY: resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- }),
- offsetX: resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- }),
- offsetY: resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- }),
- cornerRadius: resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- }),
- };
-
- const visualRect = getTextVisualRect({
- textAlign: element.textAlign,
- block,
- background: resolvedBackground,
- fontSizeRatio,
- });
-
- return {
- scaledFontSize,
- fontString,
- letterSpacing,
- lineHeightPx,
- lines,
- lineMetrics,
- block,
- fontSizeRatio,
- resolvedBackground,
- visualRect,
- };
-}
+import { + CORNER_RADIUS_MIN, + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; +import { resolveNumberAtTime } from "@/lib/animation"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import type { TextBackground, TextElement } from "@/lib/timeline"; +import { + measureTextBlock, + setCanvasLetterSpacing, + getTextVisualRect, + type TextBlockMeasurement, +} from "./layout"; + +export interface ResolvedTextBackground extends TextBackground { + paddingX: number; + paddingY: number; + offsetX: number; + offsetY: number; + cornerRadius: number; +} + +export interface MeasuredTextElement { + scaledFontSize: number; + fontString: string; + letterSpacing: number; + lineHeightPx: number; + lines: string[]; + lineMetrics: TextMetrics[]; + block: TextBlockMeasurement; + fontSizeRatio: number; + resolvedBackground: ResolvedTextBackground; + visualRect: { left: number; top: number; width: number; height: number }; +} + +let textMeasurementContext: + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null = null; + +export function getTextMeasurementContext(): + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D { + if (textMeasurementContext) { + return textMeasurementContext; + } + + if (typeof OffscreenCanvas !== "undefined") { + const canvas = new OffscreenCanvas(1, 1); + const context = canvas.getContext("2d"); + if (context) { + textMeasurementContext = context; + return context; + } + } + + if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context) { + textMeasurementContext = context; + return context; + } + } + + throw new Error("Failed to create text measurement context"); +} + +export function measureTextElement({ + element, + canvasHeight, + localTime, + ctx, +}: { + element: TextElement; + canvasHeight: number; + localTime: number; + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}): MeasuredTextElement { + const scaledFontSize = + element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; + const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; + const letterSpacing = element.letterSpacing ?? 0; + const lineHeightPx = + scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight); + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const lines = element.content.split("\n"); + + ctx.save(); + ctx.font = fontString; + ctx.textBaseline = "middle"; + setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); + const lineMetrics = lines.map((line) => ctx.measureText(line)); + ctx.restore(); + + const block = measureTextBlock({ + lineMetrics, + lineHeightPx, + }); + + const bg = element.background; + const resolvedBackground: ResolvedTextBackground = { + ...bg, + paddingX: resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }), + paddingY: resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }), + offsetX: resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }), + offsetY: resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }), + cornerRadius: resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + }), + }; + + const visualRect = getTextVisualRect({ + textAlign: element.textAlign, + block, + background: resolvedBackground, + fontSizeRatio, + }); + + return { + scaledFontSize, + fontString, + letterSpacing, + lineHeightPx, + lines, + lineMetrics, + block, + fontSizeRatio, + resolvedBackground, + visualRect, + }; +} |
