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

+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)
},
}
},
},