summaryrefslogtreecommitdiff
path: root/apps/web/src/masks/builtin
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/masks/builtin')
-rw-r--r--apps/web/src/masks/builtin/box-like.ts314
-rw-r--r--apps/web/src/masks/builtin/definitions/cinematic-bars.ts144
-rw-r--r--apps/web/src/masks/builtin/definitions/diamond.ts106
-rw-r--r--apps/web/src/masks/builtin/definitions/ellipse.ts81
-rw-r--r--apps/web/src/masks/builtin/definitions/heart.ts148
-rw-r--r--apps/web/src/masks/builtin/definitions/index.ts72
-rw-r--r--apps/web/src/masks/builtin/definitions/rectangle.ts103
-rw-r--r--apps/web/src/masks/builtin/definitions/split.ts397
-rw-r--r--apps/web/src/masks/builtin/definitions/star.ts146
-rw-r--r--apps/web/src/masks/builtin/definitions/text.ts445
10 files changed, 1956 insertions, 0 deletions
diff --git a/apps/web/src/masks/builtin/box-like.ts b/apps/web/src/masks/builtin/box-like.ts
new file mode 100644
index 00000000..37706755
--- /dev/null
+++ b/apps/web/src/masks/builtin/box-like.ts
@@ -0,0 +1,314 @@
+import {
+ DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
+ MIN_MASK_DIMENSION,
+} from "@/masks/dimensions";
+import { computeFeatherUpdate } from "../param-update";
+import type {
+ BaseMaskParams,
+ MaskDefaultContext,
+ MaskFeatures,
+ MaskInteractionDefinition,
+ MaskParamUpdateArgs,
+ RectangleMaskParams,
+} from "@/masks/types";
+import type { NumberParamDefinition, ParamDefinition } from "@/params";
+import {
+ getBoxMaskHandlePositions,
+ getBoxMaskOverlays,
+} from "@/masks/handle-positions";
+import { snapBoxMaskInteraction } from "@/masks/snap";
+
+const PERCENTAGE_DISPLAY: Pick<
+ NumberParamDefinition,
+ "displayMultiplier" | "step"
+> = {
+ displayMultiplier: 100,
+ step: 1,
+};
+
+export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
+ keyof RectangleMaskParams & string
+>[] = [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "width",
+ label: "Width",
+ type: "number",
+ default: 0.6,
+ min: 1,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "height",
+ label: "Height",
+ type: "number",
+ default: 0.6,
+ min: 1,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "scale",
+ label: "Scale",
+ type: "number",
+ default: 1,
+ min: 1,
+ max: 500,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "strokeAlign",
+ label: "Stroke Align",
+ type: "select",
+ default: "center",
+ options: [
+ { value: "inside", label: "Inside" },
+ { value: "center", label: "Center" },
+ { value: "outside", label: "Outside" },
+ ],
+ },
+];
+
+export function getDefaultBaseMaskParams(): BaseMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ };
+}
+
+export function getStrokeOffset({
+ strokeAlign,
+ strokeWidth,
+}: Pick<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 snapBoxMaskInteraction(args);
+ },
+ };
+}
+
+export function computeBoxMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ bounds,
+}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
+ if (handleId.kind === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ if (handleId.kind === "rotation") {
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ const newRotation = (startParams.rotation + currentAngle) % 360;
+ return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
+ }
+
+ if (handleId.kind === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.sin(angleRad),
+ directionY: Math.cos(angleRad),
+ });
+ }
+
+ const halfWidth = startParams.width * bounds.width;
+ const halfHeight = startParams.height * bounds.height;
+
+ if (
+ handleId.kind === "edge" &&
+ (handleId.side === "right" || handleId.side === "left")
+ ) {
+ const sign = handleId.side === "right" ? 1 : -1;
+ return {
+ width: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.width + (sign * deltaX * 2) / bounds.width,
+ ),
+ };
+ }
+
+ if (
+ handleId.kind === "edge" &&
+ (handleId.side === "bottom" || handleId.side === "top")
+ ) {
+ const sign = handleId.side === "bottom" ? 1 : -1;
+ return {
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height + (sign * deltaY * 2) / bounds.height,
+ ),
+ };
+ }
+
+ if (handleId.kind === "corner") {
+ const signX = handleId.corner.x === "right" ? 1 : -1;
+ const signY = handleId.corner.y === "bottom" ? 1 : -1;
+ const distance = Math.sqrt(
+ (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
+ );
+ const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
+ const scale = originalDistance > 0 ? distance / originalDistance : 1;
+ return {
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
+ height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
+ };
+ }
+
+ if (handleId.kind === "scale") {
+ const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
+ const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
+ const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
+ return {
+ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
+ };
+ }
+
+ return {};
+}
+
+export function rotatePoint({
+ x,
+ y,
+ centerX,
+ centerY,
+ rotationRad,
+}: {
+ x: number;
+ y: number;
+ centerX: number;
+ centerY: number;
+ rotationRad: number;
+}) {
+ const dx = x - centerX;
+ const dy = y - centerY;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+
+ return {
+ x: centerX + dx * cos - dy * sin,
+ y: centerY + dx * sin + dy * cos,
+ };
+}
diff --git a/apps/web/src/masks/builtin/definitions/cinematic-bars.ts b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts
new file mode 100644
index 00000000..0e23fd58
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts
@@ -0,0 +1,144 @@
+import type {
+ MaskDefaultContext,
+ MaskDefinition,
+ RectangleMaskParams,
+} from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getDefaultBaseMaskParams,
+ getStrokeOffset,
+ rotatePoint,
+} from "../box-like";
+
+function getDefaultCinematicBarsMaskParams({
+ elementSize,
+}: MaskDefaultContext): RectangleMaskParams {
+ const absWidth = Math.abs(elementSize?.width ?? 0);
+ const absHeight = Math.abs(elementSize?.height ?? 0);
+ const diagonal =
+ absWidth > 0 && absHeight > 0
+ ? Math.sqrt(absWidth ** 2 + absHeight ** 2)
+ : 0;
+ const fullSpanWidth =
+ absWidth > 0 ? diagonal / absWidth : Math.SQRT2;
+
+ return {
+ ...getDefaultBaseMaskParams(),
+ centerX: 0,
+ centerY: 0,
+ width: Math.max(fullSpanWidth, 1),
+ height: 0.6,
+ rotation: 0,
+ scale: 1,
+ };
+}
+
+function buildBandPath({
+ centerX,
+ centerY,
+ halfWidth,
+ halfHeight,
+ rotationRad,
+}: {
+ centerX: number;
+ centerY: number;
+ halfWidth: number;
+ halfHeight: number;
+ rotationRad: number;
+}): Path2D {
+ const corners = [
+ { x: centerX - halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY + halfHeight },
+ { x: centerX - halfWidth, y: centerY + halfHeight },
+ ].map((point) =>
+ rotatePoint({
+ ...point,
+ centerX,
+ centerY,
+ rotationRad,
+ }),
+ );
+
+ const path = new Path2D();
+ path.moveTo(corners[0].x, corners[0].y);
+ for (const corner of corners.slice(1)) {
+ path.lineTo(corner.x, corner.y);
+ }
+ path.closePath();
+ return path;
+}
+
+export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = {
+ type: "cinematic-bars",
+ name: "Cinematic Bars",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "height-only",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "height-only",
+ buildOverlayPath({ width, height }) {
+ return `M 0,0 H ${width} V ${height} H 0 Z`;
+ },
+ }),
+ buildDefault(context) {
+ return {
+ type: "cinematic-bars",
+ params: getDefaultCinematicBarsMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const centerX = width / 2 + params.centerX * width;
+ const centerY = height / 2 + params.centerY * height;
+ const maskWidth = Math.max(params.width * width, width);
+ const maskHeight = Math.max(params.height, 0.01) * height;
+ const rotationRad = (params.rotation * Math.PI) / 180;
+
+ return buildBandPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const centerX = width / 2 + params.centerX * width;
+ const centerY = height / 2 + params.centerY * height;
+ const rotationRad = (params.rotation * Math.PI) / 180;
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+
+ return buildBandPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(
+ Math.max(params.width * width, width) / 2 + offset,
+ 1,
+ ),
+ halfHeight: Math.max(
+ (Math.max(params.height, 0.01) * height) / 2 + offset,
+ 1,
+ ),
+ rotationRad,
+ });
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/builtin/definitions/diamond.ts b/apps/web/src/masks/builtin/definitions/diamond.ts
new file mode 100644
index 00000000..1effb276
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/diamond.ts
@@ -0,0 +1,106 @@
+import type { MaskDefinition } from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+ rotatePoint,
+} from "../box-like";
+
+function buildDiamondPath({
+ centerX,
+ centerY,
+ halfWidth,
+ halfHeight,
+ rotationRad,
+}: {
+ centerX: number;
+ centerY: number;
+ halfWidth: number;
+ halfHeight: number;
+ rotationRad: number;
+}): Path2D {
+ const points = [
+ { x: centerX, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY },
+ { x: centerX, y: centerY + halfHeight },
+ { x: centerX - halfWidth, y: centerY },
+ ].map((point) =>
+ rotatePoint({
+ ...point,
+ centerX,
+ centerY,
+ rotationRad,
+ }),
+ );
+
+ const path = new Path2D();
+ path.moveTo(points[0].x, points[0].y);
+ for (const point of points.slice(1)) {
+ path.lineTo(point.x, point.y);
+ }
+ path.closePath();
+ return path;
+}
+
+export const diamondMaskDefinition: MaskDefinition<"diamond"> = {
+ type: "diamond",
+ name: "Diamond",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
+ },
+ }),
+ buildDefault(context) {
+ return {
+ type: "diamond",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildDiamondPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildDiamondPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(maskWidth / 2 + offset, 1),
+ halfHeight: Math.max(maskHeight / 2 + offset, 1),
+ rotationRad,
+ });
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/builtin/definitions/ellipse.ts b/apps/web/src/masks/builtin/definitions/ellipse.ts
new file mode 100644
index 00000000..688a2fa6
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/ellipse.ts
@@ -0,0 +1,81 @@
+import type { MaskDefinition } from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+} from "../box-like";
+
+export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = {
+ type: "ellipse",
+ name: "Ellipse",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ const rx = Math.max((width - 1) / 2, 0);
+ const ry = Math.max((height - 1) / 2, 0);
+ const cx = width / 2;
+ const cy = height / 2;
+ return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
+ },
+ }),
+ buildDefault(context) {
+ return {
+ type: "ellipse",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ maskWidth / 2,
+ maskHeight / 2,
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ Math.max(1, maskWidth / 2 + offset),
+ Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/builtin/definitions/heart.ts b/apps/web/src/masks/builtin/definitions/heart.ts
new file mode 100644
index 00000000..d1af5eb2
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/heart.ts
@@ -0,0 +1,148 @@
+import type { MaskDefinition } from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+ rotatePoint,
+} from "../box-like";
+
+function buildHeartPath({
+ centerX,
+ centerY,
+ halfWidth,
+ halfHeight,
+ rotationRad,
+}: {
+ centerX: number;
+ centerY: number;
+ halfWidth: number;
+ halfHeight: number;
+ rotationRad: number;
+}): Path2D {
+ const toPoint = ({
+ localX,
+ localY,
+ }: {
+ localX: number;
+ localY: number;
+ }) =>
+ rotatePoint({
+ x: centerX + localX,
+ y: centerY + localY,
+ centerX,
+ centerY,
+ rotationRad,
+ });
+
+ const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
+ const rightControl1 = toPoint({
+ localX: halfWidth,
+ localY: -halfHeight * 1.225,
+ });
+ const rightControl2 = toPoint({
+ localX: halfWidth,
+ localY: -halfHeight * 0.125,
+ });
+ const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
+ const leftControl1 = toPoint({
+ localX: -halfWidth,
+ localY: -halfHeight * 0.125,
+ });
+ const leftControl2 = toPoint({
+ localX: -halfWidth,
+ localY: -halfHeight * 1.225,
+ });
+
+ const path = new Path2D();
+ path.moveTo(start.x, start.y);
+ path.bezierCurveTo(
+ rightControl1.x,
+ rightControl1.y,
+ rightControl2.x,
+ rightControl2.y,
+ bottom.x,
+ bottom.y,
+ );
+ path.bezierCurveTo(
+ leftControl1.x,
+ leftControl1.y,
+ leftControl2.x,
+ leftControl2.y,
+ start.x,
+ start.y,
+ );
+ path.closePath();
+ return path;
+}
+
+export const heartMaskDefinition: MaskDefinition<"heart"> = {
+ type: "heart",
+ name: "Heart",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ const cx = width / 2;
+ const cy = height / 2;
+ const halfWidth = width / 2;
+ const halfHeight = height / 2;
+ return [
+ `M ${cx},${cy - halfHeight * 0.475}`,
+ `C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
+ `C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
+ "Z",
+ ].join(" ");
+ },
+ }),
+ buildDefault(context) {
+ return {
+ type: "heart",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildHeartPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildHeartPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(maskWidth / 2 + offset, 1),
+ halfHeight: Math.max(maskHeight / 2 + offset, 1),
+ rotationRad,
+ });
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/builtin/definitions/index.ts b/apps/web/src/masks/builtin/definitions/index.ts
new file mode 100644
index 00000000..9f722876
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/index.ts
@@ -0,0 +1,72 @@
+import {
+ builtinMasksRegistry,
+ type BuiltinMaskDefinitionForRegistration,
+ type MaskIconProps,
+} from "../../registry";
+import { cinematicBarsMaskDefinition } from "./cinematic-bars";
+import { diamondMaskDefinition } from "./diamond";
+import { ellipseMaskDefinition } from "./ellipse";
+import { heartMaskDefinition } from "./heart";
+import { rectangleMaskDefinition } from "./rectangle";
+import { splitMaskDefinition } from "./split";
+import { starMaskDefinition } from "./star";
+import { textMaskDefinition } from "./text";
+import {
+ MinusSignIcon,
+ PanelRightDashedIcon,
+ SquareIcon,
+ CircleIcon,
+ FavouriteIcon,
+ DiamondIcon,
+ StarsIcon,
+ TextFontIcon,
+} from "@hugeicons/core-free-icons";
+
+function registerDefaultMask({
+ definition,
+ icon,
+}: {
+ definition: BuiltinMaskDefinitionForRegistration;
+ icon: MaskIconProps;
+}) {
+ if (builtinMasksRegistry.has(definition.type)) {
+ return;
+ }
+
+ builtinMasksRegistry.registerMask({ definition, icon });
+}
+
+export function registerBuiltinMasks(): void {
+ registerDefaultMask({
+ definition: splitMaskDefinition,
+ icon: { icon: PanelRightDashedIcon, strokeWidth: 1 },
+ });
+ registerDefaultMask({
+ definition: cinematicBarsMaskDefinition,
+ icon: { icon: MinusSignIcon },
+ });
+ registerDefaultMask({
+ definition: rectangleMaskDefinition,
+ icon: { icon: SquareIcon },
+ });
+ registerDefaultMask({
+ definition: ellipseMaskDefinition,
+ icon: { icon: CircleIcon },
+ });
+ registerDefaultMask({
+ definition: heartMaskDefinition,
+ icon: { icon: FavouriteIcon },
+ });
+ registerDefaultMask({
+ definition: diamondMaskDefinition,
+ icon: { icon: DiamondIcon },
+ });
+ registerDefaultMask({
+ definition: starMaskDefinition,
+ icon: { icon: StarsIcon },
+ });
+ registerDefaultMask({
+ definition: textMaskDefinition,
+ icon: { icon: TextFontIcon },
+ });
+}
diff --git a/apps/web/src/masks/builtin/definitions/rectangle.ts b/apps/web/src/masks/builtin/definitions/rectangle.ts
new file mode 100644
index 00000000..4dd38154
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/rectangle.ts
@@ -0,0 +1,103 @@
+import type { MaskDefinition } from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+ rotatePoint,
+} from "../box-like";
+
+function buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth,
+ halfHeight,
+ rotationRad,
+}: {
+ centerX: number;
+ centerY: number;
+ halfWidth: number;
+ halfHeight: number;
+ rotationRad: number;
+}): Path2D {
+ const corners = [
+ { x: centerX - halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY + halfHeight },
+ { x: centerX - halfWidth, y: centerY + halfHeight },
+ ].map((point) =>
+ rotatePoint({
+ ...point,
+ centerX,
+ centerY,
+ rotationRad,
+ }),
+ );
+
+ const path = new Path2D();
+ path.moveTo(corners[0].x, corners[0].y);
+ for (const corner of corners.slice(1)) {
+ path.lineTo(corner.x, corner.y);
+ }
+ path.closePath();
+ return path;
+}
+
+export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = {
+ type: "rectangle",
+ name: "Rectangle",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ }),
+ buildDefault(context) {
+ return {
+ type: "rectangle",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(1, maskWidth / 2 + offset),
+ halfHeight: Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ });
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/builtin/definitions/split.ts b/apps/web/src/masks/builtin/definitions/split.ts
new file mode 100644
index 00000000..13dd1c09
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/split.ts
@@ -0,0 +1,397 @@
+import { computeFeatherUpdate } from "@/masks/param-update";
+import type {
+ MaskDefinition,
+ MaskParamUpdateArgs,
+ SplitMaskParams,
+} from "@/masks/types";
+import { halfPlaneSign, lineEdgeIntersection } from "@/masks/utils";
+import {
+ getLineMaskHandlePositions,
+ getLineMaskOverlay,
+} from "@/masks/handle-positions";
+import { snapSplitMaskInteraction } from "@/masks/snap";
+
+// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
+// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
+// exactly on the split line, which produces spurious midpoint vertices.
+const NORMAL_SNAP_EPSILON = 1e-10;
+
+// Guards against collinear vertices from float noise at canvas edges.
+const MIN_POLYGON_AREA_PX = 0.5;
+const INTERSECTION_EPSILON = 1e-6;
+
+function polygonArea({ vertices }: { vertices: [number, number][] }): number {
+ let area = 0;
+ for (let i = 0; i < vertices.length; i++) {
+ const [x1, y1] = vertices[i];
+ const [x2, y2] = vertices[(i + 1) % vertices.length];
+ area += x1 * y2 - x2 * y1;
+ }
+ return Math.abs(area) * 0.5;
+}
+
+function splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ width: number;
+ height: number;
+}): { normalX: number; normalY: number; lineX: number; lineY: number } {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX =
+ Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
+ const normalY =
+ Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
+ const lineX = width / 2 + centerX * width;
+ const lineY = height / 2 + centerY * height;
+ return { normalX, normalY, lineX, lineY };
+}
+
+function pointsEqual({
+ a,
+ b,
+}: {
+ a: { x: number; y: number };
+ b: { x: number; y: number };
+}): boolean {
+ return (
+ Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
+ Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
+ );
+}
+
+export function getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+}: {
+ resolvedParams: SplitMaskParams;
+ width: number;
+ height: number;
+}): [{ x: number; y: number }, { x: number; y: number }] | null {
+ const { centerX, centerY, rotation } = resolvedParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+ const intersections: { x: number; y: number }[] = [];
+
+ for (const [x1, y1, x2, y2] of edges) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+
+ if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) {
+ continue;
+ }
+
+ intersections.push(hit);
+ }
+
+ if (intersections.length !== 2) {
+ return null;
+ }
+
+ return [intersections[0], intersections[1]];
+}
+
+function computeSplitMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+ canvasSize,
+}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
+ if (handleId.kind === "position") {
+ const rawX = startParams.centerX + deltaX / bounds.width;
+ const rawY = startParams.centerY + deltaY / bounds.height;
+
+ const minX = -bounds.cx / bounds.width;
+ const maxX = (canvasSize.width - bounds.cx) / bounds.width;
+ const minY = -bounds.cy / bounds.height;
+ const maxY = (canvasSize.height - bounds.cy) / bounds.height;
+
+ return {
+ centerX: Math.max(minX, Math.min(maxX, rawX)),
+ centerY: Math.max(minY, Math.min(maxY, rawY)),
+ };
+ }
+
+ if (handleId.kind === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.cos(angleRad),
+ directionY: -Math.sin(angleRad),
+ });
+ }
+
+ if (handleId.kind === "rotation") {
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ return {};
+}
+
+export const splitMaskDefinition: MaskDefinition<"split"> = {
+ type: "split",
+ name: "Split",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "none",
+ },
+ interaction: {
+ getInteraction({
+ params,
+ bounds,
+ displayScale,
+ scaleX: _scaleX,
+ scaleY: _scaleY,
+ }) {
+ return {
+ handles: getLineMaskHandlePositions({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ feather: params.feather,
+ bounds,
+ displayScale,
+ }),
+ overlays: [
+ getLineMaskOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ bounds,
+ }),
+ ],
+ };
+ },
+ snap(args) {
+ return snapSplitMaskInteraction(args);
+ },
+ },
+ buildDefault() {
+ return {
+ type: "split",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ },
+ };
+ },
+ computeParamUpdate: computeSplitMaskParamUpdate,
+ params: [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ ],
+ renderer: {
+ body: {
+ kind: "drawWithFeather",
+ drawWithFeather({ resolvedParams, ctx, width, height, feather }) {
+ const { centerX, centerY, rotation } = resolvedParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
+ const featherHalf = feather / 2;
+ const gradient = ctx.createLinearGradient(
+ lineX - normalX * featherHalf,
+ lineY - normalY * featherHalf,
+ lineX + normalX * featherHalf,
+ lineY + normalY * featherHalf,
+ );
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
+ gradient.addColorStop(1, "white");
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+ },
+
+ opaqueFastPath: {
+ buildPath({ resolvedParams, width, height }) {
+ const { centerX, centerY, rotation } = resolvedParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+
+ const isInsideHalfPlane = ({
+ x,
+ y,
+ }: {
+ x: number;
+ y: number;
+ }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
+
+ const vertices: [number, number][] = [];
+ for (const [x1, y1, x2, y2] of edges) {
+ const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 });
+ const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 });
+
+ if (isVertex1Inside && isVertex2Inside) {
+ vertices.push([x2, y2]);
+ } else if (isVertex1Inside && !isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) vertices.push([hit.x, hit.y]);
+ } else if (!isVertex1Inside && isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) {
+ vertices.push([hit.x, hit.y]);
+ vertices.push([x2, y2]);
+ }
+ }
+ }
+
+ if (
+ vertices.length < 3 ||
+ polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
+ ) {
+ return new Path2D();
+ }
+
+ const path = new Path2D();
+ path.moveTo(vertices[0][0], vertices[0][1]);
+ for (let i = 1; i < vertices.length; i++) {
+ path.lineTo(vertices[i][0], vertices[i][1]);
+ }
+ path.closePath();
+ return path;
+ },
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+ });
+ const path = new Path2D();
+
+ if (!segment) {
+ return path;
+ }
+
+ path.moveTo(segment[0].x, segment[0].y);
+ path.lineTo(segment[1].x, segment[1].y);
+ return path;
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/builtin/definitions/star.ts b/apps/web/src/masks/builtin/definitions/star.ts
new file mode 100644
index 00000000..62e9cbc4
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/star.ts
@@ -0,0 +1,146 @@
+import type { MaskDefinition } from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+ rotatePoint,
+} from "../box-like";
+
+const STAR_INNER_RADIUS_RATIO = 0.45;
+const STAR_VERTEX_COUNT = 10;
+
+function buildStarPath({
+ centerX,
+ centerY,
+ halfWidth,
+ halfHeight,
+ rotationRad,
+}: {
+ centerX: number;
+ centerY: number;
+ halfWidth: number;
+ halfHeight: number;
+ rotationRad: number;
+}): Path2D {
+ const path = new Path2D();
+
+ for (let index = 0; index < STAR_VERTEX_COUNT; index++) {
+ const isOuterVertex = index % 2 === 0;
+ const radiusX = isOuterVertex
+ ? halfWidth
+ : halfWidth * STAR_INNER_RADIUS_RATIO;
+ const radiusY = isOuterVertex
+ ? halfHeight
+ : halfHeight * STAR_INNER_RADIUS_RATIO;
+ const angle = (index * Math.PI) / 5 - Math.PI / 2;
+ const point = rotatePoint({
+ x: centerX + radiusX * Math.cos(angle),
+ y: centerY + radiusY * Math.sin(angle),
+ centerX,
+ centerY,
+ rotationRad,
+ });
+
+ if (index === 0) {
+ path.moveTo(point.x, point.y);
+ } else {
+ path.lineTo(point.x, point.y);
+ }
+ }
+
+ path.closePath();
+ return path;
+}
+
+function buildOverlayStarPath({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): string {
+ const centerX = width / 2;
+ const centerY = height / 2;
+ const halfWidth = width / 2;
+ const halfHeight = height / 2;
+ const segments: string[] = [];
+
+ for (let index = 0; index < STAR_VERTEX_COUNT; index++) {
+ const isOuterVertex = index % 2 === 0;
+ const radiusX = isOuterVertex
+ ? halfWidth
+ : halfWidth * STAR_INNER_RADIUS_RATIO;
+ const radiusY = isOuterVertex
+ ? halfHeight
+ : halfHeight * STAR_INNER_RADIUS_RATIO;
+ const angle = (index * Math.PI) / 5 - Math.PI / 2;
+ const x = centerX + radiusX * Math.cos(angle);
+ const y = centerY + radiusY * Math.sin(angle);
+ segments.push(`${index === 0 ? "M" : "L"} ${x},${y}`);
+ }
+
+ return `${segments.join(" ")} Z`;
+}
+
+export const starMaskDefinition: MaskDefinition<"star"> = {
+ type: "star",
+ name: "Star",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ return buildOverlayStarPath({ width, height });
+ },
+ }),
+ buildDefault(context) {
+ return {
+ type: "star",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildStarPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildStarPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(maskWidth / 2 + offset, 1),
+ halfHeight: Math.max(maskHeight / 2 + offset, 1),
+ rotationRad,
+ });
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/builtin/definitions/text.ts b/apps/web/src/masks/builtin/definitions/text.ts
new file mode 100644
index 00000000..db2af7e0
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/text.ts
@@ -0,0 +1,445 @@
+import type { ParamDefinition } from "@/params";
+import type {
+ MaskDefinition,
+ MaskParamUpdateArgs,
+ TextMask,
+ TextMaskParams,
+ MaskHandleId,
+} from "@/masks/types";
+import { DEFAULTS } from "@/timeline/defaults";
+import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography";
+import {
+ drawMeasuredTextLayout,
+ measureTextLayout,
+ strokeMeasuredTextLayout,
+} from "@/text/primitives";
+import { getTextMeasurementContext } from "@/text/measure-element";
+import { getTextVisualRect } from "@/text/layout";
+import {
+ getBoxMaskHandlePositions,
+ getBoxMaskRectOverlay,
+} from "@/masks/handle-positions";
+import { computeFeatherUpdate } from "@/masks/param-update";
+import {
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "@/masks/geometry";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+ type ScaleEdgePreference,
+} from "@/preview/preview-snap";
+
+const PERCENTAGE_DISPLAY = {
+ displayMultiplier: 100,
+ step: 1,
+} as const;
+
+const TEXT_MASK_ALIGNMENT = "center";
+
+const TEXT_MASK_PARAMS: ParamDefinition<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: 15,
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ step: 1,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "scale",
+ label: "Scale",
+ type: "number",
+ default: 1,
+ min: 1,
+ max: 500,
+ ...PERCENTAGE_DISPLAY,
+ },
+];
+
+function measureTextMask({
+ params,
+ height,
+}: {
+ params: TextMaskParams;
+ height: number;
+}) {
+ const layout = measureTextLayout({
+ text: {
+ content: params.content,
+ fontSize: params.fontSize,
+ fontFamily: params.fontFamily,
+ fontWeight: params.fontWeight,
+ fontStyle: params.fontStyle,
+ textAlign: TEXT_MASK_ALIGNMENT,
+ textDecoration: params.textDecoration,
+ letterSpacing: params.letterSpacing,
+ lineHeight: params.lineHeight,
+ },
+ canvasHeight: height,
+ ctx: getTextMeasurementContext(),
+ });
+ const visualRect = getTextVisualRect({
+ textAlign: layout.textAlign,
+ block: layout.block,
+ background: { enabled: false, color: "transparent" },
+ fontSizeRatio: layout.fontSizeRatio,
+ });
+
+ return {
+ layout,
+ intrinsicWidth: Math.max(1, visualRect.width),
+ intrinsicHeight: Math.max(1, visualRect.height),
+ };
+}
+
+function getScalePreferredEdges({
+ handleId,
+}: {
+ handleId: MaskHandleId;
+}): ScaleEdgePreference | undefined {
+ if (handleId.kind !== "scale") {
+ return undefined;
+ }
+
+ return {
+ right: true,
+ bottom: true,
+ };
+}
+
+function computeTextMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
+ if (handleId.kind === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+
+ if (handleId.kind === "rotation") {
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ if (handleId.kind === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.sin(angleRad),
+ directionY: Math.cos(angleRad),
+ });
+ }
+
+ if (handleId.kind === "scale") {
+ const startDistance = Math.hypot(
+ startCanvasX - pivotX,
+ startCanvasY - pivotY,
+ );
+ const currentDistance = Math.hypot(
+ startCanvasX + deltaX - pivotX,
+ startCanvasY + deltaY - pivotY,
+ );
+ const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
+ return {
+ scale: Math.max(0.01, startParams.scale * scaleFactor),
+ };
+ }
+
+ return {};
+}
+
+export const textMaskDefinition: MaskDefinition<"text"> = {
+ type: "text",
+ name: "Text",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "uniform",
+ },
+ params: TEXT_MASK_PARAMS,
+ interaction: {
+ getInteraction({
+ params,
+ bounds,
+ displayScale,
+ scaleX: _scaleX,
+ scaleY: _scaleY,
+ }) {
+ const { intrinsicWidth, intrinsicHeight } = measureTextMask({
+ params,
+ height: bounds.height,
+ });
+ const width = (intrinsicWidth * params.scale) / bounds.width;
+ const height = (intrinsicHeight * params.scale) / bounds.height;
+ return {
+ handles: getBoxMaskHandlePositions({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width,
+ height,
+ rotation: params.rotation,
+ feather: params.feather,
+ sizeMode: "uniform",
+ bounds,
+ displayScale,
+ }),
+ overlays: [
+ getBoxMaskRectOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width,
+ height,
+ rotation: params.rotation,
+ bounds,
+ }),
+ ],
+ };
+ },
+ snap({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) {
+ const { intrinsicWidth, intrinsicHeight } = measureTextMask({
+ params: startParams,
+ height: bounds.height,
+ });
+ const position = {
+ x: proposedParams.centerX * bounds.width,
+ y: proposedParams.centerY * bounds.height,
+ };
+
+ if (handleId.kind === "position") {
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: position,
+ canvasSize: bounds,
+ elementSize: {
+ width: intrinsicWidth * proposedParams.scale,
+ height: intrinsicHeight * proposedParams.scale,
+ },
+ rotation: proposedParams.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId.kind === "rotation") {
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+ }
+
+ if (handleId.kind === "scale") {
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale: proposedParams.scale,
+ position,
+ baseWidth: intrinsicWidth,
+ baseHeight: intrinsicHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: bounds,
+ snapThreshold,
+ preferredEdges: getScalePreferredEdges({ handleId }),
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(0.01, snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return {
+ params: proposedParams,
+ activeLines: [],
+ };
+ },
+ },
+ buildDefault(): Omit<TextMask, "id"> {
+ return {
+ type: "text",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ content: "Mask",
+ fontSize: 15,
+ fontFamily: "Arial",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: DEFAULTS.text.letterSpacing,
+ lineHeight: DEFAULTS.text.lineHeight,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ },
+ };
+ },
+ computeParamUpdate: computeTextMaskParamUpdate,
+ isActive(params) {
+ return params.content.trim().length > 0;
+ },
+ renderer: {
+ body: {
+ kind: "drawOpaque",
+ drawOpaque({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams;
+ const { layout } = measureTextMask({ params, height });
+
+ ctx.save();
+ ctx.translate(
+ width / 2 + params.centerX * width,
+ height / 2 + params.centerY * height,
+ );
+ ctx.scale(params.scale, params.scale);
+ if (params.rotation) {
+ ctx.rotate((params.rotation * Math.PI) / 180);
+ }
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ ctx.restore();
+ },
+ },
+ stroke: {
+ kind: "renderStroke",
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams;
+ const { layout } = measureTextMask({ params, height });
+
+ ctx.save();
+ ctx.translate(
+ width / 2 + params.centerX * width,
+ height / 2 + params.centerY * height,
+ );
+ ctx.scale(params.scale, params.scale);
+ if (params.rotation) {
+ ctx.rotate((params.rotation * Math.PI) / 180);
+ }
+
+ strokeMeasuredTextLayout({
+ ctx,
+ layout,
+ strokeColor: params.strokeColor,
+ strokeWidth: params.strokeWidth,
+ });
+
+ if (params.strokeAlign === "inside") {
+ ctx.globalCompositeOperation = "destination-in";
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ }
+
+ if (params.strokeAlign === "outside") {
+ ctx.globalCompositeOperation = "destination-out";
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ }
+
+ ctx.restore();
+ },
+ },
+ },
+};