From 5a2aaf3601b6b050603f51e487a31fb07862a21f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 13 Apr 2026 04:40:48 +0200 Subject: [PATCH] feat: cinematic bars, diamond, heart, and star masks Made-with: Cursor --- .../panels/properties/tabs/masks-tab.tsx | 38 +++ .../lib/masks/definitions/cinematic-bars.ts | 132 +++++++++ apps/web/src/lib/masks/definitions/diamond.ts | 97 +++++++ apps/web/src/lib/masks/definitions/heart.ts | 139 +++++++++ apps/web/src/lib/masks/definitions/index.ts | 102 ++++--- apps/web/src/lib/masks/definitions/star.ts | 137 +++++++++ apps/web/src/lib/masks/types.ts | 268 ++++++++++-------- 7 files changed, 759 insertions(+), 154 deletions(-) create mode 100644 apps/web/src/lib/masks/definitions/cinematic-bars.ts create mode 100644 apps/web/src/lib/masks/definitions/diamond.ts create mode 100644 apps/web/src/lib/masks/definitions/heart.ts create mode 100644 apps/web/src/lib/masks/definitions/star.ts diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx index 8148b457..cfc69720 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -450,6 +450,44 @@ function MaskParamsFields({ )} + {definition.features.sizeMode === "height-only" && + "height" in mask.params && ( + + + + )} + + {definition.features.sizeMode === "width-only" && + "width" in mask.params && ( + + + + )} + {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( 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 = { + type: "cinematic-bars", + name: "Cinematic Bars", + overlayShape: "box", + buildOverlayPath({ width, height }) { + return `M 0,0 H ${width} V ${height} H 0 Z`; + }, + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "height-only", + }, + params: BOX_LIKE_MASK_PARAMS, + 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/lib/masks/definitions/diamond.ts b/apps/web/src/lib/masks/definitions/diamond.ts new file mode 100644 index 00000000..8af3144f --- /dev/null +++ b/apps/web/src/lib/masks/definitions/diamond.ts @@ -0,0 +1,97 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + 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 = { + type: "diamond", + name: "Diamond", + overlayShape: "box", + buildOverlayPath({ width, height }) { + return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`; + }, + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + 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/lib/masks/definitions/heart.ts b/apps/web/src/lib/masks/definitions/heart.ts new file mode 100644 index 00000000..2c53367a --- /dev/null +++ b/apps/web/src/lib/masks/definitions/heart.ts @@ -0,0 +1,139 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + 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.2 }); + const rightControl1 = toPoint({ + localX: halfWidth, + localY: -halfHeight * 0.95, + }); + const rightControl2 = toPoint({ + localX: halfWidth, + localY: halfHeight * 0.15, + }); + const bottom = toPoint({ localX: 0, localY: halfHeight }); + const leftControl1 = toPoint({ + localX: -halfWidth, + localY: halfHeight * 0.15, + }); + const leftControl2 = toPoint({ + localX: -halfWidth, + localY: -halfHeight * 0.95, + }); + + 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 = { + type: "heart", + name: "Heart", + overlayShape: "box", + 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.2}`, + `C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`, + `C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`, + "Z", + ].join(" "); + }, + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + 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/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts index 49404ce1..c078ef60 100644 --- a/apps/web/src/lib/masks/definitions/index.ts +++ b/apps/web/src/lib/masks/definitions/index.ts @@ -1,39 +1,63 @@ -import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types"; -import { masksRegistry, type MaskIconProps } from "../registry"; -import { ellipseMaskDefinition } from "./ellipse"; -import { rectangleMaskDefinition } from "./rectangle"; -import { splitMaskDefinition } from "./split"; -import { - PanelRightDashedIcon, - SquareIcon, - CircleIcon, -} from "@hugeicons/core-free-icons"; - -function registerDefaultMask({ - definition, - icon, -}: { - definition: MaskDefinition; - 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: rectangleMaskDefinition, - icon: { icon: SquareIcon }, - }); - registerDefaultMask({ - definition: ellipseMaskDefinition, - icon: { icon: CircleIcon }, - }); -} +import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types"; +import { masksRegistry, type MaskIconProps } from "../registry"; +import { cinematicBarsMaskDefinition } from "./cinematic-bars"; +import { diamondMaskDefinition } from "./diamond"; +import { ellipseMaskDefinition } from "./ellipse"; +import { heartMaskDefinition } from "./heart"; +import { rectangleMaskDefinition } from "./rectangle"; +import { splitMaskDefinition } from "./split"; +import { starMaskDefinition } from "./star"; +import { + MinusSignIcon, + PanelRightDashedIcon, + SquareIcon, + CircleIcon, + FavouriteIcon, + DiamondIcon, + StarsIcon, +} from "@hugeicons/core-free-icons"; + +function registerDefaultMask({ + definition, + icon, +}: { + definition: MaskDefinition; + 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 }, + }); +} diff --git a/apps/web/src/lib/masks/definitions/star.ts b/apps/web/src/lib/masks/definitions/star.ts new file mode 100644 index 00000000..cdab2855 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/star.ts @@ -0,0 +1,137 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + 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 = { + type: "star", + name: "Star", + overlayShape: "box", + buildOverlayPath({ width, height }) { + return buildOverlayStarPath({ width, height }); + }, + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + 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/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts index 0e15bff4..fe16e15e 100644 --- a/apps/web/src/lib/masks/types.ts +++ b/apps/web/src/lib/masks/types.ts @@ -1,115 +1,153 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { ParamDefinition, ParamValues } from "@/lib/params"; - -export type MaskType = "split" | "rectangle" | "ellipse"; - -export interface BaseMaskParams extends ParamValues { - 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 SplitMask { - id: string; - type: "split"; - params: SplitMaskParams; -} - -export interface RectangleMask { - id: string; - type: "rectangle"; - params: RectangleMaskParams; -} - -export interface EllipseMask { - id: string; - type: "ellipse"; - params: RectangleMaskParams; -} - -export type Mask = SplitMask | RectangleMask | EllipseMask; - -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; -} - -export type MaskOverlayShape = "line" | "box"; - -export interface MaskFeatures { - hasPosition: boolean; - hasRotation: boolean; - sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only"; -} - -export interface MaskHandlePosition { - id: string; - x: number; - y: number; - cursor: string; -} - -export interface MaskLinePoints { - start: { x: number; y: number }; - end: { x: number; y: number }; -} - -export interface MaskDefaultContext { - elementSize?: { width: number; height: number }; -} - -export interface MaskParamUpdateArgs { - handleId: string; - startParams: TParams; - deltaX: number; - deltaY: number; - startCanvasX: number; - startCanvasY: number; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; -} - -export interface MaskDefinition { - type: MaskType; - name: string; - overlayShape: MaskOverlayShape; - features: MaskFeatures; - params: ParamDefinition[]; - renderer: MaskRenderer; - buildOverlayPath?: (params: { width: number; height: number }) => string; - buildDefault(context: MaskDefaultContext): Omit; - computeParamUpdate(args: MaskParamUpdateArgs): ParamValues; -} +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { ParamDefinition, ParamValues } from "@/lib/params"; + +export type MaskType = + | "split" + | "cinematic-bars" + | "rectangle" + | "ellipse" + | "heart" + | "diamond" + | "star"; + +export interface BaseMaskParams extends ParamValues { + 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 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 type Mask = + | SplitMask + | CinematicBarsMask + | RectangleMask + | EllipseMask + | HeartMask + | DiamondMask + | StarMask; + +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; +} + +export type MaskOverlayShape = "line" | "box"; + +export interface MaskFeatures { + hasPosition: boolean; + hasRotation: boolean; + sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only"; +} + +export interface MaskHandlePosition { + id: string; + x: number; + y: number; + cursor: string; +} + +export interface MaskLinePoints { + start: { x: number; y: number }; + end: { x: number; y: number }; +} + +export interface MaskDefaultContext { + elementSize?: { width: number; height: number }; +} + +export interface MaskParamUpdateArgs { + handleId: string; + startParams: TParams; + deltaX: number; + deltaY: number; + startCanvasX: number; + startCanvasY: number; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; +} + +export interface MaskDefinition { + type: MaskType; + name: string; + overlayShape: MaskOverlayShape; + features: MaskFeatures; + params: ParamDefinition[]; + renderer: MaskRenderer; + buildOverlayPath?: (params: { width: number; height: number }) => string; + buildDefault(context: MaskDefaultContext): Omit; + computeParamUpdate(args: MaskParamUpdateArgs): ParamValues; +}