From f29dc448fbf8197681021db36666e98e6e647861 Mon Sep 17 00:00:00 2001
From: th
Date: Tue, 21 Apr 2026 19:43:23 +0200
Subject: [PATCH] fix: masks inside buttons render across all paths
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/components/CanvasStage.vue | 23 +++++++--
src/components/Timeline.vue | 40 +++++++++++++--
src/devtools/tests.ts | 94 +++++++++++++++++++++++++++++++++-
src/lib/exportPlayer.ts | 39 ++++++++++++--
src/lib/render.ts | 44 +++++++++++++---
todo | 22 +++++---
6 files changed, 232 insertions(+), 30 deletions(-)
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index b396b6a..20871f4 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -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()
}
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index d5ba7d1..896ccd7 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -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>(() => {
const out: Record = {}
for (const ci of store.scene.clipInstances ?? []) {
@@ -2710,7 +2734,7 @@ onBeforeUnmount(() => {
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
{
@dragleave="onNewTrackDragLeave"
@drop="onNewTrackDrop"
>
- {{ t('timeline.dropNewTrackHint') }}
+
+ {{ t('timeline.dropNewTrackHint') }}
+
@@ -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;
}
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 4cb51d1..084b11e 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -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
diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts
index 02f7d55..e1213fb 100644
--- a/src/lib/exportPlayer.ts
+++ b/src/lib/exportPlayer.ts
@@ -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=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,
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,
time: number,
buttonStates?: Map,
+ 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()
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,
+ 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()
+ for (const a of scene.assets) assetMap.set(a.id, a)
+ drawMaskedGroup(ctx, button.layers, assetMap, images, time, undefined, true)
+ ctx.restore()
+}
diff --git a/todo b/todo
index 60a29b0..409d575 100644
--- a/todo
+++ b/todo
@@ -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.
Drop a clip here to add a new track
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
\ No newline at end of file
|