macro-clips slice 5: action evaluator + forward-only script frame firing

- new src/lib/actions.ts:
  - ActionRuntime interface (fps, range, markers, setPlayhead/play/pause,
    setButtonState/setButtonDisabled, warn)
  - evalAction dispatches each action kind, with clamping to [0, duration]
    for time-based jumps and a warning path for unknown markers and for
    script actions (DSL lands in slice 8)
  - scriptFramesInRange helper returns frames in the open-closed interval
    (prev, next], sorted
- store wires it up:
  - tick captures prevPlayhead, advances, then fires script frames that
    fall in (prev, landed]. Scrubs via setPlayhead never touch this path
    so the forward-only guarantee falls out of the architecture.
  - actionRuntime() builds an ActionRuntime bound to this store
  - setButtonState is a no-op in the editor for now (waits for the
    button-runtime slice); setButtonDisabled goes to the store action,
    rewindToLoop/play/pause/jumps mutate the store directly
- tests: 10 evaluator tests against a mock ActionRuntime covering all
  action kinds + marker map + scriptFramesInRange; 3 integration tests
  for tick firing (ordering, no-fire-on-scrub, firing-at-playbackOut)

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:00:59 +02:00
1 parent b9d1ca16f7
commit 8817a73c3d
3 files changed
+320 -3

No files matched your search

+184
View File
@@ -1,5 +1,12 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import {
buildMarkerMap,
evalAction,
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
import type { Action } from '@/types/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
@@ -3198,6 +3205,183 @@ export function registerAllTests(): void {
})
})
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 = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
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) {
this.calls.push(`setPlayhead:${t}`)
this.playhead = t
},
play() {
this.calls.push('play')
this.playing = true
},
pause() {
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)
},
...init,
}
return rt as ReturnType<typeof makeMockRuntime>
}
test('play / pause / rewindToLoop', () => {
const rt = makeMockRuntime({ playbackIn: 2 })
evalAction({ type: 'play' }, rt)
evalAction({ type: 'pause' }, rt)
evalAction({ type: 'rewindToLoop' }, rt)
expect(rt.playing).toBe(false)
expect(rt.playhead).toBe(2)
expect(rt.calls).toEqual(['play', 'pause', 'setPlayhead:2'])
})
test('jumpTo clamps to [0, duration]', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpTo', time: -3 }, rt)
expect(rt.playhead).toBe(0)
evalAction({ type: 'jumpTo', time: 99 }, rt)
expect(rt.playhead).toBe(10)
evalAction({ type: 'jumpTo', time: 5 }, rt)
expect(rt.playhead).toBe(5)
})
test('jumpToFrame divides by fps', () => {
const rt = makeMockRuntime({ fps: 30 })
evalAction({ type: 'jumpToFrame', frame: 90 }, rt)
expect(rt.playhead).toBe(3)
})
test('jumpToMarker resolves via markers map', () => {
const rt = makeMockRuntime()
evalAction({ type: 'jumpToMarker', name: 'outro' }, rt)
expect(rt.playhead).toBe(8)
})
test('jumpToMarker warns on unknown name and does not move playhead', () => {
const rt = makeMockRuntime()
const before = rt.playhead
evalAction({ type: 'jumpToMarker', name: 'missing' }, rt)
expect(rt.playhead).toBe(before)
expect(rt.warnings.length).toBe(1)
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 warns that DSL isn't implemented yet", () => {
const rt = makeMockRuntime()
evalAction({ type: 'script', source: 'play' }, rt)
expect(rt.warnings.length).toBe(1)
})
test('buildMarkerMap maps name → time', () => {
const map = buildMarkerMap([
{ id: 'a', name: 'intro', time: 1 },
{ id: 'b', name: 'outro', time: 5 },
])
expect(map.intro).toBe(1)
expect(map.outro).toBe(5)
})
test('scriptFramesInRange returns only forward frames, sorted', () => {
const frames = [
{ id: 'a', trackId: 't', time: 3, actions: [] as Action[] },
{ id: 'b', trackId: 't', time: 1, actions: [] as Action[] },
{ id: 'c', trackId: 't', time: 6, actions: [] as Action[] },
]
// (0, 4] → only 1 and 3, sorted
const got = scriptFramesInRange(frames, 0, 4).map((f) => f.id)
expect(got).toEqual(['b', 'a'])
// backwards → empty
expect(scriptFramesInRange(frames, 5, 1)).toHaveLength(0)
})
test('scriptFramesInRange uses open-closed interval', () => {
const frames = [{ id: 'a', trackId: 't', time: 2, actions: [] as Action[] }]
// prev=2 (exclusive), next=2 → empty
expect(scriptFramesInRange(frames, 2, 2)).toHaveLength(0)
// prev=1, next=2 → includes 2
expect(scriptFramesInRange(frames, 1, 2)).toHaveLength(1)
})
})
suite('scene store — tick fires script frames forward-only', () => {
test('tick fires frames crossed during forward advance in time order', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
s.addScriptFrame(t.id, 1, [{ type: 'jumpTo', time: 9 }])
s.play()
// From 0 → 2: both frames get crossed; time-1 frame fires first and
// jumps the playhead to 9, time-2 frame fires second and pauses.
s.tick(2)
expect(s.playhead).toBe(9)
expect(s.playing).toBe(false)
await tick()
})
test('setPlayhead alone does not fire frames', async () => {
const s = makeFreshStore()
s.setDuration(10)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 2, [{ type: 'pause' }])
s.play()
s.setPlayhead(5) // scrub over the frame without ticking
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('frames fire even after natural stop at playbackOut', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(0, 5)
const t = s.addScriptTrack()
s.addScriptFrame(t.id, 5, [{ type: 'rewindToLoop' }])
s.play()
s.tick(6) // lands at playbackOut=5, playing=false, frame at 5 fires
expect(s.playing).toBe(false)
expect(s.playhead).toBe(0) // rewindToLoop → playbackIn=0
await tick()
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()
+85
View File
@@ -0,0 +1,85 @@
import type { Action, ButtonState, Marker } from '@/types/scene'
export interface ActionRuntime {
fps: number
playbackIn: number
playbackOut: number
duration: number
markers: Record<string, number>
setPlayhead(t: number): void
play(): void
pause(): void
setButtonState(instanceId: string, state: ButtonState): void
setButtonDisabled(instanceId: string, disabled: boolean): void
warn(msg: string): void
}
export function buildMarkerMap(
markers: Marker[] | undefined,
): Record<string, number> {
const out: Record<string, number> = {}
for (const m of markers ?? []) out[m.name] = m.time
return out
}
export function evalAction(action: Action, rt: ActionRuntime): void {
switch (action.type) {
case 'play':
rt.play()
return
case 'pause':
rt.pause()
return
case 'rewindToLoop':
rt.setPlayhead(rt.playbackIn)
return
case 'jumpTo':
rt.setPlayhead(clamp(action.time, 0, rt.duration))
return
case 'jumpToFrame': {
const fps = rt.fps || 60
rt.setPlayhead(clamp(action.frame / fps, 0, rt.duration))
return
}
case 'jumpToMarker': {
const t = rt.markers[action.name]
if (t == null) {
rt.warn(`Unknown marker: ${action.name}`)
return
}
rt.setPlayhead(clamp(t, 0, rt.duration))
return
}
case 'setButtonState':
rt.setButtonState(action.instanceId, action.state)
return
case 'setButtonDisabled':
rt.setButtonDisabled(action.instanceId, action.disabled)
return
case 'script':
rt.warn('Script DSL is not implemented yet')
return
}
}
function clamp(n: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, n))
}
/**
* Return the script frames whose time sits in the open-closed interval
* `(prev, next]`, sorted by time ascending. Forward-only — callers must
* pass `next >= prev`, and the caller is responsible for suppressing
* firing across loop wraps or scrubs.
*/
export function scriptFramesInRange<
F extends { time: number; actions: unknown[] },
>(frames: F[] | undefined, prev: number, next: number): F[] {
if (!frames || !frames.length || next <= prev) return []
const out: F[] = []
for (const f of frames) {
if (f.time > prev && f.time <= next) out.push(f)
}
out.sort((a, b) => a.time - b.time)
return out
}
+51 -3
View File
@@ -21,6 +21,12 @@ import type {
} from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
import {
buildMarkerMap,
evalAction,
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
const uid = () => Math.random().toString(36).slice(2, 10)
@@ -1464,13 +1470,55 @@ export const useSceneStore = defineStore('scene', {
},
tick(deltaSeconds: number) {
if (!this.playing) return
const prev = this.playhead
const end = this.playbackOut
const next = this.playhead + deltaSeconds
if (next >= end) {
const nextRaw = prev + deltaSeconds
if (nextRaw >= end) {
this.playhead = end
this.playing = false
} else {
this.playhead = next
this.playhead = nextRaw
}
const landed = this.playhead
// Forward-only firing: script frames whose time sits in (prev, landed].
// Scrubs/setPlayhead/loop-wraps never reach this path, so the rule
// is naturally preserved.
const frames = scriptFramesInRange(this.scene.scriptFrames, prev, landed)
if (frames.length) {
const rt = this.actionRuntime()
for (const f of frames) {
for (const action of f.actions) evalAction(action, rt)
}
}
},
actionRuntime(): ActionRuntime {
const self = this
return {
fps: self.scene.fps,
playbackIn: self.playbackIn,
playbackOut: self.playbackOut,
duration: self.scene.duration,
markers: buildMarkerMap(self.scene.markers),
setPlayhead(t: number) {
self.setPlayhead(t)
},
play() {
self.play()
},
pause() {
self.pause()
},
setButtonState() {
// No-op in editor for now — runtime-state management lands in
// the export-runtime slice together with pointer handling.
},
setButtonDisabled(instanceId: string, disabled: boolean) {
self.setButtonInstanceDisabled(instanceId, disabled)
},
warn(msg: string) {
// eslint-disable-next-line no-console
console.warn('[animatr]', msg)
},
}
},
},