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;
+}