refactor: split builtin and freeform masks

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-29 18:41:15 +02:00
parent 97f8d1968f
commit 79dcec8a2e
32 changed files with 508 additions and 273 deletions

View File

@ -305,7 +305,7 @@ export function useEditorActions() {
if (!selectedMaskPointSelection) { if (!selectedMaskPointSelection) {
return; return;
} }
editor.timeline.deleteCustomMaskPoints({ editor.timeline.deleteFreeformPathMaskPoints({
trackId: selectedMaskPointSelection.trackId, trackId: selectedMaskPointSelection.trackId,
elementId: selectedMaskPointSelection.elementId, elementId: selectedMaskPointSelection.elementId,
maskId: selectedMaskPointSelection.maskId, maskId: selectedMaskPointSelection.maskId,

View File

@ -1,22 +1,22 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { import {
getCustomMaskClosedStateAfterPointRemoval, getFreeformPathClosedStateAfterPointRemoval,
removeCustomMaskPoints, removeFreeformPathPoints,
} from "@/masks/custom-path"; } from "@/masks/freeform/path";
import type { CustomMask } from "@/masks/types"; import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline";
function deletePointsFromCustomMask({ function deletePointsFromFreeformPathMask({
mask, mask,
pointIds, pointIds,
}: { }: {
mask: CustomMask; mask: FreeformPathMask;
pointIds: string[]; pointIds: string[];
}): CustomMask { }): FreeformPathMask {
const points = mask.params.path; const points = mask.params.path;
const nextPoints = removeCustomMaskPoints({ points, pointIds }); const nextPoints = removeFreeformPathPoints({ points, pointIds });
if (nextPoints.length === points.length) { if (nextPoints.length === points.length) {
return mask; return mask;
} }
@ -26,7 +26,7 @@ function deletePointsFromCustomMask({
params: { params: {
...mask.params, ...mask.params,
path: nextPoints, path: nextPoints,
closed: getCustomMaskClosedStateAfterPointRemoval({ closed: getFreeformPathClosedStateAfterPointRemoval({
wasClosed: mask.params.closed, wasClosed: mask.params.closed,
remainingPointCount: nextPoints.length, remainingPointCount: nextPoints.length,
}), }),
@ -46,11 +46,11 @@ function deletePointsFromElementMask({
const currentMasks = element.masks ?? []; const currentMasks = element.masks ?? [];
let didDeletePoints = false; let didDeletePoints = false;
const nextMasks = currentMasks.map((mask) => { const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") { if (mask.id !== maskId || mask.type !== "freeform") {
return mask; return mask;
} }
const nextMask = deletePointsFromCustomMask({ const nextMask = deletePointsFromFreeformPathMask({
mask, mask,
pointIds, pointIds,
}); });
@ -64,7 +64,7 @@ function deletePointsFromElementMask({
}; };
} }
export class DeleteCustomMaskPointsCommand extends Command { export class DeleteFreeformPathMaskPointsCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command {
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => { update: (element) => {
if (!isMaskableElement(element)) return element;
const result = deletePointsFromElementMask({ const result = deletePointsFromElementMask({
element: element as MaskableElement, element,
maskId: this.maskId, maskId: this.maskId,
pointIds: this.pointIds, pointIds: this.pointIds,
}); });

View File

@ -1,4 +1,4 @@
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points";
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point";
export { RemoveMaskCommand } from "./remove-mask"; export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";

View File

@ -1,23 +1,23 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; import { insertPointOnFreeformSegment } from "@/masks/freeform/definition";
import type { ElementBounds } from "@/preview/element-bounds"; import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMask } from "@/masks/types"; import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline";
function insertPointIntoCustomMask({ function insertPointIntoFreeformPathMask({
mask, mask,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
bounds, bounds,
}: { }: {
mask: CustomMask; mask: FreeformPathMask;
segmentIndex: number; segmentIndex: number;
canvasPoint: { x: number; y: number }; canvasPoint: { x: number; y: number };
bounds: ElementBounds; bounds: ElementBounds;
}): { mask: CustomMask; insertedPointId: string | null } { }): { mask: FreeformPathMask; insertedPointId: string | null } {
const result = insertPointOnCustomMaskSegment({ const result = insertPointOnFreeformSegment({
params: mask.params, params: mask.params,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
@ -61,11 +61,11 @@ function insertPointIntoElementMask({
let didInsertPoint = false; let didInsertPoint = false;
const nextMasks = currentMasks.map((mask) => { const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") { if (mask.id !== maskId || mask.type !== "freeform") {
return mask; return mask;
} }
const result = insertPointIntoCustomMask({ const result = insertPointIntoFreeformPathMask({
mask, mask,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
@ -85,7 +85,7 @@ function insertPointIntoElementMask({
}; };
} }
export class InsertCustomMaskPointCommand extends Command { export class InsertFreeformPathMaskPointCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command {
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => { update: (element) => {
if (!isMaskableElement(element)) return element;
const result = insertPointIntoElementMask({ const result = insertPointIntoElementMask({
element: element as MaskableElement, element,
maskId: this.maskId, maskId: this.maskId,
segmentIndex: this.segmentIndex, segmentIndex: this.segmentIndex,
canvasPoint: this.canvasPoint, canvasPoint: this.canvasPoint,

View File

@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command {
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => update: (element) => {
removeMaskFromElement({ if (!isMaskableElement(element)) return element;
element: element as MaskableElement, return removeMaskFromElement({
element,
maskId: this.maskId, maskId: this.maskId,
}), });
},
}); });
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);

View File

@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command {
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => update: (element) => {
toggleMaskInvertedOnElement({ if (!isMaskableElement(element)) return element;
element: element as MaskableElement, return toggleMaskInvertedOnElement({
element,
maskId: this.maskId, maskId: this.maskId,
}), });
},
}); });
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);

View File

@ -46,8 +46,8 @@ import {
RetimeKeyframeCommand, RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand, UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand, AddClipEffectCommand,
DeleteCustomMaskPointsCommand, DeleteFreeformPathMaskPointsCommand,
InsertCustomMaskPointCommand, InsertFreeformPathMaskPointCommand,
RemoveClipEffectCommand, RemoveClipEffectCommand,
UpdateClipEffectParamsCommand, UpdateClipEffectParamsCommand,
ToggleClipEffectCommand, ToggleClipEffectCommand,
@ -349,7 +349,7 @@ export class TimelineManager {
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
deleteCustomMaskPoints({ deleteFreeformPathMaskPoints({
trackId, trackId,
elementId, elementId,
maskId, maskId,
@ -363,7 +363,7 @@ export class TimelineManager {
if (pointIds.length === 0) { if (pointIds.length === 0) {
return; return;
} }
const command = new DeleteCustomMaskPointsCommand({ const command = new DeleteFreeformPathMaskPointsCommand({
trackId, trackId,
elementId, elementId,
maskId, maskId,
@ -372,7 +372,7 @@ export class TimelineManager {
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
insertCustomMaskPoint({ insertFreeformPathMaskPoint({
trackId, trackId,
elementId, elementId,
maskId, maskId,
@ -387,7 +387,7 @@ export class TimelineManager {
canvasPoint: { x: number; y: number }; canvasPoint: { x: number; y: number };
bounds: ElementBounds; bounds: ElementBounds;
}): void { }): void {
const command = new InsertCustomMaskPointCommand({ const command = new InsertFreeformPathMaskPointCommand({
trackId, trackId,
elementId, elementId,
maskId, maskId,

View File

@ -1,22 +1,22 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
findClosestPointOnCustomMaskSegment, findClosestPointOnFreeformSegment,
getCustomMaskClosedStateAfterPointRemoval, getFreeformPathClosedStateAfterPointRemoval,
insertPointIntoCustomMaskSegment, insertPointIntoFreeformSegment,
removeCustomMaskPoints, removeFreeformPathPoints,
} from "@/masks/custom-path"; } from "@/masks/freeform/path";
import { import {
appendPointToCustomMask, appendPointToFreeformPathMask,
customMaskDefinition, freeformMaskDefinition,
insertPointOnCustomMaskSegment, insertPointOnFreeformSegment,
} from "@/masks/definitions/custom"; } from "@/masks/freeform/definition";
import { getSplitMaskStrokeSegment } from "@/masks/definitions/split"; import { getSplitMaskStrokeSegment } from "@/masks/builtin/definitions/split";
import { textMaskDefinition } from "@/masks/definitions/text"; import { textMaskDefinition } from "@/masks/builtin/definitions/text";
import { getMaskSnapGeometry } from "@/masks/geometry"; import { getMaskSnapGeometry } from "@/masks/geometry";
import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap"; import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap";
import type { ElementBounds } from "@/preview/element-bounds"; import type { ElementBounds } from "@/preview/element-bounds";
import type { import type {
CustomMaskParams, FreeformPathMaskParams,
RectangleMaskParams, RectangleMaskParams,
SplitMaskParams, SplitMaskParams,
TextMaskParams, TextMaskParams,
@ -100,9 +100,9 @@ function buildTextMaskParams(
}; };
} }
function buildCustomMaskParams( function buildFreeformPathMaskParams(
overrides: Partial<CustomMaskParams> = {}, overrides: Partial<FreeformPathMaskParams> = {},
): CustomMaskParams { ): FreeformPathMaskParams {
return { return {
feather: 0, feather: 0,
inverted: false, inverted: false,
@ -347,11 +347,11 @@ describe("mask snapping", () => {
}); });
test("snaps custom mask movement using path geometry bounds", () => { test("snaps custom mask movement using path geometry bounds", () => {
const params = buildCustomMaskParams({ const params = buildFreeformPathMaskParams({
centerX: 0.03, centerX: 0.03,
centerY: -0.04, centerY: -0.04,
}); });
const result = customMaskDefinition.interaction.snap?.({ const result = freeformMaskDefinition.interaction.snap?.({
handleId: { kind: "position" }, handleId: { kind: "position" },
startParams: params, startParams: params,
proposedParams: params, proposedParams: params,
@ -381,11 +381,11 @@ describe("mask snapping", () => {
describe("custom mask creation", () => { describe("custom mask creation", () => {
test("anchors the first point at the click position", () => { test("anchors the first point at the click position", () => {
const params = buildCustomMaskParams({ const params = buildFreeformPathMaskParams({
path: [], path: [],
closed: false, closed: false,
}); });
const next = appendPointToCustomMask({ const next = appendPointToFreeformPathMask({
params, params,
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 }, canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
bounds, bounds,
@ -401,8 +401,8 @@ describe("custom mask creation", () => {
describe("custom mask point deletion", () => { describe("custom mask point deletion", () => {
test("removes the selected points by id", () => { test("removes the selected points by id", () => {
const points = buildCustomMaskParams().path; const points = buildFreeformPathMaskParams().path;
const nextPoints = removeCustomMaskPoints({ const nextPoints = removeFreeformPathPoints({
points, points,
pointIds: ["b"], pointIds: ["b"],
}); });
@ -411,14 +411,14 @@ describe("custom mask point deletion", () => {
}); });
test("reopens a closed path once fewer than three points remain", () => { test("reopens a closed path once fewer than three points remain", () => {
const points = buildCustomMaskParams().path; const points = buildFreeformPathMaskParams().path;
const nextPoints = removeCustomMaskPoints({ const nextPoints = removeFreeformPathPoints({
points, points,
pointIds: ["c"], pointIds: ["c"],
}); });
expect( expect(
getCustomMaskClosedStateAfterPointRemoval({ getFreeformPathClosedStateAfterPointRemoval({
wasClosed: true, wasClosed: true,
remainingPointCount: nextPoints.length, remainingPointCount: nextPoints.length,
}), }),
@ -428,9 +428,9 @@ describe("custom mask point deletion", () => {
describe("custom mask point insertion", () => { describe("custom mask point insertion", () => {
test("finds the closest point on the clicked segment", () => { test("finds the closest point on the clicked segment", () => {
const params = buildCustomMaskParams(); const params = buildFreeformPathMaskParams();
const points = params.path; const points = params.path;
const closestPoint = findClosestPointOnCustomMaskSegment({ const closestPoint = findClosestPointOnFreeformSegment({
points, points,
segmentIndex: 0, segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
@ -449,8 +449,8 @@ describe("custom mask point insertion", () => {
}); });
test("splits a segment into two segments at the insertion point", () => { test("splits a segment into two segments at the insertion point", () => {
const points = buildCustomMaskParams().path; const points = buildFreeformPathMaskParams().path;
const nextPoints = insertPointIntoCustomMaskSegment({ const nextPoints = insertPointIntoFreeformSegment({
points, points,
segmentIndex: 0, segmentIndex: 0,
pointId: "new", pointId: "new",
@ -471,8 +471,8 @@ describe("custom mask point insertion", () => {
}); });
test("builds updated custom mask params for a clicked segment", () => { test("builds updated custom mask params for a clicked segment", () => {
const result = insertPointOnCustomMaskSegment({ const result = insertPointOnFreeformSegment({
params: buildCustomMaskParams(), params: buildFreeformPathMaskParams(),
segmentIndex: 0, segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
bounds, bounds,

View File

@ -10,7 +10,7 @@ import {
getDefaultBaseMaskParams, getDefaultBaseMaskParams,
getStrokeOffset, getStrokeOffset,
rotatePoint, rotatePoint,
} from "./box-like"; } from "../box-like";
function getDefaultCinematicBarsMaskParams({ function getDefaultCinematicBarsMaskParams({
elementSize, elementSize,

View File

@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
getStrokeOffset, getStrokeOffset,
rotatePoint, rotatePoint,
} from "./box-like"; } from "../box-like";
function buildDiamondPath({ function buildDiamondPath({
centerX, centerX,

View File

@ -6,7 +6,7 @@ import {
getBoxLikeGeometry, getBoxLikeGeometry,
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
getStrokeOffset, getStrokeOffset,
} from "./box-like"; } from "../box-like";
export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = {
type: "ellipse", type: "ellipse",

View File

@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
getStrokeOffset, getStrokeOffset,
rotatePoint, rotatePoint,
} from "./box-like"; } from "../box-like";
function buildHeartPath({ function buildHeartPath({
centerX, centerX,

View File

@ -1,10 +1,9 @@
import { import {
masksRegistry, builtinMasksRegistry,
type MaskDefinitionForRegistration, type BuiltinMaskDefinitionForRegistration,
type MaskIconProps, type MaskIconProps,
} from "../registry"; } from "../../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars"; import { cinematicBarsMaskDefinition } from "./cinematic-bars";
import { customMaskDefinition } from "./custom";
import { diamondMaskDefinition } from "./diamond"; import { diamondMaskDefinition } from "./diamond";
import { ellipseMaskDefinition } from "./ellipse"; import { ellipseMaskDefinition } from "./ellipse";
import { heartMaskDefinition } from "./heart"; import { heartMaskDefinition } from "./heart";
@ -27,17 +26,17 @@ function registerDefaultMask({
definition, definition,
icon, icon,
}: { }: {
definition: MaskDefinitionForRegistration; definition: BuiltinMaskDefinitionForRegistration;
icon: MaskIconProps; icon: MaskIconProps;
}) { }) {
if (masksRegistry.has(definition.type)) { if (builtinMasksRegistry.has(definition.type)) {
return; return;
} }
masksRegistry.registerMask({ definition, icon }); builtinMasksRegistry.registerMask({ definition, icon });
} }
export function registerDefaultMasks(): void { export function registerBuiltinMasks(): void {
registerDefaultMask({ registerDefaultMask({
definition: splitMaskDefinition, definition: splitMaskDefinition,
icon: { icon: PanelRightDashedIcon, strokeWidth: 1 }, icon: { icon: PanelRightDashedIcon, strokeWidth: 1 },
@ -70,8 +69,4 @@ export function registerDefaultMasks(): void {
definition: textMaskDefinition, definition: textMaskDefinition,
icon: { icon: TextFontIcon }, icon: { icon: TextFontIcon },
}); });
registerDefaultMask({
definition: customMaskDefinition,
icon: { icon: SquareIcon },
});
} }

View File

@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
getStrokeOffset, getStrokeOffset,
rotatePoint, rotatePoint,
} from "./box-like"; } from "../box-like";
function buildRectanglePath({ function buildRectanglePath({
centerX, centerX,

View File

@ -1,10 +1,10 @@
import { computeFeatherUpdate } from "../param-update"; import { computeFeatherUpdate } from "@/masks/param-update";
import type { import type {
MaskDefinition, MaskDefinition,
MaskParamUpdateArgs, MaskParamUpdateArgs,
SplitMaskParams, SplitMaskParams,
} from "@/masks/types"; } from "@/masks/types";
import { halfPlaneSign, lineEdgeIntersection } from "../utils"; import { halfPlaneSign, lineEdgeIntersection } from "@/masks/utils";
import { import {
getLineMaskHandlePositions, getLineMaskHandlePositions,
getLineMaskOverlay, getLineMaskOverlay,

View File

@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams, getDefaultSquareMaskParams,
getStrokeOffset, getStrokeOffset,
rotatePoint, rotatePoint,
} from "./box-like"; } from "../box-like";
const STAR_INNER_RADIUS_RATIO = 0.45; const STAR_INNER_RADIUS_RATIO = 0.45;
const STAR_VERTEX_COUNT = 10; const STAR_VERTEX_COUNT = 10;

View File

@ -3,7 +3,11 @@
import type { MaskableElement } from "@/timeline"; import type { MaskableElement } from "@/timeline";
import type { Mask, MaskType, TextMask } from "@/masks/types"; import type { Mask, MaskType, TextMask } from "@/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/params"; import type { NumberParamDefinition, SelectParamDefinition } from "@/params";
import { masksRegistry, buildDefaultMaskInstance } from "@/masks"; import {
buildDefaultMaskInstance,
getMaskDefinition,
getMaskDefinitionsForMenu,
} from "@/masks";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import { useMenuPreview } from "@/editor/use-menu-preview"; import { useMenuPreview } from "@/editor/use-menu-preview";
@ -85,12 +89,43 @@ type PreviewParamHandler = (
key: string, key: string,
) => (value: number | string | boolean) => void; ) => (value: number | string | boolean) => void;
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; type RegisteredMaskDefinition = ReturnType<typeof getMaskDefinition>;
function isTextMask(mask: Mask): mask is TextMask { function isTextMask(mask: Mask): mask is TextMask {
return mask.type === "text"; return mask.type === "text";
} }
function withPreviewedMaskParam({
mask,
key,
value,
}: {
mask: Mask;
key: string;
value: number | string | boolean;
}): Mask {
switch (mask.type) {
case "split":
return { ...mask, params: { ...mask.params, [key]: value } };
case "cinematic-bars":
return { ...mask, params: { ...mask.params, [key]: value } };
case "rectangle":
return { ...mask, params: { ...mask.params, [key]: value } };
case "ellipse":
return { ...mask, params: { ...mask.params, [key]: value } };
case "heart":
return { ...mask, params: { ...mask.params, [key]: value } };
case "diamond":
return { ...mask, params: { ...mask.params, [key]: value } };
case "star":
return { ...mask, params: { ...mask.params, [key]: value } };
case "text":
return { ...mask, params: { ...mask.params, [key]: value } };
case "freeform":
return { ...mask, params: { ...mask.params, [key]: value } };
}
}
export function MasksTab({ element, trackId }: MasksTabProps) { export function MasksTab({ element, trackId }: MasksTabProps) {
const editor = useEditor(); const editor = useEditor();
const { renderElement, previewUpdates, commit } = const { renderElement, previewUpdates, commit } =
@ -99,7 +134,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
elementId: element.id, elementId: element.id,
fallback: element, fallback: element,
}); });
const maskDefs = masksRegistry.getAll(); const maskDefs = getMaskDefinitionsForMenu();
const tracks = useEditor( const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
); );
@ -210,16 +245,10 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
const updatedMasks = renderMasks.map((existingMask, maskIndex) => const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
maskIndex !== index maskIndex !== index
? existingMask ? existingMask
: { : withPreviewedMaskParam({ mask: existingMask, key, value }),
...existingMask,
params: {
...existingMask.params,
[key]: value,
},
},
); );
previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>); previewUpdates({ masks: updatedMasks });
}; };
return ( return (
@ -301,7 +330,7 @@ function MaskItem({
onCommit, onCommit,
}: MaskItemProps) { }: MaskItemProps) {
const editor = useEditor(); const editor = useEditor();
const definition = masksRegistry.get(mask.type); const definition = getMaskDefinition(mask.type);
return ( return (
<Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}> <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>

View File

@ -3,26 +3,26 @@ import type { ParamDefinition } from "@/params";
import { PEN_CURSOR } from "@/preview/components/cursors"; import { PEN_CURSOR } from "@/preview/components/cursors";
import type { ElementBounds } from "@/preview/element-bounds"; import type { ElementBounds } from "@/preview/element-bounds";
import type { import type {
CustomMask, FreeformPathMask,
CustomMaskParams, FreeformPathMaskParams,
MaskDefinition, MaskDefinition,
MaskHandlePosition, MaskHandlePosition,
MaskOverlay, MaskOverlay,
MaskParamUpdateArgs, MaskParamUpdateArgs,
} from "@/masks/types"; } from "@/masks/types";
import { import {
buildCustomMaskPath2D, buildFreeformPath2D,
buildCustomMaskSvgPath, buildFreeformSvgPath,
customMaskCanvasPointToLocal, freeformCanvasPointToLocal,
findClosestPointOnCustomMaskSegment, findClosestPointOnFreeformSegment,
getCustomMaskCanvasSegments, getFreeformCanvasSegments,
getCustomMaskCanvasGeometry, getFreeformCanvasGeometry,
getCustomMaskLocalBounds, getFreeformLocalBounds,
getCustomMaskSegmentCount, getFreeformSegmentCount,
insertPointIntoCustomMaskSegment, insertPointIntoFreeformSegment,
recenterCustomMaskPath, recenterFreeformPath,
type CustomMaskPathPoint, type FreeformPathPoint,
} from "@/masks/custom-path"; } from "@/masks/freeform/path";
import { getBoxMaskHandlePositions } from "@/masks/handle-positions"; import { getBoxMaskHandlePositions } from "@/masks/handle-positions";
import { computeFeatherUpdate } from "@/masks/param-update"; import { computeFeatherUpdate } from "@/masks/param-update";
import { import {
@ -40,7 +40,7 @@ const PERCENTAGE_DISPLAY = {
step: 1, step: 1,
} as const; } as const;
const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [ const FREEFORM_PATH_MASK_PARAMS: ParamDefinition<keyof FreeformPathMaskParams & string>[] = [
{ {
key: "centerX", key: "centerX",
label: "X", label: "X",
@ -79,12 +79,12 @@ const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
}, },
]; ];
function getCustomMaskDisplayHandles({ function getFreeformDisplayHandles({
params, params,
displayScale, displayScale,
bounds, bounds,
}: { }: {
params: CustomMaskParams; params: FreeformPathMaskParams;
displayScale: number; displayScale: number;
bounds: ElementBounds; bounds: ElementBounds;
}): { }): {
@ -92,7 +92,7 @@ function getCustomMaskDisplayHandles({
overlays: MaskOverlay[]; overlays: MaskOverlay[];
} { } {
const points = params.path; const points = params.path;
const geometry = getCustomMaskCanvasGeometry({ const geometry = getFreeformCanvasGeometry({
points, points,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
@ -108,7 +108,7 @@ function getCustomMaskDisplayHandles({
overlays.push({ overlays.push({
id: "path", id: "path",
type: "canvas-path", type: "canvas-path",
pathData: buildCustomMaskSvgPath({ pathData: buildFreeformSvgPath({
points, points,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
@ -124,7 +124,7 @@ function getCustomMaskDisplayHandles({
if (params.closed) { if (params.closed) {
const segmentStrokeWidth = 12; const segmentStrokeWidth = 12;
overlays.push( overlays.push(
...getCustomMaskCanvasSegments({ ...getFreeformCanvasSegments({
points, points,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
@ -145,7 +145,7 @@ function getCustomMaskDisplayHandles({
); );
} }
const localBounds = getCustomMaskLocalBounds({ points, bounds }); const localBounds = getFreeformLocalBounds({ points, bounds });
if (params.closed && localBounds) { if (params.closed && localBounds) {
handles.push( handles.push(
...getBoxMaskHandlePositions({ ...getBoxMaskHandlePositions({
@ -179,19 +179,19 @@ function getCustomMaskDisplayHandles({
}; };
} }
function updateCustomMaskPoint({ function updateFreeformPathMaskPoint({
points, points,
pointId, pointId,
updater, updater,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
pointId: string; pointId: string;
updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint; updater: (point: FreeformPathPoint) => FreeformPathPoint;
}) { }) {
return points.map((point) => (point.id === pointId ? updater(point) : point)); return points.map((point) => (point.id === pointId ? updater(point) : point));
} }
function computeCustomMaskParamUpdate({ function computeFreeformParamUpdate({
handleId, handleId,
startParams, startParams,
deltaX, deltaX,
@ -199,7 +199,7 @@ function computeCustomMaskParamUpdate({
startCanvasX, startCanvasX,
startCanvasY, startCanvasY,
bounds, bounds,
}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> { }: MaskParamUpdateArgs<FreeformPathMaskParams>): Partial<FreeformPathMaskParams> {
if (handleId.kind === "position") { if (handleId.kind === "position") {
return { return {
centerX: startParams.centerX + deltaX / bounds.width, centerX: startParams.centerX + deltaX / bounds.width,
@ -266,7 +266,7 @@ function computeCustomMaskParamUpdate({
x: startCanvasX + deltaX, x: startCanvasX + deltaX,
y: startCanvasY + deltaY, y: startCanvasY + deltaY,
}; };
const localPoint = customMaskCanvasPointToLocal({ const localPoint = freeformCanvasPointToLocal({
point: currentPoint, point: currentPoint,
centerX: startParams.centerX, centerX: startParams.centerX,
centerY: startParams.centerY, centerY: startParams.centerY,
@ -276,7 +276,7 @@ function computeCustomMaskParamUpdate({
}); });
return { return {
path: updateCustomMaskPoint({ path: updateFreeformPathMaskPoint({
points, points,
pointId: handleId.pointId, pointId: handleId.pointId,
updater: (point) => { updater: (point) => {
@ -306,15 +306,15 @@ function computeCustomMaskParamUpdate({
}; };
} }
export const customMaskDefinition: MaskDefinition<"custom"> = { export const freeformMaskDefinition: MaskDefinition<"freeform"> = {
type: "custom", type: "freeform",
name: "Custom", name: "Pen tool",
features: { features: {
hasPosition: true, hasPosition: true,
hasRotation: true, hasRotation: true,
sizeMode: "uniform", sizeMode: "uniform",
}, },
params: CUSTOM_MASK_PARAMS, params: FREEFORM_PATH_MASK_PARAMS,
interaction: { interaction: {
getInteraction({ getInteraction({
params, params,
@ -323,7 +323,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
scaleX: _scaleX, scaleX: _scaleX,
scaleY: _scaleY, scaleY: _scaleY,
}) { }) {
return getCustomMaskDisplayHandles({ params, bounds, displayScale }); return getFreeformDisplayHandles({ params, bounds, displayScale });
}, },
snap({ snap({
handleId, handleId,
@ -334,7 +334,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
snapThreshold, snapThreshold,
}) { }) {
const points = startParams.path; const points = startParams.path;
const localBounds = getCustomMaskLocalBounds({ points, bounds }); const localBounds = getFreeformLocalBounds({ points, bounds });
if (!startParams.closed || !localBounds) { if (!startParams.closed || !localBounds) {
return { return {
params: proposedParams, params: proposedParams,
@ -422,9 +422,9 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
}; };
}, },
}, },
buildDefault(): Omit<CustomMask, "id"> { buildDefault(): Omit<FreeformPathMask, "id"> {
return { return {
type: "custom", type: "freeform",
params: { params: {
feather: 0, feather: 0,
inverted: false, inverted: false,
@ -440,7 +440,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
}, },
}; };
}, },
computeParamUpdate: computeCustomMaskParamUpdate, computeParamUpdate: computeFreeformParamUpdate,
isActive(params) { isActive(params) {
return params.closed; return params.closed;
}, },
@ -454,7 +454,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
return new Path2D(); return new Path2D();
} }
return buildCustomMaskPath2D({ return buildFreeformPath2D({
points, points,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
@ -480,7 +480,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
} }
const points = params.path; const points = params.path;
const path = buildCustomMaskPath2D({ const path = buildFreeformPath2D({
points, points,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
@ -520,15 +520,15 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
}, },
}; };
export function appendPointToCustomMask({ export function appendPointToFreeformPathMask({
params, params,
canvasPoint, canvasPoint,
bounds, bounds,
}: { }: {
params: CustomMaskParams; params: FreeformPathMaskParams;
canvasPoint: { x: number; y: number }; canvasPoint: { x: number; y: number };
bounds: ElementBounds; bounds: ElementBounds;
}): CustomMaskParams { }): FreeformPathMaskParams {
const points = params.path; const points = params.path;
if (points.length === 0) { if (points.length === 0) {
@ -554,7 +554,7 @@ export function appendPointToCustomMask({
}; };
} }
const localPoint = customMaskCanvasPointToLocal({ const localPoint = freeformCanvasPointToLocal({
point: canvasPoint, point: canvasPoint,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
@ -574,7 +574,7 @@ export function appendPointToCustomMask({
outY: 0, outY: 0,
}, },
]; ];
const recentered = recenterCustomMaskPath({ const recentered = recenterFreeformPath({
points: nextPoints, points: nextPoints,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
@ -591,25 +591,25 @@ export function appendPointToCustomMask({
}; };
} }
export function insertPointOnCustomMaskSegment({ export function insertPointOnFreeformSegment({
params, params,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
bounds, bounds,
pointId = generateUUID(), pointId = generateUUID(),
}: { }: {
params: CustomMaskParams; params: FreeformPathMaskParams;
segmentIndex: number; segmentIndex: number;
canvasPoint: { x: number; y: number }; canvasPoint: { x: number; y: number };
bounds: ElementBounds; bounds: ElementBounds;
pointId?: string; pointId?: string;
}): { params: CustomMaskParams; pointId: string } | null { }): { params: FreeformPathMaskParams; pointId: string } | null {
const points = params.path; const points = params.path;
if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) { if (getFreeformSegmentCount({ points, closed: params.closed }) === 0) {
return null; return null;
} }
const closestPoint = findClosestPointOnCustomMaskSegment({ const closestPoint = findClosestPointOnFreeformSegment({
points, points,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
@ -624,7 +624,7 @@ export function insertPointOnCustomMaskSegment({
return null; return null;
} }
const nextPoints = insertPointIntoCustomMaskSegment({ const nextPoints = insertPointIntoFreeformSegment({
points, points,
segmentIndex, segmentIndex,
pointId, pointId,
@ -635,7 +635,7 @@ export function insertPointOnCustomMaskSegment({
return null; return null;
} }
const recentered = recenterCustomMaskPath({ const recentered = recenterFreeformPath({
points: nextPoints, points: nextPoints,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,

View File

@ -1,6 +1,6 @@
import type { ElementBounds } from "@/preview/element-bounds"; import type { ElementBounds } from "@/preview/element-bounds";
export interface CustomMaskPathPoint { export interface FreeformPathPoint {
id: string; id: string;
x: number; x: number;
y: number; y: number;
@ -10,7 +10,7 @@ export interface CustomMaskPathPoint {
outY: number; outY: number;
} }
function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { function isFreeformPathPoint(value: unknown): value is FreeformPathPoint {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return false; return false;
} }
@ -33,38 +33,38 @@ function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
); );
} }
export function parseCustomMaskPath({ export function parseFreeformPath({
path, path,
}: { }: {
path: string; path: string;
}): CustomMaskPathPoint[] { }): FreeformPathPoint[] {
if (!path) { if (!path) {
return []; return [];
} }
try { try {
const parsed = JSON.parse(path); const parsed = JSON.parse(path);
return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : [];
} catch { } catch {
return []; return [];
} }
} }
export function serializeCustomMaskPath({ export function serializeFreeformPath({
points, points,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
}): string { }): string {
return JSON.stringify(points); return JSON.stringify(points);
} }
export function removeCustomMaskPoints({ export function removeFreeformPathPoints({
points, points,
pointIds, pointIds,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
pointIds: string[]; pointIds: string[];
}): CustomMaskPathPoint[] { }): FreeformPathPoint[] {
if (pointIds.length === 0) { if (pointIds.length === 0) {
return points; return points;
} }
@ -72,7 +72,7 @@ export function removeCustomMaskPoints({
return points.filter((point) => !pointIdsToRemove.has(point.id)); return points.filter((point) => !pointIdsToRemove.has(point.id));
} }
export function getCustomMaskClosedStateAfterPointRemoval({ export function getFreeformPathClosedStateAfterPointRemoval({
wasClosed, wasClosed,
remainingPointCount, remainingPointCount,
}: { }: {
@ -100,7 +100,7 @@ function rotatePoint({
}; };
} }
export function getCustomMaskCenterCanvasPoint({ export function getFreeformCenterCanvasPoint({
centerX, centerX,
centerY, centerY,
bounds, bounds,
@ -115,7 +115,7 @@ export function getCustomMaskCenterCanvasPoint({
}; };
} }
export function customMaskLocalPointToCanvas({ export function freeformLocalPointToCanvas({
point, point,
centerX, centerX,
centerY, centerY,
@ -130,7 +130,7 @@ export function customMaskLocalPointToCanvas({
scale: number; scale: number;
bounds: ElementBounds; bounds: ElementBounds;
}): { x: number; y: number } { }): { x: number; y: number } {
const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds });
const scaledLocal = { const scaledLocal = {
x: point.x * bounds.width * scale, x: point.x * bounds.width * scale,
y: point.y * bounds.height * scale, y: point.y * bounds.height * scale,
@ -147,7 +147,7 @@ export function customMaskLocalPointToCanvas({
}; };
} }
export function customMaskCanvasPointToLocal({ export function freeformCanvasPointToLocal({
point, point,
centerX, centerX,
centerY, centerY,
@ -162,7 +162,7 @@ export function customMaskCanvasPointToLocal({
scale: number; scale: number;
bounds: ElementBounds; bounds: ElementBounds;
}): { x: number; y: number } { }): { x: number; y: number } {
const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds });
const translated = { const translated = {
x: point.x - center.x, x: point.x - center.x,
y: point.y - center.y, y: point.y - center.y,
@ -179,7 +179,7 @@ export function customMaskCanvasPointToLocal({
}; };
} }
export function getCustomMaskCanvasGeometry({ export function getFreeformCanvasGeometry({
points, points,
centerX, centerX,
centerY, centerY,
@ -187,7 +187,7 @@ export function getCustomMaskCanvasGeometry({
scale, scale,
bounds, bounds,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
centerX: number; centerX: number;
centerY: number; centerY: number;
rotation: number; rotation: number;
@ -196,7 +196,7 @@ export function getCustomMaskCanvasGeometry({
}) { }) {
const anchors = points.map((point) => ({ const anchors = points.map((point) => ({
id: point.id, id: point.id,
anchor: customMaskLocalPointToCanvas({ anchor: freeformLocalPointToCanvas({
point: { x: point.x, y: point.y }, point: { x: point.x, y: point.y },
centerX, centerX,
centerY, centerY,
@ -204,7 +204,7 @@ export function getCustomMaskCanvasGeometry({
scale, scale,
bounds, bounds,
}), }),
inHandle: customMaskLocalPointToCanvas({ inHandle: freeformLocalPointToCanvas({
point: { x: point.x + point.inX, y: point.y + point.inY }, point: { x: point.x + point.inX, y: point.y + point.inY },
centerX, centerX,
centerY, centerY,
@ -212,7 +212,7 @@ export function getCustomMaskCanvasGeometry({
scale, scale,
bounds, bounds,
}), }),
outHandle: customMaskLocalPointToCanvas({ outHandle: freeformLocalPointToCanvas({
point: { x: point.x + point.outX, y: point.y + point.outY }, point: { x: point.x + point.outX, y: point.y + point.outY },
centerX, centerX,
centerY, centerY,
@ -288,7 +288,7 @@ function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): {
}; };
} }
export interface CustomMaskCanvasSegment { export interface FreeformCanvasSegment {
index: number; index: number;
startPointId: string; startPointId: string;
endPointId: string; endPointId: string;
@ -358,16 +358,16 @@ function evaluateCubicBezier({
}; };
} }
function getCustomMaskSegmentIndices({ function getFreeformSegmentIndices({
points, points,
segmentIndex, segmentIndex,
closed, closed,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
segmentIndex: number; segmentIndex: number;
closed: boolean; closed: boolean;
}): { startIndex: number; endIndex: number } | null { }): { startIndex: number; endIndex: number } | null {
const segmentCount = getCustomMaskSegmentCount({ points, closed }); const segmentCount = getFreeformSegmentCount({ points, closed });
if (segmentIndex < 0 || segmentIndex >= segmentCount) { if (segmentIndex < 0 || segmentIndex >= segmentCount) {
return null; return null;
} }
@ -378,11 +378,11 @@ function getCustomMaskSegmentIndices({
}; };
} }
export function getCustomMaskSegmentCount({ export function getFreeformSegmentCount({
points, points,
closed, closed,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
closed: boolean; closed: boolean;
}): number { }): number {
if (points.length < 2) { if (points.length < 2) {
@ -391,7 +391,7 @@ export function getCustomMaskSegmentCount({
return closed ? points.length : points.length - 1; return closed ? points.length : points.length - 1;
} }
export function getCustomMaskCanvasSegments({ export function getFreeformCanvasSegments({
points, points,
centerX, centerX,
centerY, centerY,
@ -400,15 +400,15 @@ export function getCustomMaskCanvasSegments({
bounds, bounds,
closed, closed,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
centerX: number; centerX: number;
centerY: number; centerY: number;
rotation: number; rotation: number;
scale: number; scale: number;
bounds: ElementBounds; bounds: ElementBounds;
closed: boolean; closed: boolean;
}): CustomMaskCanvasSegment[] { }): FreeformCanvasSegment[] {
const geometry = getCustomMaskCanvasGeometry({ const geometry = getFreeformCanvasGeometry({
points, points,
centerX, centerX,
centerY, centerY,
@ -416,7 +416,7 @@ export function getCustomMaskCanvasSegments({
scale, scale,
bounds, bounds,
}); });
const segmentCount = getCustomMaskSegmentCount({ points, closed }); const segmentCount = getFreeformSegmentCount({ points, closed });
return Array.from({ length: segmentCount }, (_, segmentIndex) => { return Array.from({ length: segmentCount }, (_, segmentIndex) => {
const start = geometry.anchors[segmentIndex]; const start = geometry.anchors[segmentIndex];
@ -434,7 +434,7 @@ export function getCustomMaskCanvasSegments({
}); });
} }
export function findClosestPointOnCustomMaskSegment({ export function findClosestPointOnFreeformSegment({
points, points,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
@ -445,7 +445,7 @@ export function findClosestPointOnCustomMaskSegment({
bounds, bounds,
closed, closed,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
segmentIndex: number; segmentIndex: number;
canvasPoint: CanvasPoint; canvasPoint: CanvasPoint;
centerX: number; centerX: number;
@ -455,7 +455,7 @@ export function findClosestPointOnCustomMaskSegment({
bounds: ElementBounds; bounds: ElementBounds;
closed: boolean; closed: boolean;
}): { t: number; point: CanvasPoint } | null { }): { t: number; point: CanvasPoint } | null {
const segment = getCustomMaskCanvasSegments({ const segment = getFreeformCanvasSegments({
points, points,
centerX, centerX,
centerY, centerY,
@ -531,20 +531,20 @@ export function findClosestPointOnCustomMaskSegment({
}; };
} }
export function insertPointIntoCustomMaskSegment({ export function insertPointIntoFreeformSegment({
points, points,
segmentIndex, segmentIndex,
pointId, pointId,
t, t,
closed, closed,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
segmentIndex: number; segmentIndex: number;
pointId: string; pointId: string;
t: number; t: number;
closed: boolean; closed: boolean;
}): CustomMaskPathPoint[] { }): FreeformPathPoint[] {
const indices = getCustomMaskSegmentIndices({ const indices = getFreeformSegmentIndices({
points, points,
segmentIndex, segmentIndex,
closed, closed,
@ -596,11 +596,11 @@ export function insertPointIntoCustomMaskSegment({
return nextPoints; return nextPoints;
} }
export function getCustomMaskLocalBounds({ export function getFreeformLocalBounds({
points, points,
bounds, bounds,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
bounds: ElementBounds; bounds: ElementBounds;
}) { }) {
if (points.length === 0) { if (points.length === 0) {
@ -632,7 +632,7 @@ export function getCustomMaskLocalBounds({
}; };
} }
export function recenterCustomMaskPath({ export function recenterFreeformPath({
points, points,
centerX, centerX,
centerY, centerY,
@ -640,7 +640,7 @@ export function recenterCustomMaskPath({
scale, scale,
bounds, bounds,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
centerX: number; centerX: number;
centerY: number; centerY: number;
rotation: number; rotation: number;
@ -651,7 +651,7 @@ export function recenterCustomMaskPath({
return { centerX, centerY, points }; return { centerX, centerY, points };
} }
const geometry = getCustomMaskCanvasGeometry({ const geometry = getFreeformCanvasGeometry({
points, points,
centerX, centerX,
centerY, centerY,
@ -676,7 +676,7 @@ export function recenterCustomMaskPath({
}; };
const nextPoints = geometry.anchors.map((point) => { const nextPoints = geometry.anchors.map((point) => {
const anchor = customMaskCanvasPointToLocal({ const anchor = freeformCanvasPointToLocal({
point: point.anchor, point: point.anchor,
centerX: nextCenterLocal.x, centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y, centerY: nextCenterLocal.y,
@ -684,7 +684,7 @@ export function recenterCustomMaskPath({
scale, scale,
bounds, bounds,
}); });
const inHandle = customMaskCanvasPointToLocal({ const inHandle = freeformCanvasPointToLocal({
point: point.inHandle, point: point.inHandle,
centerX: nextCenterLocal.x, centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y, centerY: nextCenterLocal.y,
@ -692,7 +692,7 @@ export function recenterCustomMaskPath({
scale, scale,
bounds, bounds,
}); });
const outHandle = customMaskCanvasPointToLocal({ const outHandle = freeformCanvasPointToLocal({
point: point.outHandle, point: point.outHandle,
centerX: nextCenterLocal.x, centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y, centerY: nextCenterLocal.y,
@ -719,7 +719,7 @@ export function recenterCustomMaskPath({
}; };
} }
export function buildCustomMaskPath2D({ export function buildFreeformPath2D({
points, points,
centerX, centerX,
centerY, centerY,
@ -728,7 +728,7 @@ export function buildCustomMaskPath2D({
bounds, bounds,
closed, closed,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
centerX: number; centerX: number;
centerY: number; centerY: number;
rotation: number; rotation: number;
@ -741,7 +741,7 @@ export function buildCustomMaskPath2D({
return path; return path;
} }
const geometry = getCustomMaskCanvasGeometry({ const geometry = getFreeformCanvasGeometry({
points, points,
centerX, centerX,
centerY, centerY,
@ -782,7 +782,7 @@ export function buildCustomMaskPath2D({
return path; return path;
} }
export function buildCustomMaskSvgPath({ export function buildFreeformSvgPath({
points, points,
centerX, centerX,
centerY, centerY,
@ -791,7 +791,7 @@ export function buildCustomMaskSvgPath({
bounds, bounds,
closed, closed,
}: { }: {
points: CustomMaskPathPoint[]; points: FreeformPathPoint[];
centerX: number; centerX: number;
centerY: number; centerY: number;
rotation: number; rotation: number;
@ -803,7 +803,7 @@ export function buildCustomMaskSvgPath({
return ""; return "";
} }
const geometry = getCustomMaskCanvasGeometry({ const geometry = getFreeformCanvasGeometry({
points, points,
centerX, centerX,
centerY, centerY,

View File

@ -1,9 +1,15 @@
import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types"; import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
import { masksRegistry } from "./registry"; import {
BASE_MASK_PARAM_DEFINITIONS,
builtinMasksRegistry,
type RegisteredBuiltinMaskDefinition,
} from "./registry";
import { freeformMaskDefinition } from "./freeform/definition";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { SquareIcon } from "@hugeicons/core-free-icons";
export { masksRegistry } from "./registry"; export { builtinMasksRegistry } from "./registry";
export { registerDefaultMasks } from "./definitions"; export { registerBuiltinMasks as registerDefaultMasks } from "./builtin/definitions";
type MaskWithoutId = Mask extends infer TMask type MaskWithoutId = Mask extends infer TMask
? TMask extends Mask ? TMask extends Mask
@ -29,11 +35,35 @@ function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask {
return { ...mask, id }; return { ...mask, id };
case "text": case "text":
return { ...mask, id }; return { ...mask, id };
case "custom": case "freeform":
return { ...mask, id }; return { ...mask, id };
} }
} }
export function getMaskDefinition(maskType: MaskType): RegisteredBuiltinMaskDefinition {
if (maskType === "freeform") {
return {
...freeformMaskDefinition,
params: [...freeformMaskDefinition.params, ...BASE_MASK_PARAM_DEFINITIONS],
icon: { icon: SquareIcon },
} as RegisteredBuiltinMaskDefinition;
}
return builtinMasksRegistry.get(maskType);
}
export function getMaskDefinitionsForMenu() {
return [
...builtinMasksRegistry.getAll(),
{
...freeformMaskDefinition,
name: "Pen tool",
params: [...freeformMaskDefinition.params, ...BASE_MASK_PARAM_DEFINITIONS],
icon: { icon: SquareIcon },
},
];
}
export function buildDefaultMaskInstance({ export function buildDefaultMaskInstance({
maskType, maskType,
elementSize, elementSize,
@ -41,7 +71,7 @@ export function buildDefaultMaskInstance({
maskType: MaskType; maskType: MaskType;
elementSize?: { width: number; height: number }; elementSize?: { width: number; height: number };
}): Mask { }): Mask {
const definition = masksRegistry.get(maskType); const definition = getMaskDefinition(maskType);
const context: MaskDefaultContext = { elementSize }; const context: MaskDefaultContext = { elementSize };
return withMaskId({ return withMaskId({
mask: definition.buildDefault(context), mask: definition.buildDefault(context),

View File

@ -2,6 +2,7 @@ import { MAX_FEATHER } from "@/masks/feather";
import type { ParamDefinition } from "@/params"; import type { ParamDefinition } from "@/params";
import type { import type {
BaseMaskParams, BaseMaskParams,
BuiltinMaskType,
Mask, Mask,
MaskDefaultContext, MaskDefaultContext,
MaskDefinition, MaskDefinition,
@ -23,11 +24,11 @@ type RegisteredMaskWithoutId = Mask extends infer TMask
: never : never
: never; : never;
export type MaskDefinitionForRegistration = { export type BuiltinMaskDefinitionForRegistration = {
[TType in MaskType]: MaskDefinition<TType>; [TType in BuiltinMaskType]: MaskDefinition<TType>;
}[MaskType]; }[BuiltinMaskType];
const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< export const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
keyof BaseMaskParams & string keyof BaseMaskParams & string
>[] = [ >[] = [
{ {
@ -57,7 +58,7 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
}, },
]; ];
export interface RegisteredMaskDefinition { export interface RegisteredBuiltinMaskDefinition {
type: MaskType; type: MaskType;
name: string; name: string;
features: MaskDefinition["features"]; features: MaskDefinition["features"];
@ -72,9 +73,9 @@ export interface RegisteredMaskDefinition {
icon: MaskIconProps; icon: MaskIconProps;
} }
export class MasksRegistry extends DefinitionRegistry< export class BuiltinMasksRegistry extends DefinitionRegistry<
MaskType, BuiltinMaskType,
RegisteredMaskDefinition RegisteredBuiltinMaskDefinition
> { > {
constructor() { constructor() {
super("mask"); super("mask");
@ -84,10 +85,10 @@ export class MasksRegistry extends DefinitionRegistry<
definition, definition,
icon, icon,
}: { }: {
definition: MaskDefinitionForRegistration; definition: BuiltinMaskDefinitionForRegistration;
icon: MaskIconProps; icon: MaskIconProps;
}): void { }): void {
const withBaseParams: RegisteredMaskDefinition = { const withBaseParams: RegisteredBuiltinMaskDefinition = {
type: definition.type, type: definition.type,
name: definition.name, name: definition.name,
features: definition.features, features: definition.features,
@ -106,4 +107,4 @@ export class MasksRegistry extends DefinitionRegistry<
} }
} }
export const masksRegistry = new MasksRegistry(); export const builtinMasksRegistry = new BuiltinMasksRegistry();

View File

@ -1,14 +1,14 @@
import type { ElementBounds } from "@/preview/element-bounds"; import type { ElementBounds } from "@/preview/element-bounds";
import type { SnapLine } from "@/preview/preview-snap"; import type { SnapLine } from "@/preview/preview-snap";
import type { ParamDefinition } from "@/params"; import type { ParamDefinition } from "@/params";
import type { CustomMaskPathPoint } from "@/masks/custom-path"; import type { FreeformPathPoint } from "@/masks/freeform/path";
import type { import type {
TextDecoration, TextDecoration,
TextFontStyle, TextFontStyle,
TextFontWeight, TextFontWeight,
} from "@/text/primitives"; } from "@/text/primitives";
export type MaskType = export type BuiltinMaskType =
| "split" | "split"
| "cinematic-bars" | "cinematic-bars"
| "rectangle" | "rectangle"
@ -16,8 +16,9 @@ export type MaskType =
| "heart" | "heart"
| "diamond" | "diamond"
| "star" | "star"
| "text" | "text";
| "custom";
export type MaskType = BuiltinMaskType | "freeform";
export interface BaseMaskParams { export interface BaseMaskParams {
feather: number; feather: number;
@ -57,8 +58,8 @@ export interface TextMaskParams extends BaseMaskParams {
scale: number; scale: number;
} }
export interface CustomMaskParams extends BaseMaskParams { export interface FreeformPathMaskParams extends BaseMaskParams {
path: CustomMaskPathPoint[]; path: FreeformPathPoint[];
closed: boolean; closed: boolean;
centerX: number; centerX: number;
centerY: number; centerY: number;
@ -114,13 +115,7 @@ export interface TextMask {
params: TextMaskParams; params: TextMaskParams;
} }
export interface CustomMask { export type BuiltinShapeMask =
id: string;
type: "custom";
params: CustomMaskParams;
}
export type Mask =
| SplitMask | SplitMask
| CinematicBarsMask | CinematicBarsMask
| RectangleMask | RectangleMask
@ -128,8 +123,15 @@ export type Mask =
| HeartMask | HeartMask
| DiamondMask | DiamondMask
| StarMask | StarMask
| TextMask | TextMask;
| CustomMask;
export interface FreeformPathMask {
id: string;
type: "freeform";
params: FreeformPathMaskParams;
}
export type Mask = BuiltinShapeMask | FreeformPathMask;
export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>; export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>;
export type MaskParamsByType<TType extends MaskType> = export type MaskParamsByType<TType extends MaskType> =

View File

@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { masksRegistry } from "@/masks"; import { getMaskDefinition } from "@/masks";
import { appendPointToCustomMask } from "@/masks/definitions/custom"; import { appendPointToFreeformPathMask } from "@/masks/freeform/definition";
import { import {
getVisibleElementsWithBounds, getVisibleElementsWithBounds,
type ElementBounds, type ElementBounds,
@ -158,7 +158,7 @@ export function useMaskHandles({
const { handles: baseHandlePositions, overlays }: MaskInteractionResult = const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
selectedWithMask selectedWithMask
? (() => { ? (() => {
const def = masksRegistry.get(selectedWithMask.mask.type); const def = getMaskDefinition(selectedWithMask.mask.type);
const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
const displayScale = (scaleX + scaleY) / 2; const displayScale = (scaleX + scaleY) / 2;
return def.interaction.getInteraction({ return def.interaction.getInteraction({
@ -195,7 +195,7 @@ export function useMaskHandles({
const customMaskPointIds = useMemo( const customMaskPointIds = useMemo(
() => () =>
selectedWithMask?.mask.type === "custom" selectedWithMask?.mask.type === "freeform"
? handlePositions ? handlePositions
.filter((h) => h.kind === "point") .filter((h) => h.kind === "point")
.map((h) => { .map((h) => {
@ -205,8 +205,8 @@ export function useMaskHandles({
: [], : [],
[handlePositions, selectedWithMask?.mask.type], [handlePositions, selectedWithMask?.mask.type],
); );
const isCreatingCustomMask = const isCreatingFreeformPathMask =
selectedWithMask?.mask.type === "custom" && selectedWithMask?.mask.type === "freeform" &&
(!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
useEffect(() => { useEffect(() => {
@ -215,7 +215,7 @@ export function useMaskHandles({
} }
if ( if (
!selectedWithMask || !selectedWithMask ||
selectedWithMask.mask.type !== "custom" || selectedWithMask.mask.type !== "freeform" ||
!isMaskSelectionForElement({ !isMaskSelectionForElement({
trackId: selectedWithMask.trackId, trackId: selectedWithMask.trackId,
elementId: selectedWithMask.elementId, elementId: selectedWithMask.elementId,
@ -254,7 +254,7 @@ export function useMaskHandles({
selectedWithMask, selectedWithMask,
]); ]);
const updateCustomMaskPointSelection = useCallback( const updateFreeformPathMaskPointSelection = useCallback(
({ ({
pointId, pointId,
toggleSelection, toggleSelection,
@ -262,7 +262,7 @@ export function useMaskHandles({
pointId: string; pointId: string;
toggleSelection: boolean; toggleSelection: boolean;
}) => { }) => {
if (!selectedWithMask || selectedWithMask.mask.type !== "custom") { if (!selectedWithMask || selectedWithMask.mask.type !== "freeform") {
return; return;
} }
@ -347,21 +347,21 @@ export function useMaskHandles({
if (event.button !== 0) return; if (event.button !== 0) return;
event.stopPropagation(); event.stopPropagation();
const anchorHandle = const anchorHandle =
selectedWithMask.mask.type === "custom" && handleId.kind === "anchor" selectedWithMask.mask.type === "freeform" && handleId.kind === "anchor"
? handleId ? handleId
: null; : null;
const segmentHandle = const segmentHandle =
selectedWithMask.mask.type === "custom" && handleId.kind === "segment" selectedWithMask.mask.type === "freeform" && handleId.kind === "segment"
? handleId ? handleId
: null; : null;
if (isCreatingCustomMask) { if (isCreatingFreeformPathMask) {
const firstPointId = customMaskPointIds[0]; const firstPointId = customMaskPointIds[0];
if ( if (
firstPointId && firstPointId &&
handleId.kind === "anchor" && handleId.kind === "anchor" &&
handleId.pointId === firstPointId && handleId.pointId === firstPointId &&
customMaskPointIds.length >= 3 && customMaskPointIds.length >= 3 &&
selectedWithMask.mask.type === "custom" selectedWithMask.mask.type === "freeform"
) { ) {
const updatedMask = withUpdatedMaskParams({ const updatedMask = withUpdatedMaskParams({
mask: selectedWithMask.mask, mask: selectedWithMask.mask,
@ -394,7 +394,7 @@ export function useMaskHandles({
}); });
if (!pos) return; if (!pos) return;
if (segmentHandle && selectedWithMask.mask.type === "custom") { if (segmentHandle && selectedWithMask.mask.type === "freeform") {
setActiveHandleId(handleId); setActiveHandleId(handleId);
pendingSegmentInsertRef.current = { pendingSegmentInsertRef.current = {
trackId: selectedWithMask.trackId, trackId: selectedWithMask.trackId,
@ -418,14 +418,14 @@ export function useMaskHandles({
} }
if (anchorHandle) { if (anchorHandle) {
updateCustomMaskPointSelection({ updateFreeformPathMaskPointSelection({
pointId: anchorHandle.pointId, pointId: anchorHandle.pointId,
toggleSelection: event.shiftKey, toggleSelection: event.shiftKey,
}); });
if (event.shiftKey) { if (event.shiftKey) {
return; return;
} }
} else if (selectedWithMask.mask.type === "custom") { } else if (selectedWithMask.mask.type === "freeform") {
editor.selection.clearMaskPointSelection(); editor.selection.clearMaskPointSelection();
} }
@ -449,22 +449,22 @@ export function useMaskHandles({
customMaskPointIds, customMaskPointIds,
editor.selection, editor.selection,
editor.timeline, editor.timeline,
isCreatingCustomMask, isCreatingFreeformPathMask,
selectedWithMask, selectedWithMask,
updateCustomMaskPointSelection, updateFreeformPathMaskPointSelection,
viewport, viewport,
], ],
); );
const handleCanvasPointerDown = useCallback( const handleCanvasPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => { ({ event }: { event: React.PointerEvent }) => {
if (!selectedWithMask || !isCreatingCustomMask) { if (!selectedWithMask || !isCreatingFreeformPathMask) {
return; return;
} }
if (event.button !== 0) { if (event.button !== 0) {
return; return;
} }
if (selectedWithMask.mask.type !== "custom") { if (selectedWithMask.mask.type !== "freeform") {
return; return;
} }
@ -477,7 +477,7 @@ export function useMaskHandles({
return; return;
} }
const nextParams = appendPointToCustomMask({ const nextParams = appendPointToFreeformPathMask({
params: selectedWithMask.mask.params, params: selectedWithMask.mask.params,
canvasPoint: pos, canvasPoint: pos,
bounds: selectedWithMask.bounds, bounds: selectedWithMask.bounds,
@ -502,7 +502,7 @@ export function useMaskHandles({
], ],
}); });
}, },
[editor.timeline, isCreatingCustomMask, selectedWithMask, viewport], [editor.timeline, isCreatingFreeformPathMask, selectedWithMask, viewport],
); );
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
@ -538,7 +538,7 @@ export function useMaskHandles({
const deltaX = pos.x - drag.startCanvasX; const deltaX = pos.x - drag.startCanvasX;
const deltaY = pos.y - drag.startCanvasY; const deltaY = pos.y - drag.startCanvasY;
const def = masksRegistry.get(selectedWithMask.mask.type); const def = getMaskDefinition(selectedWithMask.mask.type);
const rawParams = def.computeParamUpdate({ const rawParams = def.computeParamUpdate({
handleId: drag.handleId, handleId: drag.handleId,
@ -600,7 +600,7 @@ export function useMaskHandles({
const handlePointerUp = useCallback(() => { const handlePointerUp = useCallback(() => {
const pendingSegmentInsert = pendingSegmentInsertRef.current; const pendingSegmentInsert = pendingSegmentInsertRef.current;
if (pendingSegmentInsert && !dragStateRef.current) { if (pendingSegmentInsert && !dragStateRef.current) {
editor.timeline.insertCustomMaskPoint({ editor.timeline.insertFreeformPathMaskPoint({
trackId: pendingSegmentInsert.trackId, trackId: pendingSegmentInsert.trackId,
elementId: pendingSegmentInsert.elementId, elementId: pendingSegmentInsert.elementId,
maskId: pendingSegmentInsert.maskId, maskId: pendingSegmentInsert.maskId,
@ -627,7 +627,7 @@ export function useMaskHandles({
selectedWithMask, selectedWithMask,
handlePositions, handlePositions,
overlays, overlays,
isCreatingCustomMask, isCreatingFreeformPathMask,
handleCanvasPointerDown, handleCanvasPointerDown,
activeHandleId, activeHandleId,
handlePointerDown, handlePointerDown,

View File

@ -30,7 +30,7 @@ export function MaskHandles({
selectedWithMask, selectedWithMask,
handlePositions, handlePositions,
overlays, overlays,
isCreatingCustomMask, isCreatingFreeformPathMask,
handleCanvasPointerDown, handleCanvasPointerDown,
handlePointerDown, handlePointerDown,
handlePointerMove, handlePointerMove,
@ -94,7 +94,7 @@ export function MaskHandles({
className="pointer-events-none absolute inset-0 overflow-hidden" className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden aria-hidden
> >
{isCreatingCustomMask ? ( {isCreatingFreeformPathMask ? (
<div <div
className="absolute inset-0 pointer-events-auto" className="absolute inset-0 pointer-events-auto"
style={{ cursor: PEN_CURSOR }} style={{ cursor: PEN_CURSOR }}

View File

@ -1,5 +1,5 @@
import { drawCssBackground } from "@/gradients"; import { drawCssBackground } from "@/gradients";
import { masksRegistry } from "@/masks"; import { getMaskDefinition } from "@/masks";
import { incrementCounter } from "@/diagnostics/render-perf"; import { incrementCounter } from "@/diagnostics/render-perf";
import type { AnyBaseNode } from "../nodes/base-node"; import type { AnyBaseNode } from "../nodes/base-node";
import type { CanvasRenderer } from "../canvas-renderer"; import type { CanvasRenderer } from "../canvas-renderer";
@ -390,7 +390,7 @@ function buildMaskArtifacts({
return { mask: null, strokeLayer: null }; return { mask: null, strokeLayer: null };
} }
const definition = masksRegistry.get(mask.type); const definition = getMaskDefinition(mask.type);
if (definition.isActive?.(mask.params) === false) { if (definition.isActive?.(mask.params) === false) {
return { mask: null, strokeLayer: null }; return { mask: null, strokeLayer: null };

View File

@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV30ToV31 } from "../transformers/v30-to-v31";
import { asRecord, asRecordArray } from "./helpers";
describe("V30 to V31 Migration", () => {
test("renames custom masks to freeform without changing params", () => {
const params = {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
strokeAlign: "center",
path: [{ id: "p1", x: 0, y: 0, inX: 0, inY: 0, outX: 0, outY: 0 }],
closed: true,
centerX: 0,
centerY: 0,
rotation: 0,
scale: 1,
};
const result = transformProjectV30ToV31({
project: {
id: "project-v30-freeform",
version: 30,
scenes: [
{
tracks: {
main: {
elements: [
{
id: "image-1",
type: "image",
masks: [{ id: "mask-1", type: "custom", params }],
},
],
},
},
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(31);
const scene = asRecordArray(result.project.scenes)[0];
const tracks = asRecord(scene.tracks);
const main = asRecord(tracks.main);
const element = asRecordArray(main.elements)[0];
const mask = asRecordArray(asRecord(element).masks)[0];
expect(mask).toMatchObject({
id: "mask-1",
type: "freeform",
legacyType: "custom",
params,
});
});
});

View File

@ -29,10 +29,11 @@ import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28"; import { V27toV28Migration } from "./v27-to-v28";
import { V28toV29Migration } from "./v28-to-v29"; import { V28toV29Migration } from "./v28-to-v29";
import { V29toV30Migration } from "./v29-to-v30"; import { V29toV30Migration } from "./v29-to-v30";
import { V30toV31Migration } from "./v30-to-v31";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 30; export const CURRENT_PROJECT_VERSION = 31;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -65,4 +66,5 @@ export const migrations = [
new V27toV28Migration(), new V27toV28Migration(),
new V28toV29Migration(), new V28toV29Migration(),
new V29toV30Migration(), new V29toV30Migration(),
new V30toV31Migration(),
]; ];

View File

@ -1,7 +1,7 @@
import type { MigrationResult, ProjectRecord } from "./types"; import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils"; import { getProjectId, isRecord } from "./utils";
interface CustomMaskPathPoint { interface FreeformPathPoint {
id: string; id: string;
x: number; x: number;
y: number; y: number;
@ -11,7 +11,7 @@ interface CustomMaskPathPoint {
outY: number; outY: number;
} }
function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { function isFreeformPathPoint(value: unknown): value is FreeformPathPoint {
if (!isRecord(value)) { if (!isRecord(value)) {
return false; return false;
} }
@ -27,18 +27,18 @@ function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
); );
} }
function parseCustomMaskPath({ function parseFreeformPath({
path, path,
}: { }: {
path: string; path: string;
}): CustomMaskPathPoint[] { }): FreeformPathPoint[] {
if (!path) { if (!path) {
return []; return [];
} }
try { try {
const parsed = JSON.parse(path); const parsed = JSON.parse(path);
return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : [];
} catch { } catch {
return []; return [];
} }
@ -150,7 +150,7 @@ function migrateMask({ mask }: { mask: unknown }): unknown {
...mask, ...mask,
params: { params: {
...mask.params, ...mask.params,
path: parseCustomMaskPath({ path }), path: parseFreeformPath({ path }),
}, },
}; };
} }

View File

@ -0,0 +1,99 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV30ToV31({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
const version = project.version;
if (typeof version !== "number") {
return { project, skipped: true, reason: "invalid version" };
}
if (version >= 31) {
return { project, skipped: true, reason: "already v31" };
}
if (version !== 30) {
return { project, skipped: true, reason: "not v30" };
}
return {
project: {
...migrateProject({ project }),
version: 31,
},
skipped: false,
};
}
function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord {
const nextProject = { ...project };
if (Array.isArray(project.scenes)) {
nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
}
return nextProject;
}
function migrateScene({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) {
return scene;
}
const nextScene = { ...scene };
if (isRecord(scene.tracks)) {
nextScene.tracks = migrateTracks({ tracks: scene.tracks });
}
return nextScene;
}
function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
const nextTracks = { ...tracks };
if (isRecord(tracks.main)) {
nextTracks.main = migrateTrack({ track: tracks.main });
}
if (Array.isArray(tracks.overlay)) {
nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
}
if (Array.isArray(tracks.audio)) {
nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
}
return nextTracks;
}
function migrateTrack({ track }: { track: unknown }): unknown {
if (!isRecord(track) || !Array.isArray(track.elements)) {
return track;
}
return {
...track,
elements: track.elements.map((element) => migrateElement({ element })),
};
}
function migrateElement({ element }: { element: unknown }): unknown {
if (!isRecord(element) || !Array.isArray(element.masks)) {
return element;
}
return {
...element,
masks: element.masks.map((mask) => migrateMask({ mask })),
};
}
function migrateMask({ mask }: { mask: unknown }): unknown {
if (!isRecord(mask) || mask.type !== "custom") {
return mask;
}
return {
...mask,
type: "freeform",
legacyType: "custom",
};
}

View File

@ -0,0 +1,14 @@
import { StorageMigration, type StorageMigrationRunArgs } from "./base";
import type { MigrationResult, ProjectRecord } from "./transformers/types";
import { transformProjectV30ToV31 } from "./transformers/v30-to-v31";
export class V30toV31Migration extends StorageMigration {
from = 30;
to = 31;
async run({
project,
}: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
return transformProjectV30ToV31({ project });
}
}