summaryrefslogtreecommitdiff
path: root/apps/web/src/masks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/masks
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/masks')
-rw-r--r--apps/web/src/masks/__tests__/snap.test.ts487
-rw-r--r--apps/web/src/masks/components/masks-tab.tsx823
-rw-r--r--apps/web/src/masks/custom-path.ts842
-rw-r--r--apps/web/src/masks/definitions/box-like.ts313
-rw-r--r--apps/web/src/masks/definitions/cinematic-bars.ts135
-rw-r--r--apps/web/src/masks/definitions/custom.ts652
-rw-r--r--apps/web/src/masks/definitions/diamond.ts100
-rw-r--r--apps/web/src/masks/definitions/ellipse.ts75
-rw-r--r--apps/web/src/masks/definitions/heart.ts142
-rw-r--r--apps/web/src/masks/definitions/index.ts74
-rw-r--r--apps/web/src/masks/definitions/rectangle.ts97
-rw-r--r--apps/web/src/masks/definitions/split.ts382
-rw-r--r--apps/web/src/masks/definitions/star.ts140
-rw-r--r--apps/web/src/masks/definitions/text.ts438
-rw-r--r--apps/web/src/masks/dimensions.ts2
-rw-r--r--apps/web/src/masks/feather.ts2
-rw-r--r--apps/web/src/masks/geometry.ts121
-rw-r--r--apps/web/src/masks/handle-positions.ts497
-rw-r--r--apps/web/src/masks/index.ts18
-rw-r--r--apps/web/src/masks/param-update.ts26
-rw-r--r--apps/web/src/masks/registry.ts122
-rw-r--r--apps/web/src/masks/snap.ts336
-rw-r--r--apps/web/src/masks/types.ts298
-rw-r--r--apps/web/src/masks/use-mask-handles.ts633
-rw-r--r--apps/web/src/masks/utils.ts64
25 files changed, 6819 insertions, 0 deletions
diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts
new file mode 100644
index 00000000..2f33615f
--- /dev/null
+++ b/apps/web/src/masks/__tests__/snap.test.ts
@@ -0,0 +1,487 @@
+import { describe, expect, test } from "bun:test";
+import {
+ findClosestPointOnCustomMaskSegment,
+ getCustomMaskClosedStateAfterPointRemoval,
+ insertPointIntoCustomMaskSegment,
+ removeCustomMaskPoints,
+} from "@/masks/custom-path";
+import {
+ appendPointToCustomMask,
+ customMaskDefinition,
+ insertPointOnCustomMaskSegment,
+} from "@/masks/definitions/custom";
+import { getSplitMaskStrokeSegment } from "@/masks/definitions/split";
+import { textMaskDefinition } from "@/masks/definitions/text";
+import { getMaskSnapGeometry } from "@/masks/geometry";
+import { snapMaskInteraction } from "@/masks/snap";
+import type { ElementBounds } from "@/preview/element-bounds";
+import type {
+ CustomMaskParams,
+ RectangleMaskParams,
+ SplitMaskParams,
+ TextMaskParams,
+} from "@/masks/types";
+
+const bounds: ElementBounds = {
+ cx: 200,
+ cy: 150,
+ width: 200,
+ height: 100,
+ rotation: 0,
+};
+
+const canvasSize = {
+ width: 400,
+ height: 300,
+};
+
+const snapThreshold = {
+ x: 8,
+ y: 8,
+};
+
+function buildSplitParams(
+ overrides: Partial<SplitMaskParams> = {},
+): SplitMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ ...overrides,
+ };
+}
+
+function buildRectangleParams(
+ overrides: Partial<RectangleMaskParams> = {},
+): RectangleMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ width: 0.4,
+ height: 0.2,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
+function buildTextMaskParams(
+ overrides: Partial<TextMaskParams> = {},
+): TextMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ content: "Mask",
+ fontSize: 15,
+ fontFamily: "Arial",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: 0,
+ lineHeight: 1.2,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
+function buildCustomMaskParams(
+ overrides: Partial<CustomMaskParams> = {},
+): CustomMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ path: [
+ {
+ id: "a",
+ x: -0.2,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ {
+ id: "b",
+ x: 0.2,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ {
+ id: "c",
+ x: 0,
+ y: 0.2,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ ],
+ closed: true,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
+function sortSegment(
+ segment: [{ x: number; y: number }, { x: number; y: number }],
+): [{ x: number; y: number }, { x: number; y: number }] {
+ return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
+ { x: number; y: number },
+ { x: number; y: number },
+ ];
+}
+
+describe("mask geometry", () => {
+ test("resolves split mask center from centerX and centerY", () => {
+ expect(
+ getMaskSnapGeometry({
+ params: buildSplitParams({
+ centerX: 0.25,
+ centerY: -0.5,
+ rotation: 45,
+ }),
+ bounds,
+ }),
+ ).toEqual({
+ position: { x: 50, y: -50 },
+ size: { width: 0, height: 0 },
+ rotation: 45,
+ });
+ });
+
+ test("resolves box mask center and size from centerX and centerY", () => {
+ expect(
+ getMaskSnapGeometry({
+ params: buildRectangleParams({
+ centerX: -0.25,
+ centerY: 0.5,
+ width: 0.5,
+ height: 0.6,
+ rotation: 30,
+ }),
+ bounds,
+ }),
+ ).toEqual({
+ position: { x: -50, y: 50 },
+ size: { width: 100, height: 60 },
+ rotation: 30,
+ });
+ });
+
+ test("returns a vertical split stroke segment for rotation 0", () => {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams: buildSplitParams(),
+ width: bounds.width,
+ height: bounds.height,
+ });
+
+ expect(segment).not.toBeNull();
+ if (!segment) {
+ throw new Error("Expected split stroke segment for rotation 0");
+ }
+ expect(sortSegment(segment)).toEqual([
+ { x: bounds.width / 2, y: 0 },
+ { x: bounds.width / 2, y: bounds.height },
+ ]);
+ });
+
+ test("returns a horizontal split stroke segment for rotation 90", () => {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams: buildSplitParams({ rotation: 90 }),
+ width: bounds.width,
+ height: bounds.height,
+ });
+
+ expect(segment).not.toBeNull();
+ if (!segment) {
+ throw new Error("Expected split stroke segment for rotation 90");
+ }
+ expect(sortSegment(segment)).toEqual([
+ { x: 0, y: bounds.height / 2 },
+ { x: bounds.width, y: bounds.height / 2 },
+ ]);
+ });
+});
+
+describe("mask snapping", () => {
+ test("snaps split mask movement using the shared position pipeline", () => {
+ const result = snapMaskInteraction({
+ handleId: "position",
+ startParams: buildSplitParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ }),
+ proposedParams: buildSplitParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.centerX).toBe(0);
+ expect(result.params.centerY).toBe(0);
+ expect(result.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps box mask movement against element center and edges", () => {
+ const result = snapMaskInteraction({
+ handleId: "position",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ centerX: 0.29,
+ centerY: 0.03,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.centerX).toBeCloseTo(0.3);
+ expect(result.params.centerY).toBe(0);
+ expect(result.activeLines).toEqual([
+ { type: "vertical", position: 100 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps mask rotation through the shared rotation path", () => {
+ const result = snapMaskInteraction({
+ handleId: "rotation",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ rotation: 88,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.rotation).toBe(90);
+ expect(result.activeLines).toEqual([]);
+ });
+
+ test("snaps edge resize for box masks", () => {
+ const result = snapMaskInteraction({
+ handleId: "right",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ width: 0.98,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.width).toBe(1);
+ expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
+ });
+
+ test("snaps corner resize for box masks", () => {
+ const result = snapMaskInteraction({
+ handleId: "bottom-right",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ width: 0.99,
+ height: 0.495,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.width).toBe(1);
+ expect(result.params.height).toBe(0.5);
+ expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
+ });
+
+ test("snaps text mask movement using intrinsic text bounds", () => {
+ const params = buildTextMaskParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ });
+ const result = textMaskDefinition.interaction.snap?.({
+ handleId: "position",
+ startParams: params,
+ proposedParams: params,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result?.params.centerX).toBe(0);
+ expect(result?.params.centerY).toBe(0);
+ expect(result?.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps custom mask movement using path geometry bounds", () => {
+ const params = buildCustomMaskParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ });
+ const result = customMaskDefinition.interaction.snap?.({
+ handleId: "position",
+ startParams: params,
+ proposedParams: params,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result?.params.centerX).toBe(0);
+ expect(result?.params.centerY).toBe(0);
+ expect(result?.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("marks blank text masks inactive", () => {
+ expect(
+ textMaskDefinition.isActive?.(
+ buildTextMaskParams({
+ content: " ",
+ }),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("custom mask creation", () => {
+ test("anchors the first point at the click position", () => {
+ const params = buildCustomMaskParams({
+ path: [],
+ closed: false,
+ });
+ const next = appendPointToCustomMask({
+ params,
+ canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
+ bounds,
+ });
+
+ expect(next.centerX).toBeCloseTo(0.1);
+ expect(next.centerY).toBeCloseTo(-0.1);
+ expect(next.rotation).toBe(0);
+ expect(next.scale).toBe(1);
+ expect(next.path).toHaveLength(1);
+ });
+});
+
+describe("custom mask point deletion", () => {
+ test("removes the selected points by id", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = removeCustomMaskPoints({
+ points,
+ pointIds: ["b"],
+ });
+
+ expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
+ });
+
+ test("reopens a closed path once fewer than three points remain", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = removeCustomMaskPoints({
+ points,
+ pointIds: ["c"],
+ });
+
+ expect(
+ getCustomMaskClosedStateAfterPointRemoval({
+ wasClosed: true,
+ remainingPointCount: nextPoints.length,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("custom mask point insertion", () => {
+ test("finds the closest point on the clicked segment", () => {
+ const params = buildCustomMaskParams();
+ const points = params.path;
+ const closestPoint = findClosestPointOnCustomMaskSegment({
+ points,
+ segmentIndex: 0,
+ canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: params.closed,
+ });
+
+ expect(closestPoint).not.toBeNull();
+ expect(closestPoint?.t).toBeCloseTo(0.5, 1);
+ expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
+ expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
+ });
+
+ test("splits a segment into two segments at the insertion point", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = insertPointIntoCustomMaskSegment({
+ points,
+ segmentIndex: 0,
+ pointId: "new",
+ t: 0.5,
+ closed: true,
+ });
+
+ expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
+ expect(nextPoints[1]).toMatchObject({
+ id: "new",
+ x: 0,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ });
+ });
+
+ test("builds updated custom mask params for a clicked segment", () => {
+ const result = insertPointOnCustomMaskSegment({
+ params: buildCustomMaskParams(),
+ segmentIndex: 0,
+ canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
+ bounds,
+ pointId: "new",
+ });
+
+ expect(result).not.toBeNull();
+ const nextPoints = result?.params.path ?? [];
+ expect(nextPoints).toHaveLength(4);
+ expect(nextPoints.some((point) => point.id === "new")).toBe(true);
+ });
+});
diff --git a/apps/web/src/masks/components/masks-tab.tsx b/apps/web/src/masks/components/masks-tab.tsx
new file mode 100644
index 00000000..97fba3ab
--- /dev/null
+++ b/apps/web/src/masks/components/masks-tab.tsx
@@ -0,0 +1,823 @@
+"use client";
+
+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 { useEditor } from "@/editor/use-editor";
+import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import { useMenuPreview } from "@/editor/use-menu-preview";
+import { getVisibleElementsWithBounds } from "@/preview/element-bounds";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Delete02Icon,
+ FeatherIcon,
+ PlusSignIcon,
+ RotateClockwiseIcon,
+ TextFontIcon,
+} from "@hugeicons/core-free-icons";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { FontPicker } from "@/components/ui/font-picker";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { NumberField } from "@/components/ui/number-field";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ snapToStep,
+} from "@/utils/math";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import {
+ OcMirrorIcon,
+ OcShapesIcon,
+ OcTextHeightIcon,
+ OcTextWidthIcon,
+} from "@/components/icons";
+import { cn } from "@/utils/ui";
+
+type MasksTabProps = {
+ element: MaskableElement;
+ trackId: string;
+};
+
+type MaskItemProps = {
+ trackId: string;
+ elementId: string;
+ mask: Mask;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+};
+
+type EmptyViewProps = {
+ onAddMask: () => void;
+};
+
+type PreviewParamHandler = (
+ key: string,
+) => (value: number | string | boolean) => void;
+
+type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
+
+function isTextMask(mask: Mask): mask is TextMask {
+ return mask.type === "text";
+}
+
+export function MasksTab({ element, trackId }: MasksTabProps) {
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } =
+ useElementPreview<MaskableElement>({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+ const maskDefs = masksRegistry.getAll();
+ const tracks = useEditor(
+ (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
+ );
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const masks = element.masks ?? [];
+ const renderMasks = renderElement.masks ?? masks;
+ const hasMask = masks.length > 0;
+ const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const elementBounds = useMemo(() => {
+ const clampedTime = Math.min(
+ Math.max(currentTime, element.startTime),
+ element.startTime + element.duration - 1,
+ );
+
+ return (
+ getVisibleElementsWithBounds({
+ tracks,
+ currentTime: clampedTime,
+ canvasSize,
+ mediaAssets,
+ }).find(
+ (item) => item.trackId === trackId && item.elementId === element.id,
+ )?.bounds ?? null
+ );
+ }, [
+ canvasSize,
+ currentTime,
+ element.duration,
+ element.id,
+ element.startTime,
+ mediaAssets,
+ trackId,
+ tracks,
+ ]);
+
+ const handleDropdownOpenChange = (open: boolean) => {
+ if (hasMask && open) return;
+ setIsDropdownOpen(open);
+ onOpenChange(open);
+ };
+
+ const previewMask = ({ maskType }: { maskType: MaskType }) => {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ };
+
+ const commitMask = ({ maskType }: { maskType: MaskType }) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ }
+ markCommitted();
+ setIsDropdownOpen(false);
+ };
+
+ const previewMaskParam =
+ ({ index, key }: { index: number; key: string }) =>
+ (value: number | string | boolean) => {
+ if (!renderMasks[index]) {
+ return;
+ }
+
+ const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
+ maskIndex !== index
+ ? existingMask
+ : {
+ ...existingMask,
+ params: {
+ ...existingMask.params,
+ [key]: value,
+ },
+ },
+ );
+
+ previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2">
+ <SectionTitle>Masks</SectionTitle>
+ <DropdownMenu
+ open={hasMask ? false : isDropdownOpen}
+ onOpenChange={handleDropdownOpenChange}
+ >
+ {hasMask ? (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="inline-flex">
+ <Button
+ variant="ghost"
+ size="icon"
+ disabled
+ aria-label="Add mask"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </span>
+ </TooltipTrigger>
+ <TooltipContent className="max-w-56 text-balance">
+ Only one mask is supported right now. If you need more,
+ duplicate the clip and apply a different mask to each copy.
+ </TooltipContent>
+ </Tooltip>
+ ) : (
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon" aria-label="Add mask">
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </DropdownMenuTrigger>
+ )}
+ <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}>
+ {maskDefs.map((definition) => (
+ <DropdownMenuItem
+ key={definition.type}
+ onPointerEnter={() =>
+ previewMask({ maskType: definition.type })
+ }
+ onClick={() => commitMask({ maskType: definition.type })}
+ >
+ <HugeiconsIcon {...definition.icon} />
+ {definition.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {masks.length === 0 ? (
+ <EmptyView onAddMask={() => setIsDropdownOpen(true)} />
+ ) : (
+ masks.map((mask, index) => (
+ <MaskItem
+ key={mask.id}
+ trackId={trackId}
+ elementId={element.id}
+ mask={renderMasks[index] ?? mask}
+ previewParam={(paramKey) =>
+ previewMaskParam({ index, key: paramKey })
+ }
+ onCommit={commit}
+ />
+ ))
+ )}
+ </div>
+ );
+}
+
+function MaskItem({
+ trackId,
+ elementId,
+ mask,
+ previewParam,
+ onCommit,
+}: MaskItemProps) {
+ const editor = useEditor();
+ const definition = masksRegistry.get(mask.type);
+
+ return (
+ <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
+ <SectionHeader
+ trailing={
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Toggle ${definition.name} mask inversion`}
+ onClick={() =>
+ editor.timeline.toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <OcMirrorIcon
+ className={cn(mask.params.inverted && "-scale-x-100")}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name} mask`}
+ onClick={() =>
+ editor.timeline.removeMask({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ }
+ >
+ <div className="flex items-center gap-2">
+ <HugeiconsIcon {...definition.icon} size={14} />
+ <SectionTitle className="capitalize font-normal">
+ {definition.name}
+ </SectionTitle>
+ </div>
+ </SectionHeader>
+ <SectionContent>
+ <MaskParamsFields
+ mask={mask}
+ definition={definition}
+ previewParam={previewParam}
+ onCommit={onCommit}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
+
+function MaskParamsFields({
+ mask,
+ definition,
+ previewParam,
+ onCommit,
+}: {
+ mask: Mask;
+ definition: RegisteredMaskDefinition;
+ previewParam: PreviewParamHandler;
+ onCommit: () => void;
+}) {
+ const featherParam = getNumberParamDefinition({
+ definition,
+ key: "feather",
+ });
+ const strokeWidthParam = getNumberParamDefinition({
+ definition,
+ key: "strokeWidth",
+ });
+ const previewNumberParam = (key: string) => (value: number) =>
+ previewParam(key)(value);
+ const previewStrokeColor = previewParam("strokeColor");
+ const strokeAlignParam = definition.params.find(
+ (param): param is SelectParamDefinition<string> =>
+ param.key === "strokeAlign" && param.type === "select",
+ );
+
+ return (
+ <SectionFields>
+ {isTextMask(mask) ? (
+ <TextMaskFields
+ mask={mask}
+ previewParam={previewParam}
+ onCommit={onCommit}
+ fontSizeParam={getNumberParamDefinition({
+ definition,
+ key: "fontSize",
+ })}
+ />
+ ) : null}
+ {definition.features.hasPosition &&
+ "centerX" in mask.params &&
+ "centerY" in mask.params && (
+ <SectionField label="Position">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="X"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerX",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerX",
+ })}
+ onPreview={previewNumberParam("centerX")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="Y"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerY",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerY",
+ })}
+ onPreview={previewNumberParam("centerY")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "width-height" &&
+ "width" in mask.params &&
+ "height" in mask.params && (
+ <SectionField label="Size">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={getNumberParamDefinition({
+ definition,
+ key: "width",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "width",
+ })}
+ onPreview={previewNumberParam("width")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="H"
+ param={getNumberParamDefinition({
+ definition,
+ key: "height",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "height",
+ })}
+ onPreview={previewNumberParam("height")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "height-only" &&
+ "height" in mask.params && (
+ <SectionField label="Height">
+ <MaskNumberField
+ icon="H"
+ param={getNumberParamDefinition({
+ definition,
+ key: "height",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "height",
+ })}
+ onPreview={previewNumberParam("height")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "width-only" &&
+ "width" in mask.params && (
+ <SectionField label="Width">
+ <MaskNumberField
+ icon="W"
+ param={getNumberParamDefinition({
+ definition,
+ key: "width",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "width",
+ })}
+ onPreview={previewNumberParam("width")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
+ <SectionField label="Scale">
+ <MaskNumberField
+ icon={
+ isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
+ }
+ param={getNumberParamDefinition({
+ definition,
+ key: "scale",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "scale",
+ })}
+ onPreview={previewNumberParam("scale")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ {definition.features.hasRotation && "rotation" in mask.params && (
+ <SectionField label="Rotation">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ param={getNumberParamDefinition({
+ definition,
+ key: "rotation",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "rotation",
+ })}
+ onPreview={previewNumberParam("rotation")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ <SectionField label="Feather">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={FeatherIcon} />}
+ param={featherParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "feather",
+ })}
+ onPreview={previewNumberParam("feather")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+
+ <SectionField label="Stroke">
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={strokeWidthParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "strokeWidth",
+ })}
+ onPreview={previewNumberParam("strokeWidth")}
+ onCommit={onCommit}
+ />
+ <ColorPicker
+ className=""
+ value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()}
+ onChange={(color) => previewStrokeColor(`#${color}`)}
+ onChangeEnd={(color) => {
+ previewStrokeColor(`#${color}`);
+ onCommit();
+ }}
+ />
+ </div>
+ {strokeAlignParam ? (
+ <Select
+ value={mask.params.strokeAlign}
+ onValueChange={(value) => {
+ previewParam("strokeAlign")(value);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {strokeAlignParam.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : null}
+ </div>
+ </SectionField>
+ </SectionFields>
+ );
+}
+
+const LETTER_SPACING_PARAM: NumberParamDefinition = {
+ key: "letterSpacing",
+ label: "Letter spacing",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 500,
+ step: 1,
+};
+
+const LINE_HEIGHT_PARAM: NumberParamDefinition = {
+ key: "lineHeight",
+ label: "Line height",
+ type: "number",
+ default: 1.2,
+ min: 0.1,
+ max: 10,
+ step: 0.1,
+};
+
+function TextMaskFields({
+ mask,
+ previewParam,
+ onCommit,
+ fontSizeParam,
+}: {
+ mask: TextMask;
+ previewParam: PreviewParamHandler;
+ onCommit: () => void;
+ fontSizeParam: NumberParamDefinition;
+}) {
+ const content = usePropertyDraft({
+ displayValue: mask.params.content,
+ parse: (input) => input,
+ onPreview: (value) => previewParam("content")(value),
+ onCommit,
+ });
+
+ const previewNumberParam = (key: string) => (value: number) =>
+ previewParam(key)(value);
+
+ return (
+ <>
+ <SectionField label="Content">
+ <Textarea
+ value={content.displayValue}
+ className="min-h-20"
+ onFocus={content.onFocus}
+ onChange={content.onChange}
+ onBlur={content.onBlur}
+ />
+ </SectionField>
+ <SectionField label="Font">
+ <FontPicker
+ defaultValue={mask.params.fontFamily}
+ onValueChange={(value) => {
+ previewParam("fontFamily")(value);
+ onCommit();
+ }}
+ />
+ </SectionField>
+ <SectionField label="Size">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ param={fontSizeParam}
+ value={mask.params.fontSize}
+ onPreview={previewNumberParam("fontSize")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ <SectionField label="Spacing">
+ <div className="flex items-start gap-2">
+ <MaskNumberField
+ className="w-1/2"
+ icon={<OcTextWidthIcon size={14} />}
+ param={LETTER_SPACING_PARAM}
+ value={mask.params.letterSpacing ?? 0}
+ onPreview={previewNumberParam("letterSpacing")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="w-1/2"
+ icon={<OcTextHeightIcon size={14} />}
+ param={LINE_HEIGHT_PARAM}
+ value={mask.params.lineHeight ?? 1.2}
+ onPreview={previewNumberParam("lineHeight")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ </>
+ );
+}
+
+function getNumberParamDefinition({
+ definition,
+ key,
+}: {
+ definition: RegisteredMaskDefinition;
+ key: string;
+}): NumberParamDefinition {
+ const param = definition.params.find((candidate) => candidate.key === key);
+
+ if (!param || param.type !== "number") {
+ throw new Error(`Missing number param definition for mask key "${key}"`);
+ }
+
+ return param;
+}
+
+function getMaskNumber<
+ TParams extends Mask["params"],
+ TKey extends keyof TParams & string,
+>({ params, key }: { params: TParams; key: TKey }): number {
+ const value = params[key];
+
+ if (typeof value !== "number") {
+ throw new Error(`Expected numeric mask param for "${key}"`);
+ }
+
+ return value;
+}
+
+function MaskNumberField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ icon,
+ className,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+ icon?: React.ReactNode;
+ className?: string;
+}) {
+ const isPercent = param.unit === "percent";
+ const percentMax = param.max ?? 100;
+ const displayMultiplier = isPercent
+ ? 100 / percentMax
+ : (param.displayMultiplier ?? 1);
+ const min = isPercent ? 0 : param.min;
+ const max = isPercent ? 100 : param.max;
+ const step = isPercent ? 1 : param.step;
+ const displayValue = value * displayMultiplier;
+ const maxFractionDigits = getFractionDigitsForStep({ step });
+
+ const clampDisplay = (nextDisplayValue: number) =>
+ max !== undefined
+ ? clamp({ value: nextDisplayValue, min, max })
+ : Math.max(min, nextDisplayValue);
+
+ const previewFromDisplay = (nextDisplayValue: number) => {
+ onPreview(
+ clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
+ displayMultiplier,
+ );
+ };
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return (
+ clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
+ );
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+ <NumberField
+ className={className}
+ icon={icon}
+ value={draft.displayValue}
+ dragSensitivity="slow"
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ onScrub={previewFromDisplay}
+ onScrubEnd={onCommit}
+ />
+ );
+}
+
+function EmptyView({ onAddMask }: EmptyViewProps) {
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No masks</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-40">
+ Add a mask to hide or reveal parts of this layer.
+ </p>
+ </div>
+ <Button variant="default" size="sm" onClick={onAddMask}>
+ Add mask
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/custom-path.ts
new file mode 100644
index 00000000..0349df63
--- /dev/null
+++ b/apps/web/src/masks/custom-path.ts
@@ -0,0 +1,842 @@
+import type { ElementBounds } from "@/preview/element-bounds";
+
+export interface CustomMaskPathPoint {
+ id: string;
+ x: number;
+ y: number;
+ inX: number;
+ inY: number;
+ outX: number;
+ outY: number;
+}
+
+export type CustomMaskHandlePart = "anchor" | "in" | "out";
+
+function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+
+ const candidate = value as Record<string, unknown>;
+ return (
+ typeof candidate.id === "string" &&
+ typeof candidate.x === "number" &&
+ typeof candidate.y === "number" &&
+ typeof candidate.inX === "number" &&
+ typeof candidate.inY === "number" &&
+ typeof candidate.outX === "number" &&
+ typeof candidate.outY === "number"
+ );
+}
+
+export function parseCustomMaskHandleId({
+ handleId,
+}: {
+ handleId: string;
+}): { pointId: string; part: CustomMaskHandlePart } | null {
+ const match = /^point:(.+):(anchor|in|out)$/.exec(handleId);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ pointId: match[1],
+ part: match[2] as CustomMaskHandlePart,
+ };
+}
+
+export function parseCustomMaskSegmentHandleId({
+ handleId,
+}: {
+ handleId: string;
+}): { segmentIndex: number } | null {
+ const match = /^segment:(\d+)$/.exec(handleId);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ segmentIndex: Number.parseInt(match[1], 10),
+ };
+}
+
+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: CanvasPoint, b: CanvasPoint): number {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return dx * dx + dy * dy;
+}
+
+function lerpPoint(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(canvasPoint, 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(canvasPoint, 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(canvasPoint, 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(p0, p1, clampedT);
+ const p12 = lerpPoint(p1, p2, clampedT);
+ const p23 = lerpPoint(p2, p3, clampedT);
+ const p012 = lerpPoint(p01, p12, clampedT);
+ const p123 = lerpPoint(p12, p23, clampedT);
+ const splitPoint = lerpPoint(p012, p123, 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
new file mode 100644
index 00000000..7db51df7
--- /dev/null
+++ b/apps/web/src/masks/definitions/box-like.ts
@@ -0,0 +1,313 @@
+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 { snapMaskInteraction } 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<BaseMaskParams, "strokeAlign" | "strokeWidth">): 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<RectangleMaskParams> {
+ 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 snapMaskInteraction(args);
+ },
+ };
+}
+
+export function computeBoxMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ bounds,
+}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
+ if (handleId === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ if (handleId === "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 === "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 === "right" || handleId === "left") {
+ const sign = handleId === "right" ? 1 : -1;
+ return {
+ width: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.width + (sign * deltaX * 2) / bounds.width,
+ ),
+ };
+ }
+
+ if (handleId === "bottom" || handleId === "top") {
+ const sign = handleId === "bottom" ? 1 : -1;
+ return {
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height + (sign * deltaY * 2) / bounds.height,
+ ),
+ };
+ }
+
+ if (
+ handleId === "top-left" ||
+ handleId === "top-right" ||
+ handleId === "bottom-left" ||
+ handleId === "bottom-right"
+ ) {
+ const signX = handleId.includes("right") ? 1 : -1;
+ const signY = handleId.includes("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 === "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
new file mode 100644
index 00000000..36b31328
--- /dev/null
+++ b/apps/web/src/masks/definitions/cinematic-bars.ts
@@ -0,0 +1,135 @@
+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<RectangleMaskParams> = {
+ 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: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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,
+ });
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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
new file mode 100644
index 00000000..cd6e2055
--- /dev/null
+++ b/apps/web/src/masks/definitions/custom.ts
@@ -0,0 +1,652 @@
+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,
+ parseCustomMaskHandleId,
+ 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<keyof CustomMaskParams & 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: "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) => ({
+ id: `segment:${segment.index}`,
+ type: "canvas-path" as const,
+ pathData: segment.pathData,
+ coordinateSpace: "canvas" as const,
+ handleId: `segment:${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: `point:${point.id}:anchor`,
+ 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<CustomMaskParams>): Partial<CustomMaskParams> {
+ if (handleId === "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 === "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 === "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 === "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),
+ };
+ }
+
+ const parsedHandle = parseCustomMaskHandleId({ handleId });
+ if (!parsedHandle) {
+ 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: parsedHandle.pointId,
+ updater: (point) => {
+ if (parsedHandle.part === "anchor") {
+ return {
+ ...point,
+ x: localPoint.x,
+ y: localPoint.y,
+ };
+ }
+
+ if (parsedHandle.part === "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<CustomMaskParams> = {
+ 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 === "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 === "rotation") {
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+ }
+
+ if (handleId === "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<CustomMask, "id"> {
+ 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: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as CustomMaskParams;
+ 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,
+ });
+ },
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams as CustomMaskParams;
+ 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
new file mode 100644
index 00000000..037aee70
--- /dev/null
+++ b/apps/web/src/masks/definitions/diamond.ts
@@ -0,0 +1,100 @@
+import type { MaskDefinition, RectangleMaskParams } 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<RectangleMaskParams> = {
+ 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: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildDiamondPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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
new file mode 100644
index 00000000..d0567817
--- /dev/null
+++ b/apps/web/src/masks/definitions/ellipse.ts
@@ -0,0 +1,75 @@
+import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+} from "./box-like";
+
+export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+ 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: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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;
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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
new file mode 100644
index 00000000..d15c09e6
--- /dev/null
+++ b/apps/web/src/masks/definitions/heart.ts
@@ -0,0 +1,142 @@
+import type { MaskDefinition, RectangleMaskParams } 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<RectangleMaskParams> = {
+ 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: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildHeartPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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
new file mode 100644
index 00000000..9cee1020
--- /dev/null
+++ b/apps/web/src/masks/definitions/index.ts
@@ -0,0 +1,74 @@
+import type { BaseMaskParams, MaskDefinition } from "@/masks/types";
+import { masksRegistry, 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<TParams extends BaseMaskParams>({
+ definition,
+ icon,
+}: {
+ definition: MaskDefinition<TParams>;
+ 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
new file mode 100644
index 00000000..7bf1a7fd
--- /dev/null
+++ b/apps/web/src/masks/definitions/rectangle.ts
@@ -0,0 +1,97 @@
+import type { MaskDefinition, RectangleMaskParams } 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<RectangleMaskParams> = {
+ 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: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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
new file mode 100644
index 00000000..d50a2571
--- /dev/null
+++ b/apps/web/src/masks/definitions/split.ts
@@ -0,0 +1,382 @@
+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 { snapMaskInteraction } 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: { 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: unknown;
+ width: number;
+ height: number;
+}): [{ x: number; y: number }, { x: number; y: number }] | null {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ 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(point, 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<SplitMaskParams>): Partial<SplitMaskParams> {
+ if (handleId === "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 === "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 === "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<SplitMaskParams> = {
+ 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 snapMaskInteraction(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: {
+ renderMaskHandlesFeather: true,
+ renderMask({ resolvedParams, ctx, width, height, feather }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ 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);
+ },
+
+ buildPath({ resolvedParams, width, height }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ 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: 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(x1, y1);
+ const isVertex2Inside = isInsideHalfPlane(x2, 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;
+ },
+ 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
new file mode 100644
index 00000000..a7f8819c
--- /dev/null
+++ b/apps/web/src/masks/definitions/star.ts
@@ -0,0 +1,140 @@
+import type { MaskDefinition, RectangleMaskParams } 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<RectangleMaskParams> = {
+ 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: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildStarPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ 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
new file mode 100644
index 00000000..c285f1c8
--- /dev/null
+++ b/apps/web/src/masks/definitions/text.ts
@@ -0,0 +1,438 @@
+import type { ParamDefinition } from "@/params";
+import type {
+ MaskDefinition,
+ MaskParamUpdateArgs,
+ TextMask,
+ TextMaskParams,
+} 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 = DEFAULTS.text.element.textAlign;
+
+const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & 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: "fontSize",
+ label: "Size",
+ type: "number",
+ default: DEFAULTS.text.element.fontSize,
+ 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: string;
+}): ScaleEdgePreference | undefined {
+ if (handleId !== "scale") {
+ return undefined;
+ }
+
+ return {
+ right: true,
+ bottom: true,
+ };
+}
+
+function computeTextMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
+ if (handleId === "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 === "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 === "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 === "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<TextMaskParams> = {
+ 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 === "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 === "rotation") {
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+ }
+
+ if (handleId === "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<TextMask, "id"> {
+ return {
+ type: "text",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ content: "Mask",
+ fontSize: DEFAULTS.text.element.fontSize,
+ fontFamily: DEFAULTS.text.element.fontFamily,
+ fontWeight: DEFAULTS.text.element.fontWeight,
+ fontStyle: DEFAULTS.text.element.fontStyle,
+ textDecoration: DEFAULTS.text.element.textDecoration,
+ 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: {
+ renderMask({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams as TextMaskParams;
+ 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();
+ },
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams as TextMaskParams;
+ 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/dimensions.ts b/apps/web/src/masks/dimensions.ts
new file mode 100644
index 00000000..dcfcdc0c
--- /dev/null
+++ b/apps/web/src/masks/dimensions.ts
@@ -0,0 +1,2 @@
+export const DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO = 0.6;
+export const MIN_MASK_DIMENSION = 0.01;
diff --git a/apps/web/src/masks/feather.ts b/apps/web/src/masks/feather.ts
new file mode 100644
index 00000000..83e80a0c
--- /dev/null
+++ b/apps/web/src/masks/feather.ts
@@ -0,0 +1,2 @@
+export const MAX_FEATHER = 1000;
+export const FEATHER_HANDLE_SCALE = 0.11;
diff --git a/apps/web/src/masks/geometry.ts b/apps/web/src/masks/geometry.ts
new file mode 100644
index 00000000..007dfa49
--- /dev/null
+++ b/apps/web/src/masks/geometry.ts
@@ -0,0 +1,121 @@
+import type { ElementBounds } from "@/preview/element-bounds";
+import type { SnapLine } from "@/preview/preview-snap";
+import { MIN_MASK_DIMENSION } from "@/masks/dimensions";
+import type { RectangleMaskParams } from "@/masks/types";
+
+type CenterMaskParams = {
+ centerX: number;
+ centerY: number;
+};
+
+type SnapGeometryParams = CenterMaskParams & {
+ rotation?: number;
+ width?: number;
+ height?: number;
+ scale?: number;
+};
+
+export function hasCenterParams(
+ params: Partial<CenterMaskParams>,
+): params is CenterMaskParams {
+ return (
+ typeof params.centerX === "number" && typeof params.centerY === "number"
+ );
+}
+
+export function isRectangleMaskParams(
+ params: SnapGeometryParams,
+): params is RectangleMaskParams {
+ return (
+ hasCenterParams(params) &&
+ typeof params.width === "number" &&
+ typeof params.height === "number" &&
+ typeof params.rotation === "number" &&
+ typeof params.scale === "number"
+ );
+}
+
+export function getMaskLocalCenter({
+ params,
+ bounds,
+}: {
+ params: CenterMaskParams;
+ bounds: ElementBounds;
+}): { x: number; y: number } | null {
+ if (!hasCenterParams(params)) return null;
+
+ return {
+ x: params.centerX * bounds.width,
+ y: params.centerY * bounds.height,
+ };
+}
+
+export function setMaskLocalCenter({
+ center,
+ bounds,
+}: {
+ center: { x: number; y: number };
+ bounds: ElementBounds;
+}): { centerX: number; centerY: number } {
+ return {
+ centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
+ centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
+ };
+}
+
+export function getMaskSnapGeometry({
+ params,
+ bounds,
+}: {
+ params: SnapGeometryParams;
+ bounds: ElementBounds;
+}): {
+ position: { x: number; y: number };
+ size: { width: number; height: number };
+ rotation: number;
+} | null {
+ const position = getMaskLocalCenter({ params, bounds });
+ if (!position) return null;
+
+ if (isRectangleMaskParams(params)) {
+ return {
+ position,
+ size: {
+ width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
+ height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
+ },
+ rotation: params.rotation,
+ };
+ }
+
+ return {
+ position,
+ size: { width: 0, height: 0 },
+ rotation: typeof params.rotation === "number" ? params.rotation : 0,
+ };
+}
+
+export function toGlobalMaskSnapLines({
+ lines,
+ bounds,
+ canvasSize,
+}: {
+ lines: SnapLine[];
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+}): SnapLine[] {
+ const centerX = bounds.cx - canvasSize.width / 2;
+ const centerY = bounds.cy - canvasSize.height / 2;
+
+ return lines.map((line) =>
+ line.type === "vertical"
+ ? {
+ type: "vertical" as const,
+ position: centerX + line.position,
+ }
+ : {
+ type: "horizontal" as const,
+ position: centerY + line.position,
+ },
+ );
+}
diff --git a/apps/web/src/masks/handle-positions.ts b/apps/web/src/masks/handle-positions.ts
new file mode 100644
index 00000000..75668959
--- /dev/null
+++ b/apps/web/src/masks/handle-positions.ts
@@ -0,0 +1,497 @@
+import { FEATHER_HANDLE_SCALE } from "@/masks/feather";
+import type { ElementBounds } from "@/preview/element-bounds";
+import type {
+ MaskFeatures,
+ MaskHandlePosition,
+ MaskLineOverlay,
+ MaskOverlay,
+ MaskRectOverlay,
+ RectangleMaskParams,
+ MaskShapeOverlay,
+} from "@/masks/types";
+
+const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
+const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
+const LINE_EXTENT_MULTIPLIER = 50;
+
+const CURSOR = {
+ rotate: "crosshair",
+ resizeDiagonal: "nwse-resize",
+ resizeHorizontal: "ew-resize",
+ resizeVertical: "ns-resize",
+} as const;
+
+/**
+ * The renderer defines the split line as:
+ * - normal direction: (cos(rotation), sin(rotation))
+ * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
+ * - reference point: (centerX * width, centerY * height) from element centre
+ *
+ * So rotation=0 → normal points right → line runs vertically.
+ */
+export function getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+}): { start: { x: number; y: number }; end: { x: number; y: number } } {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+ const lineDirX = -normalY;
+ const lineDirY = normalX;
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
+
+ return {
+ start: {
+ x: cx - lineDirX * extent,
+ y: cy - lineDirY * extent,
+ },
+ end: {
+ x: cx + lineDirX * extent,
+ y: cy + lineDirY * extent,
+ },
+ };
+}
+
+export function getLineMaskOverlay({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+ handleId = "position",
+ cursor = "move",
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+ handleId?: string;
+ cursor?: string;
+}): MaskLineOverlay {
+ const { start, end } = getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+ });
+
+ return {
+ id: "line",
+ type: "line",
+ start,
+ end,
+ handleId,
+ cursor,
+ };
+}
+
+export function getLineMaskHandlePositions({
+ centerX,
+ centerY,
+ rotation,
+ feather,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ feather: number;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
+ const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
+
+ return [
+ {
+ id: "rotation",
+ x: cx + normalX * iconOffsetCanvas,
+ y: cy + normalY * iconOffsetCanvas,
+ cursor: CURSOR.rotate,
+ kind: "icon",
+ icon: "rotate",
+ },
+ {
+ id: "feather",
+ x: cx - normalX * featherOffset,
+ y: cy - normalY * featherOffset,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "icon",
+ icon: "feather",
+ },
+ ];
+}
+
+function rotatePoint({
+ localX,
+ localY,
+ cx,
+ cy,
+ angleRad,
+}: {
+ localX: number;
+ localY: number;
+ cx: number;
+ cy: number;
+ angleRad: number;
+}): { x: number; y: number } {
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ return {
+ x: cx + localX * cos - localY * sin,
+ y: cy + localX * sin + localY * cos,
+ };
+}
+
+export function getBoxMaskHandlePositions({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ feather,
+ sizeMode,
+ showScaleHandle = true,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ feather: number;
+ sizeMode: MaskFeatures["sizeMode"];
+ showScaleHandle?: boolean;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+ const angleRad = (rotation * Math.PI) / 180;
+ const halfWidth = (width * bounds.width) / 2;
+ const halfHeight = (height * bounds.height) / 2;
+
+ const handles: MaskHandlePosition[] = [];
+ const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
+
+ const rotHandle = rotatePoint({
+ localX: 0,
+ localY: -halfHeight - handleOffsetCanvas,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "rotation",
+ x: rotHandle.x,
+ y: rotHandle.y,
+ cursor: CURSOR.rotate,
+ kind: "icon",
+ icon: "rotate",
+ });
+
+ const featherHandle = rotatePoint({
+ localX: 0,
+ localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "feather",
+ x: featherHandle.x,
+ y: featherHandle.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "icon",
+ icon: "feather",
+ });
+
+ if (sizeMode === "width-height") {
+ const corners = [
+ { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
+ { localX: halfWidth, localY: -halfHeight, id: "top-right" },
+ { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
+ { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
+ ];
+ for (const { localX, localY, id } of corners) {
+ const point = rotatePoint({ localX, localY, cx, cy, angleRad });
+ handles.push({
+ id,
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ kind: "corner",
+ });
+ }
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ } else if (sizeMode === "height-only") {
+ const top = rotatePoint({
+ localX: 0,
+ localY: -halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "top",
+ x: top.x,
+ y: top.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ } else if (sizeMode === "width-only") {
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ } else if (sizeMode === "uniform" && showScaleHandle) {
+ const point = rotatePoint({
+ localX: halfWidth,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "scale",
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ kind: "corner",
+ });
+ }
+
+ return handles;
+}
+
+export function getBoxMaskRectOverlay({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ bounds,
+ handleId = "position",
+ cursor = "move",
+ dashed = false,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ bounds: ElementBounds;
+ handleId?: string;
+ cursor?: string;
+ dashed?: boolean;
+}): MaskRectOverlay {
+ return {
+ id: "bounding-box",
+ type: "rect",
+ center: {
+ x: bounds.cx + centerX * bounds.width,
+ y: bounds.cy + centerY * bounds.height,
+ },
+ width: width * bounds.width,
+ height: height * bounds.height,
+ rotation,
+ handleId,
+ cursor,
+ dashed,
+ };
+}
+
+export function getBoxMaskShapeOverlay({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ bounds,
+ pathData,
+ handleId = "position",
+ cursor = "move",
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ bounds: ElementBounds;
+ pathData: string;
+ handleId?: string;
+ cursor?: string;
+}): MaskShapeOverlay {
+ return {
+ id: "shape-outline",
+ type: "shape",
+ center: {
+ x: bounds.cx + centerX * bounds.width,
+ y: bounds.cy + centerY * bounds.height,
+ },
+ width: width * bounds.width,
+ height: height * bounds.height,
+ rotation,
+ pathData,
+ handleId,
+ cursor,
+ };
+}
+
+export function getBoxMaskOverlays({
+ params,
+ bounds,
+ pathData,
+ showBoundingBox = true,
+}: {
+ params: Pick<
+ RectangleMaskParams,
+ "centerX" | "centerY" | "width" | "height" | "rotation"
+ >;
+ bounds: ElementBounds;
+ pathData?: string;
+ showBoundingBox?: boolean;
+}): MaskOverlay[] {
+ const overlays: MaskOverlay[] = [];
+ if (showBoundingBox) {
+ overlays.push(
+ getBoxMaskRectOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: params.width,
+ height: params.height,
+ rotation: params.rotation,
+ bounds,
+ dashed: Boolean(pathData),
+ }),
+ );
+ }
+
+ if (pathData) {
+ overlays.push(
+ getBoxMaskShapeOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: params.width,
+ height: params.height,
+ rotation: params.rotation,
+ bounds,
+ pathData,
+ }),
+ );
+ }
+
+ return overlays;
+}
diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts
new file mode 100644
index 00000000..3544e239
--- /dev/null
+++ b/apps/web/src/masks/index.ts
@@ -0,0 +1,18 @@
+import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
+import { masksRegistry } from "./registry";
+import { generateUUID } from "@/utils/id";
+
+export { masksRegistry } from "./registry";
+export { registerDefaultMasks } from "./definitions";
+
+export function buildDefaultMaskInstance({
+ maskType,
+ elementSize,
+}: {
+ maskType: MaskType;
+ elementSize?: { width: number; height: number };
+}): Mask {
+ const definition = masksRegistry.get(maskType);
+ const context: MaskDefaultContext = { elementSize };
+ return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
+}
diff --git a/apps/web/src/masks/param-update.ts b/apps/web/src/masks/param-update.ts
new file mode 100644
index 00000000..e5c7f5b7
--- /dev/null
+++ b/apps/web/src/masks/param-update.ts
@@ -0,0 +1,26 @@
+import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/masks/feather";
+
+export function computeFeatherUpdate({
+ startFeather,
+ deltaX,
+ deltaY,
+ directionX,
+ directionY,
+}: {
+ startFeather: number;
+ deltaX: number;
+ deltaY: number;
+ directionX: number;
+ directionY: number;
+}): { feather: number } {
+ const projection = deltaX * directionX + deltaY * directionY;
+ return {
+ feather: Math.max(
+ 0,
+ Math.min(
+ MAX_FEATHER,
+ Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
+ ),
+ ),
+ };
+}
diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts
new file mode 100644
index 00000000..00b276c8
--- /dev/null
+++ b/apps/web/src/masks/registry.ts
@@ -0,0 +1,122 @@
+import { MAX_FEATHER } from "@/masks/feather";
+import type { ParamDefinition } from "@/params";
+import type {
+ BaseMaskParams,
+ MaskDefaultContext,
+ MaskDefinition,
+ MaskInteractionResult,
+ MaskSnapArgs,
+ MaskSnapResult,
+ MaskType,
+} from "@/masks/types";
+import type { HugeiconsIconProps } from "@hugeicons/react";
+import { DefinitionRegistry } from "@/params/registry";
+
+export type MaskIconProps = {
+ icon: HugeiconsIconProps["icon"];
+ strokeWidth?: number;
+};
+
+const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
+ keyof BaseMaskParams & string
+>[] = [
+ {
+ key: "feather",
+ label: "Feather",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: MAX_FEATHER,
+ step: 1,
+ unit: "percent",
+ },
+ {
+ key: "strokeWidth",
+ label: "Stroke width",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ {
+ key: "strokeColor",
+ label: "Stroke color",
+ type: "color",
+ default: "#ffffff",
+ },
+];
+
+export interface RegisteredMaskDefinition {
+ type: MaskType;
+ name: string;
+ features: MaskDefinition<BaseMaskParams>["features"];
+ params: ParamDefinition<string>[];
+ renderer: MaskDefinition<BaseMaskParams>["renderer"];
+ interaction: {
+ getInteraction(args: {
+ params: BaseMaskParams;
+ bounds: Parameters<
+ MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
+ >[0]["bounds"];
+ displayScale: number;
+ scaleX: number;
+ scaleY: number;
+ }): MaskInteractionResult;
+ snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
+ };
+ isActive?: (params: BaseMaskParams) => boolean;
+ buildDefault(
+ context: MaskDefaultContext,
+ ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
+ computeParamUpdate(
+ args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
+ ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
+ icon: MaskIconProps;
+}
+
+export class MasksRegistry extends DefinitionRegistry<
+ MaskType,
+ RegisteredMaskDefinition
+> {
+ constructor() {
+ super("mask");
+ }
+
+ registerMask<TParams extends BaseMaskParams>({
+ definition,
+ icon,
+ }: {
+ definition: MaskDefinition<TParams>;
+ icon: MaskIconProps;
+ }): void {
+ const withBaseParams: RegisteredMaskDefinition = {
+ type: definition.type,
+ name: definition.name,
+ features: definition.features,
+ params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
+ renderer: definition.renderer,
+ interaction: {
+ getInteraction(args) {
+ return definition.interaction.getInteraction(args as never);
+ },
+ snap: definition.interaction.snap
+ ? (args) => definition.interaction.snap?.(args as never) as never
+ : undefined,
+ },
+ isActive: definition.isActive
+ ? (params) => definition.isActive?.(params as TParams) ?? true
+ : undefined,
+ buildDefault(context) {
+ return definition.buildDefault(context);
+ },
+ computeParamUpdate(args) {
+ return definition.computeParamUpdate(args as never);
+ },
+ icon,
+ };
+ this.register(definition.type, withBaseParams);
+ }
+}
+
+export const masksRegistry = new MasksRegistry();
diff --git a/apps/web/src/masks/snap.ts b/apps/web/src/masks/snap.ts
new file mode 100644
index 00000000..cff62ebb
--- /dev/null
+++ b/apps/web/src/masks/snap.ts
@@ -0,0 +1,336 @@
+import type { ElementBounds } from "@/preview/element-bounds";
+import { MIN_MASK_DIMENSION } from "@/masks/dimensions";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
+ type SnapLine,
+} from "@/preview/preview-snap";
+import type { RectangleMaskParams, SplitMaskParams } from "@/masks/types";
+import {
+ isRectangleMaskParams,
+ getMaskSnapGeometry,
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "./geometry";
+
+type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
+
+type MaskSnapResult<TParams extends SharedMaskParams> = {
+ params: TParams;
+ activeLines: SnapLine[];
+};
+
+const CORNER_SIZE_HANDLES = new Set([
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+]);
+
+function getClampedRatio({
+ next,
+ base,
+}: {
+ next: number;
+ base: number;
+}): number {
+ return (
+ Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
+ );
+}
+
+function getPreferredEdges({
+ handleId,
+}: {
+ handleId: string;
+}): ScaleEdgePreference | undefined {
+ const preferredEdges = {
+ left:
+ handleId === "left" ||
+ handleId === "top-left" ||
+ handleId === "bottom-left",
+ right:
+ handleId === "right" ||
+ handleId === "top-right" ||
+ handleId === "bottom-right",
+ top:
+ handleId === "top" || handleId === "top-left" || handleId === "top-right",
+ bottom:
+ handleId === "bottom" ||
+ handleId === "bottom-left" ||
+ handleId === "bottom-right",
+ } satisfies ScaleEdgePreference;
+
+ return Object.values(preferredEdges).some(Boolean)
+ ? preferredEdges
+ : undefined;
+}
+
+function snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ proposedParams: SharedMaskParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<SharedMaskParams> {
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: geometry.position,
+ canvasSize: bounds,
+ elementSize: geometry.size,
+ rotation: geometry.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+}
+
+function snapMaskRotation({
+ proposedParams,
+}: {
+ proposedParams: SharedMaskParams;
+}): MaskSnapResult<SharedMaskParams> {
+ if (typeof proposedParams.rotation !== "number") {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+}
+
+function snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: SharedMaskParams;
+ proposedParams: SharedMaskParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<SharedMaskParams> {
+ if (
+ !isRectangleMaskParams(startParams) ||
+ !isRectangleMaskParams(proposedParams)
+ ) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const localCanvasSize = bounds;
+ const baseWidth =
+ Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
+ const baseHeight =
+ Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
+ const preferredEdges = getPreferredEdges({ handleId });
+
+ if (handleId === "right" || handleId === "left") {
+ const proposedScaleX = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { x } = snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY: 1,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: x.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "top" || handleId === "bottom") {
+ const proposedScaleY = getClampedRatio({
+ next: proposedParams.height,
+ base: startParams.height,
+ });
+ const { y } = snapScaleAxes({
+ proposedScaleX: 1,
+ proposedScaleY,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height * y.snappedScale,
+ ),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: y.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "scale") {
+ const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
+ const proposedScale = getClampedRatio({
+ next: proposedParams.scale,
+ base: startParams.scale,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth: baseWidth * baseScale,
+ baseHeight: baseHeight * baseScale,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (CORNER_SIZE_HANDLES.has(handleId)) {
+ const proposedScale = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
+ height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return { params: proposedParams, activeLines: [] };
+}
+
+export function snapMaskInteraction<TParams extends SharedMaskParams>({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: TParams;
+ proposedParams: TParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<TParams> {
+ if (handleId === "position") {
+ return snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) as MaskSnapResult<TParams>;
+ }
+
+ if (handleId === "rotation") {
+ return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
+ }
+
+ return snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) as MaskSnapResult<TParams>;
+}
diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts
new file mode 100644
index 00000000..4f6c90aa
--- /dev/null
+++ b/apps/web/src/masks/types.ts
@@ -0,0 +1,298 @@
+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 {
+ TextDecoration,
+ TextFontStyle,
+ TextFontWeight,
+} from "@/text/primitives";
+
+export type MaskType =
+ | "split"
+ | "cinematic-bars"
+ | "rectangle"
+ | "ellipse"
+ | "heart"
+ | "diamond"
+ | "star"
+ | "text"
+ | "custom";
+
+export interface BaseMaskParams {
+ feather: number;
+ inverted: boolean;
+ strokeColor: string;
+ strokeWidth: number;
+ strokeAlign: "inside" | "center" | "outside";
+}
+
+export interface SplitMaskParams extends BaseMaskParams {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+}
+
+export interface RectangleMaskParams extends BaseMaskParams {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ scale: number;
+}
+
+export interface TextMaskParams extends BaseMaskParams {
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ fontWeight: TextFontWeight;
+ fontStyle: TextFontStyle;
+ textDecoration: TextDecoration;
+ letterSpacing: number;
+ lineHeight: number;
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+}
+
+export interface CustomMaskParams extends BaseMaskParams {
+ path: CustomMaskPathPoint[];
+ closed: boolean;
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+}
+
+export interface SplitMask {
+ id: string;
+ type: "split";
+ params: SplitMaskParams;
+}
+
+export interface CinematicBarsMask {
+ id: string;
+ type: "cinematic-bars";
+ params: RectangleMaskParams;
+}
+
+export interface RectangleMask {
+ id: string;
+ type: "rectangle";
+ params: RectangleMaskParams;
+}
+
+export interface EllipseMask {
+ id: string;
+ type: "ellipse";
+ params: RectangleMaskParams;
+}
+
+export interface HeartMask {
+ id: string;
+ type: "heart";
+ params: RectangleMaskParams;
+}
+
+export interface DiamondMask {
+ id: string;
+ type: "diamond";
+ params: RectangleMaskParams;
+}
+
+export interface StarMask {
+ id: string;
+ type: "star";
+ params: RectangleMaskParams;
+}
+
+export interface TextMask {
+ id: string;
+ type: "text";
+ params: TextMaskParams;
+}
+
+export interface CustomMask {
+ id: string;
+ type: "custom";
+ params: CustomMaskParams;
+}
+
+export type Mask =
+ | SplitMask
+ | CinematicBarsMask
+ | RectangleMask
+ | EllipseMask
+ | HeartMask
+ | DiamondMask
+ | StarMask
+ | TextMask
+ | CustomMask;
+
+export interface MaskRenderer {
+ buildPath?: (params: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+ }) => Path2D;
+ buildStrokePath?: (params: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+ }) => Path2D;
+ /** Renders the feathered mask directly onto ctx, bypassing JFA. */
+ renderMask?: (params: {
+ resolvedParams: unknown;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ feather: number;
+ }) => void;
+ renderMaskHandlesFeather?: boolean;
+ renderStroke?: (params: {
+ resolvedParams: unknown;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ }) => void;
+}
+
+export interface MaskFeatures {
+ hasPosition: boolean;
+ hasRotation: boolean;
+ sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
+}
+
+export type MaskHandleIcon = "rotate" | "feather";
+
+export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
+
+export interface MaskHandlePosition {
+ id: string;
+ x: number;
+ y: number;
+ cursor: string;
+ kind: MaskHandleKind;
+ isSelected?: boolean;
+ edgeAxis?: "horizontal" | "vertical";
+ rotation?: number;
+ icon?: MaskHandleIcon;
+}
+
+export interface MaskLineOverlay {
+ id: string;
+ type: "line";
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ cursor?: string;
+ handleId?: string;
+}
+
+export interface MaskRectOverlay {
+ id: string;
+ type: "rect";
+ center: { x: number; y: number };
+ width: number;
+ height: number;
+ rotation: number;
+ dashed?: boolean;
+ cursor?: string;
+ handleId?: string;
+}
+
+export interface MaskShapeOverlay {
+ id: string;
+ type: "shape";
+ center: { x: number; y: number };
+ width: number;
+ height: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ handleId?: string;
+}
+
+export interface MaskCanvasPathOverlay {
+ id: string;
+ type: "canvas-path";
+ pathData: string;
+ coordinateSpace?: "canvas" | "overlay";
+ cursor?: string;
+ handleId?: string;
+ strokeWidth?: number;
+ strokeOpacity?: number;
+}
+
+export type MaskOverlay =
+ | MaskLineOverlay
+ | MaskRectOverlay
+ | MaskShapeOverlay
+ | MaskCanvasPathOverlay;
+
+export interface MaskDefaultContext {
+ elementSize?: { width: number; height: number };
+}
+
+export interface MaskParamUpdateArgs<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
+ handleId: string;
+ startParams: TParams;
+ deltaX: number;
+ deltaY: number;
+ startCanvasX: number;
+ startCanvasY: number;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+}
+
+export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
+ handleId: string;
+ startParams: TParams;
+ proposedParams: TParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}
+
+export interface MaskSnapResult<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
+ params: TParams;
+ activeLines: SnapLine[];
+}
+
+export interface MaskInteractionResult {
+ handles: MaskHandlePosition[];
+ overlays: MaskOverlay[];
+}
+
+export interface MaskInteractionDefinition<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
+ getInteraction(args: {
+ params: TParams;
+ bounds: ElementBounds;
+ displayScale: number;
+ scaleX: number;
+ scaleY: number;
+ }): MaskInteractionResult;
+ snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
+}
+
+export interface MaskDefinition<
+ TParams extends BaseMaskParams = BaseMaskParams,
+> {
+ type: MaskType;
+ name: string;
+ features: MaskFeatures;
+ params: ParamDefinition<keyof TParams & string>[];
+ renderer: MaskRenderer;
+ interaction: MaskInteractionDefinition<TParams>;
+ /** When defined and returning false, the mask is not applied and the element renders fully visible. */
+ isActive?: (params: TParams) => boolean;
+ buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
+ computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
+}
diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts
new file mode 100644
index 00000000..b81de4f4
--- /dev/null
+++ b/apps/web/src/masks/use-mask-handles.ts
@@ -0,0 +1,633 @@
+import { useCallback, useEffect, 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 {
+ parseCustomMaskHandleId,
+ parseCustomMaskSegmentHandleId,
+} from "@/masks/custom-path";
+import { appendPointToCustomMask } from "@/masks/definitions/custom";
+import {
+ getVisibleElementsWithBounds,
+ type ElementBounds,
+} from "@/preview/element-bounds";
+import {
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ type SnapLine,
+} from "@/preview/preview-snap";
+import type { SelectedMaskPointSelection } from "@/selection/editor-selection";
+import type { Mask, MaskInteractionResult } from "@/masks/types";
+import type { MaskableElement } from "@/timeline";
+import { registerCanceller } from "@/editor/cancel-interaction";
+
+interface DragState {
+ trackId: string;
+ elementId: string;
+ handleId: string;
+ startCanvasX: number;
+ startCanvasY: number;
+ startParams: Mask["params"];
+}
+
+interface PendingSegmentInsertState {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ segmentIndex: number;
+ startClientX: number;
+ startClientY: number;
+ startCanvasX: number;
+ startCanvasY: number;
+ startParams: Mask["params"];
+ bounds: ElementBounds;
+}
+
+const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4;
+
+function isMaskSelectionForElement({
+ trackId,
+ elementId,
+ maskId,
+ selection,
+}: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ selection: SelectedMaskPointSelection | null;
+}): boolean {
+ if (!selection) {
+ return false;
+ }
+ return (
+ selection.trackId === trackId &&
+ selection.elementId === elementId &&
+ selection.maskId === maskId
+ );
+}
+
+function replaceElementMask({
+ masks,
+ updatedMask,
+}: {
+ masks: MaskableElement["masks"];
+ updatedMask: Mask;
+}): Mask[] {
+ return (masks ?? []).map((mask) =>
+ mask.id === updatedMask.id ? updatedMask : mask,
+ );
+}
+
+function withUpdatedMaskParams<TMask extends Mask>({
+ mask,
+ params,
+}: {
+ mask: TMask;
+ params: TMask["params"];
+}): TMask {
+ return {
+ ...mask,
+ params,
+ };
+}
+
+export function useMaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
+ const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
+ const dragStateRef = useRef<DragState | null>(null);
+ const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
+ null,
+ );
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
+ );
+
+ const tracks = useEditor(
+ (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
+ );
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const selectedMaskPointSelection = useEditor((e) =>
+ e.selection.getSelectedMaskPointSelection(),
+ );
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithMask =
+ selectedElements.length === 1
+ ? (() => {
+ const sel = selectedElements[0];
+ const entry = elementsWithBounds.find(
+ (item) =>
+ item.trackId === sel.trackId && item.elementId === sel.elementId,
+ );
+ if (!entry) return null;
+ const element = entry.element as MaskableElement;
+ const masks = element.masks ?? [];
+ if (masks.length === 0) return null;
+ const activeMaskId = masks.some((mask) =>
+ isMaskSelectionForElement({
+ trackId: entry.trackId,
+ elementId: entry.elementId,
+ maskId: mask.id,
+ selection: selectedMaskPointSelection,
+ }),
+ )
+ ? selectedMaskPointSelection?.maskId
+ : masks[0].id;
+ const mask =
+ masks.find((candidate) => candidate.id === activeMaskId) ??
+ masks[0];
+ return { ...entry, element, mask };
+ })()
+ : null;
+
+ const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
+ selectedWithMask
+ ? (() => {
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+ const displayScale = (scaleX + scaleY) / 2;
+ return def.interaction.getInteraction({
+ params: selectedWithMask.mask.params,
+ bounds: selectedWithMask.bounds,
+ displayScale,
+ scaleX,
+ scaleY,
+ });
+ })()
+ : { handles: [], overlays: [] };
+
+ const selectedPointIds = new Set(
+ selectedWithMask &&
+ isMaskSelectionForElement({
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ selection: selectedMaskPointSelection,
+ })
+ ? (selectedMaskPointSelection?.pointIds ?? [])
+ : [],
+ );
+
+ const handlePositions = baseHandlePositions.map((handle) => {
+ const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
+ if (!parsedHandle || parsedHandle.part !== "anchor") {
+ return handle;
+ }
+ return {
+ ...handle,
+ isSelected: selectedPointIds.has(parsedHandle.pointId),
+ };
+ });
+
+ const customMaskPointIds =
+ selectedWithMask?.mask.type === "custom"
+ ? handlePositions
+ .filter((h) => h.kind === "point")
+ .map((h) => {
+ const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
+ return parsedHandle?.part === "anchor"
+ ? parsedHandle.pointId
+ : null;
+ })
+ .filter((id): id is string => id !== null)
+ : [];
+ const isCreatingCustomMask =
+ selectedWithMask?.mask.type === "custom" &&
+ (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
+
+ useEffect(() => {
+ if (!selectedMaskPointSelection) {
+ return;
+ }
+ if (
+ !selectedWithMask ||
+ selectedWithMask.mask.type !== "custom" ||
+ !isMaskSelectionForElement({
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ selection: selectedMaskPointSelection,
+ })
+ ) {
+ editor.selection.clearMaskPointSelection();
+ return;
+ }
+
+ const availablePointIds = new Set(customMaskPointIds);
+ const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter(
+ (pointId) => availablePointIds.has(pointId),
+ );
+ if (
+ nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length
+ ) {
+ return;
+ }
+ if (nextSelectedPointIds.length === 0) {
+ editor.selection.clearMaskPointSelection();
+ return;
+ }
+
+ editor.selection.setSelectedMaskPoints({
+ selection: {
+ ...selectedMaskPointSelection,
+ pointIds: nextSelectedPointIds,
+ },
+ });
+ }, [
+ customMaskPointIds,
+ editor.selection,
+ selectedMaskPointSelection,
+ selectedWithMask,
+ ]);
+
+ const updateCustomMaskPointSelection = useCallback(
+ ({
+ pointId,
+ toggleSelection,
+ }: {
+ pointId: string;
+ toggleSelection: boolean;
+ }) => {
+ if (!selectedWithMask || selectedWithMask.mask.type !== "custom") {
+ return;
+ }
+
+ const isSelectionForCurrentMask = isMaskSelectionForElement({
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ selection: selectedMaskPointSelection,
+ });
+ const currentPointIds = isSelectionForCurrentMask
+ ? (selectedMaskPointSelection?.pointIds ?? [])
+ : [];
+ const nextPointIds = toggleSelection
+ ? currentPointIds.includes(pointId)
+ ? currentPointIds.filter(
+ (currentPointId) => currentPointId !== pointId,
+ )
+ : [...currentPointIds, pointId]
+ : [pointId];
+
+ if (nextPointIds.length === 0) {
+ editor.selection.clearMaskPointSelection();
+ return;
+ }
+
+ editor.selection.setSelectedMaskPoints({
+ selection: {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ pointIds: nextPointIds,
+ },
+ });
+ },
+ [editor.selection, selectedMaskPointSelection, selectedWithMask],
+ );
+
+ const clearMaskHandleState = useCallback(() => {
+ dragStateRef.current = null;
+ pendingSegmentInsertRef.current = null;
+ setActiveHandleId(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandleId) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearMaskHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [
+ activeHandleId,
+ clearMaskHandleState,
+ editor.timeline,
+ releaseCapturedPointer,
+ ]);
+
+ const handlePointerDown = useCallback(
+ ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
+ if (!selectedWithMask) return;
+ if (event.button !== 0) return;
+ event.stopPropagation();
+ const parsedHandle =
+ selectedWithMask.mask.type === "custom"
+ ? parseCustomMaskHandleId({ handleId })
+ : null;
+ const parsedSegmentHandle =
+ selectedWithMask.mask.type === "custom"
+ ? parseCustomMaskSegmentHandleId({ handleId })
+ : null;
+ if (isCreatingCustomMask) {
+ const firstPointId = customMaskPointIds[0];
+ if (
+ firstPointId &&
+ handleId === `point:${firstPointId}:anchor` &&
+ customMaskPointIds.length >= 3 &&
+ selectedWithMask.mask.type === "custom"
+ ) {
+ const updatedMask = withUpdatedMaskParams({
+ mask: selectedWithMask.mask,
+ params: {
+ ...selectedWithMask.mask.params,
+ closed: true,
+ },
+ });
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ patch: {
+ masks: replaceElementMask({
+ masks: selectedWithMask.element.masks,
+ updatedMask,
+ }),
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ }
+ return;
+ }
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") {
+ setActiveHandleId(handleId);
+ pendingSegmentInsertRef.current = {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ maskId: selectedWithMask.mask.id,
+ segmentIndex: parsedSegmentHandle.segmentIndex,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ startCanvasX: pos.x,
+ startCanvasY: pos.y,
+ startParams: { ...selectedWithMask.mask.params },
+ bounds: selectedWithMask.bounds,
+ };
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ return;
+ }
+
+ if (parsedHandle?.part === "anchor") {
+ updateCustomMaskPointSelection({
+ pointId: parsedHandle.pointId,
+ toggleSelection: event.shiftKey,
+ });
+ if (event.shiftKey) {
+ return;
+ }
+ } else if (selectedWithMask.mask.type === "custom") {
+ editor.selection.clearMaskPointSelection();
+ }
+
+ dragStateRef.current = {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ handleId,
+ startCanvasX: pos.x,
+ startCanvasY: pos.y,
+ startParams: { ...selectedWithMask.mask.params },
+ };
+ setActiveHandleId(handleId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [
+ customMaskPointIds,
+ editor.selection,
+ editor.timeline,
+ isCreatingCustomMask,
+ selectedWithMask,
+ updateCustomMaskPointSelection,
+ viewport,
+ ],
+ );
+
+ const handleCanvasPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!selectedWithMask || !isCreatingCustomMask) {
+ return;
+ }
+ if (event.button !== 0) {
+ return;
+ }
+ if (selectedWithMask.mask.type !== "custom") {
+ return;
+ }
+
+ event.stopPropagation();
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) {
+ return;
+ }
+
+ const nextParams = appendPointToCustomMask({
+ params: selectedWithMask.mask.params,
+ canvasPoint: pos,
+ bounds: selectedWithMask.bounds,
+ });
+ const updatedMask = withUpdatedMaskParams({
+ mask: selectedWithMask.mask,
+ params: nextParams,
+ });
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ patch: {
+ masks: replaceElementMask({
+ masks: selectedWithMask.element.masks,
+ updatedMask,
+ }),
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ },
+ [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const pendingSegmentInsert = pendingSegmentInsertRef.current;
+ if (pendingSegmentInsert && !dragStateRef.current) {
+ const distance = Math.hypot(
+ event.clientX - pendingSegmentInsert.startClientX,
+ event.clientY - pendingSegmentInsert.startClientY,
+ );
+ if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) {
+ dragStateRef.current = {
+ trackId: pendingSegmentInsert.trackId,
+ elementId: pendingSegmentInsert.elementId,
+ handleId: "position",
+ startCanvasX: pendingSegmentInsert.startCanvasX,
+ startCanvasY: pendingSegmentInsert.startCanvasY,
+ startParams: pendingSegmentInsert.startParams,
+ };
+ pendingSegmentInsertRef.current = null;
+ setActiveHandleId("position");
+ }
+ }
+
+ const drag = dragStateRef.current;
+ if (!drag || !selectedWithMask) return;
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ const deltaX = pos.x - drag.startCanvasX;
+ const deltaY = pos.y - drag.startCanvasY;
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+
+ const rawParams = def.computeParamUpdate({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ deltaX,
+ deltaY,
+ startCanvasX: drag.startCanvasX,
+ startCanvasY: drag.startCanvasY,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ });
+ const proposedParams = { ...drag.startParams, ...rawParams };
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { params: nextParams, activeLines } = isShiftHeldRef.current
+ ? { params: proposedParams, activeLines: [] as SnapLine[] }
+ : (def.interaction.snap?.({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ proposedParams,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ snapThreshold,
+ }) ?? { params: proposedParams, activeLines: [] as SnapLine[] });
+
+ onSnapLinesChange?.(activeLines);
+
+ const updatedMask = withUpdatedMaskParams({
+ mask: selectedWithMask.mask,
+ params: nextParams as typeof selectedWithMask.mask.params,
+ });
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: drag.trackId,
+ elementId: drag.elementId,
+ updates: {
+ masks: replaceElementMask({
+ masks: selectedWithMask.element.masks,
+ updatedMask,
+ }),
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ },
+ [
+ selectedWithMask,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ const pendingSegmentInsert = pendingSegmentInsertRef.current;
+ if (pendingSegmentInsert && !dragStateRef.current) {
+ editor.timeline.insertCustomMaskPoint({
+ trackId: pendingSegmentInsert.trackId,
+ elementId: pendingSegmentInsert.elementId,
+ maskId: pendingSegmentInsert.maskId,
+ segmentIndex: pendingSegmentInsert.segmentIndex,
+ canvasPoint: {
+ x: pendingSegmentInsert.startCanvasX,
+ y: pendingSegmentInsert.startCanvasY,
+ },
+ bounds: pendingSegmentInsert.bounds,
+ });
+ clearMaskHandleState();
+ releaseCapturedPointer();
+ return;
+ }
+
+ if (dragStateRef.current) {
+ editor.timeline.commitPreview();
+ clearMaskHandleState();
+ }
+ releaseCapturedPointer();
+ }, [clearMaskHandleState, editor, releaseCapturedPointer]);
+
+ return {
+ selectedWithMask,
+ handlePositions,
+ overlays,
+ isCreatingCustomMask,
+ handleCanvasPointerDown,
+ activeHandleId,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}
diff --git a/apps/web/src/masks/utils.ts b/apps/web/src/masks/utils.ts
new file mode 100644
index 00000000..b1ac2c39
--- /dev/null
+++ b/apps/web/src/masks/utils.ts
@@ -0,0 +1,64 @@
+const LINE_PARALLEL_EPSILON = 1e-10;
+
+export function lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}): { x: number; y: number } | null {
+ const distance1 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x1,
+ y: y1,
+ });
+ const distance2 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x2,
+ y: y2,
+ });
+ const denom = distance1 - distance2;
+ if (Math.abs(denom) < LINE_PARALLEL_EPSILON) return null;
+ const t = distance1 / denom;
+ if (t < 0 || t > 1) return null;
+ return {
+ x: x1 + (x2 - x1) * t,
+ y: y1 + (y2 - y1) * t,
+ };
+}
+
+export function halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x,
+ y,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x: number;
+ y: number;
+}): number {
+ return (x - lineX) * normalX + (y - lineY) * normalY;
+}