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:
1 parent
b9d1ca16f7
commit
8817a73c3d
3 files changed
+320
-3
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user