diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts index 6f8421a0..a5d0ceb3 100644 --- a/apps/web/src/masks/__tests__/snap.test.ts +++ b/apps/web/src/masks/__tests__/snap.test.ts @@ -324,6 +324,42 @@ describe("mask snapping", () => { expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); }); + test("snaps vertical edge resize for box masks", () => { + // bounds.height=100 → localCanvasSize.height/2=50 is the bottom snap target. + // height=0.2 → baseHeight=20 → aabbHalfH=10. + // At height=0.98 → proposedScaleY=4.9 → bottomEdge=0+10*4.9=49. + // |49-50|=1 < threshold(8) → snaps to height=0.2*5=1.0; line at position 50. + const result = snapBoxMaskInteraction({ + handleId: { kind: "edge", side: "bottom" }, + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ height: 0.98 }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.height).toBe(1); + expect(result.activeLines).toEqual([{ type: "horizontal", position: 50 }]); + }); + + test("snaps uniform scale handle for box masks", () => { + // bounds.width=200 → localCanvasSize.width/2=100 is the right snap target. + // width=0.4, scale=1 → aabbHalfW = (0.4*200)/2 * 1 = 40. + // At scale=2.48 → rightEdge=0+40*2.48=99.2; |99.2-100|=0.8 < threshold(8) + // → snaps to scale=1*(100/40)=2.5; line at position 100. + const result = snapBoxMaskInteraction({ + handleId: { kind: "scale" }, + startParams: buildRectangleParams({ scale: 1 }), + proposedParams: buildRectangleParams({ scale: 2.48 }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.scale).toBe(2.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + test("snaps text mask movement using intrinsic text bounds", () => { const params = buildTextMaskParams({ centerX: 0.03, diff --git a/apps/web/src/masks/snap.ts b/apps/web/src/masks/snap.ts index 85949823..519783a9 100644 --- a/apps/web/src/masks/snap.ts +++ b/apps/web/src/masks/snap.ts @@ -55,16 +55,7 @@ function getPreferredEdges({ }; } - const preferredEdges = { - left: false, - right: false, - top: false, - bottom: false, - } satisfies ScaleEdgePreference; - - return Object.values(preferredEdges).some(Boolean) - ? preferredEdges - : undefined; + return undefined; } function snapMaskPosition({ diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index d01da1df..d5ab88c3 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -401,6 +401,10 @@ function buildMaskArtifacts({ body.kind === "drawWithFeather" && mask.params.feather === 0 && Boolean(body.opaqueFastPath); + // drawWithFeather renderers encode feathering analytically in their canvas output + // (e.g. split mask uses a linear gradient instead of JFA). The descriptor feather is + // zeroed so the GPU compositor copies the mask texture as-is and does not run a second + // JFA feather pass on top of an already-soft texture. const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather; const maskTextureId = `${path}:mask`; diff --git a/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts index e5c82ae2..65a1ffed 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts @@ -50,8 +50,80 @@ describe("V30 to V31 Migration", () => { expect(mask).toMatchObject({ id: "mask-1", type: "freeform", - legacyType: "custom", params, }); + expect(asRecord(mask).legacyType).toBeUndefined(); + }); + + test("leaves a type:freeform mask in a v30 project unchanged", () => { + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-already-freeform", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [ + { + id: "elem-1", + masks: [{ id: "m1", type: "freeform", params: {} }], + }, + ], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const main = asRecord(asRecord(scene.tracks).main); + const element = asRecordArray(main.elements)[0]; + expect(asRecordArray(asRecord(element).masks)[0]).toMatchObject({ + id: "m1", + type: "freeform", + }); + }); + + test("leaves elements without a masks array unchanged", () => { + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-no-masks", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [{ id: "elem-1", type: "video" }], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const main = asRecord(asRecord(scene.tracks).main); + const element = asRecordArray(main.elements)[0]; + expect(element).toMatchObject({ id: "elem-1", type: "video" }); + }); + + test("skips a project that is already v31", () => { + const project = { id: "p1", version: 31, scenes: [] }; + const result = transformProjectV30ToV31({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v31"); + expect(result.project).toBe(project); + }); + + test("skips a project that is not v30", () => { + const project = { id: "p1", version: 29, scenes: [] }; + const result = transformProjectV30ToV31({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v30"); + expect(result.project).toBe(project); }); }); diff --git a/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts index acdf4061..512fc921 100644 --- a/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts +++ b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts @@ -91,9 +91,10 @@ function migrateMask({ mask }: { mask: unknown }): unknown { return mask; } + // "custom" is the only freeform-path mask type; a down-migration can map + // type === "freeform" back to "custom" without any additional marker. return { ...mask, type: "freeform", - legacyType: "custom", }; }