diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-29 18:27:21 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-05-02 03:27:23 +0200 |
| commit | ae1292a14bf5c29544a5f7a49e65b994410f87de (patch) | |
| tree | 5fd3b7fcef3d87d6fcdbe38bf34b99d32038b41f /apps/web/src/masks | |
| parent | 6ea5a156b6b7f609354a4563cfb2d00ec7f41811 (diff) | |
| download | opencut-ae1292a14bf5c29544a5f7a49e65b994410f87de.tar.gz | |
refactor: make mask renderer dispatch explicit
Made-with: Cursor
Diffstat (limited to 'apps/web/src/masks')
| -rw-r--r-- | apps/web/src/masks/definitions/cinematic-bars.ts | 77 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/custom.ts | 126 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/diamond.ts | 62 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/ellipse.ts | 78 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/heart.ts | 62 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/index.ts | 11 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/rectangle.ts | 62 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/split.ts | 223 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/star.ts | 62 | ||||
| -rw-r--r-- | apps/web/src/masks/definitions/text.ts | 110 | ||||
| -rw-r--r-- | apps/web/src/masks/index.ts | 68 | ||||
| -rw-r--r-- | apps/web/src/masks/registry.ts | 68 | ||||
| -rw-r--r-- | apps/web/src/masks/types.ts | 90 |
13 files changed, 597 insertions, 502 deletions
diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/definitions/cinematic-bars.ts index 36b31328..68717c02 100644 --- a/apps/web/src/masks/definitions/cinematic-bars.ts +++ b/apps/web/src/masks/definitions/cinematic-bars.ts @@ -71,7 +71,7 @@ function buildBandPath({ return path; } -export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = { type: "cinematic-bars", name: "Cinematic Bars", features: { @@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = }, 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; + 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, - }); + 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, - }); + 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, - }); + return buildBandPath({ + centerX, + centerY, + halfWidth: Math.max( + Math.max(params.width * width, width) / 2 + offset, + 1, + ), + halfHeight: Math.max( + (Math.max(params.height, 0.01) * height) / 2 + offset, + 1, + ), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/definitions/custom.ts index cd6e2055..6e0f7af6 100644 --- a/apps/web/src/masks/definitions/custom.ts +++ b/apps/web/src/masks/definitions/custom.ts @@ -308,7 +308,7 @@ function computeCustomMaskParamUpdate({ }; } -export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { +export const customMaskDefinition: MaskDefinition<"custom"> = { type: "custom", name: "Custom", features: { @@ -447,71 +447,77 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { return params.closed; }, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as CustomMaskParams; - const points = params.path; - if (!params.closed) { - return new Path2D(); - } + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const points = params.path; + if (!params.closed) { + return new Path2D(); + } - return buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + return buildCustomMaskPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); + }, }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as CustomMaskParams; - if (!params.closed) { - return; - } + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + if (!params.closed) { + return; + } - const points = params.path; - const path = buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + const points = params.path; + const path = buildCustomMaskPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); - ctx.save(); - ctx.strokeStyle = params.strokeColor; - ctx.lineWidth = params.strokeWidth; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - ctx.stroke(path); + ctx.save(); + ctx.strokeStyle = params.strokeColor; + ctx.lineWidth = params.strokeWidth; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(path); - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } - ctx.restore(); + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } + ctx.restore(); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/definitions/diamond.ts index 037aee70..fe96dbe9 100644 --- a/apps/web/src/masks/definitions/diamond.ts +++ b/apps/web/src/masks/definitions/diamond.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -45,7 +45,7 @@ function buildDiamondPath({ return path; } -export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const diamondMaskDefinition: MaskDefinition<"diamond"> = { type: "diamond", name: "Diamond", features: { @@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, 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, - }); + 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, + }); + }, }, - 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, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/definitions/ellipse.ts index d0567817..f57d7444 100644 --- a/apps/web/src/masks/definitions/ellipse.ts +++ b/apps/web/src/masks/definitions/ellipse.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -8,7 +8,7 @@ import { getStrokeOffset, } from "./box-like"; -export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { type: "ellipse", name: "Ellipse", features: { @@ -35,41 +35,47 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - maskWidth / 2, - maskHeight / 2, - rotationRad, - 0, - Math.PI * 2, - ); - return path; + 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; + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - Math.max(1, maskWidth / 2 + offset), - Math.max(1, maskHeight / 2 + offset), - rotationRad, - 0, - Math.PI * 2, - ); - return path; + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + Math.max(1, maskWidth / 2 + offset), + Math.max(1, maskHeight / 2 + offset), + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, }, }, }; diff --git a/apps/web/src/masks/definitions/heart.ts b/apps/web/src/masks/definitions/heart.ts index d15c09e6..ec546f18 100644 --- a/apps/web/src/masks/definitions/heart.ts +++ b/apps/web/src/masks/definitions/heart.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -78,7 +78,7 @@ function buildHeartPath({ return path; } -export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const heartMaskDefinition: MaskDefinition<"heart"> = { type: "heart", name: "Heart", features: { @@ -110,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, 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, - }); + 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, + }); + }, }, - 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, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/definitions/index.ts index 9cee1020..a5aec404 100644 --- a/apps/web/src/masks/definitions/index.ts +++ b/apps/web/src/masks/definitions/index.ts @@ -1,5 +1,8 @@ -import type { BaseMaskParams, MaskDefinition } from "@/masks/types"; -import { masksRegistry, type MaskIconProps } from "../registry"; +import { + masksRegistry, + type MaskDefinitionForRegistration, + type MaskIconProps, +} from "../registry"; import { cinematicBarsMaskDefinition } from "./cinematic-bars"; import { customMaskDefinition } from "./custom"; import { diamondMaskDefinition } from "./diamond"; @@ -20,11 +23,11 @@ import { TextFontIcon, } from "@hugeicons/core-free-icons"; -function registerDefaultMask<TParams extends BaseMaskParams>({ +function registerDefaultMask({ definition, icon, }: { - definition: MaskDefinition<TParams>; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }) { if (masksRegistry.has(definition.type)) { diff --git a/apps/web/src/masks/definitions/rectangle.ts b/apps/web/src/masks/definitions/rectangle.ts index 7bf1a7fd..db7a4578 100644 --- a/apps/web/src/masks/definitions/rectangle.ts +++ b/apps/web/src/masks/definitions/rectangle.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -45,7 +45,7 @@ function buildRectanglePath({ return path; } -export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = { type: "rectangle", name: "Rectangle", features: { @@ -65,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + 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, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: Math.max(1, maskWidth / 2 + offset), - halfHeight: Math.max(1, maskHeight / 2 + offset), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: Math.max(1, maskWidth / 2 + offset), + halfHeight: Math.max(1, maskHeight / 2 + offset), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/definitions/split.ts index ddd4e452..de67b876 100644 --- a/apps/web/src/masks/definitions/split.ts +++ b/apps/web/src/masks/definitions/split.ts @@ -71,11 +71,11 @@ export function getSplitMaskStrokeSegment({ width, height, }: { - resolvedParams: unknown; + resolvedParams: SplitMaskParams; width: number; height: number; }): [{ x: number; y: number }, { x: number; y: number }] | null { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; + const { centerX, centerY, rotation } = resolvedParams; const { normalX, normalY, lineX, lineY } = splitLineGeometry({ centerX, centerY, @@ -180,7 +180,7 @@ function computeSplitMaskParamUpdate({ return {}; } -export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { +export const splitMaskDefinition: MaskDefinition<"split"> = { type: "split", name: "Split", features: { @@ -267,124 +267,131 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { }, ], renderer: { - renderMaskHandlesFeather: true, - renderMask({ resolvedParams, ctx, width, height, feather }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); + 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"); + // 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); - }, + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + }, - buildPath({ resolvedParams, width, height }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); + 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 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 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 }); + 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 (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(); - } + 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; + const path = new Path2D(); + path.moveTo(vertices[0][0], vertices[0][1]); + for (let i = 1; i < vertices.length; i++) { + path.lineTo(vertices[i][0], vertices[i][1]); + } + path.closePath(); + return path; + }, + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const segment = getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, - }); - const path = new Path2D(); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const segment = getSplitMaskStrokeSegment({ + resolvedParams, + width, + height, + }); + const path = new Path2D(); - if (!segment) { - return path; - } + if (!segment) { + return path; + } - path.moveTo(segment[0].x, segment[0].y); - path.lineTo(segment[1].x, segment[1].y); - return path; + path.moveTo(segment[0].x, segment[0].y); + path.lineTo(segment[1].x, segment[1].y); + return path; + }, }, }, }; diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/definitions/star.ts index a7f8819c..2a1a9b7e 100644 --- a/apps/web/src/masks/definitions/star.ts +++ b/apps/web/src/masks/definitions/star.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -85,7 +85,7 @@ function buildOverlayStarPath({ return `${segments.join(" ")} Z`; } -export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const starMaskDefinition: MaskDefinition<"star"> = { type: "star", name: "Star", features: { @@ -108,33 +108,39 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, 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, - }); + 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, + }); + }, }, - 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, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildStarPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/definitions/text.ts index c65564d1..aa786e78 100644 --- a/apps/web/src/masks/definitions/text.ts +++ b/apps/web/src/masks/definitions/text.ts @@ -205,7 +205,7 @@ function computeTextMaskParamUpdate({ return {}; } -export const textMaskDefinition: MaskDefinition<TextMaskParams> = { +export const textMaskDefinition: MaskDefinition<"text"> = { type: "text", name: "Text", features: { @@ -370,69 +370,75 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { return params.content.trim().length > 0; }, renderer: { - renderMask({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); + 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(); - }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); - - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - - strokeMeasuredTextLayout({ - ctx, - layout, - strokeColor: params.strokeColor, - strokeWidth: params.strokeWidth, - }); - - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; + 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 }); - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - drawMeasuredTextLayout({ + 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, - textColor: "#ffffff", - background: null, + strokeColor: params.strokeColor, + strokeWidth: params.strokeWidth, }); - } - ctx.restore(); + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + ctx.restore(); + }, }, }, }; diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts index 3544e239..df7649e1 100644 --- a/apps/web/src/masks/index.ts +++ b/apps/web/src/masks/index.ts @@ -1,18 +1,50 @@ -import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
-import { masksRegistry } from "./registry";
-import { generateUUID } from "@/utils/id";
-
-export { masksRegistry } from "./registry";
-export { registerDefaultMasks } from "./definitions";
-
-export function buildDefaultMaskInstance({
- maskType,
- elementSize,
-}: {
- maskType: MaskType;
- elementSize?: { width: number; height: number };
-}): Mask {
- const definition = masksRegistry.get(maskType);
- const context: MaskDefaultContext = { elementSize };
- return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
-}
+import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types"; +import { masksRegistry } from "./registry"; +import { generateUUID } from "@/utils/id"; + +export { masksRegistry } from "./registry"; +export { registerDefaultMasks } from "./definitions"; + +type MaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit<TMask, "id"> + : never + : never; + +function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask { + switch (mask.type) { + case "split": + return { ...mask, id }; + case "cinematic-bars": + return { ...mask, id }; + case "rectangle": + return { ...mask, id }; + case "ellipse": + return { ...mask, id }; + case "heart": + return { ...mask, id }; + case "diamond": + return { ...mask, id }; + case "star": + return { ...mask, id }; + case "text": + return { ...mask, id }; + case "custom": + return { ...mask, id }; + } +} + +export function buildDefaultMaskInstance({ + maskType, + elementSize, +}: { + maskType: MaskType; + elementSize?: { width: number; height: number }; +}): Mask { + const definition = masksRegistry.get(maskType); + const context: MaskDefaultContext = { elementSize }; + return withMaskId({ + mask: definition.buildDefault(context), + id: generateUUID(), + }); +} diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts index 0bcf1cf0..64ab2f60 100644 --- a/apps/web/src/masks/registry.ts +++ b/apps/web/src/masks/registry.ts @@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather"; import type { ParamDefinition } from "@/params"; import type { BaseMaskParams, + Mask, MaskDefaultContext, MaskDefinition, - MaskInteractionResult, - MaskSnapArgs, - MaskSnapResult, + MaskParamUpdateArgs, + MaskRenderer, MaskType, } from "@/masks/types"; import type { HugeiconsIconProps } from "@hugeicons/react"; @@ -17,6 +17,16 @@ export type MaskIconProps = { strokeWidth?: number; }; +type RegisteredMaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit<TMask, "id"> + : never + : never; + +export type MaskDefinitionForRegistration = { + [TType in MaskType]: MaskDefinition<TType>; +}[MaskType]; + const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< keyof BaseMaskParams & string >[] = [ @@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< export interface RegisteredMaskDefinition { type: MaskType; name: string; - features: MaskDefinition<BaseMaskParams>["features"]; + features: MaskDefinition["features"]; params: ParamDefinition<string>[]; - renderer: MaskDefinition<BaseMaskParams>["renderer"]; - interaction: { - getInteraction(args: { - params: BaseMaskParams; - bounds: Parameters< - MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"] - >[0]["bounds"]; - displayScale: number; - scaleX: number; - scaleY: number; - }): MaskInteractionResult; - snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>; - }; - isActive?: (params: BaseMaskParams) => boolean; - buildDefault( - context: MaskDefaultContext, - ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>; + renderer: MaskRenderer<BaseMaskParams>; + interaction: MaskDefinition["interaction"]; + isActive?(params: BaseMaskParams): boolean; + buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId; computeParamUpdate( - args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0], - ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>; + args: MaskParamUpdateArgs<BaseMaskParams>, + ): ReturnType<MaskDefinition["computeParamUpdate"]>; icon: MaskIconProps; } @@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry< super("mask"); } - registerMask<TParams extends BaseMaskParams>({ + registerMask({ definition, icon, }: { - definition: MaskDefinition<TParams>; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }): void { const withBaseParams: RegisteredMaskDefinition = { @@ -96,23 +93,10 @@ export class MasksRegistry extends DefinitionRegistry< features: definition.features, params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], renderer: definition.renderer, - interaction: { - getInteraction(args) { - return definition.interaction.getInteraction(args as never); - }, - snap: definition.interaction.snap - ? (args) => definition.interaction.snap?.(args as never) as never - : undefined, - }, - isActive: definition.isActive - ? (params) => definition.isActive?.(params as TParams) ?? true - : undefined, - buildDefault(context) { - return definition.buildDefault(context); - }, - computeParamUpdate(args) { - return definition.computeParamUpdate(args as never); - }, + interaction: definition.interaction, + isActive: definition.isActive, + buildDefault: definition.buildDefault, + computeParamUpdate: definition.computeParamUpdate, icon, }; this.register({ diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts index 4f6c90aa..d35ca889 100644 --- a/apps/web/src/masks/types.ts +++ b/apps/web/src/masks/types.ts @@ -131,32 +131,50 @@ export type Mask = | TextMask | CustomMask; -export interface MaskRenderer { - buildPath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - buildStrokePath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - /** Renders the feathered mask directly onto ctx, bypassing JFA. */ - renderMask?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - feather: number; - }) => void; - renderMaskHandlesFeather?: boolean; - renderStroke?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - }) => void; +export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>; +export type MaskParamsByType<TType extends MaskType> = + MaskByType<TType>["params"]; + +type MaskPathArgs<TParams extends BaseMaskParams> = { + resolvedParams: TParams; + width: number; + height: number; +}; + +type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}; + +export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> = + | { + kind: "fillPath"; + buildPath(args: MaskPathArgs<TParams>): Path2D; + } + | { + kind: "drawOpaque"; + drawOpaque(args: MaskDrawArgs<TParams>): void; + } + | { + kind: "drawWithFeather"; + drawWithFeather(args: MaskDrawArgs<TParams> & { feather: number }): void; + opaqueFastPath?: { + buildPath(args: MaskPathArgs<TParams>): Path2D; + }; + }; + +export type MaskStroke<TParams extends BaseMaskParams = BaseMaskParams> = + | { + kind: "strokeFromPath"; + buildStrokePath(args: MaskPathArgs<TParams>): Path2D; + } + | { + kind: "renderStroke"; + renderStroke(args: MaskDrawArgs<TParams>): void; + }; + +export interface MaskRenderer<TParams extends BaseMaskParams = BaseMaskParams> { + body: MaskBody<TParams>; + stroke?: MaskStroke<TParams>; } export interface MaskFeatures { @@ -282,17 +300,17 @@ export interface MaskInteractionDefinition< snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>; } -export interface MaskDefinition< - TParams extends BaseMaskParams = BaseMaskParams, -> { - type: MaskType; +export interface MaskDefinition<TType extends MaskType = MaskType> { + type: TType; name: string; features: MaskFeatures; - params: ParamDefinition<keyof TParams & string>[]; - renderer: MaskRenderer; - interaction: MaskInteractionDefinition<TParams>; + params: ParamDefinition<keyof MaskParamsByType<TType> & string>[]; + renderer: MaskRenderer<MaskParamsByType<TType>>; + interaction: MaskInteractionDefinition<MaskParamsByType<TType>>; /** When defined and returning false, the mask is not applied and the element renders fully visible. */ - isActive?: (params: TParams) => boolean; - buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; - computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>; + isActive?(params: MaskParamsByType<TType>): boolean; + buildDefault(context: MaskDefaultContext): Omit<MaskByType<TType>, "id">; + computeParamUpdate( + args: MaskParamUpdateArgs<MaskParamsByType<TType>>, + ): Partial<MaskParamsByType<TType>>; } |
