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'