macro-clips slice 7: button state transitions

- ButtonClip transitions are now editable in ButtonInstanceDialog
  (from/to/duration/easing) and persisted on the clip via the new
  store.setButtonClipTransitions action; transitions are shared by
  every instance of the button (clip-scoped, not instance-scoped).
- lerpProps exported from interpolate.ts so callers outside the
  sampler can blend two poses.
- useButtonRuntime tracks an active transition per instance plus a
  reactive transitionTick that pokes Vue computeds each frame.
  A self-driving RAF loop runs only while some transition is active
  (idles when none), so animations work paused or playing.
- applyButtonStateChange(id, to, transitions, persistedDisabled)
  is the one entry point for state changes — it looks up a matching
  from→to transition and starts it, or flips state instantly. Pointer
  handlers in CanvasStage and store.actionRuntime.setButtonState both
  go through it.
- CanvasStage blends the two state stacks via sampleStateLayers +
  blendStates: layers matched by name interpolate their sampled props
  with lerpProps; unmatched layers fade in/out. Renderer uses the
  blended layer list in place of the pure state stack during transit.
- Export runtime mirrors the same flow: applyButtonChange starts
  transitions, sampleButtonTransition drives per-frame progress, and
  drawButtonStatesBlended composites via drawLayer(layer, 0, override).
  drawLayer gained an optional propsOverride param for this.
  RUNTIME_VERSION bumped to 6.
- Tests cover applyButtonStateChange matching logic and
  sampleTransition's time → u mapping plus done flag.

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 21:48:50 +02:00
1 parent f61f90d613
commit 68ea8576b6
8 files changed
+515 -32

No files matched your search

+83 -11
View File
@@ -18,8 +18,12 @@ import type {
Layer,
Point,
} from '@/types/scene'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
import {
applyButtonStateChange,
useButtonRuntime,
} from '@/composables/useButtonRuntime'
import { evalAction } from '@/lib/actions'
import { lerpProps } from '@/lib/interpolate'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -208,23 +212,86 @@ interface ButtonRenderItem {
hit: { width: number; height: number; offsetX: number; offsetY: number }
}
function sampleStateLayers(
clip: ButtonClip,
state: ButtonState,
): ButtonRenderItem['layers'] {
const layers = clip.states[state] ?? []
const out: ButtonRenderItem['layers'] = []
for (const layer of layers) {
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset) continue
out.push({ layer, asset, props: sampleLayer(layer, 0) })
}
return out
}
/**
* Blend the "from" state's layers into the "to" state's layers by layer
* name. Matched pairs lerp their props; unmatched from-layers fade out,
* unmatched to-layers fade in.
*/
function blendStates(
clip: ButtonClip,
from: ButtonState,
to: ButtonState,
u: number,
): ButtonRenderItem['layers'] {
const fromLayers = clip.states[from] ?? []
const toLayers = clip.states[to] ?? []
const fromByName = new Map<string, Layer>()
const toByName = new Map<string, Layer>()
for (const l of fromLayers) fromByName.set(l.name, l)
for (const l of toLayers) toByName.set(l.name, l)
const seen = new Set<string>()
const out: ButtonRenderItem['layers'] = []
// Preserve to-state order primarily, with unmatched from-layers pinned
// underneath so the new state visually rises into place.
for (const l of fromLayers) {
if (toByName.has(l.name)) continue
const asset = store.scene.assets.find((a) => a.id === l.assetId)
if (!asset) continue
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } })
}
for (const l of toLayers) {
const asset = store.scene.assets.find((a) => a.id === l.assetId)
if (!asset) continue
seen.add(l.name)
const fromLayer = fromByName.get(l.name)
if (fromLayer) {
const a = sampleLayer(fromLayer, 0)
const b = sampleLayer(l, 0)
out.push({ layer: l, asset, props: lerpProps(a, b, u) })
} else {
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } })
}
}
void seen
return out
}
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
// Force re-evaluation on every transition RAF tick.
void buttonRt.transitionTick.value
const out: ButtonRenderItem[] = []
const instances = store.scene.buttonInstances ?? []
const clips = store.scene.buttonClips ?? []
const clipById = new Map(clips.map((c) => [c.id, c]))
const now = performance.now()
for (const bi of instances) {
if (store.playhead < bi.startTime) continue
const clip = clipById.get(bi.buttonClipId)
if (!clip) continue
const disabled = buttonRt.isDisabled(bi.id, bi.disabled)
const state = buttonRt.getState(bi.id, bi.disabled)
const layers = clip.states[state] ?? []
const rendered: ButtonRenderItem['layers'] = []
for (const layer of layers) {
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset) continue
rendered.push({ layer, asset, props: sampleLayer(layer, 0) })
const tr = buttonRt.sampleTransition(bi.id, now)
let rendered: ButtonRenderItem['layers']
if (tr && !tr.done && !disabled) {
rendered = blendStates(clip, tr.from, tr.to, tr.u)
} else {
rendered = sampleStateLayers(clip, state)
}
// Hit area: explicit hitArea on the clip, or fall back to the bbox of
// the first rect-like asset in the normal state.
@@ -293,25 +360,30 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
function transitionsFor(bi: ButtonInstance) {
return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)?.transitions
}
function onButtonMouseEnter(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'normal') buttonRt.setState(bi.id, 'hover')
if (cur === 'normal') applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
}
function onButtonMouseLeave(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(bi.id, 'normal')
if (cur === 'hover' || cur === 'pressed')
applyButtonStateChange(bi.id, 'normal', transitionsFor(bi), bi.disabled)
}
function onButtonMouseDown(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover') buttonRt.setState(bi.id, 'pressed')
if (cur === 'hover') applyButtonStateChange(bi.id, 'pressed', transitionsFor(bi), bi.disabled)
}
function onButtonMouseUp(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur !== 'pressed') return
buttonRt.setState(bi.id, 'hover')
applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
const actions = bi.onClick
if (actions && actions.length) runButtonActions(actions)
}