From 8817a73c3dfaa760d6c0e1a760e21b73ad91c12c Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 21:00:59 +0200
Subject: [PATCH] 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)
---
src/devtools/tests.ts | 184 ++++++++++++++++++++++++++++++++++++++++++
src/lib/actions.ts | 85 +++++++++++++++++++
src/stores/scene.ts | 54 ++++++++++++-
3 files changed, 320 insertions(+), 3 deletions(-)
create mode 100644 src/lib/actions.ts
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 06eb7af..e0479a8 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -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 & {
+ calls: string[]
+ playhead: number
+ playing: boolean
+ buttonStates: Record
+ buttonDisabled: Record
+ warnings: string[]
+ } {
+ const rt = {
+ fps: 60,
+ playbackIn: 0,
+ playbackOut: 10,
+ duration: 10,
+ markers: { intro: 2, outro: 8 },
+ playhead: 0,
+ playing: false,
+ buttonStates: {} as Record,
+ buttonDisabled: {} as Record,
+ 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
+ }
+
+ 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()
diff --git a/src/lib/actions.ts b/src/lib/actions.ts
new file mode 100644
index 0000000..4f85bde
--- /dev/null
+++ b/src/lib/actions.ts
@@ -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
+ 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 {
+ const out: Record = {}
+ 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
+}
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 6f80856..5d9ec5b 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -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)
+ },
}
},
},
|