fix: masks inside buttons render across all paths

Buttons used to ignore `isMask` on every render path — the mask
shape drew as normal content instead of clipping the button's
other layers. Every entry point now routes button children through
`drawMaskedGroup`:

- `drawLayers` nested-button branch
- new `drawButtonFrame` (used by the mask-preview overlay in
  button-edit mode so the preview reflects the button's own
  stage instead of the main scene)
- `renderButtonThumbnail` so sidebar previews match the stage
- exported-runtime button branch mirrors the split; bumped
  `RUNTIME_VERSION` to '11' so cached players reload

Also:

- Timeline clip-track dropzone hides until the library has at
  least one clip, and the hint stays centred in the visible
  ruler area instead of drifting with horizontal scroll.
- Regression tests pin positive/negative button masks in
  `drawButtonFrame` and verify `drawSceneFrame` applies
  button-internal masks when the button sits on the main stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 19:43:23 +02:00
1 parent c3764201b8
commit f29dc448fb
6 files changed
+232 -30

No files matched your search

+92 -2
View File
@@ -7,7 +7,8 @@ import {
type ActionRuntime,
} from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl'
import type { Action, HitShape } from '@/types/scene'
import type { Action, ButtonAsset, HitShape } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
@@ -20,7 +21,7 @@ import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
import { APP_NAME, APP_VERSION } from '@/version'
import type { PolygonAsset } from '@/types/scene'
import { drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
import { t as i18nT } from '@/i18n'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import {
@@ -3772,6 +3773,95 @@ export function registerAllTests(): void {
})
})
suite('button asset — mask rendering', () => {
// Regression: masks inside buttons used to be ignored by every render
// path — the button branch in drawLayers called drawLayers recursively
// without the drawMaskedGroup split, so `isMask` layers drew as normal
// content. That made mask-preview / thumbnail / main-stage / exported
// runtime all show the mask shape instead of clipping. All paths now
// route button layers through drawMaskedGroup; these tests pin that.
function setupMaskedButton(mode: 'positive' | 'negative') {
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
// Content: full-canvas red rect. addLayer already keyframes it at
// (canvas.width/2, canvas.height/2) at BUTTON_STATE_TIMES.normal,
// so we don't need to set anything else.
const red = s.addAsset({
kind: 'rect',
name: 'R',
width: 100,
height: 100,
fill: '#ff0000',
})
s.addLayer(red.id)!
// Mask: small circle at canvas center.
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)!
s.setLayerMask(maskLayer.id, true, mode)
s.exitButtonAssetEdit()
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
return { store: s, button }
}
test('drawButtonFrame applies a positive mask inside the button', () => {
const { store, button } = setupMaskedButton('positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
expect(outside[3]).toBe(0)
})
test('drawButtonFrame applies a negative mask inside the button', () => {
const { store, button } = setupMaskedButton('negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawButtonFrame(ctx, button, store.scene, BUTTON_STATE_TIMES.normal, {})
const inside = ctx.getImageData(50, 50, 1, 1).data
const outside = ctx.getImageData(5, 5, 1, 1).data
// Inside mask: punched out
expect(inside[3]).toBe(0)
// Outside mask: red content still there
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
test('drawSceneFrame applies button-internal masks when the button is placed on the main stage', () => {
const { store, button } = setupMaskedButton('positive')
// Size the main scene to match the button exactly so coordinates line up.
store.scene.canvas.width = 100
store.scene.canvas.height = 100
store.scene.canvas.background = 'transparent'
// Place the button as a layer on the main stage, centered.
store.addLayer(button.id)!
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 the circular mask: red content visible
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside the circle: masked away (alpha 0)
expect(outside[3]).toBe(0)
})
})
suite('i18n — track-menu rename/delete labels', () => {
// Regression: the script-track context menu used to surface the raw
// keys ("timeline.renameScriptTrack" / ".deleteScriptTrack") because