This commit is contained in:
th committed 2026-04-20 01:05:27 +02:00
1 parent 43933f93d1
commit 03b1147a93
10 files changed
+804 -80

No files matched your search

+134 -1
View File
@@ -33,6 +33,7 @@ interface MountedHandle {
interface RecordingCtx {
ctx: CanvasRenderingContext2D
filterSets: string[]
compositeOps: string[]
}
function makeRecordingCtx(): RecordingCtx {
@@ -41,9 +42,11 @@ function makeRecordingCtx(): RecordingCtx {
canvas.height = 200
const real = canvas.getContext('2d')!
const filterSets: string[] = []
const compositeOps: string[] = []
const proxy = new Proxy(real, {
set(target, prop, value) {
if (prop === 'filter') filterSets.push(value as string)
if (prop === 'globalCompositeOperation') compositeOps.push(value as string)
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
return true
},
@@ -55,7 +58,7 @@ function makeRecordingCtx(): RecordingCtx {
return v
},
})
return { ctx: proxy as CanvasRenderingContext2D, filterSets }
return { ctx: proxy as CanvasRenderingContext2D, filterSets, compositeOps }
}
function mountComponent(
@@ -1571,6 +1574,136 @@ export function registerAllTests(): void {
})
})
suite('mask layers — store', () => {
test('setLayerMask(true) enables positive mask by default', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('positive')
})
test('setLayerMask(true, "negative") stores negative mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'N', radius: 20, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(false) clears mask flags', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true, 'negative')
s.setLayerMask(l.id, false)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(undefined)
expect(layer.maskMode).toBe(undefined)
})
test('setLayerMaskMode flips mode without changing isMask', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
s.setLayerMaskMode(l.id, 'negative')
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('mask layers — rendering', () => {
function setupMaskScene(mode: 'positive' | 'negative') {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
// Content: full-canvas red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask: small circle centered at (50,50)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, mode)
const group = s.addTrackGroup('G')
s.moveTrackIntoGroup(maskLayer.id, group.id)
s.moveTrackIntoGroup(contentLayer.id, group.id)
return { store: s }
}
test('positive mask keeps content inside the mask shape and clears outside', () => {
const { store } = setupMaskScene('positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: red content visible
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask: cleared (alpha 0)
expect(outside[3]).toBe(0)
})
test('negative mask punches a hole in the content where the mask overlaps', () => {
const { store } = setupMaskScene('negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: hole (alpha 0)
expect(inside[3]).toBe(0)
// Outside mask: red content still there
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('layer outside a masked group is not affected by the mask', () => {
const { store } = setupMaskScene('positive')
// Add a green rect layer OUTSIDE the group
const outer = store.addAsset({
kind: 'rect',
name: 'Outer',
width: 20,
height: 20,
fill: '#00ff00',
})
const outerLayer = store.addLayer(outer.id)
store.upsertKeyframe(outerLayer.id, 0, { x: 90, y: 90 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, store.scene, 0, {})
// The outer layer renders fully regardless of mask
const outerPx = ctx.getImageData(90, 90, 1, 1).data
expect(outerPx[1] > 200).toBe(true)
expect(outerPx[3] > 200).toBe(true)
})
})
suite('scene store — shiftKeyframesPosition', () => {
test('shifts x/y of a single keyframe by delta', async () => {
const s = makeFreshStore()