From b32b8a6a3df1ae1c3cc1ad14b606ac5800171e56 Mon Sep 17 00:00:00 2001
From: th
Date: Sun, 26 Apr 2026 00:05:40 +0200
Subject: [PATCH] 1.2.4: mask cascade strict bottom-up + clip-internal masks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
package.json | 2 +-
src/devtools/tests.ts | 244 ++++++++++++++++++++++++++++++++++++++++++
src/lib/render.ts | 106 ++++++++++++------
src/version.ts | 2 +-
4 files changed, 319 insertions(+), 35 deletions(-)
diff --git a/package.json b/package.json
index 3457d27..677c089 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "1.2.3",
+ "version": "1.2.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 59a54d7..b31a7c1 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -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', () => {
diff --git a/src/lib/render.ts b/src/lib/render.ts
index af846b0..b977b98 100644
--- a/src/lib/render.ts
+++ b/src/lib/render.ts
@@ -343,22 +343,27 @@ function drawTrackGroup(
images: Record,
buttonStates?: Map,
): void {
- // Partition into masks vs. renderable ids (layer ids + clip-track
- // ids). Editor-hidden clip tracks drop out here so the cascade
- // matches the Konva stage.
- const masks: Layer[] = []
- const renderIds: string[] = []
+ // Walk the child list bottom-up so the mask cascade follows
+ // stack order: a mask masks layers below it but not layers
+ // above it. Items are kept as their resolved kind (layer or
+ // clip-track id) plus a flag for masks; the loop below applies
+ // each item to the offscreen buffer in z-order.
+ type Item =
+ | { kind: 'mask'; layer: Layer }
+ | { kind: 'layer'; id: string }
+ | { kind: 'clip'; id: string }
+ const items: Item[] = []
for (let k = childIds.length - 1; k >= 0; k--) {
const cid = childIds[k]
const layer = layerById.get(cid)
if (layer) {
- if (layer.isMask) masks.push(layer)
- else renderIds.push(cid)
+ if (layer.isMask) items.push({ kind: 'mask', layer })
+ else items.push({ kind: 'layer', id: cid })
continue
}
if (clipTrackById.get(cid)?.editorHidden) continue
if (instancesByTrack.has(cid)) {
- renderIds.push(cid)
+ items.push({ kind: 'clip', id: cid })
}
// Script / audio tracks don't paint on the canvas — skip silently.
}
@@ -391,13 +396,22 @@ function drawTrackGroup(
if (irot !== 0) target.rotate((irot * Math.PI) / 180)
target.scale(isx, isy)
}
- drawLayers(target, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
+ // Clip-internal masks: same scoping as the main timeline,
+ // confined to the clip. drawMaskedGroup partitions the clip's
+ // layers into masks vs. content and composites the result via
+ // destination-in / destination-out, so a mask placed above
+ // content layers in the clip masks the content but doesn't
+ // bleed out to anything else on the canvas.
+ drawMaskedGroup(target, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
if (needsTransform) target.restore()
}
}
- if (masks.length === 0) {
- for (const id of renderIds) drawChild(ctx, id)
+ const hasMask = items.some((it) => it.kind === 'mask')
+ if (!hasMask) {
+ for (const it of items) {
+ if (it.kind === 'layer' || it.kind === 'clip') drawChild(ctx, it.id)
+ }
return
}
@@ -406,19 +420,26 @@ function drawTrackGroup(
off.height = ctx.canvas.height
const offCtx = off.getContext('2d')
if (!offCtx) {
- for (const id of renderIds) drawChild(ctx, id)
+ for (const it of items) {
+ if (it.kind === 'layer' || it.kind === 'clip') drawChild(ctx, it.id)
+ }
return
}
const m = ctx.getTransform()
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
- for (const id of renderIds) drawChild(offCtx, id)
- for (const mask of masks) {
- if (!isLayerActiveAt(mask, time)) continue
- offCtx.save()
- offCtx.globalCompositeOperation =
- mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
- drawMaskShape(offCtx, mask, assetMap, time)
- offCtx.restore()
+ // Incremental cascade: render content / apply mask in z-order.
+ for (const it of items) {
+ if (it.kind === 'mask') {
+ const mask = it.layer
+ if (!isLayerActiveAt(mask, time)) continue
+ offCtx.save()
+ offCtx.globalCompositeOperation =
+ mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
+ drawMaskShape(offCtx, mask, assetMap, time)
+ offCtx.restore()
+ continue
+ }
+ drawChild(offCtx, it.id)
}
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
@@ -435,9 +456,8 @@ function drawMaskedGroup(
buttonStates?: Map,
buttonMode?: boolean,
) {
- const masks = children.filter((l) => l.isMask)
- const content = children.filter((l) => !l.isMask)
- if (masks.length === 0) {
+ const hasMask = children.some((l) => l.isMask)
+ if (!hasMask) {
drawLayers(ctx, children, assetMap, images, time, buttonStates, buttonMode)
return
}
@@ -449,20 +469,38 @@ function drawMaskedGroup(
off.height = h
const offCtx = off.getContext('2d')
if (!offCtx) {
- drawLayers(ctx, content, assetMap, images, time, buttonStates, buttonMode)
+ drawLayers(
+ ctx,
+ children.filter((l) => !l.isMask),
+ assetMap, images, time, buttonStates, buttonMode,
+ )
return
}
const m = ctx.getTransform()
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
- drawLayers(offCtx, content, assetMap, images, time, buttonStates, buttonMode)
- for (const mask of masks) {
+
+ // Bottom-up traversal so masks only affect what was drawn below
+ // them in the z-order — matches the top-level drawSceneFrame
+ // cascade. A mask placed in the middle of the stack masks just
+ // the layers underneath; layers above the mask render on top
+ // unmasked. Was previously "collect all masks, apply to all
+ // content" which applied a mid-stack mask to layers above it too.
+ for (let i = children.length - 1; i >= 0; i--) {
+ const child = children[i]
+ if (!child.visible) continue
+ if (child.editorHidden) continue
if (buttonMode) {
- if (!isButtonLayerActiveAt(mask, time)) continue
- } else if (!isLayerActiveAt(mask, time)) continue
- offCtx.save()
- offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
- drawMaskShape(offCtx, mask, assetMap, time, buttonMode)
- offCtx.restore()
+ if (!isButtonLayerActiveAt(child, time)) continue
+ } else if (!isLayerActiveAt(child, time)) continue
+ if (child.isMask) {
+ offCtx.save()
+ offCtx.globalCompositeOperation =
+ child.maskMode === 'negative' ? 'destination-out' : 'destination-in'
+ drawMaskShape(offCtx, child, assetMap, time, buttonMode)
+ offCtx.restore()
+ } else {
+ drawLayers(offCtx, [child], assetMap, images, time, buttonStates, buttonMode)
+ }
}
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
@@ -595,7 +633,9 @@ export function drawSceneFrame(
if (irot !== 0) drawCtx.rotate((irot * Math.PI) / 180)
drawCtx.scale(isx, isy)
}
- drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
+ // Same as the in-group clip path above: drawMaskedGroup so
+ // clip-local masks apply to layers within this clip only.
+ drawMaskedGroup(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
if (needsTransform) drawCtx.restore()
}
continue
diff --git a/src/version.ts b/src/version.ts
index 68dd785..445b245 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '1.2.3'
+export const APP_VERSION = '1.2.4'
export const APP_NAME = 'RUWIO'
|