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;
}