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:
1 parent
c3764201b8
commit
f29dc448fb
6 files changed
+232
-30
No files matched your search
+92
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user