0.10.4: button still-frames render only on their own state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 18:32:18 +02:00
1 parent fcb8edbb88
commit 8895fc76ef
8 files changed
+311 -23

No files matched your search

+186 -1
View File
@@ -12,7 +12,7 @@ import {
type ActionRuntime,
} from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl'
import type { Action, ButtonAsset, HitShape } from '@/types/scene'
import type { Action, ButtonAsset, HitShape, Layer } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isLayerActiveAt, sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
@@ -6697,6 +6697,191 @@ export function registerAllTests(): void {
})
})
suite('button asset — still-frame visibility gate', () => {
// Button assets have 4 discrete state times (1,2,3,4). A still-frame
// (hold) keyframe must only be visible on its exact state frame —
// not held forward to the neighbours as on regular timelines.
test('isButtonLayerActiveAt: hold kf at t=2 only visible at t=2', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l1',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{
id: 'k1',
time: 2,
props: { x: 10 },
easing: 'linear',
hold: true,
},
],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
expect(isButtonLayerActiveAt(layer, 4)).toBe(false)
})
test('isButtonLayerActiveAt: non-hold kfs at t=1 and t=4 still interpolate between states', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'k1', time: 1, props: { x: 0 }, easing: 'linear' },
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
})
test('isButtonLayerActiveAt: a hold kf followed by a regular kf shows only on both exact frames', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'k1', time: 2, props: { x: 0 }, easing: 'linear', hold: true },
{ id: 'k2', time: 4, props: { x: 100 }, easing: 'linear' },
],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(false)
expect(isButtonLayerActiveAt(layer, 2)).toBe(true)
// hold at t=2 → the region (2, 4) is "holding in silence", invisible.
expect(isButtonLayerActiveAt(layer, 3)).toBe(false)
expect(isButtonLayerActiveAt(layer, 4)).toBe(true)
})
test('isButtonLayerActiveAt: no keyframes → always visible (static layer)', async () => {
const { isButtonLayerActiveAt } = await import('@/lib/interpolate')
const layer: Layer = {
id: 'l',
name: '',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
}
expect(isButtonLayerActiveAt(layer, 1)).toBe(true)
expect(isButtonLayerActiveAt(layer, 3)).toBe(true)
})
test('runtime engine embeds button-specific still-frame visibility', () => {
const r = generateRuntimeScript()
expect(r).toContain('buttonLayerActiveAt')
})
test('timeline segments skip the connector line before a still-frame kf in button mode', async () => {
// In button-edit the state frames are discrete: the connector
// line that spans between kfs on a regular timeline must NOT
// be drawn from a preceding kf into a hold kf — that would
// suggest the layer is on-screen across the intervening states.
// Pin the button-mode branch of segmentsOf structurally: three
// kfs, middle one hold → no segment coming INTO the still.
const { default: T } = await import('@/components/Timeline.vue')
void T
// Reach into the private helper via a minimal contract: mount
// Timeline inside a button-asset-edit context and inspect the
// rendered .kf-seg elements for the track.
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#abc' })
const layer = s.addLayer(a.id)!
// Lay out three kfs: normal (non-hold), hover (hold), disabled (non-hold).
layer.keyframes = [
{ id: 'k1', time: BUTTON_STATE_TIMES.normal, props: {}, easing: 'linear' },
{ id: 'k2', time: BUTTON_STATE_TIMES.hover, props: {}, easing: 'linear', hold: true },
{ id: 'k3', time: BUTTON_STATE_TIMES.disabled, props: {}, easing: 'linear' },
]
const handle = mountComponent(Timeline, {})
await nextTick()
const segs = handle.host.querySelectorAll('.kf-seg')
// Expected segments in button-mode:
// 1→2 (non-hold kf1 to hold kf2): drawn (interpolation into still)
// …actually no: hold k2 terminates *emerging* lines, not
// incoming. Only the OUT-segments from a hold are skipped.
// 2→3 (hold kf2 to non-hold kf3): SKIPPED (no line out of still).
expect(segs.length).toBe(1)
s.exitButtonAssetEdit()
handle.unmount()
})
test('addLayer in button-edit seeds the initial kf on the ACTIVE state, not always normal', () => {
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
s.setActiveButtonState('disabled')
const asset = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
const layer = s.addLayer(asset.id)!
expect(layer.keyframes).toHaveLength(1)
expect(layer.keyframes[0].time).toBe(BUTTON_STATE_TIMES.disabled)
s.exitButtonAssetEdit()
void btn
})
test('drawButtonFrame renders a hold-kf layer ONLY on its own state frame', () => {
// End-to-end UI test: construct a button with a single rect
// whose layer has a hold-keyframe at the hover state. Then
// rasterise `drawButtonFrame` against each of the 4 state times
// and verify the rect's centre pixel is red only at hover and
// transparent on every other state.
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 100, 100)
s.enterButtonAssetEdit(btn.id)
const red = s.addAsset({
kind: 'rect',
name: 'R',
width: 100,
height: 100,
fill: '#ff0000',
})
const layer = s.addLayer(red.id)!
// Replace the default normal-state kf with a single hold-kf at hover.
layer.keyframes = [
{
id: 'k',
time: BUTTON_STATE_TIMES.hover,
props: {
x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1,
},
easing: 'linear',
hold: true,
},
]
s.exitButtonAssetEdit()
const button = s.scene.assets.find((x) => x.id === btn.id) as ButtonAsset
function probe(stateTime: number): boolean {
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawButtonFrame(ctx, button, s.scene, stateTime, {})
const data = ctx.getImageData(50, 50, 1, 1).data
// Red + opaque = visible. Any other reading (alpha 0, or colour
// washed out) → not rendered.
return data[0] > 200 && data[3] > 200
}
expect(probe(BUTTON_STATE_TIMES.normal)).toBe(false)
expect(probe(BUTTON_STATE_TIMES.hover)).toBe(true)
expect(probe(BUTTON_STATE_TIMES.pressed)).toBe(false)
expect(probe(BUTTON_STATE_TIMES.disabled)).toBe(false)
})
})
suite('button asset — data model + edit mode', () => {
test('addButtonAsset creates asset with empty layers and hitArea', () => {
const s = makeFreshStore()