From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: feat: text / custom masks, fix preview pan, refactor selection --- .../renderer/compositor/frame-descriptor.ts | 36 +++++-- apps/web/src/services/renderer/nodes/text-node.ts | 89 ++-------------- .../migrations/__tests__/v26-to-v27.test.ts | 83 +++++++++++++++ apps/web/src/services/storage/migrations/index.ts | 4 +- .../storage/migrations/transformers/v26-to-v27.ts | 114 +++++++++++++++++++++ .../src/services/storage/migrations/v26-to-v27.ts | 16 +++ 6 files changed, 256 insertions(+), 86 deletions(-) create mode 100644 apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts create mode 100644 apps/web/src/services/storage/migrations/v26-to-v27.ts (limited to 'apps/web/src/services') diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index 8b8201d5..f93eae27 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -401,6 +401,11 @@ function buildMaskArtifacts({ } const definition = masksRegistry.get(mask.type); + + if (definition.isActive?.(mask.params) === false) { + return { mask: null, strokeLayer: null }; + } + const elementMaskCanvas = createOffscreenCanvas({ width: Math.round(transform.width), height: Math.round(transform.height), @@ -416,7 +421,12 @@ function buildMaskArtifacts({ let strokePath: Path2D | null = null; let feather = mask.params.feather; - if (mask.params.feather > 0 && definition.renderer.renderMask) { + const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); + const shouldRenderMaskDirectly = + canRenderMaskDirectly && + (!definition.renderer.buildPath || + (mask.params.feather > 0 && definition.renderer.renderMaskHandlesFeather)); + if (shouldRenderMaskDirectly && definition.renderer.renderMask) { definition.renderer.renderMask({ resolvedParams: mask.params, ctx: elementMaskCtx, @@ -424,13 +434,18 @@ function buildMaskArtifacts({ height: Math.round(transform.height), feather: mask.params.feather, }); - feather = 0; + if (definition.renderer.renderMaskHandlesFeather) { + feather = 0; + } strokePath = definition.renderer.buildStrokePath?.({ resolvedParams: mask.params, width: transform.width, height: transform.height, }) ?? null; } else { + if (!definition.renderer.buildPath) { + return { mask: null, strokeLayer: null }; + } const path2d = definition.renderer.buildPath({ resolvedParams: mask.params, width: transform.width, @@ -472,7 +487,7 @@ function buildMaskArtifacts({ }); let strokeLayer: FrameItemDescriptor | null = null; - if (mask.params.strokeWidth > 0 && strokePath) { + if (mask.params.strokeWidth > 0 && (strokePath || definition.renderer.renderStroke)) { const strokeCanvas = createOffscreenCanvas({ width: Math.round(transform.width), height: Math.round(transform.height), @@ -482,9 +497,18 @@ function buildMaskArtifacts({ | OffscreenCanvasRenderingContext2D | null; if (strokeCtx) { - strokeCtx.strokeStyle = mask.params.strokeColor; - strokeCtx.lineWidth = mask.params.strokeWidth; - strokeCtx.stroke(strokePath); + if (definition.renderer.renderStroke) { + definition.renderer.renderStroke({ + resolvedParams: mask.params, + ctx: strokeCtx, + width: transform.width, + height: transform.height, + }); + } else if (strokePath) { + strokeCtx.strokeStyle = mask.params.strokeColor; + strokeCtx.lineWidth = mask.params.strokeWidth; + strokeCtx.stroke(strokePath); + } const fullStrokeCanvas = createOffscreenCanvas({ width: renderer.width, diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index c9e13261..86384328 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -3,16 +3,9 @@ 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 "@/lib/text/background"; -import { - drawTextDecoration, - getTextBackgroundRect, - setCanvasLetterSpacing, -} from "@/lib/text/layout"; + drawMeasuredTextLayout, +} from "@/lib/text/primitives"; import type { MeasuredTextElement } from "@/lib/text/measure-element"; -import { clamp } from "@/utils/math"; export type TextNodeParams = TextElement & { canvasCenter: { x: number; y: number }; @@ -46,22 +39,6 @@ export function renderTextToContext({ 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, - }; ctx.save(); ctx.translate(x, y); @@ -70,60 +47,14 @@ export function renderTextToContext({ ctx.rotate((resolved.transform.rotate * Math.PI) / 180); } - ctx.font = fontString; - ctx.textAlign = node.params.textAlign; - ctx.textBaseline = baseline; - ctx.fillStyle = resolved.textColor; - setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); - - 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, - }); - 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; - } - } - - 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, - }); - } + drawMeasuredTextLayout({ + ctx, + layout: resolved.measuredText, + textColor: resolved.textColor, + background: resolved.measuredText.resolvedBackground, + backgroundColor: resolved.backgroundColor, + textBaseline: baseline, + }); ctx.restore(); } diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts new file mode 100644 index 00000000..730d958d --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV26ToV27 } from "../transformers/v26-to-v27"; + +describe("V26 to V27 Migration", () => { + test("converts custom mask paths from JSON strings to typed point arrays", () => { + const result = transformProjectV26ToV27({ + project: { + id: "project-v26-custom-mask", + version: 26, + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "track-1", + type: "video", + elements: [ + { + id: "element-1", + type: "image", + masks: [ + { + id: "mask-custom", + type: "custom", + params: { + path: JSON.stringify([ + { + id: "point-1", + x: 0, + y: 0, + inX: 0, + inY: 0, + outX: 0.1, + outY: 0, + }, + ]), + closed: false, + }, + }, + { + id: "mask-rectangle", + type: "rectangle", + params: { + width: 0.5, + }, + }, + ], + }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(27); + + const scenes = result.project.scenes as Array>; + const tracks = scenes[0].tracks as Record; + const mainTrack = tracks.main as Record; + const elements = mainTrack.elements as Array>; + const masks = elements[0].masks as Array>; + const customParams = masks[0].params as Record; + const rectangleParams = masks[1].params as Record; + + expect(customParams.path).toEqual([ + { + id: "point-1", + x: 0, + y: 0, + inX: 0, + inY: 0, + outX: 0.1, + outY: 0, + }, + ]); + expect(rectangleParams.width).toBe(0.5); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index c5a2e237..b290b713 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -25,10 +25,11 @@ import { V22toV23Migration } from "./v22-to-v23"; import { V23toV24Migration } from "./v23-to-v24"; import { V24toV25Migration } from "./v24-to-v25"; import { V25toV26Migration } from "./v25-to-v26"; +import { V26toV27Migration } from "./v26-to-v27"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 26; +export const CURRENT_PROJECT_VERSION = 27; export const migrations = [ new V0toV1Migration(), @@ -57,4 +58,5 @@ export const migrations = [ new V23toV24Migration(), new V24toV25Migration(), new V25toV26Migration(), + new V26toV27Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts new file mode 100644 index 00000000..186ae1e9 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts @@ -0,0 +1,114 @@ +import { parseCustomMaskPath } from "@/lib/masks/custom-path"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV26ToV27({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 27) { + return { project, skipped: true, reason: "already v27" }; + } + if (version !== 26) { + return { project, skipped: true, reason: "not v26" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 27, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + if (!Array.isArray(project.scenes)) { + return project; + } + + return { + ...project, + scenes: project.scenes.map((scene) => migrateScene({ scene })), + }; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene) || !isRecord(scene.tracks)) { + return scene; + } + + const tracks = scene.tracks; + const nextTracks: ProjectRecord = { ...tracks }; + + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + + return { + ...scene, + tracks: nextTracks, + }; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || !Array.isArray(element.masks)) { + return element; + } + + return { + ...element, + masks: element.masks.map((mask) => migrateMask({ mask })), + }; +} + +function migrateMask({ mask }: { mask: unknown }): unknown { + if (!isRecord(mask) || mask.type !== "custom" || !isRecord(mask.params)) { + return mask; + } + + const path = mask.params.path; + if (typeof path !== "string") { + return mask; + } + + return { + ...mask, + params: { + ...mask.params, + path: parseCustomMaskPath({ path }), + }, + }; +} diff --git a/apps/web/src/services/storage/migrations/v26-to-v27.ts b/apps/web/src/services/storage/migrations/v26-to-v27.ts new file mode 100644 index 00000000..2df5329d --- /dev/null +++ b/apps/web/src/services/storage/migrations/v26-to-v27.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV26ToV27 } from "./transformers/v26-to-v27"; + +export class V26toV27Migration extends StorageMigration { + from = 26; + to = 27; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV26ToV27({ project }); + } +} -- cgit v1.3.1