From 79dcec8a2ec2f5cad9be95e5b52fbfa752fb6ac4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 18:41:15 +0200 Subject: [PATCH] refactor: split builtin and freeform masks Made-with: Cursor --- apps/web/src/actions/use-editor-actions.ts | 2 +- .../masks/delete-custom-mask-points.ts | 27 ++--- .../commands/timeline/element/masks/index.ts | 4 +- .../element/masks/insert-custom-mask-point.ts | 21 ++-- .../timeline/element/masks/remove-mask.ts | 10 +- .../element/masks/toggle-mask-inverted.ts | 10 +- .../web/src/core/managers/timeline-manager.ts | 12 +- apps/web/src/masks/__tests__/snap.test.ts | 60 +++++----- .../{definitions => builtin}/box-like.ts | 0 .../definitions/cinematic-bars.ts | 2 +- .../{ => builtin}/definitions/diamond.ts | 2 +- .../{ => builtin}/definitions/ellipse.ts | 2 +- .../masks/{ => builtin}/definitions/heart.ts | 2 +- .../masks/{ => builtin}/definitions/index.ts | 19 ++-- .../{ => builtin}/definitions/rectangle.ts | 2 +- .../masks/{ => builtin}/definitions/split.ts | 4 +- .../masks/{ => builtin}/definitions/star.ts | 2 +- .../masks/{ => builtin}/definitions/text.ts | 0 apps/web/src/masks/components/masks-tab.tsx | 53 +++++++-- .../custom.ts => freeform/definition.ts} | 102 ++++++++--------- .../{custom-path.ts => freeform/path.ts} | 106 +++++++++--------- apps/web/src/masks/index.ts | 40 ++++++- apps/web/src/masks/registry.ts | 23 ++-- apps/web/src/masks/types.ts | 32 +++--- apps/web/src/masks/use-mask-handles.ts | 50 ++++----- .../src/preview/components/mask-handles.tsx | 4 +- .../renderer/compositor/frame-descriptor.ts | 4 +- .../migrations/__tests__/v30-to-v31.test.ts | 57 ++++++++++ .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v26-to-v27.ts | 12 +- .../migrations/transformers/v30-to-v31.ts | 99 ++++++++++++++++ .../services/storage/migrations/v30-to-v31.ts | 14 +++ 32 files changed, 508 insertions(+), 273 deletions(-) rename apps/web/src/masks/{definitions => builtin}/box-like.ts (100%) rename apps/web/src/masks/{ => builtin}/definitions/cinematic-bars.ts (99%) rename apps/web/src/masks/{ => builtin}/definitions/diamond.ts (99%) rename apps/web/src/masks/{ => builtin}/definitions/ellipse.ts (98%) rename apps/web/src/masks/{ => builtin}/definitions/heart.ts (99%) rename apps/web/src/masks/{ => builtin}/definitions/index.ts (78%) rename apps/web/src/masks/{ => builtin}/definitions/rectangle.ts (99%) rename apps/web/src/masks/{ => builtin}/definitions/split.ts (98%) rename apps/web/src/masks/{ => builtin}/definitions/star.ts (99%) rename apps/web/src/masks/{ => builtin}/definitions/text.ts (100%) rename apps/web/src/masks/{definitions/custom.ts => freeform/definition.ts} (84%) rename apps/web/src/masks/{custom-path.ts => freeform/path.ts} (85%) create mode 100644 apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts create mode 100644 apps/web/src/services/storage/migrations/v30-to-v31.ts diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index 73300c6e..e5d3a23b 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -305,7 +305,7 @@ export function useEditorActions() { if (!selectedMaskPointSelection) { return; } - editor.timeline.deleteCustomMaskPoints({ + editor.timeline.deleteFreeformPathMaskPoints({ trackId: selectedMaskPointSelection.trackId, elementId: selectedMaskPointSelection.elementId, maskId: selectedMaskPointSelection.maskId, diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts index 1445b1f8..fb02554d 100644 --- a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts +++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -1,22 +1,22 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; import { - getCustomMaskClosedStateAfterPointRemoval, - removeCustomMaskPoints, -} from "@/masks/custom-path"; -import type { CustomMask } from "@/masks/types"; + getFreeformPathClosedStateAfterPointRemoval, + removeFreeformPathPoints, +} from "@/masks/freeform/path"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function deletePointsFromCustomMask({ +function deletePointsFromFreeformPathMask({ mask, pointIds, }: { - mask: CustomMask; + mask: FreeformPathMask; pointIds: string[]; -}): CustomMask { +}): FreeformPathMask { const points = mask.params.path; - const nextPoints = removeCustomMaskPoints({ points, pointIds }); + const nextPoints = removeFreeformPathPoints({ points, pointIds }); if (nextPoints.length === points.length) { return mask; } @@ -26,7 +26,7 @@ function deletePointsFromCustomMask({ params: { ...mask.params, path: nextPoints, - closed: getCustomMaskClosedStateAfterPointRemoval({ + closed: getFreeformPathClosedStateAfterPointRemoval({ wasClosed: mask.params.closed, remainingPointCount: nextPoints.length, }), @@ -46,11 +46,11 @@ function deletePointsFromElementMask({ const currentMasks = element.masks ?? []; let didDeletePoints = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const nextMask = deletePointsFromCustomMask({ + const nextMask = deletePointsFromFreeformPathMask({ mask, pointIds, }); @@ -64,7 +64,7 @@ function deletePointsFromElementMask({ }; } -export class DeleteCustomMaskPointsCommand extends Command { +export class DeleteFreeformPathMaskPointsCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = deletePointsFromElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, pointIds: this.pointIds, }); diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts index dd6373b1..70b4333e 100644 --- a/apps/web/src/commands/timeline/element/masks/index.ts +++ b/apps/web/src/commands/timeline/element/masks/index.ts @@ -1,4 +1,4 @@ -export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; -export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; +export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points"; +export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point"; export { RemoveMaskCommand } from "./remove-mask"; export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts index 58559af6..0f714884 100644 --- a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts +++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -1,23 +1,23 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; -import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; +import { insertPointOnFreeformSegment } from "@/masks/freeform/definition"; import type { ElementBounds } from "@/preview/element-bounds"; -import type { CustomMask } from "@/masks/types"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function insertPointIntoCustomMask({ +function insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, bounds, }: { - mask: CustomMask; + mask: FreeformPathMask; segmentIndex: number; canvasPoint: { x: number; y: number }; bounds: ElementBounds; -}): { mask: CustomMask; insertedPointId: string | null } { - const result = insertPointOnCustomMaskSegment({ +}): { mask: FreeformPathMask; insertedPointId: string | null } { + const result = insertPointOnFreeformSegment({ params: mask.params, segmentIndex, canvasPoint, @@ -61,11 +61,11 @@ function insertPointIntoElementMask({ let didInsertPoint = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const result = insertPointIntoCustomMask({ + const result = insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, @@ -85,7 +85,7 @@ function insertPointIntoElementMask({ }; } -export class InsertCustomMaskPointCommand extends Command { +export class InsertFreeformPathMaskPointCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = insertPointIntoElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, segmentIndex: this.segmentIndex, canvasPoint: this.canvasPoint, diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts index b543bce1..b92d1d8c 100644 --- a/apps/web/src/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts @@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - removeMaskFromElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return removeMaskFromElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts index fec9b923..b53e24ca 100644 --- a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - toggleMaskInvertedOnElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return toggleMaskInvertedOnElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index dfabca01..6fb28625 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -46,8 +46,8 @@ import { RetimeKeyframeCommand, UpdateScalarKeyframeCurveCommand, AddClipEffectCommand, - DeleteCustomMaskPointsCommand, - InsertCustomMaskPointCommand, + DeleteFreeformPathMaskPointsCommand, + InsertFreeformPathMaskPointCommand, RemoveClipEffectCommand, UpdateClipEffectParamsCommand, ToggleClipEffectCommand, @@ -349,7 +349,7 @@ export class TimelineManager { this.editor.command.execute({ command }); } - deleteCustomMaskPoints({ + deleteFreeformPathMaskPoints({ trackId, elementId, maskId, @@ -363,7 +363,7 @@ export class TimelineManager { if (pointIds.length === 0) { return; } - const command = new DeleteCustomMaskPointsCommand({ + const command = new DeleteFreeformPathMaskPointsCommand({ trackId, elementId, maskId, @@ -372,7 +372,7 @@ export class TimelineManager { this.editor.command.execute({ command }); } - insertCustomMaskPoint({ + insertFreeformPathMaskPoint({ trackId, elementId, maskId, @@ -387,7 +387,7 @@ export class TimelineManager { canvasPoint: { x: number; y: number }; bounds: ElementBounds; }): void { - const command = new InsertCustomMaskPointCommand({ + const command = new InsertFreeformPathMaskPointCommand({ trackId, elementId, maskId, diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts index 152b04f9..6f8421a0 100644 --- a/apps/web/src/masks/__tests__/snap.test.ts +++ b/apps/web/src/masks/__tests__/snap.test.ts @@ -1,22 +1,22 @@ import { describe, expect, test } from "bun:test"; import { - findClosestPointOnCustomMaskSegment, - getCustomMaskClosedStateAfterPointRemoval, - insertPointIntoCustomMaskSegment, - removeCustomMaskPoints, -} from "@/masks/custom-path"; + findClosestPointOnFreeformSegment, + getFreeformPathClosedStateAfterPointRemoval, + insertPointIntoFreeformSegment, + removeFreeformPathPoints, +} from "@/masks/freeform/path"; import { - appendPointToCustomMask, - customMaskDefinition, - insertPointOnCustomMaskSegment, -} from "@/masks/definitions/custom"; -import { getSplitMaskStrokeSegment } from "@/masks/definitions/split"; -import { textMaskDefinition } from "@/masks/definitions/text"; + appendPointToFreeformPathMask, + freeformMaskDefinition, + insertPointOnFreeformSegment, +} from "@/masks/freeform/definition"; +import { getSplitMaskStrokeSegment } from "@/masks/builtin/definitions/split"; +import { textMaskDefinition } from "@/masks/builtin/definitions/text"; import { getMaskSnapGeometry } from "@/masks/geometry"; import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap"; import type { ElementBounds } from "@/preview/element-bounds"; import type { - CustomMaskParams, + FreeformPathMaskParams, RectangleMaskParams, SplitMaskParams, TextMaskParams, @@ -100,9 +100,9 @@ function buildTextMaskParams( }; } -function buildCustomMaskParams( - overrides: Partial = {}, -): CustomMaskParams { +function buildFreeformPathMaskParams( + overrides: Partial = {}, +): FreeformPathMaskParams { return { feather: 0, inverted: false, @@ -347,11 +347,11 @@ describe("mask snapping", () => { }); test("snaps custom mask movement using path geometry bounds", () => { - const params = buildCustomMaskParams({ + const params = buildFreeformPathMaskParams({ centerX: 0.03, centerY: -0.04, }); - const result = customMaskDefinition.interaction.snap?.({ + const result = freeformMaskDefinition.interaction.snap?.({ handleId: { kind: "position" }, startParams: params, proposedParams: params, @@ -381,11 +381,11 @@ describe("mask snapping", () => { describe("custom mask creation", () => { test("anchors the first point at the click position", () => { - const params = buildCustomMaskParams({ + const params = buildFreeformPathMaskParams({ path: [], closed: false, }); - const next = appendPointToCustomMask({ + const next = appendPointToFreeformPathMask({ params, canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 }, bounds, @@ -401,8 +401,8 @@ describe("custom mask creation", () => { describe("custom mask point deletion", () => { test("removes the selected points by id", () => { - const points = buildCustomMaskParams().path; - const nextPoints = removeCustomMaskPoints({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = removeFreeformPathPoints({ points, pointIds: ["b"], }); @@ -411,14 +411,14 @@ describe("custom mask point deletion", () => { }); test("reopens a closed path once fewer than three points remain", () => { - const points = buildCustomMaskParams().path; - const nextPoints = removeCustomMaskPoints({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = removeFreeformPathPoints({ points, pointIds: ["c"], }); expect( - getCustomMaskClosedStateAfterPointRemoval({ + getFreeformPathClosedStateAfterPointRemoval({ wasClosed: true, remainingPointCount: nextPoints.length, }), @@ -428,9 +428,9 @@ describe("custom mask point deletion", () => { describe("custom mask point insertion", () => { test("finds the closest point on the clicked segment", () => { - const params = buildCustomMaskParams(); + const params = buildFreeformPathMaskParams(); const points = params.path; - const closestPoint = findClosestPointOnCustomMaskSegment({ + const closestPoint = findClosestPointOnFreeformSegment({ points, segmentIndex: 0, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, @@ -449,8 +449,8 @@ describe("custom mask point insertion", () => { }); test("splits a segment into two segments at the insertion point", () => { - const points = buildCustomMaskParams().path; - const nextPoints = insertPointIntoCustomMaskSegment({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = insertPointIntoFreeformSegment({ points, segmentIndex: 0, pointId: "new", @@ -471,8 +471,8 @@ describe("custom mask point insertion", () => { }); test("builds updated custom mask params for a clicked segment", () => { - const result = insertPointOnCustomMaskSegment({ - params: buildCustomMaskParams(), + const result = insertPointOnFreeformSegment({ + params: buildFreeformPathMaskParams(), segmentIndex: 0, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, bounds, diff --git a/apps/web/src/masks/definitions/box-like.ts b/apps/web/src/masks/builtin/box-like.ts similarity index 100% rename from apps/web/src/masks/definitions/box-like.ts rename to apps/web/src/masks/builtin/box-like.ts diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts similarity index 99% rename from apps/web/src/masks/definitions/cinematic-bars.ts rename to apps/web/src/masks/builtin/definitions/cinematic-bars.ts index 68717c02..0e23fd58 100644 --- a/apps/web/src/masks/definitions/cinematic-bars.ts +++ b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts @@ -10,7 +10,7 @@ import { getDefaultBaseMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function getDefaultCinematicBarsMaskParams({ elementSize, diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/builtin/definitions/diamond.ts similarity index 99% rename from apps/web/src/masks/definitions/diamond.ts rename to apps/web/src/masks/builtin/definitions/diamond.ts index fe96dbe9..1effb276 100644 --- a/apps/web/src/masks/definitions/diamond.ts +++ b/apps/web/src/masks/builtin/definitions/diamond.ts @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildDiamondPath({ centerX, diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/builtin/definitions/ellipse.ts similarity index 98% rename from apps/web/src/masks/definitions/ellipse.ts rename to apps/web/src/masks/builtin/definitions/ellipse.ts index f57d7444..688a2fa6 100644 --- a/apps/web/src/masks/definitions/ellipse.ts +++ b/apps/web/src/masks/builtin/definitions/ellipse.ts @@ -6,7 +6,7 @@ import { getBoxLikeGeometry, getDefaultSquareMaskParams, getStrokeOffset, -} from "./box-like"; +} from "../box-like"; export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { type: "ellipse", diff --git a/apps/web/src/masks/definitions/heart.ts b/apps/web/src/masks/builtin/definitions/heart.ts similarity index 99% rename from apps/web/src/masks/definitions/heart.ts rename to apps/web/src/masks/builtin/definitions/heart.ts index ec546f18..d1af5eb2 100644 --- a/apps/web/src/masks/definitions/heart.ts +++ b/apps/web/src/masks/builtin/definitions/heart.ts @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildHeartPath({ centerX, diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/builtin/definitions/index.ts similarity index 78% rename from apps/web/src/masks/definitions/index.ts rename to apps/web/src/masks/builtin/definitions/index.ts index a5aec404..9f722876 100644 --- a/apps/web/src/masks/definitions/index.ts +++ b/apps/web/src/masks/builtin/definitions/index.ts @@ -1,10 +1,9 @@ import { - masksRegistry, - type MaskDefinitionForRegistration, + builtinMasksRegistry, + type BuiltinMaskDefinitionForRegistration, type MaskIconProps, -} from "../registry"; +} from "../../registry"; import { cinematicBarsMaskDefinition } from "./cinematic-bars"; -import { customMaskDefinition } from "./custom"; import { diamondMaskDefinition } from "./diamond"; import { ellipseMaskDefinition } from "./ellipse"; import { heartMaskDefinition } from "./heart"; @@ -27,17 +26,17 @@ function registerDefaultMask({ definition, icon, }: { - definition: MaskDefinitionForRegistration; + definition: BuiltinMaskDefinitionForRegistration; icon: MaskIconProps; }) { - if (masksRegistry.has(definition.type)) { + if (builtinMasksRegistry.has(definition.type)) { return; } - masksRegistry.registerMask({ definition, icon }); + builtinMasksRegistry.registerMask({ definition, icon }); } -export function registerDefaultMasks(): void { +export function registerBuiltinMasks(): void { registerDefaultMask({ definition: splitMaskDefinition, icon: { icon: PanelRightDashedIcon, strokeWidth: 1 }, @@ -70,8 +69,4 @@ export function registerDefaultMasks(): void { definition: textMaskDefinition, icon: { icon: TextFontIcon }, }); - registerDefaultMask({ - definition: customMaskDefinition, - icon: { icon: SquareIcon }, - }); } diff --git a/apps/web/src/masks/definitions/rectangle.ts b/apps/web/src/masks/builtin/definitions/rectangle.ts similarity index 99% rename from apps/web/src/masks/definitions/rectangle.ts rename to apps/web/src/masks/builtin/definitions/rectangle.ts index db7a4578..4dd38154 100644 --- a/apps/web/src/masks/definitions/rectangle.ts +++ b/apps/web/src/masks/builtin/definitions/rectangle.ts @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildRectanglePath({ centerX, diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/builtin/definitions/split.ts similarity index 98% rename from apps/web/src/masks/definitions/split.ts rename to apps/web/src/masks/builtin/definitions/split.ts index 138db91d..13dd1c09 100644 --- a/apps/web/src/masks/definitions/split.ts +++ b/apps/web/src/masks/builtin/definitions/split.ts @@ -1,10 +1,10 @@ -import { computeFeatherUpdate } from "../param-update"; +import { computeFeatherUpdate } from "@/masks/param-update"; import type { MaskDefinition, MaskParamUpdateArgs, SplitMaskParams, } from "@/masks/types"; -import { halfPlaneSign, lineEdgeIntersection } from "../utils"; +import { halfPlaneSign, lineEdgeIntersection } from "@/masks/utils"; import { getLineMaskHandlePositions, getLineMaskOverlay, diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/builtin/definitions/star.ts similarity index 99% rename from apps/web/src/masks/definitions/star.ts rename to apps/web/src/masks/builtin/definitions/star.ts index 2a1a9b7e..62e9cbc4 100644 --- a/apps/web/src/masks/definitions/star.ts +++ b/apps/web/src/masks/builtin/definitions/star.ts @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; const STAR_INNER_RADIUS_RATIO = 0.45; const STAR_VERTEX_COUNT = 10; diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/builtin/definitions/text.ts similarity index 100% rename from apps/web/src/masks/definitions/text.ts rename to apps/web/src/masks/builtin/definitions/text.ts diff --git a/apps/web/src/masks/components/masks-tab.tsx b/apps/web/src/masks/components/masks-tab.tsx index 97fba3ab..d6729d6d 100644 --- a/apps/web/src/masks/components/masks-tab.tsx +++ b/apps/web/src/masks/components/masks-tab.tsx @@ -3,7 +3,11 @@ import type { MaskableElement } from "@/timeline"; import type { Mask, MaskType, TextMask } from "@/masks/types"; import type { NumberParamDefinition, SelectParamDefinition } from "@/params"; -import { masksRegistry, buildDefaultMaskInstance } from "@/masks"; +import { + buildDefaultMaskInstance, + getMaskDefinition, + getMaskDefinitionsForMenu, +} from "@/masks"; import { useEditor } from "@/editor/use-editor"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useMenuPreview } from "@/editor/use-menu-preview"; @@ -85,12 +89,43 @@ type PreviewParamHandler = ( key: string, ) => (value: number | string | boolean) => void; -type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; +type RegisteredMaskDefinition = ReturnType; function isTextMask(mask: Mask): mask is TextMask { return mask.type === "text"; } +function withPreviewedMaskParam({ + mask, + key, + value, +}: { + mask: Mask; + key: string; + value: number | string | boolean; +}): Mask { + switch (mask.type) { + case "split": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "cinematic-bars": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "rectangle": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "ellipse": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "heart": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "diamond": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "star": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "text": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "freeform": + return { ...mask, params: { ...mask.params, [key]: value } }; + } +} + export function MasksTab({ element, trackId }: MasksTabProps) { const editor = useEditor(); const { renderElement, previewUpdates, commit } = @@ -99,7 +134,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) { elementId: element.id, fallback: element, }); - const maskDefs = masksRegistry.getAll(); + const maskDefs = getMaskDefinitionsForMenu(); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); @@ -210,16 +245,10 @@ export function MasksTab({ element, trackId }: MasksTabProps) { const updatedMasks = renderMasks.map((existingMask, maskIndex) => maskIndex !== index ? existingMask - : { - ...existingMask, - params: { - ...existingMask.params, - [key]: value, - }, - }, + : withPreviewedMaskParam({ mask: existingMask, key, value }), ); - previewUpdates({ masks: updatedMasks } as Partial); + previewUpdates({ masks: updatedMasks }); }; return ( @@ -301,7 +330,7 @@ function MaskItem({ onCommit, }: MaskItemProps) { const editor = useEditor(); - const definition = masksRegistry.get(mask.type); + const definition = getMaskDefinition(mask.type); return (
diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/freeform/definition.ts similarity index 84% rename from apps/web/src/masks/definitions/custom.ts rename to apps/web/src/masks/freeform/definition.ts index 08155aae..b657218d 100644 --- a/apps/web/src/masks/definitions/custom.ts +++ b/apps/web/src/masks/freeform/definition.ts @@ -3,26 +3,26 @@ import type { ParamDefinition } from "@/params"; import { PEN_CURSOR } from "@/preview/components/cursors"; import type { ElementBounds } from "@/preview/element-bounds"; import type { - CustomMask, - CustomMaskParams, + FreeformPathMask, + FreeformPathMaskParams, MaskDefinition, MaskHandlePosition, MaskOverlay, MaskParamUpdateArgs, } from "@/masks/types"; import { - buildCustomMaskPath2D, - buildCustomMaskSvgPath, - customMaskCanvasPointToLocal, - findClosestPointOnCustomMaskSegment, - getCustomMaskCanvasSegments, - getCustomMaskCanvasGeometry, - getCustomMaskLocalBounds, - getCustomMaskSegmentCount, - insertPointIntoCustomMaskSegment, - recenterCustomMaskPath, - type CustomMaskPathPoint, -} from "@/masks/custom-path"; + buildFreeformPath2D, + buildFreeformSvgPath, + freeformCanvasPointToLocal, + findClosestPointOnFreeformSegment, + getFreeformCanvasSegments, + getFreeformCanvasGeometry, + getFreeformLocalBounds, + getFreeformSegmentCount, + insertPointIntoFreeformSegment, + recenterFreeformPath, + type FreeformPathPoint, +} from "@/masks/freeform/path"; import { getBoxMaskHandlePositions } from "@/masks/handle-positions"; import { computeFeatherUpdate } from "@/masks/param-update"; import { @@ -40,7 +40,7 @@ const PERCENTAGE_DISPLAY = { step: 1, } as const; -const CUSTOM_MASK_PARAMS: ParamDefinition[] = [ +const FREEFORM_PATH_MASK_PARAMS: ParamDefinition[] = [ { key: "centerX", label: "X", @@ -79,12 +79,12 @@ const CUSTOM_MASK_PARAMS: ParamDefinition[] = [ }, ]; -function getCustomMaskDisplayHandles({ +function getFreeformDisplayHandles({ params, displayScale, bounds, }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; displayScale: number; bounds: ElementBounds; }): { @@ -92,7 +92,7 @@ function getCustomMaskDisplayHandles({ overlays: MaskOverlay[]; } { const points = params.path; - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX: params.centerX, centerY: params.centerY, @@ -108,7 +108,7 @@ function getCustomMaskDisplayHandles({ overlays.push({ id: "path", type: "canvas-path", - pathData: buildCustomMaskSvgPath({ + pathData: buildFreeformSvgPath({ points, centerX: params.centerX, centerY: params.centerY, @@ -124,7 +124,7 @@ function getCustomMaskDisplayHandles({ if (params.closed) { const segmentStrokeWidth = 12; overlays.push( - ...getCustomMaskCanvasSegments({ + ...getFreeformCanvasSegments({ points, centerX: params.centerX, centerY: params.centerY, @@ -145,7 +145,7 @@ function getCustomMaskDisplayHandles({ ); } - const localBounds = getCustomMaskLocalBounds({ points, bounds }); + const localBounds = getFreeformLocalBounds({ points, bounds }); if (params.closed && localBounds) { handles.push( ...getBoxMaskHandlePositions({ @@ -179,19 +179,19 @@ function getCustomMaskDisplayHandles({ }; } -function updateCustomMaskPoint({ +function updateFreeformPathMaskPoint({ points, pointId, updater, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; pointId: string; - updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint; + updater: (point: FreeformPathPoint) => FreeformPathPoint; }) { return points.map((point) => (point.id === pointId ? updater(point) : point)); } -function computeCustomMaskParamUpdate({ +function computeFreeformParamUpdate({ handleId, startParams, deltaX, @@ -199,7 +199,7 @@ function computeCustomMaskParamUpdate({ startCanvasX, startCanvasY, bounds, -}: MaskParamUpdateArgs): Partial { +}: MaskParamUpdateArgs): Partial { if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, @@ -266,7 +266,7 @@ function computeCustomMaskParamUpdate({ x: startCanvasX + deltaX, y: startCanvasY + deltaY, }; - const localPoint = customMaskCanvasPointToLocal({ + const localPoint = freeformCanvasPointToLocal({ point: currentPoint, centerX: startParams.centerX, centerY: startParams.centerY, @@ -276,7 +276,7 @@ function computeCustomMaskParamUpdate({ }); return { - path: updateCustomMaskPoint({ + path: updateFreeformPathMaskPoint({ points, pointId: handleId.pointId, updater: (point) => { @@ -306,15 +306,15 @@ function computeCustomMaskParamUpdate({ }; } -export const customMaskDefinition: MaskDefinition<"custom"> = { - type: "custom", - name: "Custom", +export const freeformMaskDefinition: MaskDefinition<"freeform"> = { + type: "freeform", + name: "Pen tool", features: { hasPosition: true, hasRotation: true, sizeMode: "uniform", }, - params: CUSTOM_MASK_PARAMS, + params: FREEFORM_PATH_MASK_PARAMS, interaction: { getInteraction({ params, @@ -323,7 +323,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { scaleX: _scaleX, scaleY: _scaleY, }) { - return getCustomMaskDisplayHandles({ params, bounds, displayScale }); + return getFreeformDisplayHandles({ params, bounds, displayScale }); }, snap({ handleId, @@ -334,7 +334,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { snapThreshold, }) { const points = startParams.path; - const localBounds = getCustomMaskLocalBounds({ points, bounds }); + const localBounds = getFreeformLocalBounds({ points, bounds }); if (!startParams.closed || !localBounds) { return { params: proposedParams, @@ -422,9 +422,9 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { }; }, }, - buildDefault(): Omit { + buildDefault(): Omit { return { - type: "custom", + type: "freeform", params: { feather: 0, inverted: false, @@ -440,7 +440,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { }, }; }, - computeParamUpdate: computeCustomMaskParamUpdate, + computeParamUpdate: computeFreeformParamUpdate, isActive(params) { return params.closed; }, @@ -454,7 +454,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { return new Path2D(); } - return buildCustomMaskPath2D({ + return buildFreeformPath2D({ points, centerX: params.centerX, centerY: params.centerY, @@ -480,7 +480,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { } const points = params.path; - const path = buildCustomMaskPath2D({ + const path = buildFreeformPath2D({ points, centerX: params.centerX, centerY: params.centerY, @@ -520,15 +520,15 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { }, }; -export function appendPointToCustomMask({ +export function appendPointToFreeformPathMask({ params, canvasPoint, bounds, }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; canvasPoint: { x: number; y: number }; bounds: ElementBounds; -}): CustomMaskParams { +}): FreeformPathMaskParams { const points = params.path; if (points.length === 0) { @@ -554,7 +554,7 @@ export function appendPointToCustomMask({ }; } - const localPoint = customMaskCanvasPointToLocal({ + const localPoint = freeformCanvasPointToLocal({ point: canvasPoint, centerX: params.centerX, centerY: params.centerY, @@ -574,7 +574,7 @@ export function appendPointToCustomMask({ outY: 0, }, ]; - const recentered = recenterCustomMaskPath({ + const recentered = recenterFreeformPath({ points: nextPoints, centerX: params.centerX, centerY: params.centerY, @@ -591,25 +591,25 @@ export function appendPointToCustomMask({ }; } -export function insertPointOnCustomMaskSegment({ +export function insertPointOnFreeformSegment({ params, segmentIndex, canvasPoint, bounds, pointId = generateUUID(), }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; segmentIndex: number; canvasPoint: { x: number; y: number }; bounds: ElementBounds; pointId?: string; -}): { params: CustomMaskParams; pointId: string } | null { +}): { params: FreeformPathMaskParams; pointId: string } | null { const points = params.path; - if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) { + if (getFreeformSegmentCount({ points, closed: params.closed }) === 0) { return null; } - const closestPoint = findClosestPointOnCustomMaskSegment({ + const closestPoint = findClosestPointOnFreeformSegment({ points, segmentIndex, canvasPoint, @@ -624,7 +624,7 @@ export function insertPointOnCustomMaskSegment({ return null; } - const nextPoints = insertPointIntoCustomMaskSegment({ + const nextPoints = insertPointIntoFreeformSegment({ points, segmentIndex, pointId, @@ -635,7 +635,7 @@ export function insertPointOnCustomMaskSegment({ return null; } - const recentered = recenterCustomMaskPath({ + const recentered = recenterFreeformPath({ points: nextPoints, centerX: params.centerX, centerY: params.centerY, diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/freeform/path.ts similarity index 85% rename from apps/web/src/masks/custom-path.ts rename to apps/web/src/masks/freeform/path.ts index 363bb3bd..fa6217c2 100644 --- a/apps/web/src/masks/custom-path.ts +++ b/apps/web/src/masks/freeform/path.ts @@ -1,6 +1,6 @@ import type { ElementBounds } from "@/preview/element-bounds"; -export interface CustomMaskPathPoint { +export interface FreeformPathPoint { id: string; x: number; y: number; @@ -10,7 +10,7 @@ export interface CustomMaskPathPoint { outY: number; } -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { +function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { if (!value || typeof value !== "object") { return false; } @@ -33,38 +33,38 @@ function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { ); } -export function parseCustomMaskPath({ +export function parseFreeformPath({ path, }: { path: string; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (!path) { return []; } try { const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; + return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; } catch { return []; } } -export function serializeCustomMaskPath({ +export function serializeFreeformPath({ points, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; }): string { return JSON.stringify(points); } -export function removeCustomMaskPoints({ +export function removeFreeformPathPoints({ points, pointIds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; pointIds: string[]; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (pointIds.length === 0) { return points; } @@ -72,7 +72,7 @@ export function removeCustomMaskPoints({ return points.filter((point) => !pointIdsToRemove.has(point.id)); } -export function getCustomMaskClosedStateAfterPointRemoval({ +export function getFreeformPathClosedStateAfterPointRemoval({ wasClosed, remainingPointCount, }: { @@ -100,7 +100,7 @@ function rotatePoint({ }; } -export function getCustomMaskCenterCanvasPoint({ +export function getFreeformCenterCanvasPoint({ centerX, centerY, bounds, @@ -115,7 +115,7 @@ export function getCustomMaskCenterCanvasPoint({ }; } -export function customMaskLocalPointToCanvas({ +export function freeformLocalPointToCanvas({ point, centerX, centerY, @@ -130,7 +130,7 @@ export function customMaskLocalPointToCanvas({ scale: number; bounds: ElementBounds; }): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const scaledLocal = { x: point.x * bounds.width * scale, y: point.y * bounds.height * scale, @@ -147,7 +147,7 @@ export function customMaskLocalPointToCanvas({ }; } -export function customMaskCanvasPointToLocal({ +export function freeformCanvasPointToLocal({ point, centerX, centerY, @@ -162,7 +162,7 @@ export function customMaskCanvasPointToLocal({ scale: number; bounds: ElementBounds; }): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const translated = { x: point.x - center.x, y: point.y - center.y, @@ -179,7 +179,7 @@ export function customMaskCanvasPointToLocal({ }; } -export function getCustomMaskCanvasGeometry({ +export function getFreeformCanvasGeometry({ points, centerX, centerY, @@ -187,7 +187,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -196,7 +196,7 @@ export function getCustomMaskCanvasGeometry({ }) { const anchors = points.map((point) => ({ id: point.id, - anchor: customMaskLocalPointToCanvas({ + anchor: freeformLocalPointToCanvas({ point: { x: point.x, y: point.y }, centerX, centerY, @@ -204,7 +204,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }), - inHandle: customMaskLocalPointToCanvas({ + inHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.inX, y: point.y + point.inY }, centerX, centerY, @@ -212,7 +212,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }), - outHandle: customMaskLocalPointToCanvas({ + outHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.outX, y: point.y + point.outY }, centerX, centerY, @@ -288,7 +288,7 @@ function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { }; } -export interface CustomMaskCanvasSegment { +export interface FreeformCanvasSegment { index: number; startPointId: string; endPointId: string; @@ -358,16 +358,16 @@ function evaluateCubicBezier({ }; } -function getCustomMaskSegmentIndices({ +function getFreeformSegmentIndices({ points, segmentIndex, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; closed: boolean; }): { startIndex: number; endIndex: number } | null { - const segmentCount = getCustomMaskSegmentCount({ points, closed }); + const segmentCount = getFreeformSegmentCount({ points, closed }); if (segmentIndex < 0 || segmentIndex >= segmentCount) { return null; } @@ -378,11 +378,11 @@ function getCustomMaskSegmentIndices({ }; } -export function getCustomMaskSegmentCount({ +export function getFreeformSegmentCount({ points, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; closed: boolean; }): number { if (points.length < 2) { @@ -391,7 +391,7 @@ export function getCustomMaskSegmentCount({ return closed ? points.length : points.length - 1; } -export function getCustomMaskCanvasSegments({ +export function getFreeformCanvasSegments({ points, centerX, centerY, @@ -400,15 +400,15 @@ export function getCustomMaskCanvasSegments({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; closed: boolean; -}): CustomMaskCanvasSegment[] { - const geometry = getCustomMaskCanvasGeometry({ +}): FreeformCanvasSegment[] { + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -416,7 +416,7 @@ export function getCustomMaskCanvasSegments({ scale, bounds, }); - const segmentCount = getCustomMaskSegmentCount({ points, closed }); + const segmentCount = getFreeformSegmentCount({ points, closed }); return Array.from({ length: segmentCount }, (_, segmentIndex) => { const start = geometry.anchors[segmentIndex]; @@ -434,7 +434,7 @@ export function getCustomMaskCanvasSegments({ }); } -export function findClosestPointOnCustomMaskSegment({ +export function findClosestPointOnFreeformSegment({ points, segmentIndex, canvasPoint, @@ -445,7 +445,7 @@ export function findClosestPointOnCustomMaskSegment({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; canvasPoint: CanvasPoint; centerX: number; @@ -455,7 +455,7 @@ export function findClosestPointOnCustomMaskSegment({ bounds: ElementBounds; closed: boolean; }): { t: number; point: CanvasPoint } | null { - const segment = getCustomMaskCanvasSegments({ + const segment = getFreeformCanvasSegments({ points, centerX, centerY, @@ -531,20 +531,20 @@ export function findClosestPointOnCustomMaskSegment({ }; } -export function insertPointIntoCustomMaskSegment({ +export function insertPointIntoFreeformSegment({ points, segmentIndex, pointId, t, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; pointId: string; t: number; closed: boolean; -}): CustomMaskPathPoint[] { - const indices = getCustomMaskSegmentIndices({ +}): FreeformPathPoint[] { + const indices = getFreeformSegmentIndices({ points, segmentIndex, closed, @@ -596,11 +596,11 @@ export function insertPointIntoCustomMaskSegment({ return nextPoints; } -export function getCustomMaskLocalBounds({ +export function getFreeformLocalBounds({ points, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; bounds: ElementBounds; }) { if (points.length === 0) { @@ -632,7 +632,7 @@ export function getCustomMaskLocalBounds({ }; } -export function recenterCustomMaskPath({ +export function recenterFreeformPath({ points, centerX, centerY, @@ -640,7 +640,7 @@ export function recenterCustomMaskPath({ scale, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -651,7 +651,7 @@ export function recenterCustomMaskPath({ return { centerX, centerY, points }; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -676,7 +676,7 @@ export function recenterCustomMaskPath({ }; const nextPoints = geometry.anchors.map((point) => { - const anchor = customMaskCanvasPointToLocal({ + const anchor = freeformCanvasPointToLocal({ point: point.anchor, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -684,7 +684,7 @@ export function recenterCustomMaskPath({ scale, bounds, }); - const inHandle = customMaskCanvasPointToLocal({ + const inHandle = freeformCanvasPointToLocal({ point: point.inHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -692,7 +692,7 @@ export function recenterCustomMaskPath({ scale, bounds, }); - const outHandle = customMaskCanvasPointToLocal({ + const outHandle = freeformCanvasPointToLocal({ point: point.outHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -719,7 +719,7 @@ export function recenterCustomMaskPath({ }; } -export function buildCustomMaskPath2D({ +export function buildFreeformPath2D({ points, centerX, centerY, @@ -728,7 +728,7 @@ export function buildCustomMaskPath2D({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -741,7 +741,7 @@ export function buildCustomMaskPath2D({ return path; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -782,7 +782,7 @@ export function buildCustomMaskPath2D({ return path; } -export function buildCustomMaskSvgPath({ +export function buildFreeformSvgPath({ points, centerX, centerY, @@ -791,7 +791,7 @@ export function buildCustomMaskSvgPath({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -803,7 +803,7 @@ export function buildCustomMaskSvgPath({ return ""; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts index df7649e1..be5faef7 100644 --- a/apps/web/src/masks/index.ts +++ b/apps/web/src/masks/index.ts @@ -1,9 +1,15 @@ import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types"; -import { masksRegistry } from "./registry"; +import { + BASE_MASK_PARAM_DEFINITIONS, + builtinMasksRegistry, + type RegisteredBuiltinMaskDefinition, +} from "./registry"; +import { freeformMaskDefinition } from "./freeform/definition"; import { generateUUID } from "@/utils/id"; +import { SquareIcon } from "@hugeicons/core-free-icons"; -export { masksRegistry } from "./registry"; -export { registerDefaultMasks } from "./definitions"; +export { builtinMasksRegistry } from "./registry"; +export { registerBuiltinMasks as registerDefaultMasks } from "./builtin/definitions"; type MaskWithoutId = Mask extends infer TMask ? TMask extends Mask @@ -29,11 +35,35 @@ function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask { return { ...mask, id }; case "text": return { ...mask, id }; - case "custom": + case "freeform": return { ...mask, id }; } } +export function getMaskDefinition(maskType: MaskType): RegisteredBuiltinMaskDefinition { + if (maskType === "freeform") { + return { + ...freeformMaskDefinition, + params: [...freeformMaskDefinition.params, ...BASE_MASK_PARAM_DEFINITIONS], + icon: { icon: SquareIcon }, + } as RegisteredBuiltinMaskDefinition; + } + + return builtinMasksRegistry.get(maskType); +} + +export function getMaskDefinitionsForMenu() { + return [ + ...builtinMasksRegistry.getAll(), + { + ...freeformMaskDefinition, + name: "Pen tool", + params: [...freeformMaskDefinition.params, ...BASE_MASK_PARAM_DEFINITIONS], + icon: { icon: SquareIcon }, + }, + ]; +} + export function buildDefaultMaskInstance({ maskType, elementSize, @@ -41,7 +71,7 @@ export function buildDefaultMaskInstance({ maskType: MaskType; elementSize?: { width: number; height: number }; }): Mask { - const definition = masksRegistry.get(maskType); + const definition = getMaskDefinition(maskType); const context: MaskDefaultContext = { elementSize }; return withMaskId({ mask: definition.buildDefault(context), diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts index 64ab2f60..77861926 100644 --- a/apps/web/src/masks/registry.ts +++ b/apps/web/src/masks/registry.ts @@ -2,6 +2,7 @@ import { MAX_FEATHER } from "@/masks/feather"; import type { ParamDefinition } from "@/params"; import type { BaseMaskParams, + BuiltinMaskType, Mask, MaskDefaultContext, MaskDefinition, @@ -23,11 +24,11 @@ type RegisteredMaskWithoutId = Mask extends infer TMask : never : never; -export type MaskDefinitionForRegistration = { - [TType in MaskType]: MaskDefinition; -}[MaskType]; +export type BuiltinMaskDefinitionForRegistration = { + [TType in BuiltinMaskType]: MaskDefinition; +}[BuiltinMaskType]; -const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< +export const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< keyof BaseMaskParams & string >[] = [ { @@ -57,7 +58,7 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< }, ]; -export interface RegisteredMaskDefinition { +export interface RegisteredBuiltinMaskDefinition { type: MaskType; name: string; features: MaskDefinition["features"]; @@ -72,9 +73,9 @@ export interface RegisteredMaskDefinition { icon: MaskIconProps; } -export class MasksRegistry extends DefinitionRegistry< - MaskType, - RegisteredMaskDefinition +export class BuiltinMasksRegistry extends DefinitionRegistry< + BuiltinMaskType, + RegisteredBuiltinMaskDefinition > { constructor() { super("mask"); @@ -84,10 +85,10 @@ export class MasksRegistry extends DefinitionRegistry< definition, icon, }: { - definition: MaskDefinitionForRegistration; + definition: BuiltinMaskDefinitionForRegistration; icon: MaskIconProps; }): void { - const withBaseParams: RegisteredMaskDefinition = { + const withBaseParams: RegisteredBuiltinMaskDefinition = { type: definition.type, name: definition.name, features: definition.features, @@ -106,4 +107,4 @@ export class MasksRegistry extends DefinitionRegistry< } } -export const masksRegistry = new MasksRegistry(); +export const builtinMasksRegistry = new BuiltinMasksRegistry(); diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts index 261edcaf..59f84f71 100644 --- a/apps/web/src/masks/types.ts +++ b/apps/web/src/masks/types.ts @@ -1,14 +1,14 @@ import type { ElementBounds } from "@/preview/element-bounds"; import type { SnapLine } from "@/preview/preview-snap"; import type { ParamDefinition } from "@/params"; -import type { CustomMaskPathPoint } from "@/masks/custom-path"; +import type { FreeformPathPoint } from "@/masks/freeform/path"; import type { TextDecoration, TextFontStyle, TextFontWeight, } from "@/text/primitives"; -export type MaskType = +export type BuiltinMaskType = | "split" | "cinematic-bars" | "rectangle" @@ -16,8 +16,9 @@ export type MaskType = | "heart" | "diamond" | "star" - | "text" - | "custom"; + | "text"; + +export type MaskType = BuiltinMaskType | "freeform"; export interface BaseMaskParams { feather: number; @@ -57,8 +58,8 @@ export interface TextMaskParams extends BaseMaskParams { scale: number; } -export interface CustomMaskParams extends BaseMaskParams { - path: CustomMaskPathPoint[]; +export interface FreeformPathMaskParams extends BaseMaskParams { + path: FreeformPathPoint[]; closed: boolean; centerX: number; centerY: number; @@ -114,13 +115,7 @@ export interface TextMask { params: TextMaskParams; } -export interface CustomMask { - id: string; - type: "custom"; - params: CustomMaskParams; -} - -export type Mask = +export type BuiltinShapeMask = | SplitMask | CinematicBarsMask | RectangleMask @@ -128,8 +123,15 @@ export type Mask = | HeartMask | DiamondMask | StarMask - | TextMask - | CustomMask; + | TextMask; + +export interface FreeformPathMask { + id: string; + type: "freeform"; + params: FreeformPathMaskParams; +} + +export type Mask = BuiltinShapeMask | FreeformPathMask; export type MaskByType = Extract; export type MaskParamsByType = diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts index 00ab0de8..f889e029 100644 --- a/apps/web/src/masks/use-mask-handles.ts +++ b/apps/web/src/masks/use-mask-handles.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { masksRegistry } from "@/masks"; -import { appendPointToCustomMask } from "@/masks/definitions/custom"; +import { getMaskDefinition } from "@/masks"; +import { appendPointToFreeformPathMask } from "@/masks/freeform/definition"; import { getVisibleElementsWithBounds, type ElementBounds, @@ -158,7 +158,7 @@ export function useMaskHandles({ const { handles: baseHandlePositions, overlays }: MaskInteractionResult = selectedWithMask ? (() => { - const def = masksRegistry.get(selectedWithMask.mask.type); + const def = getMaskDefinition(selectedWithMask.mask.type); const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); const displayScale = (scaleX + scaleY) / 2; return def.interaction.getInteraction({ @@ -195,7 +195,7 @@ export function useMaskHandles({ const customMaskPointIds = useMemo( () => - selectedWithMask?.mask.type === "custom" + selectedWithMask?.mask.type === "freeform" ? handlePositions .filter((h) => h.kind === "point") .map((h) => { @@ -205,8 +205,8 @@ export function useMaskHandles({ : [], [handlePositions, selectedWithMask?.mask.type], ); - const isCreatingCustomMask = - selectedWithMask?.mask.type === "custom" && + const isCreatingFreeformPathMask = + selectedWithMask?.mask.type === "freeform" && (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); useEffect(() => { @@ -215,7 +215,7 @@ export function useMaskHandles({ } if ( !selectedWithMask || - selectedWithMask.mask.type !== "custom" || + selectedWithMask.mask.type !== "freeform" || !isMaskSelectionForElement({ trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, @@ -254,7 +254,7 @@ export function useMaskHandles({ selectedWithMask, ]); - const updateCustomMaskPointSelection = useCallback( + const updateFreeformPathMaskPointSelection = useCallback( ({ pointId, toggleSelection, @@ -262,7 +262,7 @@ export function useMaskHandles({ pointId: string; toggleSelection: boolean; }) => { - if (!selectedWithMask || selectedWithMask.mask.type !== "custom") { + if (!selectedWithMask || selectedWithMask.mask.type !== "freeform") { return; } @@ -347,21 +347,21 @@ export function useMaskHandles({ if (event.button !== 0) return; event.stopPropagation(); const anchorHandle = - selectedWithMask.mask.type === "custom" && handleId.kind === "anchor" + selectedWithMask.mask.type === "freeform" && handleId.kind === "anchor" ? handleId : null; const segmentHandle = - selectedWithMask.mask.type === "custom" && handleId.kind === "segment" + selectedWithMask.mask.type === "freeform" && handleId.kind === "segment" ? handleId : null; - if (isCreatingCustomMask) { + if (isCreatingFreeformPathMask) { const firstPointId = customMaskPointIds[0]; if ( firstPointId && handleId.kind === "anchor" && handleId.pointId === firstPointId && customMaskPointIds.length >= 3 && - selectedWithMask.mask.type === "custom" + selectedWithMask.mask.type === "freeform" ) { const updatedMask = withUpdatedMaskParams({ mask: selectedWithMask.mask, @@ -394,7 +394,7 @@ export function useMaskHandles({ }); if (!pos) return; - if (segmentHandle && selectedWithMask.mask.type === "custom") { + if (segmentHandle && selectedWithMask.mask.type === "freeform") { setActiveHandleId(handleId); pendingSegmentInsertRef.current = { trackId: selectedWithMask.trackId, @@ -418,14 +418,14 @@ export function useMaskHandles({ } if (anchorHandle) { - updateCustomMaskPointSelection({ + updateFreeformPathMaskPointSelection({ pointId: anchorHandle.pointId, toggleSelection: event.shiftKey, }); if (event.shiftKey) { return; } - } else if (selectedWithMask.mask.type === "custom") { + } else if (selectedWithMask.mask.type === "freeform") { editor.selection.clearMaskPointSelection(); } @@ -449,22 +449,22 @@ export function useMaskHandles({ customMaskPointIds, editor.selection, editor.timeline, - isCreatingCustomMask, + isCreatingFreeformPathMask, selectedWithMask, - updateCustomMaskPointSelection, + updateFreeformPathMaskPointSelection, viewport, ], ); const handleCanvasPointerDown = useCallback( ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithMask || !isCreatingCustomMask) { + if (!selectedWithMask || !isCreatingFreeformPathMask) { return; } if (event.button !== 0) { return; } - if (selectedWithMask.mask.type !== "custom") { + if (selectedWithMask.mask.type !== "freeform") { return; } @@ -477,7 +477,7 @@ export function useMaskHandles({ return; } - const nextParams = appendPointToCustomMask({ + const nextParams = appendPointToFreeformPathMask({ params: selectedWithMask.mask.params, canvasPoint: pos, bounds: selectedWithMask.bounds, @@ -502,7 +502,7 @@ export function useMaskHandles({ ], }); }, - [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport], + [editor.timeline, isCreatingFreeformPathMask, selectedWithMask, viewport], ); const handlePointerMove = useCallback( @@ -538,7 +538,7 @@ export function useMaskHandles({ const deltaX = pos.x - drag.startCanvasX; const deltaY = pos.y - drag.startCanvasY; - const def = masksRegistry.get(selectedWithMask.mask.type); + const def = getMaskDefinition(selectedWithMask.mask.type); const rawParams = def.computeParamUpdate({ handleId: drag.handleId, @@ -600,7 +600,7 @@ export function useMaskHandles({ const handlePointerUp = useCallback(() => { const pendingSegmentInsert = pendingSegmentInsertRef.current; if (pendingSegmentInsert && !dragStateRef.current) { - editor.timeline.insertCustomMaskPoint({ + editor.timeline.insertFreeformPathMaskPoint({ trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, maskId: pendingSegmentInsert.maskId, @@ -627,7 +627,7 @@ export function useMaskHandles({ selectedWithMask, handlePositions, overlays, - isCreatingCustomMask, + isCreatingFreeformPathMask, handleCanvasPointerDown, activeHandleId, handlePointerDown, diff --git a/apps/web/src/preview/components/mask-handles.tsx b/apps/web/src/preview/components/mask-handles.tsx index e19df45f..99f80007 100644 --- a/apps/web/src/preview/components/mask-handles.tsx +++ b/apps/web/src/preview/components/mask-handles.tsx @@ -30,7 +30,7 @@ export function MaskHandles({ selectedWithMask, handlePositions, overlays, - isCreatingCustomMask, + isCreatingFreeformPathMask, handleCanvasPointerDown, handlePointerDown, handlePointerMove, @@ -94,7 +94,7 @@ export function MaskHandles({ className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden > - {isCreatingCustomMask ? ( + {isCreatingFreeformPathMask ? (
{ + test("renames custom masks to freeform without changing params", () => { + const params = { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + path: [{ id: "p1", x: 0, y: 0, inX: 0, inY: 0, outX: 0, outY: 0 }], + closed: true, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + }; + + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-freeform", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [ + { + id: "image-1", + type: "image", + masks: [{ id: "mask-1", type: "custom", params }], + }, + ], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(31); + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const element = asRecordArray(main.elements)[0]; + const mask = asRecordArray(asRecord(element).masks)[0]; + expect(mask).toMatchObject({ + id: "mask-1", + type: "freeform", + legacyType: "custom", + params, + }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 03b5ed0c..cbc35bb3 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -29,10 +29,11 @@ import { V26toV27Migration } from "./v26-to-v27"; import { V27toV28Migration } from "./v27-to-v28"; import { V28toV29Migration } from "./v28-to-v29"; import { V29toV30Migration } from "./v29-to-v30"; +import { V30toV31Migration } from "./v30-to-v31"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 30; +export const CURRENT_PROJECT_VERSION = 31; export const migrations = [ new V0toV1Migration(), @@ -65,4 +66,5 @@ export const migrations = [ new V27toV28Migration(), new V28toV29Migration(), new V29toV30Migration(), + new V30toV31Migration(), ]; 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 index 4477d363..ccebb980 100644 --- a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts +++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts @@ -1,7 +1,7 @@ import type { MigrationResult, ProjectRecord } from "./types"; import { getProjectId, isRecord } from "./utils"; -interface CustomMaskPathPoint { +interface FreeformPathPoint { id: string; x: number; y: number; @@ -11,7 +11,7 @@ interface CustomMaskPathPoint { outY: number; } -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { +function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { if (!isRecord(value)) { return false; } @@ -27,18 +27,18 @@ function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { ); } -function parseCustomMaskPath({ +function parseFreeformPath({ path, }: { path: string; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (!path) { return []; } try { const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; + return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; } catch { return []; } @@ -150,7 +150,7 @@ function migrateMask({ mask }: { mask: unknown }): unknown { ...mask, params: { ...mask.params, - path: parseCustomMaskPath({ path }), + path: parseFreeformPath({ path }), }, }; } diff --git a/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts new file mode 100644 index 00000000..acdf4061 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts @@ -0,0 +1,99 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV30ToV31({ + 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 >= 31) { + return { project, skipped: true, reason: "already v31" }; + } + if (version !== 30) { + return { project, skipped: true, reason: "not v30" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 31, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...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 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") { + return mask; + } + + return { + ...mask, + type: "freeform", + legacyType: "custom", + }; +} diff --git a/apps/web/src/services/storage/migrations/v30-to-v31.ts b/apps/web/src/services/storage/migrations/v30-to-v31.ts new file mode 100644 index 00000000..35bf0969 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v30-to-v31.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV30ToV31 } from "./transformers/v30-to-v31"; + +export class V30toV31Migration extends StorageMigration { + from = 30; + to = 31; + + async run({ + project, + }: StorageMigrationRunArgs): Promise> { + return transformProjectV30ToV31({ project }); + } +}