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

+19 -4
View File
@@ -16,7 +16,7 @@ import { useSceneStore } from '@/stores/scene'
import { isLayerActiveAt, sampleLayer } from '@/lib/interpolate'
import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import type {
Action,
AnimatableProps,
@@ -2697,9 +2697,24 @@ function drawPreview() {
ctx.save()
ctx.translate(viewX.value, viewY.value)
ctx.scale(zoom.value, zoom.value)
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, {
crop: cropObjects.value,
})
// In button-edit mode the preview should reflect the *button*'s stage,
// not the main scene behind it. `store.playhead` is already pinned to
// BUTTON_STATE_TIMES[activeButtonState] (or to normal in hit mode), so
// we can pass it straight through.
if (store.activeButtonAsset) {
drawButtonFrame(
ctx,
store.activeButtonAsset,
store.scene,
store.playhead,
previewImages.value,
{ background: '#1a1a1f' },
)
} else {
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, {
crop: cropObjects.value,
})
}
ctx.restore()
}
+35 -5
View File
@@ -102,6 +102,30 @@ const duration = computed(() =>
store.activeButtonAssetId ? 5 : store.activeDuration,
)
const inClipEdit = computed(() => store.activeClipId !== null)
// Only surface the "drop a clip to add a new track" dropzone when the user
// has at least one clip in the library — otherwise the hint is confusing
// (you can't drop what you don't have).
const showClipDropzone = computed(
() =>
!inClipEdit.value &&
!store.activeButtonAssetId &&
(store.scene.clips?.length ?? 0) > 0,
)
// Keep the dropzone hint horizontally centred in the *visible* ruler area
// (right of the sticky name-spacer) regardless of horizontal scroll. The
// dropzone itself is as wide as .tl-content, so its natural flex-center
// is not at the visual center — we position the hint absolutely at
// `scrollLeft + trackNameWidth + (visibleRulerWidth / 2)`.
const dropzoneHintStyle = computed(() => {
const tn = trackNameWidth.value
const vp = viewportWidth.value
if (vp <= 0) return {}
const visibleRulerW = Math.max(0, vp - tn)
const leftPx = scrollLeft.value + tn + visibleRulerW / 2
return { left: `${leftPx}px` }
})
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
const out: Record<string, ClipInstance[]> = {}
for (const ci of store.scene.clipInstances ?? []) {
@@ -2710,7 +2734,7 @@ onBeforeUnmount(() => {
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
</p>
<div
v-if="!inClipEdit && !store.activeButtonAssetId"
v-if="showClipDropzone"
class="track clip-track-dropzone"
:class="{ hot: clipDragOverDropzone }"
@dragenter="onNewTrackDragEnter"
@@ -2718,7 +2742,9 @@ onBeforeUnmount(() => {
@dragleave="onNewTrackDragLeave"
@drop="onNewTrackDrop"
>
<span class="dropzone-hint">{{ t('timeline.dropNewTrackHint') }}</span>
<span class="dropzone-hint" :style="dropzoneHintStyle">
{{ t('timeline.dropNewTrackHint') }}
</span>
</div>
</div>
</div>
@@ -4118,6 +4144,7 @@ onBeforeUnmount(() => {
}
.clip-track-dropzone {
position: relative;
height: 26px;
display: flex;
align-items: center;
@@ -4138,9 +4165,12 @@ onBeforeUnmount(() => {
}
.dropzone-hint {
position: sticky;
left: 50%;
transform: translateX(-50%);
position: absolute;
top: 50%;
/* `left` is driven from a computed so the hint stays in the centre of
the *visible* ruler area (i.e. centred in the user's viewport, right
of the sticky name-spacer) regardless of horizontal scroll. */
transform: translate(-50%, -50%);
pointer-events: none;
white-space: nowrap;
}
+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
+34 -5
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '10'
const RUNTIME_VERSION = '11'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -444,11 +444,11 @@ const ENGINE_SOURCE = `(function(){
}
if(closed) tctx.closePath();
}
function drawMaskShape(ctxm,layer,time){
function drawMaskShape(ctxm,layer,time,alwaysActive){
var asset=assetMap[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
if(!layerActiveAt(layer,time)) return;
if(!alwaysActive && !layerActiveAt(layer,time)) return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
@@ -617,8 +617,37 @@ const ENGINE_SOURCE = `(function(){
var bState=resolveButtonState(layer);
var stateTime=bState==='hover'?2:(bState==='pressed'?3:(bState==='disabled'?4:1));
var bLayers=asset.layers||[];
for(var bli=bLayers.length-1;bli>=0;bli--){
drawLayer(bLayers[bli], stateTime, true);
var bContent=[], bMasks=[];
for(var bli=0;bli<bLayers.length;bli++){
if(bLayers[bli].isMask) bMasks.push(bLayers[bli]); else bContent.push(bLayers[bli]);
}
if(!bMasks.length){
for(var bli2=bContent.length-1;bli2>=0;bli2--){
drawLayer(bContent[bli2], stateTime, true);
}
} else {
var bcanvas=document.createElement('canvas');
bcanvas.width=canvas.width; bcanvas.height=canvas.height;
var bctx=bcanvas.getContext('2d');
var btm=ctx.getTransform();
bctx.setTransform(btm.a,btm.b,btm.c,btm.d,btm.e,btm.f);
var savedBCtx=ctx;
ctx=bctx;
for(var bli3=bContent.length-1;bli3>=0;bli3--){
drawLayer(bContent[bli3], stateTime, true);
}
for(var bmk=0;bmk<bMasks.length;bmk++){
var bML=bMasks[bmk];
bctx.save();
bctx.globalCompositeOperation=(bML.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(bctx,bML,stateTime,true);
bctx.restore();
}
ctx=savedBCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(bcanvas,0,0);
ctx.restore();
}
}
ctx.shadowColor='transparent';
+37 -7
View File
@@ -241,7 +241,7 @@ function drawLayers(
const offY = layer.offsetY ?? asset.canvas.height / 2
ctx.translate(-offX, -offY)
const bs = buttonStates?.get(layer.id) ?? 'normal'
drawLayers(ctx, asset.layers, assetMap, images, BUTTON_STATE_TIMES[bs], undefined, true)
drawMaskedGroup(ctx, asset.layers, assetMap, images, BUTTON_STATE_TIMES[bs], undefined, true)
}
clearShadow(ctx)
@@ -254,6 +254,7 @@ function drawMaskShape(
layer: Layer,
assetMap: Map<string, Asset>,
time: number,
alwaysActive?: boolean,
) {
const asset = assetMap.get(layer.assetId)
if (!asset) return
@@ -264,7 +265,7 @@ function drawMaskShape(
asset.kind !== 'polygon'
)
return
if (!isLayerActiveAt(layer, time)) return
if (!alwaysActive && !isLayerActiveAt(layer, time)) return
const p = sampleLayer(layer, time)
ctx.save()
ctx.translate(p.x, p.y)
@@ -307,11 +308,12 @@ function drawMaskedGroup(
images: Record<string, HTMLImageElement>,
time: number,
buttonStates?: Map<string, ButtonState>,
alwaysActive?: boolean,
) {
const masks = children.filter((l) => l.isMask)
const content = children.filter((l) => !l.isMask)
if (masks.length === 0) {
drawLayers(ctx, children, assetMap, images, time, buttonStates)
drawLayers(ctx, children, assetMap, images, time, buttonStates, alwaysActive)
return
}
@@ -322,16 +324,17 @@ function drawMaskedGroup(
off.height = h
const offCtx = off.getContext('2d')
if (!offCtx) {
drawLayers(ctx, content, assetMap, images, time, buttonStates)
drawLayers(ctx, content, assetMap, images, time, buttonStates, alwaysActive)
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)
drawLayers(offCtx, content, assetMap, images, time, buttonStates, alwaysActive)
for (const mask of masks) {
if (!alwaysActive && !isLayerActiveAt(mask, time)) continue
offCtx.save()
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
drawMaskShape(offCtx, mask, assetMap, time)
drawMaskShape(offCtx, mask, assetMap, time, alwaysActive)
offCtx.restore()
}
ctx.save()
@@ -481,6 +484,33 @@ export function renderButtonThumbnail(
ctx.scale(scale, scale)
const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
drawLayers(ctx, button.layers, assetMap, images, BUTTON_STATE_TIMES.normal, undefined, true)
drawMaskedGroup(ctx, button.layers, assetMap, images, BUTTON_STATE_TIMES.normal, undefined, true)
return canvas.toDataURL('image/png')
}
/**
* Draw a single button-asset frame into the supplied context. Used by the
* mask-preview overlay in button-edit mode so the preview reflects the
* button's own stage instead of the main scene. The caller is responsible
* for applying viewport translate/zoom before calling.
*/
export function drawButtonFrame(
ctx: CanvasRenderingContext2D,
button: ButtonAsset,
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: { background?: string },
): void {
const { width: w, height: h } = button.canvas
if (w <= 0 || h <= 0) return
ctx.save()
if (options?.background !== undefined) {
ctx.fillStyle = options.background
ctx.fillRect(0, 0, w, h)
}
const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
drawMaskedGroup(ctx, button.layers, assetMap, images, time, undefined, true)
ctx.restore()
}
+15 -7
View File
@@ -114,21 +114,29 @@ die states sind verschiedene zeitleisten innerhalb des buttons die umgeschaltet
also ich würde stark bevorzugen wenn du den code ließt statt immer den vite dev zu nehmen
ich kann das visuell kontrollieren und dir screenshots von den sachen machen. und der handle ist genau wie vorher
diese spur darf nur erscheinen wenn man überhaupt schon mindestens einen clip hat.
<div data-v-228c0a9d="" class="track clip-track-dropzone"><span data-v-228c0a9d="" class="dropzone-hint">Drop a clip here to add a new track</span></div>
und der text muss immer zentriert stehen bleiben und darf nicht horizontal mit der timeline mitgescrollt werden
im button editor zeigt das preview mit angewendeten masken die hauptbühne an - nicht die button-bühne
die farbe einer gruppe muss automatisch auf alle kinder angewendet werden. es sei denn man färbt einen track HINTERHER anders ein
im button dürfen keyframes nur auf den 4 states einrasten
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen