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

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