revert macro-clips button feature (keep markers + scripts)

The button authoring ended up too far from the intended model after
three correction passes. Pulling it out entirely so we can rebuild
from a clean spec. Markers, script tracks, script frames, the Action
editor, the Script DSL and the runtime for all of the above stay —
they work.

Removed:
- types: ButtonState, StateTransition, ButtonHitArea, ButtonClip,
  ButtonInstance, setButtonState/setButtonDisabled Action variants,
  Scene.buttonClips/buttonInstances
- store: activeButtonClip* getters, all add/remove/setButton* actions,
  seedDefaultButtonClip, enterButtonEdit/setActiveButtonState/
  exitButtonEdit, button-label resolution in actionRuntime
- useButtonRuntime composable, buttonBlend helper,
  ButtonInstanceDialog, sidebar Buttons section, Timeline button
  rendering + pointer handlers, CanvasStage button rendering +
  pointer state machine, exportPlayer button runtime + hit-testing
- DSL commands state/enable/disable and their parse errors
- 35 button-related tests + helpers; stale testing doc; stale design
  doc

Re-applied three uncommitted fixes that were unrelated to the button
work:
- clicking a marker no longer jumps the playhead (only drag / dblclick
  act on it)
- script tracks get a right-click context menu (rename + delete,
  color in the existing palette)
- onTracksPointerDown excludes .script-lane / .script-frame so
  clicks on empty lanes or on frame dots reach the frame-open path
  instead of being swallowed by box-select pointer capture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 23:37:49 +02:00
1 parent 616973b0fa
commit 31604052ca
16 files changed
+84 -2809

No files matched your search

+3 -449
View File
@@ -1,10 +1,5 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import {
applyButtonStateChange,
useButtonRuntime,
} from '@/composables/useButtonRuntime'
import { blendButtonStates } from '@/lib/buttonBlend'
import {
buildMarkerMap,
evalAction,
@@ -12,7 +7,7 @@ import {
type ActionRuntime,
} from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl'
import type { Action, Asset, ButtonClip, Layer } from '@/types/scene'
import type { Action } from '@/types/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
@@ -3146,97 +3141,11 @@ export function registerAllTests(): void {
})
})
suite('scene store — button clips & instances', () => {
test('addButtonClip creates 4 empty state stacks', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('Play')
expect(bc.name).toBe('Play')
expect(bc.states.normal).toHaveLength(0)
expect(bc.states.hover).toHaveLength(0)
expect(bc.states.pressed).toHaveLength(0)
expect(bc.states.disabled).toHaveLength(0)
})
test('addButtonInstance validates clip and track ids', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
expect(s.addButtonInstance('no-clip', ct.id, 0)).toBe(null)
expect(s.addButtonInstance(bc.id, 'no-track', 0)).toBe(null)
const inst = s.addButtonInstance(bc.id, ct.id, 0)
expect(inst).toBeTruthy()
expect(s.scene.buttonInstances).toHaveLength(1)
})
test('removeButtonClip cascades its instances', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
s.addButtonInstance(bc.id, ct.id, 0)
s.addButtonInstance(bc.id, ct.id, 1)
s.removeButtonClip(bc.id)
expect(s.scene.buttonClips).toHaveLength(0)
expect(s.scene.buttonInstances).toHaveLength(0)
})
test('setButtonInstanceLabel enforces scene-wide uniqueness', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const a = s.addButtonInstance(bc.id, ct.id, 0)!
const b = s.addButtonInstance(bc.id, ct.id, 1)!
expect(s.setButtonInstanceLabel(a.id, 'play')).toBe(true)
expect(s.setButtonInstanceLabel(b.id, 'play')).toBe(false)
expect(s.setButtonInstanceLabel(b.id, 'mute')).toBe(true)
const getA = s.scene.buttonInstances?.find((x) => x.id === a.id)
const getB = s.scene.buttonInstances?.find((x) => x.id === b.id)
expect(getA?.label).toBe('play')
expect(getB?.label).toBe('mute')
})
test('seedDefaultButtonClip produces four populated state stacks', () => {
const s = makeFreshStore()
const bc = s.seedDefaultButtonClip('Play')
expect(bc.states.normal).toHaveLength(1)
expect(bc.states.hover).toHaveLength(1)
expect(bc.states.pressed).toHaveLength(1)
expect(bc.states.disabled).toHaveLength(1)
// Each state has its own face layer, all pointing at the same asset.
const assetIds = new Set(
[...bc.states.normal, ...bc.states.hover, ...bc.states.pressed, ...bc.states.disabled]
.map((l) => l.assetId),
)
expect(assetIds.size).toBe(1)
// Layer names consistent so transitions can match across states.
for (const state of ['normal', 'hover', 'pressed', 'disabled'] as const) {
expect(bc.states[state][0].name).toBe('Face')
}
})
test('setButtonInstanceOnClick + setButtonInstanceDisabled', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceOnClick(inst.id, [{ type: 'pause' }])
s.setButtonInstanceDisabled(inst.id, true)
const get = () => s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(get()?.onClick).toHaveLength(1)
expect(get()?.disabled).toBe(true)
s.setButtonInstanceOnClick(inst.id, [])
s.setButtonInstanceDisabled(inst.id, false)
expect(get()?.onClick).toBe(undefined)
expect(get()?.disabled).toBe(undefined)
})
})
suite('actions — evalAction with mock runtime', () => {
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
calls: string[]
playhead: number
playing: boolean
buttonStates: Record<string, string>
buttonDisabled: Record<string, boolean>
warnings: string[]
} {
const rt = {
@@ -3247,8 +3156,6 @@ export function registerAllTests(): void {
markers: { intro: 2, outro: 8 },
playhead: 0,
playing: false,
buttonStates: {} as Record<string, string>,
buttonDisabled: {} as Record<string, boolean>,
warnings: [] as string[],
calls: [] as string[],
setPlayhead(t: number) {
@@ -3263,14 +3170,6 @@ export function registerAllTests(): void {
this.calls.push('pause')
this.playing = false
},
setButtonState(id: string, state: string) {
this.calls.push(`btnState:${id}=${state}`)
this.buttonStates[id] = state
},
setButtonDisabled(id: string, d: boolean) {
this.calls.push(`btnDisabled:${id}=${d}`)
this.buttonDisabled[id] = d
},
warn(msg: string) {
this.warnings.push(msg)
},
@@ -3320,14 +3219,6 @@ export function registerAllTests(): void {
expect(rt.warnings[0]).toContain('missing')
})
test('setButtonState + setButtonDisabled dispatch to runtime', () => {
const rt = makeMockRuntime()
evalAction({ type: 'setButtonState', instanceId: 'b1', state: 'pressed' }, rt)
evalAction({ type: 'setButtonDisabled', instanceId: 'b2', disabled: true }, rt)
expect(rt.buttonStates.b1).toBe('pressed')
expect(rt.buttonDisabled.b2).toBe(true)
})
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
const rt = makeMockRuntime()
evalAction({ type: 'script', source: 'play\npause' }, rt)
@@ -3451,34 +3342,6 @@ export function registerAllTests(): void {
expect(errors).toHaveLength(4)
})
test('state requires btn:<label> and a valid state', () => {
const good = parseScript('state btn:play pressed')
expect(good.errors).toHaveLength(0)
expect(good.actions[0]).toEqual({
type: 'setButtonState',
instanceId: 'play',
state: 'pressed',
})
const bad1 = parseScript('state play pressed')
expect(bad1.errors).toHaveLength(1)
const bad2 = parseScript('state btn:play weird')
expect(bad2.errors).toHaveLength(1)
})
test('enable / disable map to setButtonDisabled', () => {
const { actions } = parseScript('enable btn:play\ndisable btn:play')
expect(actions[0]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: false,
})
expect(actions[1]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: true,
})
})
test('unknown commands report error, other lines still parse', () => {
const { actions, errors } = parseScript('play\nwat\npause')
expect(errors).toHaveLength(1)
@@ -3499,18 +3362,13 @@ export function registerAllTests(): void {
setPlayhead: (t) => calls.push(`seek:${t}`),
play: () => calls.push('play'),
pause: () => calls.push('pause'),
setButtonState: (id, s) => calls.push(`state:${id}=${s}`),
setButtonDisabled: (id, d) => calls.push(`disabled:${id}=${d}`),
warn: () => {},
}
evalAction(
{
type: 'script',
source: 'pause\njump @intro\nstate btn:play pressed',
},
{ type: 'script', source: 'pause\njump @intro\nplay' },
rt,
)
expect(calls).toEqual(['pause', 'seek:2', 'state:play=pressed'])
expect(calls).toEqual(['pause', 'seek:2', 'play'])
})
test('script errors surface via rt.warn, valid actions still fire', () => {
@@ -3525,8 +3383,6 @@ export function registerAllTests(): void {
setPlayhead: () => {},
play: () => fired.push('play'),
pause: () => fired.push('pause'),
setButtonState: () => {},
setButtonDisabled: () => {},
warn: (m) => warnings.push(m),
}
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
@@ -3536,80 +3392,6 @@ export function registerAllTests(): void {
})
})
suite('button runtime — state machine + action wiring', () => {
test('default state is normal; set/clear round-trip works', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.getState('b1')).toBe('normal')
rt.setState('b1', 'pressed')
expect(rt.getState('b1')).toBe('pressed')
rt.setState('b1', 'normal') // normal uses delete internally
expect(rt.getState('b1')).toBe('normal')
})
test('persisted disabled surfaces as state=disabled until runtime overrides', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.getState('b1', true)).toBe('disabled')
rt.setDisabled('b1', false) // runtime enables
expect(rt.getState('b1', true)).toBe('normal')
rt.setDisabled('b1', true)
expect(rt.getState('b1', false)).toBe('disabled')
})
test('applyButtonStateChange starts a transition only when one matches', () => {
const rt = useButtonRuntime()
rt.reset()
// No transitions defined: state flips instantly, no transition entry.
applyButtonStateChange('b1', 'hover', undefined, undefined)
expect(rt.getState('b1')).toBe('hover')
expect(rt.transition.has('b1')).toBe(false)
// With a matching transition: flip state AND start transition.
rt.reset()
applyButtonStateChange(
'b1',
'hover',
[{ from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' }],
undefined,
)
expect(rt.getState('b1')).toBe('hover')
expect(rt.transition.has('b1')).toBe(true)
rt.endTransition('b1')
})
test('sampleTransition returns null outside of transitions, u∈[0..1] within', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.sampleTransition('b1', performance.now())).toBe(null)
const t0 = performance.now()
rt.startTransition('b1', 'normal', 'hover', 1, 'linear', t0)
const mid = rt.sampleTransition('b1', t0 + 500)!
expect(mid.done).toBe(false)
expect(mid.u > 0.4 && mid.u < 0.6).toBe(true)
const end = rt.sampleTransition('b1', t0 + 1100)!
expect(end.done).toBe(true)
expect(end.u).toBe(1)
rt.endTransition('b1')
})
test('store.actionRuntime resolves labels and drives useButtonRuntime', () => {
const s = makeFreshStore()
const rt = useButtonRuntime()
rt.reset()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceLabel(inst.id, 'play')
const ar = s.actionRuntime()
ar.setButtonState('play', 'pressed')
expect(rt.getState(inst.id)).toBe('pressed')
ar.setButtonDisabled('play', true)
expect(rt.getState(inst.id)).toBe('disabled')
ar.setButtonDisabled('play', false)
expect(rt.getState(inst.id, inst.disabled)).toBe('pressed')
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()
@@ -3622,8 +3404,6 @@ export function registerAllTests(): void {
layers: [],
}
s.loadScene(bare)
expect(s.scene.buttonClips).toEqual([])
expect(s.scene.buttonInstances).toEqual([])
expect(s.scene.scriptTracks).toEqual([])
expect(s.scene.scriptFrames).toEqual([])
expect(s.scene.markers).toEqual([])
@@ -3669,220 +3449,6 @@ export function registerAllTests(): void {
expect(s.scene.scriptFrames).toEqual([])
})
test('removeButtonInstance removes the instance but keeps the clip', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.removeButtonInstance(inst.id)
expect(s.scene.buttonInstances).toHaveLength(0)
expect(s.scene.buttonClips).toHaveLength(1)
})
test('addButtonInstance defaults x/y to stage center', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
expect(inst.x).toBe(s.scene.canvas.width / 2)
expect(inst.y).toBe(s.scene.canvas.height / 2)
})
test('setButtonInstancePosition updates x/y', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstancePosition(inst.id, 42, 99)
const got = s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(got?.x).toBe(42)
expect(got?.y).toBe(99)
})
test('setButtonInstanceLabel("") clears the label', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceLabel(inst.id, 'play')
expect(s.setButtonInstanceLabel(inst.id, '')).toBe(true)
const got = s.scene.buttonInstances?.find((x) => x.id === inst.id)
expect(got?.label).toBe(undefined)
})
test('setButtonClipTransitions stores list and deletes it when empty', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
s.setButtonClipTransitions(bc.id, [
{ from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' },
])
const got = () => s.scene.buttonClips?.find((c) => c.id === bc.id)
expect(got()?.transitions).toHaveLength(1)
s.setButtonClipTransitions(bc.id, [])
expect(got()?.transitions).toBe(undefined)
})
})
suite('macro-clips — button runtime edge cases', () => {
test('applyButtonStateChange is a no-op when target equals current', () => {
const rt = useButtonRuntime()
rt.reset()
applyButtonStateChange(
'b1',
'normal',
[{ from: 'normal', to: 'normal', duration: 1, easing: 'linear' }],
undefined,
)
expect(rt.getState('b1')).toBe('normal')
expect(rt.transition.has('b1')).toBe(false)
})
test('applyButtonStateChange honors persisted disabled flag as the from-state', () => {
const rt = useButtonRuntime()
rt.reset()
// persistedDisabled=true → current 'disabled'; transition from disabled
// to normal should fire if defined.
applyButtonStateChange(
'b1',
'normal',
[{ from: 'disabled', to: 'normal', duration: 0.1, easing: 'linear' }],
true,
)
expect(rt.transition.has('b1')).toBe(true)
rt.endTransition('b1')
})
test('reset() clears state, disabled and transitions', () => {
const rt = useButtonRuntime()
rt.setState('b1', 'pressed')
rt.setDisabled('b2', true)
rt.startTransition('b1', 'normal', 'hover', 1, 'linear', performance.now())
rt.reset()
expect(rt.getState('b1')).toBe('normal')
expect(rt.isDisabled('b2')).toBe(false)
expect(rt.transition.size).toBe(0)
})
test('store.actionRuntime warns + skips for unknown button refs', () => {
const s = makeFreshStore()
const warned: string[] = []
const origWarn = console.warn
console.warn = (...a: unknown[]) => { warned.push(a.map(String).join(' ')) }
try {
const ar = s.actionRuntime()
ar.setButtonState('ghost', 'pressed')
ar.setButtonDisabled('ghost', true)
} finally {
console.warn = origWarn
}
expect(warned.length >= 2).toBe(true)
})
})
suite('macro-clips — blendButtonStates', () => {
function makeLayer(name: string, fill: string): Layer {
return {
id: `l-${name}-${fill}`,
name,
assetId: 'a1',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill },
keyframes: [
{
id: `kf-${name}-${fill}`,
time: 0,
props: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, fill },
easing: 'linear',
},
],
}
}
const asset: Asset = {
id: 'a1',
kind: 'rect',
name: 'face',
width: 40,
height: 20,
fill: '#000',
}
const assetBy = (id: string) => (id === 'a1' ? asset : undefined)
test('matched layer interpolates props via lerpProps', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const out = blendButtonStates(clip, 'normal', 'hover', 0.5, assetBy)
expect(out).toHaveLength(1)
expect(out[0].layer.name).toBe('Face')
// At u=0.5 the fill should be the mid of #ff0000 → #00ff00 (roughly).
const f = out[0].props.fill!
// Red and green channels should each be around 0x7f-ish
const n = parseInt(f.replace('#', ''), 16)
const r = (n >> 16) & 255
const g = (n >> 8) & 255
expect(r > 100 && r < 160).toBe(true)
expect(g > 100 && g < 160).toBe(true)
})
test('unmatched from-layer fades out, unmatched to-layer fades in', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Only-N', '#ff0000')],
hover: [makeLayer('Only-H', '#00ff00')],
pressed: [],
disabled: [],
},
}
const u = 0.25
const out = blendButtonStates(clip, 'normal', 'hover', u, assetBy)
// First: unmatched from-layer, opacity * (1 - u) = 0.75
expect(out[0].layer.name).toBe('Only-N')
expect(out[0].props.opacity).toBeCloseTo(1 - u, 3)
// Second: unmatched to-layer, opacity * u = 0.25
expect(out[1].layer.name).toBe('Only-H')
expect(out[1].props.opacity).toBeCloseTo(u, 3)
})
test('u=0 is pure from-state, u=1 is pure to-state (matched layer)', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const atZero = blendButtonStates(clip, 'normal', 'hover', 0, assetBy)
expect(atZero[0].props.fill).toBe('#ff0000')
const atOne = blendButtonStates(clip, 'normal', 'hover', 1, assetBy)
expect(atOne[0].props.fill).toBe('#00ff00')
})
test('skips layers whose asset id does not resolve', () => {
const clip: ButtonClip = {
id: 'bc',
name: 'b',
states: {
normal: [makeLayer('Face', '#ff0000')],
hover: [makeLayer('Face', '#00ff00')],
pressed: [],
disabled: [],
},
}
const out = blendButtonStates(clip, 'normal', 'hover', 0.5, () => undefined)
expect(out).toHaveLength(0)
})
})
suite('macro-clips — DSL edge cases', () => {
@@ -3904,18 +3470,6 @@ export function registerAllTests(): void {
expect(errors).toHaveLength(0)
})
test('state command with wrong ref prefix reports error', () => {
const { errors } = parseScript('state label pressed')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('btn:')
})
test('disable without argument reports error', () => {
const { errors } = parseScript('disable')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('disable')
})
test('jump with empty @marker reports error', () => {
const { errors } = parseScript('jump @')
expect(errors).toHaveLength(1)