macro-clips slice 8: script DSL parser + runtime label resolution

- src/lib/scriptDsl.ts: small line-based parser producing Action[] plus
  a list of ParseError{line,message}. Handles: play/pause/rewind,
  jump <sec> | #<frame> | @<marker>, state btn:<label> <state>,
  enable/disable btn:<label>, // comments, skips blanks
- evalAction for type:'script' parses the source and recursively
  dispatches sub-actions; errors surface via rt.warn
- ActionRuntime.setButtonState/setButtonDisabled are documented as
  taking an instance id OR a label; the store's actionRuntime builds
  a label→id map and resolves refs before applying
- exportPlayer template gets a matching parseScriptSource plus
  resolveButtonRef; evalAction-in-engine mirrors the TS version
- RUNTIME_VERSION bumped to '4'
- tests: parser covers commands, comments, jump variants, state/enable
  errors, unknown commands; two integration tests verify that script
  actions dispatch via evalAction and that parse errors don't block
  valid actions

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:20:15 +02:00
1 parent 94ebbac238
commit fb58cd18f7
5 files changed
+368 -10

No files matched your search

+120
View File
@@ -6,6 +6,7 @@ import {
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
import { parseScript } from '@/lib/scriptDsl'
import type { Action } from '@/types/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
@@ -3382,6 +3383,125 @@ export function registerAllTests(): void {
})
})
suite('scriptDsl — parser', () => {
test('parses bare commands', () => {
const { actions, errors } = parseScript('play\npause\nrewind')
expect(errors).toHaveLength(0)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause', 'rewindToLoop'])
})
test('skips blank lines and comments', () => {
const src = [
'// leading comment',
'',
'play // trailing comment',
' ',
'pause',
].join('\n')
const { actions, errors } = parseScript(src)
expect(errors).toHaveLength(0)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
})
test('jump accepts seconds, #frame, and @marker', () => {
const { actions, errors } = parseScript('jump 2.5\njump #60\njump @intro')
expect(errors).toHaveLength(0)
expect(actions[0]).toEqual({ type: 'jumpTo', time: 2.5 })
expect(actions[1]).toEqual({ type: 'jumpToFrame', frame: 60 })
expect(actions[2]).toEqual({ type: 'jumpToMarker', name: 'intro' })
})
test('jump reports invalid arguments', () => {
const { actions, errors } = parseScript('jump\njump abc\njump #xx\njump @')
expect(actions).toHaveLength(0)
expect(errors).toHaveLength(4)
})
test('state requires btn:<label> and a valid state', () => {
const good = parseScript('state btn:play pressed')
expect(good.errors).toHaveLength(0)
expect(good.actions[0]).toEqual({
type: 'setButtonState',
instanceId: 'play',
state: 'pressed',
})
const bad1 = parseScript('state play pressed')
expect(bad1.errors).toHaveLength(1)
const bad2 = parseScript('state btn:play weird')
expect(bad2.errors).toHaveLength(1)
})
test('enable / disable map to setButtonDisabled', () => {
const { actions } = parseScript('enable btn:play\ndisable btn:play')
expect(actions[0]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: false,
})
expect(actions[1]).toEqual({
type: 'setButtonDisabled',
instanceId: 'play',
disabled: true,
})
})
test('unknown commands report error, other lines still parse', () => {
const { actions, errors } = parseScript('play\nwat\npause')
expect(errors).toHaveLength(1)
expect(errors[0].line).toBe(2)
expect(actions.map((a) => a.type)).toEqual(['play', 'pause'])
})
})
suite('actions — script type expands via evalAction', () => {
test('script source is parsed and each action dispatched in order', () => {
const calls: string[] = []
const rt: ActionRuntime = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: { intro: 2 },
setPlayhead: (t) => calls.push(`seek:${t}`),
play: () => calls.push('play'),
pause: () => calls.push('pause'),
setButtonState: (id, s) => calls.push(`state:${id}=${s}`),
setButtonDisabled: (id, d) => calls.push(`disabled:${id}=${d}`),
warn: () => {},
}
evalAction(
{
type: 'script',
source: 'pause\njump @intro\nstate btn:play pressed',
},
rt,
)
expect(calls).toEqual(['pause', 'seek:2', 'state:play=pressed'])
})
test('script errors surface via rt.warn, valid actions still fire', () => {
const warnings: string[] = []
const fired: string[] = []
const rt: ActionRuntime = {
fps: 60,
playbackIn: 0,
playbackOut: 10,
duration: 10,
markers: {},
setPlayhead: () => {},
play: () => fired.push('play'),
pause: () => fired.push('pause'),
setButtonState: () => {},
setButtonDisabled: () => {},
warn: (m) => warnings.push(m),
}
evalAction({ type: 'script', source: 'play\nwat\npause' }, rt)
expect(fired).toEqual(['play', 'pause'])
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('wat')
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()