1.2.4: mask cascade strict bottom-up + clip-internal masks
Audited masking against the four-scope spec (groups, main timeline, clips, SVG-internal). Rule 2 was already correct; rules 1, 3, 4 needed fixes: - drawMaskedGroup now walks children bottom-up and applies each mask to the offscreen buffer immediately, so a mask only affects layers below it in the stack — layers above the mask render unmasked on top. - drawTrackGroup rewritten with an Item discriminated union collected bottom-up, then a single z-order loop renders/applies, replacing the old "collect all masks → apply at end" pattern. - Both clip-rendering sites (drawSceneFrame top-level clip path and drawTrackGroup's clip-child path) switched from drawLayers to drawMaskedGroup — previously a mask layer inside a clip was rendered as a regular shape instead of masking. - 5 new tests under "mask layers — rendering" cover the four-rule spec (group below-only, group negative below-only, clip-scoped mask, SVG-asset mask scoped to interior, clip-internal layers above mask unmasked). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
127812d3bf
commit
b32b8a6a3d
4 files changed
+319
-35
No files matched your search
@@ -5556,6 +5556,250 @@ export function registerAllTests(): void {
|
||||
expect(outerPx[1] > 200).toBe(true)
|
||||
expect(outerPx[3] > 200).toBe(true)
|
||||
})
|
||||
|
||||
test('group mask masks only layers below it in the stack, not above', () => {
|
||||
// Strict reading of rule 1: a mask in a group affects the
|
||||
// elements below it in the z-order, not the entire group.
|
||||
// Layout (top→bottom): green rect (above mask), positive
|
||||
// mask circle, red rect (below mask). After render, the
|
||||
// green rect must be unmasked (visible everywhere it covers)
|
||||
// while the red rect is clipped to the mask circle.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100; s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
// Z-order: addLayer prepends, so we add bottom-up.
|
||||
const red = s.addAsset({
|
||||
kind: 'rect', name: 'Red', width: 100, height: 100, fill: '#ff0000',
|
||||
})
|
||||
const redLayer = s.addLayer(red.id)!
|
||||
s.upsertKeyframe(redLayer.id, 0, { x: 50, y: 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, 'positive')
|
||||
const green = s.addAsset({
|
||||
kind: 'rect', name: 'Green', width: 80, height: 5, fill: '#00ff00',
|
||||
})
|
||||
const greenLayer = s.addLayer(green.id)!
|
||||
s.upsertKeyframe(greenLayer.id, 0, { x: 50, y: 95 })
|
||||
// Group all three so the cascade is local to the group.
|
||||
// moveTrackIntoGroup APPENDS to childIds and childIds[0] is
|
||||
// the TOP — so we move in top-to-bottom order (green first,
|
||||
// then mask, then red) to get the desired stack.
|
||||
const group = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(greenLayer.id, group.id)
|
||||
s.moveTrackIntoGroup(maskLayer.id, group.id)
|
||||
s.moveTrackIntoGroup(redLayer.id, group.id)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100; canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
// Green rect at y=95: ABOVE the mask in z-order, so it
|
||||
// renders unmasked even though it sits well outside the
|
||||
// 20px mask circle around (50,50).
|
||||
const greenPx = ctx.getImageData(50, 95, 1, 1).data
|
||||
expect(greenPx[1] > 200).toBe(true)
|
||||
expect(greenPx[3] > 200).toBe(true)
|
||||
// Red rect inside the mask circle → visible.
|
||||
const insideRed = ctx.getImageData(50, 50, 1, 1).data
|
||||
expect(insideRed[0] > 200).toBe(true)
|
||||
// Red rect outside the mask, where green DOESN'T cover →
|
||||
// cleared by the mask.
|
||||
const outsideRed = ctx.getImageData(5, 5, 1, 1).data
|
||||
expect(outsideRed[3]).toBe(0)
|
||||
})
|
||||
|
||||
test('group negative-mask: only below-mask layers get the hole', () => {
|
||||
// Mirror of the positive group-cascade test for negative mode.
|
||||
// A negative mask cuts a hole in the layers BELOW it; layers
|
||||
// above the mask render normally, even where the mask shape
|
||||
// would have punched through.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100; s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
const red = s.addAsset({
|
||||
kind: 'rect', name: 'Red', width: 100, height: 100, fill: '#ff0000',
|
||||
})
|
||||
const redLayer = s.addLayer(red.id)!
|
||||
s.upsertKeyframe(redLayer.id, 0, { x: 50, y: 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, 'negative')
|
||||
const blue = s.addAsset({
|
||||
kind: 'circle', name: 'B', radius: 5, fill: '#0000ff',
|
||||
})
|
||||
const blueLayer = s.addLayer(blue.id)!
|
||||
s.upsertKeyframe(blueLayer.id, 0, { x: 50, y: 50 })
|
||||
// Move in top-to-bottom order so childIds[0] is the top
|
||||
// (blue), mask in the middle, red at the bottom.
|
||||
const group = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(blueLayer.id, group.id)
|
||||
s.moveTrackIntoGroup(maskLayer.id, group.id)
|
||||
s.moveTrackIntoGroup(redLayer.id, group.id)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100; canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
// Center: negative mask hole would have cleared red, but the
|
||||
// blue circle (above the mask) renders on top → blue.
|
||||
const center = ctx.getImageData(50, 50, 1, 1).data
|
||||
expect(center[2] > 200).toBe(true)
|
||||
expect(center[3] > 200).toBe(true)
|
||||
// Inside the mask circle but outside the small blue circle
|
||||
// (e.g. (50,68) which is 18px from center, inside the 20px
|
||||
// mask, outside the 5px blue) → cleared.
|
||||
const hole = ctx.getImageData(50, 68, 1, 1).data
|
||||
expect(hole[3]).toBe(0)
|
||||
// Outside the mask circle entirely → red still there.
|
||||
const outside = ctx.getImageData(5, 5, 1, 1).data
|
||||
expect(outside[0] > 200).toBe(true)
|
||||
expect(outside[3] > 200).toBe(true)
|
||||
})
|
||||
|
||||
test('clip-internal mask: layers above the mask in the clip render unmasked', () => {
|
||||
// Stack-order rule scoped to a clip: a mask inside the clip
|
||||
// affects only layers below it in that clip's z-order, NOT
|
||||
// layers placed above the mask in the same clip.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100; s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
const clip = s.addClip('C')
|
||||
clip.duration = 1
|
||||
s.enterClip(clip.id)
|
||||
const red = s.addAsset({
|
||||
kind: 'rect', name: 'Red', width: 100, height: 100, fill: '#ff0000',
|
||||
})
|
||||
const redLayer = s.addLayer(red.id)!
|
||||
s.upsertKeyframe(redLayer.id, 0, { x: 50, y: 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, 'positive')
|
||||
// Above-mask green dot — must render even outside the mask.
|
||||
const green = s.addAsset({
|
||||
kind: 'circle', name: 'G', radius: 4, fill: '#00ff00',
|
||||
})
|
||||
const greenLayer = s.addLayer(green.id)!
|
||||
s.upsertKeyframe(greenLayer.id, 0, { x: 90, y: 90 })
|
||||
s.exitClip()
|
||||
const track = s.scene.clipTracks![0]
|
||||
s.addClipInstance(clip.id, 0, track.id)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100; canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
// Green dot at (90,90): well outside the mask but above it
|
||||
// in the stack → still rendered.
|
||||
const greenPx = ctx.getImageData(90, 90, 1, 1).data
|
||||
expect(greenPx[1] > 200).toBe(true)
|
||||
expect(greenPx[3] > 200).toBe(true)
|
||||
// Red corner (no green coverage) → cleared by mask.
|
||||
const corner = ctx.getImageData(5, 5, 1, 1).data
|
||||
expect(corner[3]).toBe(0)
|
||||
})
|
||||
|
||||
test('SVG-asset mask: scoped to SVG interior, doesn\'t bleed onto the main scene', () => {
|
||||
// SVG-asset placement composites its interior through
|
||||
// drawMaskedGroup; a mask inside the SVG must NOT affect
|
||||
// anything else in the main scene (incl. layers below the
|
||||
// SVG placement in the main-scene stack).
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100; s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
// Build an SVG asset with a mask + content inside.
|
||||
const svg = s.addSvgAsset('Logo', 100, 100)
|
||||
s.enterSvgAssetEdit(svg.id)
|
||||
const inner = s.addAsset({
|
||||
kind: 'rect', name: 'inner', width: 60, height: 60, fill: '#ff0000',
|
||||
})
|
||||
s.addLayer(inner.id)
|
||||
const innerMask = s.addAsset({
|
||||
kind: 'circle', name: 'mInner', radius: 15, fill: '#ffffff',
|
||||
})
|
||||
const innerMaskLayer = s.addLayer(innerMask.id)!
|
||||
s.setLayerMask(innerMaskLayer.id, true, 'positive')
|
||||
s.exitSvgAssetEdit()
|
||||
// Place the SVG, then a green rect BELOW it in the main
|
||||
// scene that should NOT be touched by the SVG's interior mask.
|
||||
const green = s.addAsset({
|
||||
kind: 'rect', name: 'green', width: 20, height: 20, fill: '#00ff00',
|
||||
})
|
||||
const greenLayer = s.addLayer(green.id)!
|
||||
s.upsertKeyframe(greenLayer.id, 0, { x: 90, y: 90 })
|
||||
const svgPlacement = s.addLayer(svg.id)!
|
||||
s.upsertKeyframe(svgPlacement.id, 0, { x: 0, y: 0 })
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100; canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
drawSceneFrame(ctx, s.scene, 0, {})
|
||||
// Green rect (90,90) is well outside the SVG interior mask,
|
||||
// and is on a separate main-scene layer → must render fully.
|
||||
const greenPx = ctx.getImageData(90, 90, 1, 1).data
|
||||
expect(greenPx[1] > 200).toBe(true)
|
||||
expect(greenPx[3] > 200).toBe(true)
|
||||
})
|
||||
|
||||
test('mask inside a clip masks only that clip\'s content', () => {
|
||||
// Regression: clip rendering used drawLayers which doesn't
|
||||
// honour isMask, so a clip-local mask just rendered as a
|
||||
// regular shape and never cut the layers below it. With
|
||||
// drawMaskedGroup the cascade matches "main timeline but
|
||||
// scoped to the clip".
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = 'transparent'
|
||||
const clip = s.addClip('C')
|
||||
clip.duration = 1
|
||||
// Build the clip with a full-rect content + a small circle
|
||||
// mask above it. addClip-scope: enter the clip so addLayer
|
||||
// appends to clip.layers.
|
||||
s.enterClip(clip.id)
|
||||
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 })
|
||||
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, 'positive')
|
||||
s.exitClip()
|
||||
// Place the clip on a track in the main scene + a green rect
|
||||
// OUTSIDE the clip. The mask must not bleed onto the green.
|
||||
const track = s.scene.clipTracks![0]
|
||||
s.addClipInstance(clip.id, 0, track.id)
|
||||
const outer = s.addAsset({
|
||||
kind: 'rect', name: 'Outer', width: 20, height: 20, fill: '#00ff00',
|
||||
})
|
||||
const outerLayer = s.addLayer(outer.id)!
|
||||
s.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, s.scene, 0, {})
|
||||
// Inside the mask circle → red content visible.
|
||||
const inside = ctx.getImageData(50, 50, 1, 1).data
|
||||
expect(inside[0] > 200).toBe(true)
|
||||
expect(inside[3] > 200).toBe(true)
|
||||
// Outside the mask but still inside the clip's content rect
|
||||
// (e.g. corner) → cleared by the positive mask.
|
||||
const corner = ctx.getImageData(5, 5, 1, 1).data
|
||||
expect(corner[3]).toBe(0)
|
||||
// The green outer rect (not in the clip) renders fully.
|
||||
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', () => {
|
||||
|
||||
Reference in new issue
Block a user