From 79dcec8a2ec2f5cad9be95e5b52fbfa752fb6ac4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 18:41:15 +0200 Subject: refactor: split builtin and freeform masks Made-with: Cursor --- apps/web/src/actions/use-editor-actions.ts | 2 +- .../element/masks/delete-custom-mask-points.ts | 27 +- .../src/commands/timeline/element/masks/index.ts | 4 +- .../element/masks/insert-custom-mask-point.ts | 21 +- .../commands/timeline/element/masks/remove-mask.ts | 10 +- .../timeline/element/masks/toggle-mask-inverted.ts | 10 +- apps/web/src/core/managers/timeline-manager.ts | 12 +- apps/web/src/masks/__tests__/snap.test.ts | 60 +- apps/web/src/masks/builtin/box-like.ts | 314 ++++++++ .../masks/builtin/definitions/cinematic-bars.ts | 144 ++++ apps/web/src/masks/builtin/definitions/diamond.ts | 106 +++ apps/web/src/masks/builtin/definitions/ellipse.ts | 81 ++ apps/web/src/masks/builtin/definitions/heart.ts | 148 ++++ apps/web/src/masks/builtin/definitions/index.ts | 72 ++ .../web/src/masks/builtin/definitions/rectangle.ts | 103 +++ apps/web/src/masks/builtin/definitions/split.ts | 397 ++++++++++ apps/web/src/masks/builtin/definitions/star.ts | 146 ++++ apps/web/src/masks/builtin/definitions/text.ts | 445 +++++++++++ apps/web/src/masks/components/masks-tab.tsx | 53 +- apps/web/src/masks/custom-path.ts | 835 --------------------- apps/web/src/masks/definitions/box-like.ts | 314 -------- apps/web/src/masks/definitions/cinematic-bars.ts | 144 ---- apps/web/src/masks/definitions/custom.ts | 656 ---------------- apps/web/src/masks/definitions/diamond.ts | 106 --- apps/web/src/masks/definitions/ellipse.ts | 81 -- apps/web/src/masks/definitions/heart.ts | 148 ---- apps/web/src/masks/definitions/index.ts | 77 -- apps/web/src/masks/definitions/rectangle.ts | 103 --- apps/web/src/masks/definitions/split.ts | 397 ---------- apps/web/src/masks/definitions/star.ts | 146 ---- apps/web/src/masks/definitions/text.ts | 445 ----------- apps/web/src/masks/freeform/definition.ts | 656 ++++++++++++++++ apps/web/src/masks/freeform/path.ts | 835 +++++++++++++++++++++ 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 +- apps/web/src/preview/components/mask-handles.tsx | 4 +- .../renderer/compositor/frame-descriptor.ts | 4 +- .../migrations/__tests__/v30-to-v31.test.ts | 57 ++ apps/web/src/services/storage/migrations/index.ts | 4 +- .../storage/migrations/transformers/v26-to-v27.ts | 12 +- .../storage/migrations/transformers/v30-to-v31.ts | 99 +++ .../src/services/storage/migrations/v30-to-v31.ts | 14 + 44 files changed, 3836 insertions(+), 3601 deletions(-) create mode 100644 apps/web/src/masks/builtin/box-like.ts create mode 100644 apps/web/src/masks/builtin/definitions/cinematic-bars.ts create mode 100644 apps/web/src/masks/builtin/definitions/diamond.ts create mode 100644 apps/web/src/masks/builtin/definitions/ellipse.ts create mode 100644 apps/web/src/masks/builtin/definitions/heart.ts create mode 100644 apps/web/src/masks/builtin/definitions/index.ts create mode 100644 apps/web/src/masks/builtin/definitions/rectangle.ts create mode 100644 apps/web/src/masks/builtin/definitions/split.ts create mode 100644 apps/web/src/masks/builtin/definitions/star.ts create mode 100644 apps/web/src/masks/builtin/definitions/text.ts delete mode 100644 apps/web/src/masks/custom-path.ts delete mode 100644 apps/web/src/masks/definitions/box-like.ts delete mode 100644 apps/web/src/masks/definitions/cinematic-bars.ts delete mode 100644 apps/web/src/masks/definitions/custom.ts delete mode 100644 apps/web/src/masks/definitions/diamond.ts delete mode 100644 apps/web/src/masks/definitions/ellipse.ts delete mode 100644 apps/web/src/masks/definitions/heart.ts delete mode 100644 apps/web/src/masks/definitions/index.ts delete mode 100644 apps/web/src/masks/definitions/rectangle.ts delete mode 100644 apps/web/src/masks/definitions/split.ts delete mode 100644 apps/web/src/masks/definitions/star.ts delete mode 100644 apps/web/src/masks/definitions/text.ts create mode 100644 apps/web/src/masks/freeform/definition.ts create mode 100644 apps/web/src/masks/freeform/path.ts 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/builtin/box-like.ts b/apps/web/src/masks/builtin/box-like.ts new file mode 100644 index 00000000..37706755 --- /dev/null +++ b/apps/web/src/masks/builtin/box-like.ts @@ -0,0 +1,314 @@ +import { + DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, + MIN_MASK_DIMENSION, +} from "@/masks/dimensions"; +import { computeFeatherUpdate } from "../param-update"; +import type { + BaseMaskParams, + MaskDefaultContext, + MaskFeatures, + MaskInteractionDefinition, + MaskParamUpdateArgs, + RectangleMaskParams, +} from "@/masks/types"; +import type { NumberParamDefinition, ParamDefinition } from "@/params"; +import { + getBoxMaskHandlePositions, + getBoxMaskOverlays, +} from "@/masks/handle-positions"; +import { snapBoxMaskInteraction } from "@/masks/snap"; + +const PERCENTAGE_DISPLAY: Pick< + NumberParamDefinition, + "displayMultiplier" | "step" +> = { + displayMultiplier: 100, + step: 1, +}; + +export const BOX_LIKE_MASK_PARAMS: ParamDefinition< + keyof RectangleMaskParams & string +>[] = [ + { + key: "centerX", + label: "X", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "centerY", + label: "Y", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "width", + label: "Width", + type: "number", + default: 0.6, + min: 1, + ...PERCENTAGE_DISPLAY, + }, + { + key: "height", + label: "Height", + type: "number", + default: 0.6, + min: 1, + ...PERCENTAGE_DISPLAY, + }, + { + key: "rotation", + label: "Rotation", + type: "number", + default: 0, + min: 0, + max: 360, + step: 1, + }, + { + key: "scale", + label: "Scale", + type: "number", + default: 1, + min: 1, + max: 500, + ...PERCENTAGE_DISPLAY, + }, + { + key: "strokeAlign", + label: "Stroke Align", + type: "select", + default: "center", + options: [ + { value: "inside", label: "Inside" }, + { value: "center", label: "Center" }, + { value: "outside", label: "Outside" }, + ], + }, +]; + +export function getDefaultBaseMaskParams(): BaseMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + }; +} + +export function getStrokeOffset({ + strokeAlign, + strokeWidth, +}: Pick): number { + if (strokeAlign === "inside") { + return -(strokeWidth / 2); + } + + if (strokeAlign === "outside") { + return strokeWidth / 2; + } + + return 0; +} + +export function getDefaultSquareMaskParams({ + elementSize, +}: MaskDefaultContext): RectangleMaskParams { + const absWidth = Math.abs(elementSize?.width ?? 0); + const absHeight = Math.abs(elementSize?.height ?? 0); + const shortSide = Math.min(absWidth, absHeight); + const squareSide = + shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0; + const width = + absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; + const height = + absHeight > 0 + ? squareSide / absHeight + : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; + + return { + ...getDefaultBaseMaskParams(), + centerX: 0, + centerY: 0, + width, + height, + rotation: 0, + scale: 1, + }; +} + +export function getBoxLikeGeometry({ + params, + width, + height, +}: { + params: RectangleMaskParams; + width: number; + height: number; +}) { + return { + centerX: width / 2 + params.centerX * width, + centerY: height / 2 + params.centerY * height, + maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width, + maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height, + rotationRad: (params.rotation * Math.PI) / 180, + }; +} + +export function buildBoxMaskInteraction({ + sizeMode, + buildOverlayPath, + showBoundingBox = true, +}: { + sizeMode: MaskFeatures["sizeMode"]; + buildOverlayPath?: (args: { width: number; height: number }) => string; + showBoundingBox?: boolean; +}): MaskInteractionDefinition { + return { + getInteraction({ params, bounds, displayScale, scaleX, scaleY }) { + return { + handles: getBoxMaskHandlePositions({ + centerX: params.centerX, + centerY: params.centerY, + width: params.width, + height: params.height, + rotation: params.rotation, + feather: params.feather, + sizeMode, + bounds, + displayScale, + }), + overlays: getBoxMaskOverlays({ + params, + bounds, + pathData: buildOverlayPath?.({ + width: params.width * bounds.width * scaleX, + height: params.height * bounds.height * scaleY, + }), + showBoundingBox, + }), + }; + }, + snap(args) { + return snapBoxMaskInteraction(args); + }, + }; +} + +export function computeBoxMaskParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + bounds, +}: MaskParamUpdateArgs): Partial { + if (handleId.kind === "position") { + return { + centerX: startParams.centerX + deltaX / bounds.width, + centerY: startParams.centerY + deltaY / bounds.height, + }; + } + + if (handleId.kind === "rotation") { + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + const newRotation = (startParams.rotation + currentAngle) % 360; + return { rotation: newRotation < 0 ? newRotation + 360 : newRotation }; + } + + if (handleId.kind === "feather") { + const angleRad = (startParams.rotation * Math.PI) / 180; + return computeFeatherUpdate({ + startFeather: startParams.feather, + deltaX, + deltaY, + directionX: -Math.sin(angleRad), + directionY: Math.cos(angleRad), + }); + } + + const halfWidth = startParams.width * bounds.width; + const halfHeight = startParams.height * bounds.height; + + if ( + handleId.kind === "edge" && + (handleId.side === "right" || handleId.side === "left") + ) { + const sign = handleId.side === "right" ? 1 : -1; + return { + width: Math.max( + MIN_MASK_DIMENSION, + startParams.width + (sign * deltaX * 2) / bounds.width, + ), + }; + } + + if ( + handleId.kind === "edge" && + (handleId.side === "bottom" || handleId.side === "top") + ) { + const sign = handleId.side === "bottom" ? 1 : -1; + return { + height: Math.max( + MIN_MASK_DIMENSION, + startParams.height + (sign * deltaY * 2) / bounds.height, + ), + }; + } + + if (handleId.kind === "corner") { + const signX = handleId.corner.x === "right" ? 1 : -1; + const signY = handleId.corner.y === "bottom" ? 1 : -1; + const distance = Math.sqrt( + (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2, + ); + const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); + const scale = originalDistance > 0 ? distance / originalDistance : 1; + return { + width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale), + height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale), + }; + } + + if (handleId.kind === "scale") { + const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); + const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); + const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1; + return { + scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale), + }; + } + + return {}; +} + +export function rotatePoint({ + x, + y, + centerX, + centerY, + rotationRad, +}: { + x: number; + y: number; + centerX: number; + centerY: number; + rotationRad: number; +}) { + const dx = x - centerX; + const dy = y - centerY; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); + + return { + x: centerX + dx * cos - dy * sin, + y: centerY + dx * sin + dy * cos, + }; +} diff --git a/apps/web/src/masks/builtin/definitions/cinematic-bars.ts b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts new file mode 100644 index 00000000..0e23fd58 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts @@ -0,0 +1,144 @@ +import type { + MaskDefaultContext, + MaskDefinition, + RectangleMaskParams, +} from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getDefaultBaseMaskParams, + getStrokeOffset, + rotatePoint, +} from "../box-like"; + +function getDefaultCinematicBarsMaskParams({ + elementSize, +}: MaskDefaultContext): RectangleMaskParams { + const absWidth = Math.abs(elementSize?.width ?? 0); + const absHeight = Math.abs(elementSize?.height ?? 0); + const diagonal = + absWidth > 0 && absHeight > 0 + ? Math.sqrt(absWidth ** 2 + absHeight ** 2) + : 0; + const fullSpanWidth = + absWidth > 0 ? diagonal / absWidth : Math.SQRT2; + + return { + ...getDefaultBaseMaskParams(), + centerX: 0, + centerY: 0, + width: Math.max(fullSpanWidth, 1), + height: 0.6, + rotation: 0, + scale: 1, + }; +} + +function buildBandPath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const corners = [ + { x: centerX - halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY + halfHeight }, + { x: centerX - halfWidth, y: centerY + halfHeight }, + ].map((point) => + rotatePoint({ + ...point, + centerX, + centerY, + rotationRad, + }), + ); + + const path = new Path2D(); + path.moveTo(corners[0].x, corners[0].y); + for (const corner of corners.slice(1)) { + path.lineTo(corner.x, corner.y); + } + path.closePath(); + return path; +} + +export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = { + type: "cinematic-bars", + name: "Cinematic Bars", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "height-only", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "height-only", + buildOverlayPath({ width, height }) { + return `M 0,0 H ${width} V ${height} H 0 Z`; + }, + }), + buildDefault(context) { + return { + type: "cinematic-bars", + params: getDefaultCinematicBarsMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const maskWidth = Math.max(params.width * width, width); + const maskHeight = Math.max(params.height, 0.01) * height; + const rotationRad = (params.rotation * Math.PI) / 180; + + return buildBandPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const rotationRad = (params.rotation * Math.PI) / 180; + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + + return buildBandPath({ + centerX, + centerY, + halfWidth: Math.max( + Math.max(params.width * width, width) / 2 + offset, + 1, + ), + halfHeight: Math.max( + (Math.max(params.height, 0.01) * height) / 2 + offset, + 1, + ), + rotationRad, + }); + }, + }, + }, +}; diff --git a/apps/web/src/masks/builtin/definitions/diamond.ts b/apps/web/src/masks/builtin/definitions/diamond.ts new file mode 100644 index 00000000..1effb276 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/diamond.ts @@ -0,0 +1,106 @@ +import type { MaskDefinition } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "../box-like"; + +function buildDiamondPath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const points = [ + { x: centerX, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY }, + { x: centerX, y: centerY + halfHeight }, + { x: centerX - halfWidth, y: centerY }, + ].map((point) => + rotatePoint({ + ...point, + centerX, + centerY, + rotationRad, + }), + ); + + const path = new Path2D(); + path.moveTo(points[0].x, points[0].y); + for (const point of points.slice(1)) { + path.lineTo(point.x, point.y); + } + path.closePath(); + return path; +} + +export const diamondMaskDefinition: MaskDefinition<"diamond"> = { + type: "diamond", + name: "Diamond", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`; + }, + }), + buildDefault(context) { + return { + type: "diamond", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, + }, + }, +}; diff --git a/apps/web/src/masks/builtin/definitions/ellipse.ts b/apps/web/src/masks/builtin/definitions/ellipse.ts new file mode 100644 index 00000000..688a2fa6 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/ellipse.ts @@ -0,0 +1,81 @@ +import type { MaskDefinition } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, +} from "../box-like"; + +export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { + type: "ellipse", + name: "Ellipse", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + const rx = Math.max((width - 1) / 2, 0); + const ry = Math.max((height - 1) / 2, 0); + const cx = width / 2; + const cy = height / 2; + return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; + }, + }), + buildDefault(context) { + return { + type: "ellipse", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + maskWidth / 2, + maskHeight / 2, + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + Math.max(1, maskWidth / 2 + offset), + Math.max(1, maskHeight / 2 + offset), + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + }, + }, +}; diff --git a/apps/web/src/masks/builtin/definitions/heart.ts b/apps/web/src/masks/builtin/definitions/heart.ts new file mode 100644 index 00000000..d1af5eb2 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/heart.ts @@ -0,0 +1,148 @@ +import type { MaskDefinition } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "../box-like"; + +function buildHeartPath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const toPoint = ({ + localX, + localY, + }: { + localX: number; + localY: number; + }) => + rotatePoint({ + x: centerX + localX, + y: centerY + localY, + centerX, + centerY, + rotationRad, + }); + + const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 }); + const rightControl1 = toPoint({ + localX: halfWidth, + localY: -halfHeight * 1.225, + }); + const rightControl2 = toPoint({ + localX: halfWidth, + localY: -halfHeight * 0.125, + }); + const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 }); + const leftControl1 = toPoint({ + localX: -halfWidth, + localY: -halfHeight * 0.125, + }); + const leftControl2 = toPoint({ + localX: -halfWidth, + localY: -halfHeight * 1.225, + }); + + const path = new Path2D(); + path.moveTo(start.x, start.y); + path.bezierCurveTo( + rightControl1.x, + rightControl1.y, + rightControl2.x, + rightControl2.y, + bottom.x, + bottom.y, + ); + path.bezierCurveTo( + leftControl1.x, + leftControl1.y, + leftControl2.x, + leftControl2.y, + start.x, + start.y, + ); + path.closePath(); + return path; +} + +export const heartMaskDefinition: MaskDefinition<"heart"> = { + type: "heart", + name: "Heart", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + const cx = width / 2; + const cy = height / 2; + const halfWidth = width / 2; + const halfHeight = height / 2; + return [ + `M ${cx},${cy - halfHeight * 0.475}`, + `C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`, + `C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`, + "Z", + ].join(" "); + }, + }), + buildDefault(context) { + return { + type: "heart", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, + }, + }, +}; diff --git a/apps/web/src/masks/builtin/definitions/index.ts b/apps/web/src/masks/builtin/definitions/index.ts new file mode 100644 index 00000000..9f722876 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/index.ts @@ -0,0 +1,72 @@ +import { + builtinMasksRegistry, + type BuiltinMaskDefinitionForRegistration, + type MaskIconProps, +} from "../../registry"; +import { cinematicBarsMaskDefinition } from "./cinematic-bars"; +import { diamondMaskDefinition } from "./diamond"; +import { ellipseMaskDefinition } from "./ellipse"; +import { heartMaskDefinition } from "./heart"; +import { rectangleMaskDefinition } from "./rectangle"; +import { splitMaskDefinition } from "./split"; +import { starMaskDefinition } from "./star"; +import { textMaskDefinition } from "./text"; +import { + MinusSignIcon, + PanelRightDashedIcon, + SquareIcon, + CircleIcon, + FavouriteIcon, + DiamondIcon, + StarsIcon, + TextFontIcon, +} from "@hugeicons/core-free-icons"; + +function registerDefaultMask({ + definition, + icon, +}: { + definition: BuiltinMaskDefinitionForRegistration; + icon: MaskIconProps; +}) { + if (builtinMasksRegistry.has(definition.type)) { + return; + } + + builtinMasksRegistry.registerMask({ definition, icon }); +} + +export function registerBuiltinMasks(): void { + registerDefaultMask({ + definition: splitMaskDefinition, + icon: { icon: PanelRightDashedIcon, strokeWidth: 1 }, + }); + registerDefaultMask({ + definition: cinematicBarsMaskDefinition, + icon: { icon: MinusSignIcon }, + }); + registerDefaultMask({ + definition: rectangleMaskDefinition, + icon: { icon: SquareIcon }, + }); + registerDefaultMask({ + definition: ellipseMaskDefinition, + icon: { icon: CircleIcon }, + }); + registerDefaultMask({ + definition: heartMaskDefinition, + icon: { icon: FavouriteIcon }, + }); + registerDefaultMask({ + definition: diamondMaskDefinition, + icon: { icon: DiamondIcon }, + }); + registerDefaultMask({ + definition: starMaskDefinition, + icon: { icon: StarsIcon }, + }); + registerDefaultMask({ + definition: textMaskDefinition, + icon: { icon: TextFontIcon }, + }); +} diff --git a/apps/web/src/masks/builtin/definitions/rectangle.ts b/apps/web/src/masks/builtin/definitions/rectangle.ts new file mode 100644 index 00000000..4dd38154 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/rectangle.ts @@ -0,0 +1,103 @@ +import type { MaskDefinition } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "../box-like"; + +function buildRectanglePath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const corners = [ + { x: centerX - halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY + halfHeight }, + { x: centerX - halfWidth, y: centerY + halfHeight }, + ].map((point) => + rotatePoint({ + ...point, + centerX, + centerY, + rotationRad, + }), + ); + + const path = new Path2D(); + path.moveTo(corners[0].x, corners[0].y); + for (const corner of corners.slice(1)) { + path.lineTo(corner.x, corner.y); + } + path.closePath(); + return path; +} + +export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = { + type: "rectangle", + name: "Rectangle", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + }), + buildDefault(context) { + return { + type: "rectangle", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: Math.max(1, maskWidth / 2 + offset), + halfHeight: Math.max(1, maskHeight / 2 + offset), + rotationRad, + }); + }, + }, + }, +}; diff --git a/apps/web/src/masks/builtin/definitions/split.ts b/apps/web/src/masks/builtin/definitions/split.ts new file mode 100644 index 00000000..13dd1c09 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/split.ts @@ -0,0 +1,397 @@ +import { computeFeatherUpdate } from "@/masks/param-update"; +import type { + MaskDefinition, + MaskParamUpdateArgs, + SplitMaskParams, +} from "@/masks/types"; +import { halfPlaneSign, lineEdgeIntersection } from "@/masks/utils"; +import { + getLineMaskHandlePositions, + getLineMaskOverlay, +} from "@/masks/handle-positions"; +import { snapSplitMaskInteraction } from "@/masks/snap"; + +// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped +// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie +// exactly on the split line, which produces spurious midpoint vertices. +const NORMAL_SNAP_EPSILON = 1e-10; + +// Guards against collinear vertices from float noise at canvas edges. +const MIN_POLYGON_AREA_PX = 0.5; +const INTERSECTION_EPSILON = 1e-6; + +function polygonArea({ vertices }: { vertices: [number, number][] }): number { + let area = 0; + for (let i = 0; i < vertices.length; i++) { + const [x1, y1] = vertices[i]; + const [x2, y2] = vertices[(i + 1) % vertices.length]; + area += x1 * y2 - x2 * y1; + } + return Math.abs(area) * 0.5; +} + +function splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, +}: { + centerX: number; + centerY: number; + rotation: number; + width: number; + height: number; +}): { normalX: number; normalY: number; lineX: number; lineY: number } { + const angleRad = (rotation * Math.PI) / 180; + const normalX = + Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad); + const normalY = + Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad); + const lineX = width / 2 + centerX * width; + const lineY = height / 2 + centerY * height; + return { normalX, normalY, lineX, lineY }; +} + +function pointsEqual({ + a, + b, +}: { + a: { x: number; y: number }; + b: { x: number; y: number }; +}): boolean { + return ( + Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && + Math.abs(a.y - b.y) <= INTERSECTION_EPSILON + ); +} + +export function getSplitMaskStrokeSegment({ + resolvedParams, + width, + height, +}: { + resolvedParams: SplitMaskParams; + width: number; + height: number; +}): [{ x: number; y: number }, { x: number; y: number }] | null { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); + + const edges: [number, number, number, number][] = [ + [0, 0, width, 0], + [width, 0, width, height], + [width, height, 0, height], + [0, height, 0, 0], + ]; + const intersections: { x: number; y: number }[] = []; + + for (const [x1, y1, x2, y2] of edges) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + + if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) { + continue; + } + + intersections.push(hit); + } + + if (intersections.length !== 2) { + return null; + } + + return [intersections[0], intersections[1]]; +} + +function computeSplitMaskParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + startCanvasX, + startCanvasY, + bounds, + canvasSize, +}: MaskParamUpdateArgs): Partial { + if (handleId.kind === "position") { + const rawX = startParams.centerX + deltaX / bounds.width; + const rawY = startParams.centerY + deltaY / bounds.height; + + const minX = -bounds.cx / bounds.width; + const maxX = (canvasSize.width - bounds.cx) / bounds.width; + const minY = -bounds.cy / bounds.height; + const maxY = (canvasSize.height - bounds.cy) / bounds.height; + + return { + centerX: Math.max(minX, Math.min(maxX, rawX)), + centerY: Math.max(minY, Math.min(maxY, rawY)), + }; + } + + if (handleId.kind === "feather") { + const angleRad = (startParams.rotation * Math.PI) / 180; + return computeFeatherUpdate({ + startFeather: startParams.feather, + deltaX, + deltaY, + directionX: -Math.cos(angleRad), + directionY: -Math.sin(angleRad), + }); + } + + if (handleId.kind === "rotation") { + const pivotX = bounds.cx + startParams.centerX * bounds.width; + const pivotY = bounds.cy + startParams.centerY * bounds.height; + const startAngle = + (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / + Math.PI; + const currentAngle = + (Math.atan2( + startCanvasY + deltaY - pivotY, + startCanvasX + deltaX - pivotX, + ) * + 180) / + Math.PI; + + let deltaAngle = currentAngle - startAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + return { + rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, + }; + } + + return {}; +} + +export const splitMaskDefinition: MaskDefinition<"split"> = { + type: "split", + name: "Split", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "none", + }, + interaction: { + getInteraction({ + params, + bounds, + displayScale, + scaleX: _scaleX, + scaleY: _scaleY, + }) { + return { + handles: getLineMaskHandlePositions({ + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + feather: params.feather, + bounds, + displayScale, + }), + overlays: [ + getLineMaskOverlay({ + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + bounds, + }), + ], + }; + }, + snap(args) { + return snapSplitMaskInteraction(args); + }, + }, + buildDefault() { + return { + type: "split", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + }, + }; + }, + computeParamUpdate: computeSplitMaskParamUpdate, + params: [ + { + key: "centerX", + label: "X", + type: "number", + default: 0, + min: -100, + max: 100, + step: 1, + displayMultiplier: 100, + }, + { + key: "centerY", + label: "Y", + type: "number", + default: 0, + min: -100, + max: 100, + step: 1, + displayMultiplier: 100, + }, + { + key: "rotation", + label: "Rotation", + type: "number", + default: 0, + min: 0, + max: 360, + step: 1, + }, + ], + renderer: { + body: { + kind: "drawWithFeather", + drawWithFeather({ resolvedParams, ctx, width, height, feather }) { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); + + // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. + const featherHalf = feather / 2; + const gradient = ctx.createLinearGradient( + lineX - normalX * featherHalf, + lineY - normalY * featherHalf, + lineX + normalX * featherHalf, + lineY + normalY * featherHalf, + ); + gradient.addColorStop(0, "rgba(255,255,255,0)"); + gradient.addColorStop(1, "white"); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + }, + + opaqueFastPath: { + buildPath({ resolvedParams, width, height }) { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); + + const edges: [number, number, number, number][] = [ + [0, 0, width, 0], + [width, 0, width, height], + [width, height, 0, height], + [0, height, 0, 0], + ]; + + const isInsideHalfPlane = ({ + x, + y, + }: { + x: number; + y: number; + }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; + + const vertices: [number, number][] = []; + for (const [x1, y1, x2, y2] of edges) { + const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 }); + const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 }); + + if (isVertex1Inside && isVertex2Inside) { + vertices.push([x2, y2]); + } else if (isVertex1Inside && !isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) vertices.push([hit.x, hit.y]); + } else if (!isVertex1Inside && isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) { + vertices.push([hit.x, hit.y]); + vertices.push([x2, y2]); + } + } + } + + if ( + vertices.length < 3 || + polygonArea({ vertices }) < MIN_POLYGON_AREA_PX + ) { + return new Path2D(); + } + + const path = new Path2D(); + path.moveTo(vertices[0][0], vertices[0][1]); + for (let i = 1; i < vertices.length; i++) { + path.lineTo(vertices[i][0], vertices[i][1]); + } + path.closePath(); + return path; + }, + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const segment = getSplitMaskStrokeSegment({ + resolvedParams, + width, + height, + }); + const path = new Path2D(); + + if (!segment) { + return path; + } + + path.moveTo(segment[0].x, segment[0].y); + path.lineTo(segment[1].x, segment[1].y); + return path; + }, + }, + }, +}; diff --git a/apps/web/src/masks/builtin/definitions/star.ts b/apps/web/src/masks/builtin/definitions/star.ts new file mode 100644 index 00000000..62e9cbc4 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/star.ts @@ -0,0 +1,146 @@ +import type { MaskDefinition } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "../box-like"; + +const STAR_INNER_RADIUS_RATIO = 0.45; +const STAR_VERTEX_COUNT = 10; + +function buildStarPath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const path = new Path2D(); + + for (let index = 0; index < STAR_VERTEX_COUNT; index++) { + const isOuterVertex = index % 2 === 0; + const radiusX = isOuterVertex + ? halfWidth + : halfWidth * STAR_INNER_RADIUS_RATIO; + const radiusY = isOuterVertex + ? halfHeight + : halfHeight * STAR_INNER_RADIUS_RATIO; + const angle = (index * Math.PI) / 5 - Math.PI / 2; + const point = rotatePoint({ + x: centerX + radiusX * Math.cos(angle), + y: centerY + radiusY * Math.sin(angle), + centerX, + centerY, + rotationRad, + }); + + if (index === 0) { + path.moveTo(point.x, point.y); + } else { + path.lineTo(point.x, point.y); + } + } + + path.closePath(); + return path; +} + +function buildOverlayStarPath({ + width, + height, +}: { + width: number; + height: number; +}): string { + const centerX = width / 2; + const centerY = height / 2; + const halfWidth = width / 2; + const halfHeight = height / 2; + const segments: string[] = []; + + for (let index = 0; index < STAR_VERTEX_COUNT; index++) { + const isOuterVertex = index % 2 === 0; + const radiusX = isOuterVertex + ? halfWidth + : halfWidth * STAR_INNER_RADIUS_RATIO; + const radiusY = isOuterVertex + ? halfHeight + : halfHeight * STAR_INNER_RADIUS_RATIO; + const angle = (index * Math.PI) / 5 - Math.PI / 2; + const x = centerX + radiusX * Math.cos(angle); + const y = centerY + radiusY * Math.sin(angle); + segments.push(`${index === 0 ? "M" : "L"} ${x},${y}`); + } + + return `${segments.join(" ")} Z`; +} + +export const starMaskDefinition: MaskDefinition<"star"> = { + type: "star", + name: "Star", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + return buildOverlayStarPath({ width, height }); + }, + }), + buildDefault(context) { + return { + type: "star", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildStarPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildStarPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, + }, + }, +}; diff --git a/apps/web/src/masks/builtin/definitions/text.ts b/apps/web/src/masks/builtin/definitions/text.ts new file mode 100644 index 00000000..db2af7e0 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/text.ts @@ -0,0 +1,445 @@ +import type { ParamDefinition } from "@/params"; +import type { + MaskDefinition, + MaskParamUpdateArgs, + TextMask, + TextMaskParams, + MaskHandleId, +} from "@/masks/types"; +import { DEFAULTS } from "@/timeline/defaults"; +import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography"; +import { + drawMeasuredTextLayout, + measureTextLayout, + strokeMeasuredTextLayout, +} from "@/text/primitives"; +import { getTextMeasurementContext } from "@/text/measure-element"; +import { getTextVisualRect } from "@/text/layout"; +import { + getBoxMaskHandlePositions, + getBoxMaskRectOverlay, +} from "@/masks/handle-positions"; +import { computeFeatherUpdate } from "@/masks/param-update"; +import { + setMaskLocalCenter, + toGlobalMaskSnapLines, +} from "@/masks/geometry"; +import { + snapPosition, + snapRotation, + snapScale, + type ScaleEdgePreference, +} from "@/preview/preview-snap"; + +const PERCENTAGE_DISPLAY = { + displayMultiplier: 100, + step: 1, +} as const; + +const TEXT_MASK_ALIGNMENT = "center"; + +const TEXT_MASK_PARAMS: ParamDefinition[] = [ + { + key: "centerX", + label: "X", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "centerY", + label: "Y", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "fontSize", + label: "Size", + type: "number", + default: 15, + min: MIN_FONT_SIZE, + max: MAX_FONT_SIZE, + step: 1, + }, + { + key: "rotation", + label: "Rotation", + type: "number", + default: 0, + min: 0, + max: 360, + step: 1, + }, + { + key: "scale", + label: "Scale", + type: "number", + default: 1, + min: 1, + max: 500, + ...PERCENTAGE_DISPLAY, + }, +]; + +function measureTextMask({ + params, + height, +}: { + params: TextMaskParams; + height: number; +}) { + const layout = measureTextLayout({ + text: { + content: params.content, + fontSize: params.fontSize, + fontFamily: params.fontFamily, + fontWeight: params.fontWeight, + fontStyle: params.fontStyle, + textAlign: TEXT_MASK_ALIGNMENT, + textDecoration: params.textDecoration, + letterSpacing: params.letterSpacing, + lineHeight: params.lineHeight, + }, + canvasHeight: height, + ctx: getTextMeasurementContext(), + }); + const visualRect = getTextVisualRect({ + textAlign: layout.textAlign, + block: layout.block, + background: { enabled: false, color: "transparent" }, + fontSizeRatio: layout.fontSizeRatio, + }); + + return { + layout, + intrinsicWidth: Math.max(1, visualRect.width), + intrinsicHeight: Math.max(1, visualRect.height), + }; +} + +function getScalePreferredEdges({ + handleId, +}: { + handleId: MaskHandleId; +}): ScaleEdgePreference | undefined { + if (handleId.kind !== "scale") { + return undefined; + } + + return { + right: true, + bottom: true, + }; +} + +function computeTextMaskParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + startCanvasX, + startCanvasY, + bounds, +}: MaskParamUpdateArgs): Partial { + if (handleId.kind === "position") { + return { + centerX: startParams.centerX + deltaX / bounds.width, + centerY: startParams.centerY + deltaY / bounds.height, + }; + } + + const pivotX = bounds.cx + startParams.centerX * bounds.width; + const pivotY = bounds.cy + startParams.centerY * bounds.height; + + if (handleId.kind === "rotation") { + const startAngle = + (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / + Math.PI; + const currentAngle = + (Math.atan2( + startCanvasY + deltaY - pivotY, + startCanvasX + deltaX - pivotX, + ) * + 180) / + Math.PI; + + let deltaAngle = currentAngle - startAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + return { + rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, + }; + } + + if (handleId.kind === "feather") { + const angleRad = (startParams.rotation * Math.PI) / 180; + return computeFeatherUpdate({ + startFeather: startParams.feather, + deltaX, + deltaY, + directionX: -Math.sin(angleRad), + directionY: Math.cos(angleRad), + }); + } + + if (handleId.kind === "scale") { + const startDistance = Math.hypot( + startCanvasX - pivotX, + startCanvasY - pivotY, + ); + const currentDistance = Math.hypot( + startCanvasX + deltaX - pivotX, + startCanvasY + deltaY - pivotY, + ); + const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1; + return { + scale: Math.max(0.01, startParams.scale * scaleFactor), + }; + } + + return {}; +} + +export const textMaskDefinition: MaskDefinition<"text"> = { + type: "text", + name: "Text", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "uniform", + }, + params: TEXT_MASK_PARAMS, + interaction: { + getInteraction({ + params, + bounds, + displayScale, + scaleX: _scaleX, + scaleY: _scaleY, + }) { + const { intrinsicWidth, intrinsicHeight } = measureTextMask({ + params, + height: bounds.height, + }); + const width = (intrinsicWidth * params.scale) / bounds.width; + const height = (intrinsicHeight * params.scale) / bounds.height; + return { + handles: getBoxMaskHandlePositions({ + centerX: params.centerX, + centerY: params.centerY, + width, + height, + rotation: params.rotation, + feather: params.feather, + sizeMode: "uniform", + bounds, + displayScale, + }), + overlays: [ + getBoxMaskRectOverlay({ + centerX: params.centerX, + centerY: params.centerY, + width, + height, + rotation: params.rotation, + bounds, + }), + ], + }; + }, + snap({ + handleId, + startParams, + proposedParams, + bounds, + canvasSize, + snapThreshold, + }) { + const { intrinsicWidth, intrinsicHeight } = measureTextMask({ + params: startParams, + height: bounds.height, + }); + const position = { + x: proposedParams.centerX * bounds.width, + y: proposedParams.centerY * bounds.height, + }; + + if (handleId.kind === "position") { + const { snappedPosition, activeLines } = snapPosition({ + proposedPosition: position, + canvasSize: bounds, + elementSize: { + width: intrinsicWidth * proposedParams.scale, + height: intrinsicHeight * proposedParams.scale, + }, + rotation: proposedParams.rotation, + snapThreshold, + }); + + return { + params: { + ...proposedParams, + ...setMaskLocalCenter({ + center: snappedPosition, + bounds, + }), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + if (handleId.kind === "rotation") { + const { snappedRotation } = snapRotation({ + proposedRotation: proposedParams.rotation, + }); + return { + params: { + ...proposedParams, + rotation: snappedRotation, + }, + activeLines: [], + }; + } + + if (handleId.kind === "scale") { + const { snappedScale, activeLines } = snapScale({ + proposedScale: proposedParams.scale, + position, + baseWidth: intrinsicWidth, + baseHeight: intrinsicHeight, + rotation: proposedParams.rotation, + canvasSize: bounds, + snapThreshold, + preferredEdges: getScalePreferredEdges({ handleId }), + }); + + return { + params: { + ...proposedParams, + scale: Math.max(0.01, snappedScale), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + return { + params: proposedParams, + activeLines: [], + }; + }, + }, + buildDefault(): Omit { + return { + type: "text", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + content: "Mask", + fontSize: 15, + fontFamily: "Arial", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: DEFAULTS.text.letterSpacing, + lineHeight: DEFAULTS.text.lineHeight, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + }, + }; + }, + computeParamUpdate: computeTextMaskParamUpdate, + isActive(params) { + return params.content.trim().length > 0; + }, + renderer: { + body: { + kind: "drawOpaque", + drawOpaque({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); + + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + ctx.restore(); + }, + }, + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); + + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } + + strokeMeasuredTextLayout({ + ctx, + layout, + strokeColor: params.strokeColor, + strokeWidth: params.strokeWidth, + }); + + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + ctx.restore(); + }, + }, + }, +}; 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/custom-path.ts b/apps/web/src/masks/custom-path.ts deleted file mode 100644 index 363bb3bd..00000000 --- a/apps/web/src/masks/custom-path.ts +++ /dev/null @@ -1,835 +0,0 @@ -import type { ElementBounds } from "@/preview/element-bounds"; - -export interface CustomMaskPathPoint { - id: string; - x: number; - y: number; - inX: number; - inY: number; - outX: number; - outY: number; -} - -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { - if (!value || typeof value !== "object") { - return false; - } - - return ( - "id" in value && - typeof value.id === "string" && - "x" in value && - typeof value.x === "number" && - "y" in value && - typeof value.y === "number" && - "inX" in value && - typeof value.inX === "number" && - "inY" in value && - typeof value.inY === "number" && - "outX" in value && - typeof value.outX === "number" && - "outY" in value && - typeof value.outY === "number" - ); -} - -export function parseCustomMaskPath({ - path, -}: { - path: string; -}): CustomMaskPathPoint[] { - if (!path) { - return []; - } - - try { - const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; - } catch { - return []; - } -} - -export function serializeCustomMaskPath({ - points, -}: { - points: CustomMaskPathPoint[]; -}): string { - return JSON.stringify(points); -} - -export function removeCustomMaskPoints({ - points, - pointIds, -}: { - points: CustomMaskPathPoint[]; - pointIds: string[]; -}): CustomMaskPathPoint[] { - if (pointIds.length === 0) { - return points; - } - const pointIdsToRemove = new Set(pointIds); - return points.filter((point) => !pointIdsToRemove.has(point.id)); -} - -export function getCustomMaskClosedStateAfterPointRemoval({ - wasClosed, - remainingPointCount, -}: { - wasClosed: boolean; - remainingPointCount: number; -}): boolean { - return wasClosed && remainingPointCount >= 3; -} - -function rotatePoint({ - x, - y, - rotationDegrees, -}: { - x: number; - y: number; - rotationDegrees: number; -}): { x: number; y: number } { - const angleRad = (rotationDegrees * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - return { - x: x * cos - y * sin, - y: x * sin + y * cos, - }; -} - -export function getCustomMaskCenterCanvasPoint({ - centerX, - centerY, - bounds, -}: { - centerX: number; - centerY: number; - bounds: ElementBounds; -}): { x: number; y: number } { - return { - x: bounds.cx + centerX * bounds.width, - y: bounds.cy + centerY * bounds.height, - }; -} - -export function customMaskLocalPointToCanvas({ - point, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - point: { x: number; y: number }; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); - const scaledLocal = { - x: point.x * bounds.width * scale, - y: point.y * bounds.height * scale, - }; - const rotated = rotatePoint({ - x: scaledLocal.x, - y: scaledLocal.y, - rotationDegrees: rotation, - }); - - return { - x: center.x + rotated.x, - y: center.y + rotated.y, - }; -} - -export function customMaskCanvasPointToLocal({ - point, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - point: { x: number; y: number }; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); - const translated = { - x: point.x - center.x, - y: point.y - center.y, - }; - const rotated = rotatePoint({ - x: translated.x, - y: translated.y, - rotationDegrees: -rotation, - }); - - return { - x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale), - y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale), - }; -} - -export function getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}) { - const anchors = points.map((point) => ({ - id: point.id, - anchor: customMaskLocalPointToCanvas({ - point: { x: point.x, y: point.y }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - inHandle: customMaskLocalPointToCanvas({ - point: { x: point.x + point.inX, y: point.y + point.inY }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - outHandle: customMaskLocalPointToCanvas({ - point: { x: point.x + point.outX, y: point.y + point.outY }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - })); - - const geometryPoints = anchors.flatMap((point) => [ - point.anchor, - point.inHandle, - point.outHandle, - ]); - if (geometryPoints.length === 0) { - return { - anchors, - bounds: null, - }; - } - - const geometryBounds = getCanvasPointBounds({ - points: geometryPoints, - }); - - return { - anchors, - bounds: geometryBounds, - }; -} - -export interface CanvasPoint { - x: number; - y: number; -} - -function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { - minX: number; - maxX: number; - minY: number; - maxY: number; - width: number; - height: number; - centerX: number; - centerY: number; -} | null { - if (points.length === 0) { - return null; - } - - const [firstPoint, ...restPoints] = points; - let minX = firstPoint.x; - let maxX = firstPoint.x; - let minY = firstPoint.y; - let maxY = firstPoint.y; - - for (const point of restPoints) { - minX = Math.min(minX, point.x); - maxX = Math.max(maxX, point.x); - minY = Math.min(minY, point.y); - maxY = Math.max(maxY, point.y); - } - - return { - minX, - maxX, - minY, - maxY, - width: Math.max(1, maxX - minX), - height: Math.max(1, maxY - minY), - centerX: (minX + maxX) / 2, - centerY: (minY + maxY) / 2, - }; -} - -export interface CustomMaskCanvasSegment { - index: number; - startPointId: string; - endPointId: string; - start: CanvasPoint; - startOut: CanvasPoint; - endIn: CanvasPoint; - end: CanvasPoint; - pathData: string; -} - -function clampUnit(value: number): number { - return Math.min(1, Math.max(0, value)); -} - -function getDistanceSquared({ - a, - b, -}: { - a: CanvasPoint; - b: CanvasPoint; -}): number { - const dx = a.x - b.x; - const dy = a.y - b.y; - return dx * dx + dy * dy; -} - -function lerpPoint({ - a, - b, - t, -}: { - a: CanvasPoint; - b: CanvasPoint; - t: number; -}): CanvasPoint { - return { - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - }; -} - -function evaluateCubicBezier({ - p0, - p1, - p2, - p3, - t, -}: { - p0: CanvasPoint; - p1: CanvasPoint; - p2: CanvasPoint; - p3: CanvasPoint; - t: number; -}): CanvasPoint { - const oneMinusT = 1 - t; - return { - x: - oneMinusT ** 3 * p0.x + - 3 * oneMinusT ** 2 * t * p1.x + - 3 * oneMinusT * t ** 2 * p2.x + - t ** 3 * p3.x, - y: - oneMinusT ** 3 * p0.y + - 3 * oneMinusT ** 2 * t * p1.y + - 3 * oneMinusT * t ** 2 * p2.y + - t ** 3 * p3.y, - }; -} - -function getCustomMaskSegmentIndices({ - points, - segmentIndex, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - closed: boolean; -}): { startIndex: number; endIndex: number } | null { - const segmentCount = getCustomMaskSegmentCount({ points, closed }); - if (segmentIndex < 0 || segmentIndex >= segmentCount) { - return null; - } - - return { - startIndex: segmentIndex, - endIndex: (segmentIndex + 1) % points.length, - }; -} - -export function getCustomMaskSegmentCount({ - points, - closed, -}: { - points: CustomMaskPathPoint[]; - closed: boolean; -}): number { - if (points.length < 2) { - return 0; - } - return closed ? points.length : points.length - 1; -} - -export function getCustomMaskCanvasSegments({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): CustomMaskCanvasSegment[] { - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - const segmentCount = getCustomMaskSegmentCount({ points, closed }); - - return Array.from({ length: segmentCount }, (_, segmentIndex) => { - const start = geometry.anchors[segmentIndex]; - const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length]; - return { - index: segmentIndex, - startPointId: start.id, - endPointId: end.id, - start: start.anchor, - startOut: start.outHandle, - endIn: end.inHandle, - end: end.anchor, - pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`, - }; - }); -} - -export function findClosestPointOnCustomMaskSegment({ - points, - segmentIndex, - canvasPoint, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - canvasPoint: CanvasPoint; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): { t: number; point: CanvasPoint } | null { - const segment = getCustomMaskCanvasSegments({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, - }).find((candidate) => candidate.index === segmentIndex); - if (!segment) { - return null; - } - - const sampleCount = 24; - let bestT = 0; - let bestDistanceSquared = getDistanceSquared({ - a: canvasPoint, - b: segment.start, - }); - - for (let step = 0; step <= sampleCount; step++) { - const t = step / sampleCount; - const point = evaluateCubicBezier({ - p0: segment.start, - p1: segment.startOut, - p2: segment.endIn, - p3: segment.end, - t, - }); - const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point }); - if (distanceSquared < bestDistanceSquared) { - bestDistanceSquared = distanceSquared; - bestT = t; - } - } - - let searchStep = 1 / sampleCount; - for (let iteration = 0; iteration < 8; iteration++) { - const candidates = [bestT - searchStep, bestT, bestT + searchStep] - .map(clampUnit) - .map((t) => ({ - t, - point: evaluateCubicBezier({ - p0: segment.start, - p1: segment.startOut, - p2: segment.endIn, - p3: segment.end, - t, - }), - })); - for (const candidate of candidates) { - const distanceSquared = getDistanceSquared({ - a: canvasPoint, - b: candidate.point, - }); - if (distanceSquared < bestDistanceSquared) { - bestDistanceSquared = distanceSquared; - bestT = candidate.t; - } - } - searchStep /= 2; - } - - const clampedT = Math.min(0.999, Math.max(0.001, bestT)); - return { - t: clampedT, - point: evaluateCubicBezier({ - p0: segment.start, - p1: segment.startOut, - p2: segment.endIn, - p3: segment.end, - t: clampedT, - }), - }; -} - -export function insertPointIntoCustomMaskSegment({ - points, - segmentIndex, - pointId, - t, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - pointId: string; - t: number; - closed: boolean; -}): CustomMaskPathPoint[] { - const indices = getCustomMaskSegmentIndices({ - points, - segmentIndex, - closed, - }); - if (!indices) { - return points; - } - - const startPoint = points[indices.startIndex]; - const endPoint = points[indices.endIndex]; - const clampedT = Math.min(0.999, Math.max(0.001, t)); - const p0 = { x: startPoint.x, y: startPoint.y }; - const p1 = { - x: startPoint.x + startPoint.outX, - y: startPoint.y + startPoint.outY, - }; - const p2 = { - x: endPoint.x + endPoint.inX, - y: endPoint.y + endPoint.inY, - }; - const p3 = { x: endPoint.x, y: endPoint.y }; - const p01 = lerpPoint({ a: p0, b: p1, t: clampedT }); - const p12 = lerpPoint({ a: p1, b: p2, t: clampedT }); - const p23 = lerpPoint({ a: p2, b: p3, t: clampedT }); - const p012 = lerpPoint({ a: p01, b: p12, t: clampedT }); - const p123 = lerpPoint({ a: p12, b: p23, t: clampedT }); - const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT }); - - const nextPoints = [...points]; - nextPoints[indices.startIndex] = { - ...startPoint, - outX: p01.x - startPoint.x, - outY: p01.y - startPoint.y, - }; - nextPoints[indices.endIndex] = { - ...endPoint, - inX: p23.x - endPoint.x, - inY: p23.y - endPoint.y, - }; - nextPoints.splice(indices.endIndex, 0, { - id: pointId, - x: splitPoint.x, - y: splitPoint.y, - inX: p012.x - splitPoint.x, - inY: p012.y - splitPoint.y, - outX: p123.x - splitPoint.x, - outY: p123.y - splitPoint.y, - }); - return nextPoints; -} - -export function getCustomMaskLocalBounds({ - points, - bounds, -}: { - points: CustomMaskPathPoint[]; - bounds: ElementBounds; -}) { - if (points.length === 0) { - return null; - } - - const values = points.flatMap((point) => [ - { x: point.x * bounds.width, y: point.y * bounds.height }, - { - x: (point.x + point.inX) * bounds.width, - y: (point.y + point.inY) * bounds.height, - }, - { - x: (point.x + point.outX) * bounds.width, - y: (point.y + point.outY) * bounds.height, - }, - ]); - - const localBounds = getCanvasPointBounds({ - points: values, - }); - if (!localBounds) { - return null; - } - - return { - width: localBounds.width, - height: localBounds.height, - }; -} - -export function recenterCustomMaskPath({ - points, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}) { - if (points.length === 0) { - return { centerX, centerY, points }; - } - - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - if (!geometry.bounds) { - return { centerX, centerY, points }; - } - - const nextCenterCanvas = { - x: geometry.bounds.centerX, - y: geometry.bounds.centerY, - }; - const nextCenterLocal = { - x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width, - y: - bounds.height === 0 - ? 0 - : (nextCenterCanvas.y - bounds.cy) / bounds.height, - }; - - const nextPoints = geometry.anchors.map((point) => { - const anchor = customMaskCanvasPointToLocal({ - point: point.anchor, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - const inHandle = customMaskCanvasPointToLocal({ - point: point.inHandle, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - const outHandle = customMaskCanvasPointToLocal({ - point: point.outHandle, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - - return { - id: point.id, - x: anchor.x, - y: anchor.y, - inX: inHandle.x - anchor.x, - inY: inHandle.y - anchor.y, - outX: outHandle.x - anchor.x, - outY: outHandle.y - anchor.y, - }; - }); - - return { - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - points: nextPoints, - }; -} - -export function buildCustomMaskPath2D({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): Path2D { - const path = new Path2D(); - if (points.length === 0) { - return path; - } - - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - const anchors = geometry.anchors; - path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y); - - for (let index = 1; index < anchors.length; index++) { - const previous = anchors[index - 1]; - const current = anchors[index]; - path.bezierCurveTo( - previous.outHandle.x, - previous.outHandle.y, - current.inHandle.x, - current.inHandle.y, - current.anchor.x, - current.anchor.y, - ); - } - - if (closed && anchors.length > 1) { - const last = anchors[anchors.length - 1]; - const first = anchors[0]; - path.bezierCurveTo( - last.outHandle.x, - last.outHandle.y, - first.inHandle.x, - first.inHandle.y, - first.anchor.x, - first.anchor.y, - ); - path.closePath(); - } - - return path; -} - -export function buildCustomMaskSvgPath({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): string { - if (points.length === 0) { - return ""; - } - - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - const anchors = geometry.anchors; - const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`]; - - for (let index = 1; index < anchors.length; index++) { - const previous = anchors[index - 1]; - const current = anchors[index]; - segments.push( - `C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`, - ); - } - - if (closed && anchors.length > 1) { - const last = anchors[anchors.length - 1]; - const first = anchors[0]; - segments.push( - `C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`, - ); - segments.push("Z"); - } - - return segments.join(" "); -} diff --git a/apps/web/src/masks/definitions/box-like.ts b/apps/web/src/masks/definitions/box-like.ts deleted file mode 100644 index 37706755..00000000 --- a/apps/web/src/masks/definitions/box-like.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { - DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, - MIN_MASK_DIMENSION, -} from "@/masks/dimensions"; -import { computeFeatherUpdate } from "../param-update"; -import type { - BaseMaskParams, - MaskDefaultContext, - MaskFeatures, - MaskInteractionDefinition, - MaskParamUpdateArgs, - RectangleMaskParams, -} from "@/masks/types"; -import type { NumberParamDefinition, ParamDefinition } from "@/params"; -import { - getBoxMaskHandlePositions, - getBoxMaskOverlays, -} from "@/masks/handle-positions"; -import { snapBoxMaskInteraction } from "@/masks/snap"; - -const PERCENTAGE_DISPLAY: Pick< - NumberParamDefinition, - "displayMultiplier" | "step" -> = { - displayMultiplier: 100, - step: 1, -}; - -export const BOX_LIKE_MASK_PARAMS: ParamDefinition< - keyof RectangleMaskParams & string ->[] = [ - { - key: "centerX", - label: "X", - type: "number", - default: 0, - min: -100, - max: 100, - ...PERCENTAGE_DISPLAY, - }, - { - key: "centerY", - label: "Y", - type: "number", - default: 0, - min: -100, - max: 100, - ...PERCENTAGE_DISPLAY, - }, - { - key: "width", - label: "Width", - type: "number", - default: 0.6, - min: 1, - ...PERCENTAGE_DISPLAY, - }, - { - key: "height", - label: "Height", - type: "number", - default: 0.6, - min: 1, - ...PERCENTAGE_DISPLAY, - }, - { - key: "rotation", - label: "Rotation", - type: "number", - default: 0, - min: 0, - max: 360, - step: 1, - }, - { - key: "scale", - label: "Scale", - type: "number", - default: 1, - min: 1, - max: 500, - ...PERCENTAGE_DISPLAY, - }, - { - key: "strokeAlign", - label: "Stroke Align", - type: "select", - default: "center", - options: [ - { value: "inside", label: "Inside" }, - { value: "center", label: "Center" }, - { value: "outside", label: "Outside" }, - ], - }, -]; - -export function getDefaultBaseMaskParams(): BaseMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - }; -} - -export function getStrokeOffset({ - strokeAlign, - strokeWidth, -}: Pick): number { - if (strokeAlign === "inside") { - return -(strokeWidth / 2); - } - - if (strokeAlign === "outside") { - return strokeWidth / 2; - } - - return 0; -} - -export function getDefaultSquareMaskParams({ - elementSize, -}: MaskDefaultContext): RectangleMaskParams { - const absWidth = Math.abs(elementSize?.width ?? 0); - const absHeight = Math.abs(elementSize?.height ?? 0); - const shortSide = Math.min(absWidth, absHeight); - const squareSide = - shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0; - const width = - absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; - const height = - absHeight > 0 - ? squareSide / absHeight - : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; - - return { - ...getDefaultBaseMaskParams(), - centerX: 0, - centerY: 0, - width, - height, - rotation: 0, - scale: 1, - }; -} - -export function getBoxLikeGeometry({ - params, - width, - height, -}: { - params: RectangleMaskParams; - width: number; - height: number; -}) { - return { - centerX: width / 2 + params.centerX * width, - centerY: height / 2 + params.centerY * height, - maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width, - maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height, - rotationRad: (params.rotation * Math.PI) / 180, - }; -} - -export function buildBoxMaskInteraction({ - sizeMode, - buildOverlayPath, - showBoundingBox = true, -}: { - sizeMode: MaskFeatures["sizeMode"]; - buildOverlayPath?: (args: { width: number; height: number }) => string; - showBoundingBox?: boolean; -}): MaskInteractionDefinition { - return { - getInteraction({ params, bounds, displayScale, scaleX, scaleY }) { - return { - handles: getBoxMaskHandlePositions({ - centerX: params.centerX, - centerY: params.centerY, - width: params.width, - height: params.height, - rotation: params.rotation, - feather: params.feather, - sizeMode, - bounds, - displayScale, - }), - overlays: getBoxMaskOverlays({ - params, - bounds, - pathData: buildOverlayPath?.({ - width: params.width * bounds.width * scaleX, - height: params.height * bounds.height * scaleY, - }), - showBoundingBox, - }), - }; - }, - snap(args) { - return snapBoxMaskInteraction(args); - }, - }; -} - -export function computeBoxMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - bounds, -}: MaskParamUpdateArgs): Partial { - if (handleId.kind === "position") { - return { - centerX: startParams.centerX + deltaX / bounds.width, - centerY: startParams.centerY + deltaY / bounds.height, - }; - } - - if (handleId.kind === "rotation") { - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - const newRotation = (startParams.rotation + currentAngle) % 360; - return { rotation: newRotation < 0 ? newRotation + 360 : newRotation }; - } - - if (handleId.kind === "feather") { - const angleRad = (startParams.rotation * Math.PI) / 180; - return computeFeatherUpdate({ - startFeather: startParams.feather, - deltaX, - deltaY, - directionX: -Math.sin(angleRad), - directionY: Math.cos(angleRad), - }); - } - - const halfWidth = startParams.width * bounds.width; - const halfHeight = startParams.height * bounds.height; - - if ( - handleId.kind === "edge" && - (handleId.side === "right" || handleId.side === "left") - ) { - const sign = handleId.side === "right" ? 1 : -1; - return { - width: Math.max( - MIN_MASK_DIMENSION, - startParams.width + (sign * deltaX * 2) / bounds.width, - ), - }; - } - - if ( - handleId.kind === "edge" && - (handleId.side === "bottom" || handleId.side === "top") - ) { - const sign = handleId.side === "bottom" ? 1 : -1; - return { - height: Math.max( - MIN_MASK_DIMENSION, - startParams.height + (sign * deltaY * 2) / bounds.height, - ), - }; - } - - if (handleId.kind === "corner") { - const signX = handleId.corner.x === "right" ? 1 : -1; - const signY = handleId.corner.y === "bottom" ? 1 : -1; - const distance = Math.sqrt( - (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2, - ); - const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); - const scale = originalDistance > 0 ? distance / originalDistance : 1; - return { - width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale), - height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale), - }; - } - - if (handleId.kind === "scale") { - const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); - const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); - const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1; - return { - scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale), - }; - } - - return {}; -} - -export function rotatePoint({ - x, - y, - centerX, - centerY, - rotationRad, -}: { - x: number; - y: number; - centerX: number; - centerY: number; - rotationRad: number; -}) { - const dx = x - centerX; - const dy = y - centerY; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - - return { - x: centerX + dx * cos - dy * sin, - y: centerY + dx * sin + dy * cos, - }; -} diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/definitions/cinematic-bars.ts deleted file mode 100644 index 68717c02..00000000 --- a/apps/web/src/masks/definitions/cinematic-bars.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { - MaskDefaultContext, - MaskDefinition, - RectangleMaskParams, -} from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getDefaultBaseMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function getDefaultCinematicBarsMaskParams({ - elementSize, -}: MaskDefaultContext): RectangleMaskParams { - const absWidth = Math.abs(elementSize?.width ?? 0); - const absHeight = Math.abs(elementSize?.height ?? 0); - const diagonal = - absWidth > 0 && absHeight > 0 - ? Math.sqrt(absWidth ** 2 + absHeight ** 2) - : 0; - const fullSpanWidth = - absWidth > 0 ? diagonal / absWidth : Math.SQRT2; - - return { - ...getDefaultBaseMaskParams(), - centerX: 0, - centerY: 0, - width: Math.max(fullSpanWidth, 1), - height: 0.6, - rotation: 0, - scale: 1, - }; -} - -function buildBandPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const corners = [ - { x: centerX - halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY + halfHeight }, - { x: centerX - halfWidth, y: centerY + halfHeight }, - ].map((point) => - rotatePoint({ - ...point, - centerX, - centerY, - rotationRad, - }), - ); - - const path = new Path2D(); - path.moveTo(corners[0].x, corners[0].y); - for (const corner of corners.slice(1)) { - path.lineTo(corner.x, corner.y); - } - path.closePath(); - return path; -} - -export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = { - type: "cinematic-bars", - name: "Cinematic Bars", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "height-only", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "height-only", - buildOverlayPath({ width, height }) { - return `M 0,0 H ${width} V ${height} H 0 Z`; - }, - }), - buildDefault(context) { - return { - type: "cinematic-bars", - params: getDefaultCinematicBarsMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - body: { - kind: "fillPath", - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const maskWidth = Math.max(params.width * width, width); - const maskHeight = Math.max(params.height, 0.01) * height; - const rotationRad = (params.rotation * Math.PI) / 180; - - return buildBandPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - }, - stroke: { - kind: "strokeFromPath", - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const rotationRad = (params.rotation * Math.PI) / 180; - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - - return buildBandPath({ - centerX, - centerY, - halfWidth: Math.max( - Math.max(params.width * width, width) / 2 + offset, - 1, - ), - halfHeight: Math.max( - (Math.max(params.height, 0.01) * height) / 2 + offset, - 1, - ), - rotationRad, - }); - }, - }, - }, -}; diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/definitions/custom.ts deleted file mode 100644 index 08155aae..00000000 --- a/apps/web/src/masks/definitions/custom.ts +++ /dev/null @@ -1,656 +0,0 @@ -import { generateUUID } from "@/utils/id"; -import type { ParamDefinition } from "@/params"; -import { PEN_CURSOR } from "@/preview/components/cursors"; -import type { ElementBounds } from "@/preview/element-bounds"; -import type { - CustomMask, - CustomMaskParams, - MaskDefinition, - MaskHandlePosition, - MaskOverlay, - MaskParamUpdateArgs, -} from "@/masks/types"; -import { - buildCustomMaskPath2D, - buildCustomMaskSvgPath, - customMaskCanvasPointToLocal, - findClosestPointOnCustomMaskSegment, - getCustomMaskCanvasSegments, - getCustomMaskCanvasGeometry, - getCustomMaskLocalBounds, - getCustomMaskSegmentCount, - insertPointIntoCustomMaskSegment, - recenterCustomMaskPath, - type CustomMaskPathPoint, -} from "@/masks/custom-path"; -import { getBoxMaskHandlePositions } from "@/masks/handle-positions"; -import { computeFeatherUpdate } from "@/masks/param-update"; -import { - setMaskLocalCenter, - toGlobalMaskSnapLines, -} from "@/masks/geometry"; -import { - snapPosition, - snapRotation, - snapScale, -} from "@/preview/preview-snap"; - -const PERCENTAGE_DISPLAY = { - displayMultiplier: 100, - step: 1, -} as const; - -const CUSTOM_MASK_PARAMS: ParamDefinition[] = [ - { - key: "centerX", - label: "X", - type: "number", - default: 0, - min: -100, - max: 100, - ...PERCENTAGE_DISPLAY, - }, - { - key: "centerY", - label: "Y", - type: "number", - default: 0, - min: -100, - max: 100, - ...PERCENTAGE_DISPLAY, - }, - { - key: "rotation", - label: "Rotation", - type: "number", - default: 0, - min: 0, - max: 360, - step: 1, - }, - { - key: "scale", - label: "Scale", - type: "number", - default: 1, - min: 1, - max: 500, - ...PERCENTAGE_DISPLAY, - }, -]; - -function getCustomMaskDisplayHandles({ - params, - displayScale, - bounds, -}: { - params: CustomMaskParams; - displayScale: number; - bounds: ElementBounds; -}): { - handles: MaskHandlePosition[]; - overlays: MaskOverlay[]; -} { - const points = params.path; - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds, - }); - - const handles: MaskHandlePosition[] = []; - const overlays: MaskOverlay[] = []; - - if (points.length > 0) { - overlays.push({ - id: "path", - type: "canvas-path", - pathData: buildCustomMaskSvgPath({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds, - closed: params.closed, - }), - coordinateSpace: "canvas", - }); - } - - if (params.closed) { - const segmentStrokeWidth = 12; - overlays.push( - ...getCustomMaskCanvasSegments({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds, - closed: true, - }).map((segment): MaskOverlay => ({ - id: `segment:${segment.index}`, - type: "canvas-path" as const, - pathData: segment.pathData, - coordinateSpace: "canvas" as const, - handleId: { kind: "segment", index: segment.index }, - cursor: PEN_CURSOR, - strokeOpacity: 0, - strokeWidth: segmentStrokeWidth, - })), - ); - } - - const localBounds = getCustomMaskLocalBounds({ points, bounds }); - if (params.closed && localBounds) { - handles.push( - ...getBoxMaskHandlePositions({ - centerX: params.centerX, - centerY: params.centerY, - width: (localBounds.width * params.scale) / bounds.width, - height: (localBounds.height * params.scale) / bounds.height, - rotation: params.rotation, - feather: params.feather, - sizeMode: "uniform", - showScaleHandle: false, - bounds, - displayScale, - }), - ); - } - - geometry.anchors.forEach((point) => { - handles.push({ - id: { kind: "anchor", pointId: point.id }, - x: point.anchor.x, - y: point.anchor.y, - cursor: params.closed ? "move" : "pointer", - kind: "point", - }); - }); - - return { - handles, - overlays, - }; -} - -function updateCustomMaskPoint({ - points, - pointId, - updater, -}: { - points: CustomMaskPathPoint[]; - pointId: string; - updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint; -}) { - return points.map((point) => (point.id === pointId ? updater(point) : point)); -} - -function computeCustomMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - startCanvasX, - startCanvasY, - bounds, -}: MaskParamUpdateArgs): Partial { - if (handleId.kind === "position") { - return { - centerX: startParams.centerX + deltaX / bounds.width, - centerY: startParams.centerY + deltaY / bounds.height, - }; - } - - const pivotX = bounds.cx + startParams.centerX * bounds.width; - const pivotY = bounds.cy + startParams.centerY * bounds.height; - - if (handleId.kind === "rotation") { - const startAngle = - (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / - Math.PI; - const currentAngle = - (Math.atan2( - startCanvasY + deltaY - pivotY, - startCanvasX + deltaX - pivotX, - ) * - 180) / - Math.PI; - - let deltaAngle = currentAngle - startAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - - return { - rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, - }; - } - - if (handleId.kind === "feather") { - const angleRad = (startParams.rotation * Math.PI) / 180; - return computeFeatherUpdate({ - startFeather: startParams.feather, - deltaX, - deltaY, - directionX: -Math.sin(angleRad), - directionY: Math.cos(angleRad), - }); - } - - if (handleId.kind === "scale") { - const startDistance = Math.hypot( - startCanvasX - pivotX, - startCanvasY - pivotY, - ); - const currentDistance = Math.hypot( - startCanvasX + deltaX - pivotX, - startCanvasY + deltaY - pivotY, - ); - const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1; - return { - scale: Math.max(0.01, startParams.scale * scaleFactor), - }; - } - - if (handleId.kind !== "anchor" && handleId.kind !== "tangent") { - return {}; - } - - const points = startParams.path; - const currentPoint = { - x: startCanvasX + deltaX, - y: startCanvasY + deltaY, - }; - const localPoint = customMaskCanvasPointToLocal({ - point: currentPoint, - centerX: startParams.centerX, - centerY: startParams.centerY, - rotation: startParams.rotation, - scale: startParams.scale, - bounds, - }); - - return { - path: updateCustomMaskPoint({ - points, - pointId: handleId.pointId, - updater: (point) => { - if (handleId.kind === "anchor") { - return { - ...point, - x: localPoint.x, - y: localPoint.y, - }; - } - - if (handleId.side === "in") { - return { - ...point, - inX: localPoint.x - point.x, - inY: localPoint.y - point.y, - }; - } - - return { - ...point, - outX: localPoint.x - point.x, - outY: localPoint.y - point.y, - }; - }, - }), - }; -} - -export const customMaskDefinition: MaskDefinition<"custom"> = { - type: "custom", - name: "Custom", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "uniform", - }, - params: CUSTOM_MASK_PARAMS, - interaction: { - getInteraction({ - params, - bounds, - displayScale, - scaleX: _scaleX, - scaleY: _scaleY, - }) { - return getCustomMaskDisplayHandles({ params, bounds, displayScale }); - }, - snap({ - handleId, - proposedParams, - startParams, - bounds, - canvasSize, - snapThreshold, - }) { - const points = startParams.path; - const localBounds = getCustomMaskLocalBounds({ points, bounds }); - if (!startParams.closed || !localBounds) { - return { - params: proposedParams, - activeLines: [], - }; - } - - const position = { - x: proposedParams.centerX * bounds.width, - y: proposedParams.centerY * bounds.height, - }; - - if (handleId.kind === "position") { - const { snappedPosition, activeLines } = snapPosition({ - proposedPosition: position, - canvasSize: bounds, - elementSize: { - width: localBounds.width * proposedParams.scale, - height: localBounds.height * proposedParams.scale, - }, - rotation: proposedParams.rotation, - snapThreshold, - }); - - return { - params: { - ...proposedParams, - ...setMaskLocalCenter({ - center: snappedPosition, - bounds, - }), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - if (handleId.kind === "rotation") { - const { snappedRotation } = snapRotation({ - proposedRotation: proposedParams.rotation, - }); - return { - params: { - ...proposedParams, - rotation: snappedRotation, - }, - activeLines: [], - }; - } - - if (handleId.kind === "scale") { - const { snappedScale, activeLines } = snapScale({ - proposedScale: proposedParams.scale, - position, - baseWidth: localBounds.width, - baseHeight: localBounds.height, - rotation: proposedParams.rotation, - canvasSize: bounds, - snapThreshold, - preferredEdges: { - right: true, - bottom: true, - }, - }); - - return { - params: { - ...proposedParams, - scale: Math.max(0.01, snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - return { - params: proposedParams, - activeLines: [], - }; - }, - }, - buildDefault(): Omit { - return { - type: "custom", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - path: [], - closed: false, - centerX: 0, - centerY: 0, - rotation: 0, - scale: 1, - }, - }; - }, - computeParamUpdate: computeCustomMaskParamUpdate, - isActive(params) { - return params.closed; - }, - renderer: { - body: { - kind: "fillPath", - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams; - const points = params.path; - if (!params.closed) { - return new Path2D(); - } - - return buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); - }, - }, - stroke: { - kind: "renderStroke", - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams; - if (!params.closed) { - return; - } - - const points = params.path; - const path = buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); - - ctx.save(); - ctx.strokeStyle = params.strokeColor; - ctx.lineWidth = params.strokeWidth; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - ctx.stroke(path); - - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } - - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } - ctx.restore(); - }, - }, - }, -}; - -export function appendPointToCustomMask({ - params, - canvasPoint, - bounds, -}: { - params: CustomMaskParams; - canvasPoint: { x: number; y: number }; - bounds: ElementBounds; -}): CustomMaskParams { - const points = params.path; - - if (points.length === 0) { - return { - ...params, - centerX: - bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width, - centerY: - bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height, - rotation: 0, - scale: 1, - path: [ - { - id: generateUUID(), - x: 0, - y: 0, - inX: 0, - inY: 0, - outX: 0, - outY: 0, - }, - ], - }; - } - - const localPoint = customMaskCanvasPointToLocal({ - point: canvasPoint, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds, - }); - const nextPoints = [ - ...points, - { - id: generateUUID(), - x: localPoint.x, - y: localPoint.y, - inX: 0, - inY: 0, - outX: 0, - outY: 0, - }, - ]; - const recentered = recenterCustomMaskPath({ - points: nextPoints, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds, - }); - - return { - ...params, - centerX: recentered.centerX, - centerY: recentered.centerY, - path: recentered.points, - }; -} - -export function insertPointOnCustomMaskSegment({ - params, - segmentIndex, - canvasPoint, - bounds, - pointId = generateUUID(), -}: { - params: CustomMaskParams; - segmentIndex: number; - canvasPoint: { x: number; y: number }; - bounds: ElementBounds; - pointId?: string; -}): { params: CustomMaskParams; pointId: string } | null { - const points = params.path; - if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) { - return null; - } - - const closestPoint = findClosestPointOnCustomMaskSegment({ - points, - segmentIndex, - canvasPoint, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds, - closed: params.closed, - }); - if (!closestPoint) { - return null; - } - - const nextPoints = insertPointIntoCustomMaskSegment({ - points, - segmentIndex, - pointId, - t: closestPoint.t, - closed: params.closed, - }); - if (nextPoints.length === points.length) { - return null; - } - - const recentered = recenterCustomMaskPath({ - points: nextPoints, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds, - }); - - return { - pointId, - params: { - ...params, - centerX: recentered.centerX, - centerY: recentered.centerY, - path: recentered.points, - }, - }; -} diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/definitions/diamond.ts deleted file mode 100644 index fe96dbe9..00000000 --- a/apps/web/src/masks/definitions/diamond.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { MaskDefinition } from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function buildDiamondPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const points = [ - { x: centerX, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY }, - { x: centerX, y: centerY + halfHeight }, - { x: centerX - halfWidth, y: centerY }, - ].map((point) => - rotatePoint({ - ...point, - centerX, - centerY, - rotationRad, - }), - ); - - const path = new Path2D(); - path.moveTo(points[0].x, points[0].y); - for (const point of points.slice(1)) { - path.lineTo(point.x, point.y); - } - path.closePath(); - return path; -} - -export const diamondMaskDefinition: MaskDefinition<"diamond"> = { - type: "diamond", - name: "Diamond", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`; - }, - }), - buildDefault(context) { - return { - type: "diamond", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - body: { - kind: "fillPath", - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - }, - stroke: { - kind: "strokeFromPath", - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); - }, - }, - }, -}; diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/definitions/ellipse.ts deleted file mode 100644 index f57d7444..00000000 --- a/apps/web/src/masks/definitions/ellipse.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { MaskDefinition } from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, -} from "./box-like"; - -export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { - type: "ellipse", - name: "Ellipse", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - const rx = Math.max((width - 1) / 2, 0); - const ry = Math.max((height - 1) / 2, 0); - const cx = width / 2; - const cy = height / 2; - return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; - }, - }), - buildDefault(context) { - return { - type: "ellipse", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - body: { - kind: "fillPath", - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - maskWidth / 2, - maskHeight / 2, - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - }, - stroke: { - kind: "strokeFromPath", - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - Math.max(1, maskWidth / 2 + offset), - Math.max(1, maskHeight / 2 + offset), - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - }, - }, -}; diff --git a/apps/web/src/masks/definitions/heart.ts b/apps/web/src/masks/definitions/heart.ts deleted file mode 100644 index ec546f18..00000000 --- a/apps/web/src/masks/definitions/heart.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { MaskDefinition } from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function buildHeartPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const toPoint = ({ - localX, - localY, - }: { - localX: number; - localY: number; - }) => - rotatePoint({ - x: centerX + localX, - y: centerY + localY, - centerX, - centerY, - rotationRad, - }); - - const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 }); - const rightControl1 = toPoint({ - localX: halfWidth, - localY: -halfHeight * 1.225, - }); - const rightControl2 = toPoint({ - localX: halfWidth, - localY: -halfHeight * 0.125, - }); - const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 }); - const leftControl1 = toPoint({ - localX: -halfWidth, - localY: -halfHeight * 0.125, - }); - const leftControl2 = toPoint({ - localX: -halfWidth, - localY: -halfHeight * 1.225, - }); - - const path = new Path2D(); - path.moveTo(start.x, start.y); - path.bezierCurveTo( - rightControl1.x, - rightControl1.y, - rightControl2.x, - rightControl2.y, - bottom.x, - bottom.y, - ); - path.bezierCurveTo( - leftControl1.x, - leftControl1.y, - leftControl2.x, - leftControl2.y, - start.x, - start.y, - ); - path.closePath(); - return path; -} - -export const heartMaskDefinition: MaskDefinition<"heart"> = { - type: "heart", - name: "Heart", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - const cx = width / 2; - const cy = height / 2; - const halfWidth = width / 2; - const halfHeight = height / 2; - return [ - `M ${cx},${cy - halfHeight * 0.475}`, - `C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`, - `C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`, - "Z", - ].join(" "); - }, - }), - buildDefault(context) { - return { - type: "heart", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - body: { - kind: "fillPath", - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - }, - stroke: { - kind: "strokeFromPath", - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); - }, - }, - }, -}; diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/definitions/index.ts deleted file mode 100644 index a5aec404..00000000 --- a/apps/web/src/masks/definitions/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - masksRegistry, - type MaskDefinitionForRegistration, - type MaskIconProps, -} from "../registry"; -import { cinematicBarsMaskDefinition } from "./cinematic-bars"; -import { customMaskDefinition } from "./custom"; -import { diamondMaskDefinition } from "./diamond"; -import { ellipseMaskDefinition } from "./ellipse"; -import { heartMaskDefinition } from "./heart"; -import { rectangleMaskDefinition } from "./rectangle"; -import { splitMaskDefinition } from "./split"; -import { starMaskDefinition } from "./star"; -import { textMaskDefinition } from "./text"; -import { - MinusSignIcon, - PanelRightDashedIcon, - SquareIcon, - CircleIcon, - FavouriteIcon, - DiamondIcon, - StarsIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; - -function registerDefaultMask({ - definition, - icon, -}: { - definition: MaskDefinitionForRegistration; - icon: MaskIconProps; -}) { - if (masksRegistry.has(definition.type)) { - return; - } - - masksRegistry.registerMask({ definition, icon }); -} - -export function registerDefaultMasks(): void { - registerDefaultMask({ - definition: splitMaskDefinition, - icon: { icon: PanelRightDashedIcon, strokeWidth: 1 }, - }); - registerDefaultMask({ - definition: cinematicBarsMaskDefinition, - icon: { icon: MinusSignIcon }, - }); - registerDefaultMask({ - definition: rectangleMaskDefinition, - icon: { icon: SquareIcon }, - }); - registerDefaultMask({ - definition: ellipseMaskDefinition, - icon: { icon: CircleIcon }, - }); - registerDefaultMask({ - definition: heartMaskDefinition, - icon: { icon: FavouriteIcon }, - }); - registerDefaultMask({ - definition: diamondMaskDefinition, - icon: { icon: DiamondIcon }, - }); - registerDefaultMask({ - definition: starMaskDefinition, - icon: { icon: StarsIcon }, - }); - registerDefaultMask({ - 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/definitions/rectangle.ts deleted file mode 100644 index db7a4578..00000000 --- a/apps/web/src/masks/definitions/rectangle.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { MaskDefinition } from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function buildRectanglePath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const corners = [ - { x: centerX - halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY + halfHeight }, - { x: centerX - halfWidth, y: centerY + halfHeight }, - ].map((point) => - rotatePoint({ - ...point, - centerX, - centerY, - rotationRad, - }), - ); - - const path = new Path2D(); - path.moveTo(corners[0].x, corners[0].y); - for (const corner of corners.slice(1)) { - path.lineTo(corner.x, corner.y); - } - path.closePath(); - return path; -} - -export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = { - type: "rectangle", - name: "Rectangle", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - }), - buildDefault(context) { - return { - type: "rectangle", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - body: { - kind: "fillPath", - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - }, - stroke: { - kind: "strokeFromPath", - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: Math.max(1, maskWidth / 2 + offset), - halfHeight: Math.max(1, maskHeight / 2 + offset), - rotationRad, - }); - }, - }, - }, -}; diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/definitions/split.ts deleted file mode 100644 index 138db91d..00000000 --- a/apps/web/src/masks/definitions/split.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { computeFeatherUpdate } from "../param-update"; -import type { - MaskDefinition, - MaskParamUpdateArgs, - SplitMaskParams, -} from "@/masks/types"; -import { halfPlaneSign, lineEdgeIntersection } from "../utils"; -import { - getLineMaskHandlePositions, - getLineMaskOverlay, -} from "@/masks/handle-positions"; -import { snapSplitMaskInteraction } from "@/masks/snap"; - -// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped -// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie -// exactly on the split line, which produces spurious midpoint vertices. -const NORMAL_SNAP_EPSILON = 1e-10; - -// Guards against collinear vertices from float noise at canvas edges. -const MIN_POLYGON_AREA_PX = 0.5; -const INTERSECTION_EPSILON = 1e-6; - -function polygonArea({ vertices }: { vertices: [number, number][] }): number { - let area = 0; - for (let i = 0; i < vertices.length; i++) { - const [x1, y1] = vertices[i]; - const [x2, y2] = vertices[(i + 1) % vertices.length]; - area += x1 * y2 - x2 * y1; - } - return Math.abs(area) * 0.5; -} - -function splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, -}: { - centerX: number; - centerY: number; - rotation: number; - width: number; - height: number; -}): { normalX: number; normalY: number; lineX: number; lineY: number } { - const angleRad = (rotation * Math.PI) / 180; - const normalX = - Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad); - const normalY = - Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad); - const lineX = width / 2 + centerX * width; - const lineY = height / 2 + centerY * height; - return { normalX, normalY, lineX, lineY }; -} - -function pointsEqual({ - a, - b, -}: { - a: { x: number; y: number }; - b: { x: number; y: number }; -}): boolean { - return ( - Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && - Math.abs(a.y - b.y) <= INTERSECTION_EPSILON - ); -} - -export function getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, -}: { - resolvedParams: SplitMaskParams; - width: number; - height: number; -}): [{ x: number; y: number }, { x: number; y: number }] | null { - const { centerX, centerY, rotation } = resolvedParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; - const intersections: { x: number; y: number }[] = []; - - for (const [x1, y1, x2, y2] of edges) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - - if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) { - continue; - } - - intersections.push(hit); - } - - if (intersections.length !== 2) { - return null; - } - - return [intersections[0], intersections[1]]; -} - -function computeSplitMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - startCanvasX, - startCanvasY, - bounds, - canvasSize, -}: MaskParamUpdateArgs): Partial { - if (handleId.kind === "position") { - const rawX = startParams.centerX + deltaX / bounds.width; - const rawY = startParams.centerY + deltaY / bounds.height; - - const minX = -bounds.cx / bounds.width; - const maxX = (canvasSize.width - bounds.cx) / bounds.width; - const minY = -bounds.cy / bounds.height; - const maxY = (canvasSize.height - bounds.cy) / bounds.height; - - return { - centerX: Math.max(minX, Math.min(maxX, rawX)), - centerY: Math.max(minY, Math.min(maxY, rawY)), - }; - } - - if (handleId.kind === "feather") { - const angleRad = (startParams.rotation * Math.PI) / 180; - return computeFeatherUpdate({ - startFeather: startParams.feather, - deltaX, - deltaY, - directionX: -Math.cos(angleRad), - directionY: -Math.sin(angleRad), - }); - } - - if (handleId.kind === "rotation") { - const pivotX = bounds.cx + startParams.centerX * bounds.width; - const pivotY = bounds.cy + startParams.centerY * bounds.height; - const startAngle = - (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / - Math.PI; - const currentAngle = - (Math.atan2( - startCanvasY + deltaY - pivotY, - startCanvasX + deltaX - pivotX, - ) * - 180) / - Math.PI; - - let deltaAngle = currentAngle - startAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - - return { - rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, - }; - } - - return {}; -} - -export const splitMaskDefinition: MaskDefinition<"split"> = { - type: "split", - name: "Split", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "none", - }, - interaction: { - getInteraction({ - params, - bounds, - displayScale, - scaleX: _scaleX, - scaleY: _scaleY, - }) { - return { - handles: getLineMaskHandlePositions({ - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - feather: params.feather, - bounds, - displayScale, - }), - overlays: [ - getLineMaskOverlay({ - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - bounds, - }), - ], - }; - }, - snap(args) { - return snapSplitMaskInteraction(args); - }, - }, - buildDefault() { - return { - type: "split", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - centerX: 0, - centerY: 0, - rotation: 0, - }, - }; - }, - computeParamUpdate: computeSplitMaskParamUpdate, - params: [ - { - key: "centerX", - label: "X", - type: "number", - default: 0, - min: -100, - max: 100, - step: 1, - displayMultiplier: 100, - }, - { - key: "centerY", - label: "Y", - type: "number", - default: 0, - min: -100, - max: 100, - step: 1, - displayMultiplier: 100, - }, - { - key: "rotation", - label: "Rotation", - type: "number", - default: 0, - min: 0, - max: 360, - step: 1, - }, - ], - renderer: { - body: { - kind: "drawWithFeather", - drawWithFeather({ resolvedParams, ctx, width, height, feather }) { - const { centerX, centerY, rotation } = resolvedParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. - const featherHalf = feather / 2; - const gradient = ctx.createLinearGradient( - lineX - normalX * featherHalf, - lineY - normalY * featherHalf, - lineX + normalX * featherHalf, - lineY + normalY * featherHalf, - ); - gradient.addColorStop(0, "rgba(255,255,255,0)"); - gradient.addColorStop(1, "white"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); - }, - - opaqueFastPath: { - buildPath({ resolvedParams, width, height }) { - const { centerX, centerY, rotation } = resolvedParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; - - const isInsideHalfPlane = ({ - x, - y, - }: { - x: number; - y: number; - }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; - - const vertices: [number, number][] = []; - for (const [x1, y1, x2, y2] of edges) { - const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 }); - const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 }); - - if (isVertex1Inside && isVertex2Inside) { - vertices.push([x2, y2]); - } else if (isVertex1Inside && !isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) vertices.push([hit.x, hit.y]); - } else if (!isVertex1Inside && isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) { - vertices.push([hit.x, hit.y]); - vertices.push([x2, y2]); - } - } - } - - if ( - vertices.length < 3 || - polygonArea({ vertices }) < MIN_POLYGON_AREA_PX - ) { - return new Path2D(); - } - - const path = new Path2D(); - path.moveTo(vertices[0][0], vertices[0][1]); - for (let i = 1; i < vertices.length; i++) { - path.lineTo(vertices[i][0], vertices[i][1]); - } - path.closePath(); - return path; - }, - }, - }, - stroke: { - kind: "strokeFromPath", - buildStrokePath({ resolvedParams, width, height }) { - const segment = getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, - }); - const path = new Path2D(); - - if (!segment) { - return path; - } - - path.moveTo(segment[0].x, segment[0].y); - path.lineTo(segment[1].x, segment[1].y); - return path; - }, - }, - }, -}; diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/definitions/star.ts deleted file mode 100644 index 2a1a9b7e..00000000 --- a/apps/web/src/masks/definitions/star.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { MaskDefinition } from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -const STAR_INNER_RADIUS_RATIO = 0.45; -const STAR_VERTEX_COUNT = 10; - -function buildStarPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const path = new Path2D(); - - for (let index = 0; index < STAR_VERTEX_COUNT; index++) { - const isOuterVertex = index % 2 === 0; - const radiusX = isOuterVertex - ? halfWidth - : halfWidth * STAR_INNER_RADIUS_RATIO; - const radiusY = isOuterVertex - ? halfHeight - : halfHeight * STAR_INNER_RADIUS_RATIO; - const angle = (index * Math.PI) / 5 - Math.PI / 2; - const point = rotatePoint({ - x: centerX + radiusX * Math.cos(angle), - y: centerY + radiusY * Math.sin(angle), - centerX, - centerY, - rotationRad, - }); - - if (index === 0) { - path.moveTo(point.x, point.y); - } else { - path.lineTo(point.x, point.y); - } - } - - path.closePath(); - return path; -} - -function buildOverlayStarPath({ - width, - height, -}: { - width: number; - height: number; -}): string { - const centerX = width / 2; - const centerY = height / 2; - const halfWidth = width / 2; - const halfHeight = height / 2; - const segments: string[] = []; - - for (let index = 0; index < STAR_VERTEX_COUNT; index++) { - const isOuterVertex = index % 2 === 0; - const radiusX = isOuterVertex - ? halfWidth - : halfWidth * STAR_INNER_RADIUS_RATIO; - const radiusY = isOuterVertex - ? halfHeight - : halfHeight * STAR_INNER_RADIUS_RATIO; - const angle = (index * Math.PI) / 5 - Math.PI / 2; - const x = centerX + radiusX * Math.cos(angle); - const y = centerY + radiusY * Math.sin(angle); - segments.push(`${index === 0 ? "M" : "L"} ${x},${y}`); - } - - return `${segments.join(" ")} Z`; -} - -export const starMaskDefinition: MaskDefinition<"star"> = { - type: "star", - name: "Star", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - return buildOverlayStarPath({ width, height }); - }, - }), - buildDefault(context) { - return { - type: "star", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - body: { - kind: "fillPath", - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildStarPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - }, - stroke: { - kind: "strokeFromPath", - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildStarPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); - }, - }, - }, -}; diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/definitions/text.ts deleted file mode 100644 index db2af7e0..00000000 --- a/apps/web/src/masks/definitions/text.ts +++ /dev/null @@ -1,445 +0,0 @@ -import type { ParamDefinition } from "@/params"; -import type { - MaskDefinition, - MaskParamUpdateArgs, - TextMask, - TextMaskParams, - MaskHandleId, -} from "@/masks/types"; -import { DEFAULTS } from "@/timeline/defaults"; -import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography"; -import { - drawMeasuredTextLayout, - measureTextLayout, - strokeMeasuredTextLayout, -} from "@/text/primitives"; -import { getTextMeasurementContext } from "@/text/measure-element"; -import { getTextVisualRect } from "@/text/layout"; -import { - getBoxMaskHandlePositions, - getBoxMaskRectOverlay, -} from "@/masks/handle-positions"; -import { computeFeatherUpdate } from "@/masks/param-update"; -import { - setMaskLocalCenter, - toGlobalMaskSnapLines, -} from "@/masks/geometry"; -import { - snapPosition, - snapRotation, - snapScale, - type ScaleEdgePreference, -} from "@/preview/preview-snap"; - -const PERCENTAGE_DISPLAY = { - displayMultiplier: 100, - step: 1, -} as const; - -const TEXT_MASK_ALIGNMENT = "center"; - -const TEXT_MASK_PARAMS: ParamDefinition[] = [ - { - key: "centerX", - label: "X", - type: "number", - default: 0, - min: -100, - max: 100, - ...PERCENTAGE_DISPLAY, - }, - { - key: "centerY", - label: "Y", - type: "number", - default: 0, - min: -100, - max: 100, - ...PERCENTAGE_DISPLAY, - }, - { - key: "fontSize", - label: "Size", - type: "number", - default: 15, - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - step: 1, - }, - { - key: "rotation", - label: "Rotation", - type: "number", - default: 0, - min: 0, - max: 360, - step: 1, - }, - { - key: "scale", - label: "Scale", - type: "number", - default: 1, - min: 1, - max: 500, - ...PERCENTAGE_DISPLAY, - }, -]; - -function measureTextMask({ - params, - height, -}: { - params: TextMaskParams; - height: number; -}) { - const layout = measureTextLayout({ - text: { - content: params.content, - fontSize: params.fontSize, - fontFamily: params.fontFamily, - fontWeight: params.fontWeight, - fontStyle: params.fontStyle, - textAlign: TEXT_MASK_ALIGNMENT, - textDecoration: params.textDecoration, - letterSpacing: params.letterSpacing, - lineHeight: params.lineHeight, - }, - canvasHeight: height, - ctx: getTextMeasurementContext(), - }); - const visualRect = getTextVisualRect({ - textAlign: layout.textAlign, - block: layout.block, - background: { enabled: false, color: "transparent" }, - fontSizeRatio: layout.fontSizeRatio, - }); - - return { - layout, - intrinsicWidth: Math.max(1, visualRect.width), - intrinsicHeight: Math.max(1, visualRect.height), - }; -} - -function getScalePreferredEdges({ - handleId, -}: { - handleId: MaskHandleId; -}): ScaleEdgePreference | undefined { - if (handleId.kind !== "scale") { - return undefined; - } - - return { - right: true, - bottom: true, - }; -} - -function computeTextMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - startCanvasX, - startCanvasY, - bounds, -}: MaskParamUpdateArgs): Partial { - if (handleId.kind === "position") { - return { - centerX: startParams.centerX + deltaX / bounds.width, - centerY: startParams.centerY + deltaY / bounds.height, - }; - } - - const pivotX = bounds.cx + startParams.centerX * bounds.width; - const pivotY = bounds.cy + startParams.centerY * bounds.height; - - if (handleId.kind === "rotation") { - const startAngle = - (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / - Math.PI; - const currentAngle = - (Math.atan2( - startCanvasY + deltaY - pivotY, - startCanvasX + deltaX - pivotX, - ) * - 180) / - Math.PI; - - let deltaAngle = currentAngle - startAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - - return { - rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, - }; - } - - if (handleId.kind === "feather") { - const angleRad = (startParams.rotation * Math.PI) / 180; - return computeFeatherUpdate({ - startFeather: startParams.feather, - deltaX, - deltaY, - directionX: -Math.sin(angleRad), - directionY: Math.cos(angleRad), - }); - } - - if (handleId.kind === "scale") { - const startDistance = Math.hypot( - startCanvasX - pivotX, - startCanvasY - pivotY, - ); - const currentDistance = Math.hypot( - startCanvasX + deltaX - pivotX, - startCanvasY + deltaY - pivotY, - ); - const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1; - return { - scale: Math.max(0.01, startParams.scale * scaleFactor), - }; - } - - return {}; -} - -export const textMaskDefinition: MaskDefinition<"text"> = { - type: "text", - name: "Text", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "uniform", - }, - params: TEXT_MASK_PARAMS, - interaction: { - getInteraction({ - params, - bounds, - displayScale, - scaleX: _scaleX, - scaleY: _scaleY, - }) { - const { intrinsicWidth, intrinsicHeight } = measureTextMask({ - params, - height: bounds.height, - }); - const width = (intrinsicWidth * params.scale) / bounds.width; - const height = (intrinsicHeight * params.scale) / bounds.height; - return { - handles: getBoxMaskHandlePositions({ - centerX: params.centerX, - centerY: params.centerY, - width, - height, - rotation: params.rotation, - feather: params.feather, - sizeMode: "uniform", - bounds, - displayScale, - }), - overlays: [ - getBoxMaskRectOverlay({ - centerX: params.centerX, - centerY: params.centerY, - width, - height, - rotation: params.rotation, - bounds, - }), - ], - }; - }, - snap({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, - }) { - const { intrinsicWidth, intrinsicHeight } = measureTextMask({ - params: startParams, - height: bounds.height, - }); - const position = { - x: proposedParams.centerX * bounds.width, - y: proposedParams.centerY * bounds.height, - }; - - if (handleId.kind === "position") { - const { snappedPosition, activeLines } = snapPosition({ - proposedPosition: position, - canvasSize: bounds, - elementSize: { - width: intrinsicWidth * proposedParams.scale, - height: intrinsicHeight * proposedParams.scale, - }, - rotation: proposedParams.rotation, - snapThreshold, - }); - - return { - params: { - ...proposedParams, - ...setMaskLocalCenter({ - center: snappedPosition, - bounds, - }), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - if (handleId.kind === "rotation") { - const { snappedRotation } = snapRotation({ - proposedRotation: proposedParams.rotation, - }); - return { - params: { - ...proposedParams, - rotation: snappedRotation, - }, - activeLines: [], - }; - } - - if (handleId.kind === "scale") { - const { snappedScale, activeLines } = snapScale({ - proposedScale: proposedParams.scale, - position, - baseWidth: intrinsicWidth, - baseHeight: intrinsicHeight, - rotation: proposedParams.rotation, - canvasSize: bounds, - snapThreshold, - preferredEdges: getScalePreferredEdges({ handleId }), - }); - - return { - params: { - ...proposedParams, - scale: Math.max(0.01, snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - return { - params: proposedParams, - activeLines: [], - }; - }, - }, - buildDefault(): Omit { - return { - type: "text", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - content: "Mask", - fontSize: 15, - fontFamily: "Arial", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - letterSpacing: DEFAULTS.text.letterSpacing, - lineHeight: DEFAULTS.text.lineHeight, - centerX: 0, - centerY: 0, - rotation: 0, - scale: 1, - }, - }; - }, - computeParamUpdate: computeTextMaskParamUpdate, - isActive(params) { - return params.content.trim().length > 0; - }, - renderer: { - body: { - kind: "drawOpaque", - drawOpaque({ resolvedParams, ctx, width, height }) { - const params = resolvedParams; - const { layout } = measureTextMask({ params, height }); - - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - ctx.restore(); - }, - }, - stroke: { - kind: "renderStroke", - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams; - const { layout } = measureTextMask({ params, height }); - - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - - strokeMeasuredTextLayout({ - ctx, - layout, - strokeColor: params.strokeColor, - strokeWidth: params.strokeWidth, - }); - - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - } - - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - } - - ctx.restore(); - }, - }, - }, -}; diff --git a/apps/web/src/masks/freeform/definition.ts b/apps/web/src/masks/freeform/definition.ts new file mode 100644 index 00000000..b657218d --- /dev/null +++ b/apps/web/src/masks/freeform/definition.ts @@ -0,0 +1,656 @@ +import { generateUUID } from "@/utils/id"; +import type { ParamDefinition } from "@/params"; +import { PEN_CURSOR } from "@/preview/components/cursors"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { + FreeformPathMask, + FreeformPathMaskParams, + MaskDefinition, + MaskHandlePosition, + MaskOverlay, + MaskParamUpdateArgs, +} from "@/masks/types"; +import { + 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 { + setMaskLocalCenter, + toGlobalMaskSnapLines, +} from "@/masks/geometry"; +import { + snapPosition, + snapRotation, + snapScale, +} from "@/preview/preview-snap"; + +const PERCENTAGE_DISPLAY = { + displayMultiplier: 100, + step: 1, +} as const; + +const FREEFORM_PATH_MASK_PARAMS: ParamDefinition[] = [ + { + key: "centerX", + label: "X", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "centerY", + label: "Y", + type: "number", + default: 0, + min: -100, + max: 100, + ...PERCENTAGE_DISPLAY, + }, + { + key: "rotation", + label: "Rotation", + type: "number", + default: 0, + min: 0, + max: 360, + step: 1, + }, + { + key: "scale", + label: "Scale", + type: "number", + default: 1, + min: 1, + max: 500, + ...PERCENTAGE_DISPLAY, + }, +]; + +function getFreeformDisplayHandles({ + params, + displayScale, + bounds, +}: { + params: FreeformPathMaskParams; + displayScale: number; + bounds: ElementBounds; +}): { + handles: MaskHandlePosition[]; + overlays: MaskOverlay[]; +} { + const points = params.path; + const geometry = getFreeformCanvasGeometry({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + + const handles: MaskHandlePosition[] = []; + const overlays: MaskOverlay[] = []; + + if (points.length > 0) { + overlays.push({ + id: "path", + type: "canvas-path", + pathData: buildFreeformSvgPath({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: params.closed, + }), + coordinateSpace: "canvas", + }); + } + + if (params.closed) { + const segmentStrokeWidth = 12; + overlays.push( + ...getFreeformCanvasSegments({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: true, + }).map((segment): MaskOverlay => ({ + id: `segment:${segment.index}`, + type: "canvas-path" as const, + pathData: segment.pathData, + coordinateSpace: "canvas" as const, + handleId: { kind: "segment", index: segment.index }, + cursor: PEN_CURSOR, + strokeOpacity: 0, + strokeWidth: segmentStrokeWidth, + })), + ); + } + + const localBounds = getFreeformLocalBounds({ points, bounds }); + if (params.closed && localBounds) { + handles.push( + ...getBoxMaskHandlePositions({ + centerX: params.centerX, + centerY: params.centerY, + width: (localBounds.width * params.scale) / bounds.width, + height: (localBounds.height * params.scale) / bounds.height, + rotation: params.rotation, + feather: params.feather, + sizeMode: "uniform", + showScaleHandle: false, + bounds, + displayScale, + }), + ); + } + + geometry.anchors.forEach((point) => { + handles.push({ + id: { kind: "anchor", pointId: point.id }, + x: point.anchor.x, + y: point.anchor.y, + cursor: params.closed ? "move" : "pointer", + kind: "point", + }); + }); + + return { + handles, + overlays, + }; +} + +function updateFreeformPathMaskPoint({ + points, + pointId, + updater, +}: { + points: FreeformPathPoint[]; + pointId: string; + updater: (point: FreeformPathPoint) => FreeformPathPoint; +}) { + return points.map((point) => (point.id === pointId ? updater(point) : point)); +} + +function computeFreeformParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + startCanvasX, + startCanvasY, + bounds, +}: MaskParamUpdateArgs): Partial { + if (handleId.kind === "position") { + return { + centerX: startParams.centerX + deltaX / bounds.width, + centerY: startParams.centerY + deltaY / bounds.height, + }; + } + + const pivotX = bounds.cx + startParams.centerX * bounds.width; + const pivotY = bounds.cy + startParams.centerY * bounds.height; + + if (handleId.kind === "rotation") { + const startAngle = + (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / + Math.PI; + const currentAngle = + (Math.atan2( + startCanvasY + deltaY - pivotY, + startCanvasX + deltaX - pivotX, + ) * + 180) / + Math.PI; + + let deltaAngle = currentAngle - startAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + return { + rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, + }; + } + + if (handleId.kind === "feather") { + const angleRad = (startParams.rotation * Math.PI) / 180; + return computeFeatherUpdate({ + startFeather: startParams.feather, + deltaX, + deltaY, + directionX: -Math.sin(angleRad), + directionY: Math.cos(angleRad), + }); + } + + if (handleId.kind === "scale") { + const startDistance = Math.hypot( + startCanvasX - pivotX, + startCanvasY - pivotY, + ); + const currentDistance = Math.hypot( + startCanvasX + deltaX - pivotX, + startCanvasY + deltaY - pivotY, + ); + const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1; + return { + scale: Math.max(0.01, startParams.scale * scaleFactor), + }; + } + + if (handleId.kind !== "anchor" && handleId.kind !== "tangent") { + return {}; + } + + const points = startParams.path; + const currentPoint = { + x: startCanvasX + deltaX, + y: startCanvasY + deltaY, + }; + const localPoint = freeformCanvasPointToLocal({ + point: currentPoint, + centerX: startParams.centerX, + centerY: startParams.centerY, + rotation: startParams.rotation, + scale: startParams.scale, + bounds, + }); + + return { + path: updateFreeformPathMaskPoint({ + points, + pointId: handleId.pointId, + updater: (point) => { + if (handleId.kind === "anchor") { + return { + ...point, + x: localPoint.x, + y: localPoint.y, + }; + } + + if (handleId.side === "in") { + return { + ...point, + inX: localPoint.x - point.x, + inY: localPoint.y - point.y, + }; + } + + return { + ...point, + outX: localPoint.x - point.x, + outY: localPoint.y - point.y, + }; + }, + }), + }; +} + +export const freeformMaskDefinition: MaskDefinition<"freeform"> = { + type: "freeform", + name: "Pen tool", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "uniform", + }, + params: FREEFORM_PATH_MASK_PARAMS, + interaction: { + getInteraction({ + params, + bounds, + displayScale, + scaleX: _scaleX, + scaleY: _scaleY, + }) { + return getFreeformDisplayHandles({ params, bounds, displayScale }); + }, + snap({ + handleId, + proposedParams, + startParams, + bounds, + canvasSize, + snapThreshold, + }) { + const points = startParams.path; + const localBounds = getFreeformLocalBounds({ points, bounds }); + if (!startParams.closed || !localBounds) { + return { + params: proposedParams, + activeLines: [], + }; + } + + const position = { + x: proposedParams.centerX * bounds.width, + y: proposedParams.centerY * bounds.height, + }; + + if (handleId.kind === "position") { + const { snappedPosition, activeLines } = snapPosition({ + proposedPosition: position, + canvasSize: bounds, + elementSize: { + width: localBounds.width * proposedParams.scale, + height: localBounds.height * proposedParams.scale, + }, + rotation: proposedParams.rotation, + snapThreshold, + }); + + return { + params: { + ...proposedParams, + ...setMaskLocalCenter({ + center: snappedPosition, + bounds, + }), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + if (handleId.kind === "rotation") { + const { snappedRotation } = snapRotation({ + proposedRotation: proposedParams.rotation, + }); + return { + params: { + ...proposedParams, + rotation: snappedRotation, + }, + activeLines: [], + }; + } + + if (handleId.kind === "scale") { + const { snappedScale, activeLines } = snapScale({ + proposedScale: proposedParams.scale, + position, + baseWidth: localBounds.width, + baseHeight: localBounds.height, + rotation: proposedParams.rotation, + canvasSize: bounds, + snapThreshold, + preferredEdges: { + right: true, + bottom: true, + }, + }); + + return { + params: { + ...proposedParams, + scale: Math.max(0.01, snappedScale), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + return { + params: proposedParams, + activeLines: [], + }; + }, + }, + buildDefault(): Omit { + return { + type: "freeform", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + path: [], + closed: false, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + }, + }; + }, + computeParamUpdate: computeFreeformParamUpdate, + isActive(params) { + return params.closed; + }, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const points = params.path; + if (!params.closed) { + return new Path2D(); + } + + return buildFreeformPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); + }, + }, + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + if (!params.closed) { + return; + } + + const points = params.path; + const path = buildFreeformPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); + + ctx.save(); + ctx.strokeStyle = params.strokeColor; + ctx.lineWidth = params.strokeWidth; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(path); + + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } + + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } + ctx.restore(); + }, + }, + }, +}; + +export function appendPointToFreeformPathMask({ + params, + canvasPoint, + bounds, +}: { + params: FreeformPathMaskParams; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): FreeformPathMaskParams { + const points = params.path; + + if (points.length === 0) { + return { + ...params, + centerX: + bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width, + centerY: + bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height, + rotation: 0, + scale: 1, + path: [ + { + id: generateUUID(), + x: 0, + y: 0, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + ], + }; + } + + const localPoint = freeformCanvasPointToLocal({ + point: canvasPoint, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + const nextPoints = [ + ...points, + { + id: generateUUID(), + x: localPoint.x, + y: localPoint.y, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + ]; + const recentered = recenterFreeformPath({ + points: nextPoints, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + + return { + ...params, + centerX: recentered.centerX, + centerY: recentered.centerY, + path: recentered.points, + }; +} + +export function insertPointOnFreeformSegment({ + params, + segmentIndex, + canvasPoint, + bounds, + pointId = generateUUID(), +}: { + params: FreeformPathMaskParams; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; + pointId?: string; +}): { params: FreeformPathMaskParams; pointId: string } | null { + const points = params.path; + if (getFreeformSegmentCount({ points, closed: params.closed }) === 0) { + return null; + } + + const closestPoint = findClosestPointOnFreeformSegment({ + points, + segmentIndex, + canvasPoint, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: params.closed, + }); + if (!closestPoint) { + return null; + } + + const nextPoints = insertPointIntoFreeformSegment({ + points, + segmentIndex, + pointId, + t: closestPoint.t, + closed: params.closed, + }); + if (nextPoints.length === points.length) { + return null; + } + + const recentered = recenterFreeformPath({ + points: nextPoints, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + + return { + pointId, + params: { + ...params, + centerX: recentered.centerX, + centerY: recentered.centerY, + path: recentered.points, + }, + }; +} diff --git a/apps/web/src/masks/freeform/path.ts b/apps/web/src/masks/freeform/path.ts new file mode 100644 index 00000000..fa6217c2 --- /dev/null +++ b/apps/web/src/masks/freeform/path.ts @@ -0,0 +1,835 @@ +import type { ElementBounds } from "@/preview/element-bounds"; + +export interface FreeformPathPoint { + id: string; + x: number; + y: number; + inX: number; + inY: number; + outX: number; + outY: number; +} + +function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { + if (!value || typeof value !== "object") { + return false; + } + + return ( + "id" in value && + typeof value.id === "string" && + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "number" && + "inX" in value && + typeof value.inX === "number" && + "inY" in value && + typeof value.inY === "number" && + "outX" in value && + typeof value.outX === "number" && + "outY" in value && + typeof value.outY === "number" + ); +} + +export function parseFreeformPath({ + path, +}: { + path: string; +}): FreeformPathPoint[] { + if (!path) { + return []; + } + + try { + const parsed = JSON.parse(path); + return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; + } catch { + return []; + } +} + +export function serializeFreeformPath({ + points, +}: { + points: FreeformPathPoint[]; +}): string { + return JSON.stringify(points); +} + +export function removeFreeformPathPoints({ + points, + pointIds, +}: { + points: FreeformPathPoint[]; + pointIds: string[]; +}): FreeformPathPoint[] { + if (pointIds.length === 0) { + return points; + } + const pointIdsToRemove = new Set(pointIds); + return points.filter((point) => !pointIdsToRemove.has(point.id)); +} + +export function getFreeformPathClosedStateAfterPointRemoval({ + wasClosed, + remainingPointCount, +}: { + wasClosed: boolean; + remainingPointCount: number; +}): boolean { + return wasClosed && remainingPointCount >= 3; +} + +function rotatePoint({ + x, + y, + rotationDegrees, +}: { + x: number; + y: number; + rotationDegrees: number; +}): { x: number; y: number } { + const angleRad = (rotationDegrees * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + return { + x: x * cos - y * sin, + y: x * sin + y * cos, + }; +} + +export function getFreeformCenterCanvasPoint({ + centerX, + centerY, + bounds, +}: { + centerX: number; + centerY: number; + bounds: ElementBounds; +}): { x: number; y: number } { + return { + x: bounds.cx + centerX * bounds.width, + y: bounds.cy + centerY * bounds.height, + }; +} + +export function freeformLocalPointToCanvas({ + point, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + point: { x: number; y: number }; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}): { x: number; y: number } { + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); + const scaledLocal = { + x: point.x * bounds.width * scale, + y: point.y * bounds.height * scale, + }; + const rotated = rotatePoint({ + x: scaledLocal.x, + y: scaledLocal.y, + rotationDegrees: rotation, + }); + + return { + x: center.x + rotated.x, + y: center.y + rotated.y, + }; +} + +export function freeformCanvasPointToLocal({ + point, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + point: { x: number; y: number }; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}): { x: number; y: number } { + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); + const translated = { + x: point.x - center.x, + y: point.y - center.y, + }; + const rotated = rotatePoint({ + x: translated.x, + y: translated.y, + rotationDegrees: -rotation, + }); + + return { + x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale), + y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale), + }; +} + +export function getFreeformCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + points: FreeformPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}) { + const anchors = points.map((point) => ({ + id: point.id, + anchor: freeformLocalPointToCanvas({ + point: { x: point.x, y: point.y }, + centerX, + centerY, + rotation, + scale, + bounds, + }), + inHandle: freeformLocalPointToCanvas({ + point: { x: point.x + point.inX, y: point.y + point.inY }, + centerX, + centerY, + rotation, + scale, + bounds, + }), + outHandle: freeformLocalPointToCanvas({ + point: { x: point.x + point.outX, y: point.y + point.outY }, + centerX, + centerY, + rotation, + scale, + bounds, + }), + })); + + const geometryPoints = anchors.flatMap((point) => [ + point.anchor, + point.inHandle, + point.outHandle, + ]); + if (geometryPoints.length === 0) { + return { + anchors, + bounds: null, + }; + } + + const geometryBounds = getCanvasPointBounds({ + points: geometryPoints, + }); + + return { + anchors, + bounds: geometryBounds, + }; +} + +export interface CanvasPoint { + x: number; + y: number; +} + +function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { + minX: number; + maxX: number; + minY: number; + maxY: number; + width: number; + height: number; + centerX: number; + centerY: number; +} | null { + if (points.length === 0) { + return null; + } + + const [firstPoint, ...restPoints] = points; + let minX = firstPoint.x; + let maxX = firstPoint.x; + let minY = firstPoint.y; + let maxY = firstPoint.y; + + for (const point of restPoints) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + + return { + minX, + maxX, + minY, + maxY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + centerX: (minX + maxX) / 2, + centerY: (minY + maxY) / 2, + }; +} + +export interface FreeformCanvasSegment { + index: number; + startPointId: string; + endPointId: string; + start: CanvasPoint; + startOut: CanvasPoint; + endIn: CanvasPoint; + end: CanvasPoint; + pathData: string; +} + +function clampUnit(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +function getDistanceSquared({ + a, + b, +}: { + a: CanvasPoint; + b: CanvasPoint; +}): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function lerpPoint({ + a, + b, + t, +}: { + a: CanvasPoint; + b: CanvasPoint; + t: number; +}): CanvasPoint { + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + }; +} + +function evaluateCubicBezier({ + p0, + p1, + p2, + p3, + t, +}: { + p0: CanvasPoint; + p1: CanvasPoint; + p2: CanvasPoint; + p3: CanvasPoint; + t: number; +}): CanvasPoint { + const oneMinusT = 1 - t; + return { + x: + oneMinusT ** 3 * p0.x + + 3 * oneMinusT ** 2 * t * p1.x + + 3 * oneMinusT * t ** 2 * p2.x + + t ** 3 * p3.x, + y: + oneMinusT ** 3 * p0.y + + 3 * oneMinusT ** 2 * t * p1.y + + 3 * oneMinusT * t ** 2 * p2.y + + t ** 3 * p3.y, + }; +} + +function getFreeformSegmentIndices({ + points, + segmentIndex, + closed, +}: { + points: FreeformPathPoint[]; + segmentIndex: number; + closed: boolean; +}): { startIndex: number; endIndex: number } | null { + const segmentCount = getFreeformSegmentCount({ points, closed }); + if (segmentIndex < 0 || segmentIndex >= segmentCount) { + return null; + } + + return { + startIndex: segmentIndex, + endIndex: (segmentIndex + 1) % points.length, + }; +} + +export function getFreeformSegmentCount({ + points, + closed, +}: { + points: FreeformPathPoint[]; + closed: boolean; +}): number { + if (points.length < 2) { + return 0; + } + return closed ? points.length : points.length - 1; +} + +export function getFreeformCanvasSegments({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: FreeformPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): FreeformCanvasSegment[] { + const geometry = getFreeformCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + const segmentCount = getFreeformSegmentCount({ points, closed }); + + return Array.from({ length: segmentCount }, (_, segmentIndex) => { + const start = geometry.anchors[segmentIndex]; + const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length]; + return { + index: segmentIndex, + startPointId: start.id, + endPointId: end.id, + start: start.anchor, + startOut: start.outHandle, + endIn: end.inHandle, + end: end.anchor, + pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`, + }; + }); +} + +export function findClosestPointOnFreeformSegment({ + points, + segmentIndex, + canvasPoint, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: FreeformPathPoint[]; + segmentIndex: number; + canvasPoint: CanvasPoint; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): { t: number; point: CanvasPoint } | null { + const segment = getFreeformCanvasSegments({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, + }).find((candidate) => candidate.index === segmentIndex); + if (!segment) { + return null; + } + + const sampleCount = 24; + let bestT = 0; + let bestDistanceSquared = getDistanceSquared({ + a: canvasPoint, + b: segment.start, + }); + + for (let step = 0; step <= sampleCount; step++) { + const t = step / sampleCount; + const point = evaluateCubicBezier({ + p0: segment.start, + p1: segment.startOut, + p2: segment.endIn, + p3: segment.end, + t, + }); + const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point }); + if (distanceSquared < bestDistanceSquared) { + bestDistanceSquared = distanceSquared; + bestT = t; + } + } + + let searchStep = 1 / sampleCount; + for (let iteration = 0; iteration < 8; iteration++) { + const candidates = [bestT - searchStep, bestT, bestT + searchStep] + .map(clampUnit) + .map((t) => ({ + t, + point: evaluateCubicBezier({ + p0: segment.start, + p1: segment.startOut, + p2: segment.endIn, + p3: segment.end, + t, + }), + })); + for (const candidate of candidates) { + const distanceSquared = getDistanceSquared({ + a: canvasPoint, + b: candidate.point, + }); + if (distanceSquared < bestDistanceSquared) { + bestDistanceSquared = distanceSquared; + bestT = candidate.t; + } + } + searchStep /= 2; + } + + const clampedT = Math.min(0.999, Math.max(0.001, bestT)); + return { + t: clampedT, + point: evaluateCubicBezier({ + p0: segment.start, + p1: segment.startOut, + p2: segment.endIn, + p3: segment.end, + t: clampedT, + }), + }; +} + +export function insertPointIntoFreeformSegment({ + points, + segmentIndex, + pointId, + t, + closed, +}: { + points: FreeformPathPoint[]; + segmentIndex: number; + pointId: string; + t: number; + closed: boolean; +}): FreeformPathPoint[] { + const indices = getFreeformSegmentIndices({ + points, + segmentIndex, + closed, + }); + if (!indices) { + return points; + } + + const startPoint = points[indices.startIndex]; + const endPoint = points[indices.endIndex]; + const clampedT = Math.min(0.999, Math.max(0.001, t)); + const p0 = { x: startPoint.x, y: startPoint.y }; + const p1 = { + x: startPoint.x + startPoint.outX, + y: startPoint.y + startPoint.outY, + }; + const p2 = { + x: endPoint.x + endPoint.inX, + y: endPoint.y + endPoint.inY, + }; + const p3 = { x: endPoint.x, y: endPoint.y }; + const p01 = lerpPoint({ a: p0, b: p1, t: clampedT }); + const p12 = lerpPoint({ a: p1, b: p2, t: clampedT }); + const p23 = lerpPoint({ a: p2, b: p3, t: clampedT }); + const p012 = lerpPoint({ a: p01, b: p12, t: clampedT }); + const p123 = lerpPoint({ a: p12, b: p23, t: clampedT }); + const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT }); + + const nextPoints = [...points]; + nextPoints[indices.startIndex] = { + ...startPoint, + outX: p01.x - startPoint.x, + outY: p01.y - startPoint.y, + }; + nextPoints[indices.endIndex] = { + ...endPoint, + inX: p23.x - endPoint.x, + inY: p23.y - endPoint.y, + }; + nextPoints.splice(indices.endIndex, 0, { + id: pointId, + x: splitPoint.x, + y: splitPoint.y, + inX: p012.x - splitPoint.x, + inY: p012.y - splitPoint.y, + outX: p123.x - splitPoint.x, + outY: p123.y - splitPoint.y, + }); + return nextPoints; +} + +export function getFreeformLocalBounds({ + points, + bounds, +}: { + points: FreeformPathPoint[]; + bounds: ElementBounds; +}) { + if (points.length === 0) { + return null; + } + + const values = points.flatMap((point) => [ + { x: point.x * bounds.width, y: point.y * bounds.height }, + { + x: (point.x + point.inX) * bounds.width, + y: (point.y + point.inY) * bounds.height, + }, + { + x: (point.x + point.outX) * bounds.width, + y: (point.y + point.outY) * bounds.height, + }, + ]); + + const localBounds = getCanvasPointBounds({ + points: values, + }); + if (!localBounds) { + return null; + } + + return { + width: localBounds.width, + height: localBounds.height, + }; +} + +export function recenterFreeformPath({ + points, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + points: FreeformPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}) { + if (points.length === 0) { + return { centerX, centerY, points }; + } + + const geometry = getFreeformCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + if (!geometry.bounds) { + return { centerX, centerY, points }; + } + + const nextCenterCanvas = { + x: geometry.bounds.centerX, + y: geometry.bounds.centerY, + }; + const nextCenterLocal = { + x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width, + y: + bounds.height === 0 + ? 0 + : (nextCenterCanvas.y - bounds.cy) / bounds.height, + }; + + const nextPoints = geometry.anchors.map((point) => { + const anchor = freeformCanvasPointToLocal({ + point: point.anchor, + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + rotation, + scale, + bounds, + }); + const inHandle = freeformCanvasPointToLocal({ + point: point.inHandle, + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + rotation, + scale, + bounds, + }); + const outHandle = freeformCanvasPointToLocal({ + point: point.outHandle, + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + rotation, + scale, + bounds, + }); + + return { + id: point.id, + x: anchor.x, + y: anchor.y, + inX: inHandle.x - anchor.x, + inY: inHandle.y - anchor.y, + outX: outHandle.x - anchor.x, + outY: outHandle.y - anchor.y, + }; + }); + + return { + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + points: nextPoints, + }; +} + +export function buildFreeformPath2D({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: FreeformPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): Path2D { + const path = new Path2D(); + if (points.length === 0) { + return path; + } + + const geometry = getFreeformCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + const anchors = geometry.anchors; + path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y); + + for (let index = 1; index < anchors.length; index++) { + const previous = anchors[index - 1]; + const current = anchors[index]; + path.bezierCurveTo( + previous.outHandle.x, + previous.outHandle.y, + current.inHandle.x, + current.inHandle.y, + current.anchor.x, + current.anchor.y, + ); + } + + if (closed && anchors.length > 1) { + const last = anchors[anchors.length - 1]; + const first = anchors[0]; + path.bezierCurveTo( + last.outHandle.x, + last.outHandle.y, + first.inHandle.x, + first.inHandle.y, + first.anchor.x, + first.anchor.y, + ); + path.closePath(); + } + + return path; +} + +export function buildFreeformSvgPath({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: FreeformPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): string { + if (points.length === 0) { + return ""; + } + + const geometry = getFreeformCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + const anchors = geometry.anchors; + const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`]; + + for (let index = 1; index < anchors.length; index++) { + const previous = anchors[index - 1]; + const current = anchors[index]; + segments.push( + `C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`, + ); + } + + if (closed && anchors.length > 1) { + const last = anchors[anchors.length - 1]; + const first = anchors[0]; + segments.push( + `C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`, + ); + segments.push("Z"); + } + + return segments.join(" "); +} 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 }); + } +} -- cgit v1.3.1