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