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()
+16 -4
View File
@@ -1,4 +1,5 @@
import type { Action, ButtonState, Marker } from '@/types/scene'
import { parseScript } from './scriptDsl'
export interface ActionRuntime {
fps: number
@@ -9,8 +10,12 @@ export interface ActionRuntime {
setPlayhead(t: number): void
play(): void
pause(): void
setButtonState(instanceId: string, state: ButtonState): void
setButtonDisabled(instanceId: string, disabled: boolean): void
/**
* `instanceIdOrLabel` may be a real ButtonInstance id OR a user-facing
* label — the runtime resolves via the label map internally.
*/
setButtonState(instanceIdOrLabel: string, state: ButtonState): void
setButtonDisabled(instanceIdOrLabel: string, disabled: boolean): void
warn(msg: string): void
}
@@ -56,9 +61,16 @@ export function evalAction(action: Action, rt: ActionRuntime): void {
case 'setButtonDisabled':
rt.setButtonDisabled(action.instanceId, action.disabled)
return
case 'script':
rt.warn('Script DSL is not implemented yet')
case 'script': {
const parsed = parseScript(action.source)
if (parsed.errors.length) {
for (const e of parsed.errors) {
rt.warn(`Script line ${e.line}: ${e.message}`)
}
}
for (const sub of parsed.actions) evalAction(sub, rt)
return
}
}
}
+88 -4
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '3'
const RUNTIME_VERSION = '4'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -199,9 +199,74 @@ const ENGINE_SOURCE = `(function(){
forcePlay: false,
buttonStates: {},
buttonDisabled: {},
buttonLabels: {},
warn: function(m){ if(window.console)window.console.warn('[animatr]',m); }
};
(scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; });
(scene.buttonInstances||[]).forEach(function(bi){
if(bi.label) runtime.buttonLabels[bi.label]=bi.id;
});
function resolveButtonRef(ref){
// Try as real instance id first, then as label.
var bis=scene.buttonInstances||[];
for(var bri=0;bri<bis.length;bri++) if(bis[bri].id===ref) return ref;
return runtime.buttonLabels[ref]||null;
}
var BUTTON_STATES=['normal','hover','pressed','disabled'];
function parseScriptSource(source){
var outActs=[];
var outErrs=[];
var lines=String(source||'').split(/\\r?\\n/);
for(var pi=0;pi<lines.length;pi++){
var ln=lines[pi];
var ci=ln.indexOf('//');
if(ci>=0) ln=ln.slice(0,ci);
ln=ln.trim();
if(!ln) continue;
var toks=ln.split(/\\s+/).filter(function(x){return x;});
var cmd=(toks[0]||'').toLowerCase();
var parsed=null, err=null;
if(cmd==='play') parsed={type:'play'};
else if(cmd==='pause') parsed={type:'pause'};
else if(cmd==='rewind') parsed={type:'rewindToLoop'};
else if(cmd==='jump'){
if(toks.length<2) err='jump: missing argument';
else {
var a=toks[1];
if(a.charAt(0)==='#'){
var fr=Number(a.slice(1));
if(!isFinite(fr)) err='jump: invalid frame '+a;
else parsed={type:'jumpToFrame',frame:fr};
} else if(a.charAt(0)==='@'){
var nm=a.slice(1);
if(!nm) err='jump: missing marker name';
else parsed={type:'jumpToMarker',name:nm};
} else {
var tm=Number(a);
if(!isFinite(tm)) err='jump: invalid time '+a;
else parsed={type:'jumpTo',time:tm};
}
}
} else if(cmd==='state'){
if(toks.length<3) err='state: expected btn:<label> <state>';
else if(toks[1].indexOf('btn:')!==0) err='state: expected btn:<label>';
else {
var st=toks[2];
if(BUTTON_STATES.indexOf(st)<0) err='state: unknown state '+st;
else parsed={type:'setButtonState',instanceId:toks[1].slice(4),state:st};
}
} else if(cmd==='enable'||cmd==='disable'){
if(toks.length<2) err=cmd+': missing btn:<label>';
else if(toks[1].indexOf('btn:')!==0) err=cmd+': expected btn:<label>';
else parsed={type:'setButtonDisabled',instanceId:toks[1].slice(4),disabled:cmd==='disable'};
} else {
err='unknown command "'+cmd+'"';
}
if(err) outErrs.push({line:pi+1,message:err});
else if(parsed) outActs.push(parsed);
}
return {actions:outActs, errors:outErrs};
}
function evalAction(act){
switch(act.type){
case 'play': runtime.forcePlay=true; runtime.forcePause=false; return;
@@ -215,9 +280,28 @@ const ENGINE_SOURCE = `(function(){
runtime.jumpTo=Math.max(0,Math.min(runtime.duration,mt));
return;
}
case 'setButtonState': runtime.buttonStates[act.instanceId]=act.state; return;
case 'setButtonDisabled': runtime.buttonDisabled[act.instanceId]=act.disabled; return;
case 'script': runtime.warn('Script DSL not implemented'); return;
case 'setButtonState': {
var sid=resolveButtonRef(act.instanceId);
if(!sid){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
runtime.buttonStates[sid]=act.state;
return;
}
case 'setButtonDisabled': {
var did=resolveButtonRef(act.instanceId);
if(!did){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
runtime.buttonDisabled[did]=act.disabled;
return;
}
case 'script': {
var parsed=parseScriptSource(act.source);
if(parsed.errors.length){
for(var sei=0; sei<parsed.errors.length; sei++){
runtime.warn('Script line '+parsed.errors[sei].line+': '+parsed.errors[sei].message);
}
}
for(var sai=0; sai<parsed.actions.length; sai++) evalAction(parsed.actions[sai]);
return;
}
}
}
function fireFrames(prev, next){
+128
View File
@@ -0,0 +1,128 @@
import type { Action, ButtonState } from '@/types/scene'
export interface ParseError {
line: number
message: string
}
export interface ParseResult {
actions: Action[]
errors: ParseError[]
}
const BUTTON_STATES = new Set<ButtonState>([
'normal',
'hover',
'pressed',
'disabled',
])
/**
* Parse a small line-based DSL into Action[]. Comments start with `//`
* and each non-empty line is one command. Known commands:
*
* play | pause | rewind
* jump <seconds> (e.g. `jump 2.5`)
* jump #<frame> (e.g. `jump #135`)
* jump @<marker> (e.g. `jump @intro`)
* state btn:<label> <state> (state ∈ normal|hover|pressed|disabled)
* enable btn:<label>
* disable btn:<label>
*
* Button labels are left as-is in `instanceId`. The runtime resolves
* labels to real instance IDs at eval time.
*/
export function parseScript(source: string): ParseResult {
const actions: Action[] = []
const errors: ParseError[] = []
const lines = source.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const stripped = stripComment(lines[i]).trim()
if (!stripped) continue
const res = parseLine(stripped)
if (res.error) errors.push({ line: i + 1, message: res.error })
else if (res.action) actions.push(res.action)
}
return { actions, errors }
}
function stripComment(line: string): string {
const i = line.indexOf('//')
return i < 0 ? line : line.slice(0, i)
}
function parseLine(line: string): { action?: Action; error?: string } {
const tokens = line.split(/\s+/).filter(Boolean)
if (!tokens.length) return {}
const cmd = tokens[0].toLowerCase()
switch (cmd) {
case 'play':
return ok({ type: 'play' })
case 'pause':
return ok({ type: 'pause' })
case 'rewind':
return ok({ type: 'rewindToLoop' })
case 'jump':
return parseJump(tokens)
case 'state':
return parseState(tokens)
case 'enable':
return parseEnable(tokens, false)
case 'disable':
return parseEnable(tokens, true)
default:
return { error: `unknown command "${cmd}"` }
}
}
function ok(action: Action): { action: Action } {
return { action }
}
function parseJump(tokens: string[]): { action?: Action; error?: string } {
if (tokens.length < 2) return { error: 'jump: missing argument' }
const arg = tokens[1]
if (arg.startsWith('#')) {
const frame = Number(arg.slice(1))
if (!Number.isFinite(frame)) return { error: `jump: invalid frame "${arg}"` }
return ok({ type: 'jumpToFrame', frame })
}
if (arg.startsWith('@')) {
const name = arg.slice(1)
if (!name) return { error: 'jump: missing marker name after @' }
return ok({ type: 'jumpToMarker', name })
}
const time = Number(arg)
if (!Number.isFinite(time)) return { error: `jump: invalid time "${arg}"` }
return ok({ type: 'jumpTo', time })
}
function parseButtonRef(token: string): string | null {
if (!token.startsWith('btn:')) return null
const label = token.slice(4)
return label || null
}
function parseState(tokens: string[]): { action?: Action; error?: string } {
if (tokens.length < 3) return { error: 'state: expected `btn:<label> <state>`' }
const label = parseButtonRef(tokens[1])
if (!label) return { error: `state: expected button ref like btn:<label>, got "${tokens[1]}"` }
const state = tokens[2] as ButtonState
if (!BUTTON_STATES.has(state)) {
return { error: `state: unknown state "${state}"` }
}
return ok({ type: 'setButtonState', instanceId: label, state })
}
function parseEnable(
tokens: string[],
disabled: boolean,
): { action?: Action; error?: string } {
const verb = disabled ? 'disable' : 'enable'
if (tokens.length < 2) return { error: `${verb}: missing button ref` }
const label = parseButtonRef(tokens[1])
if (!label) {
return { error: `${verb}: expected button ref like btn:<label>` }
}
return ok({ type: 'setButtonDisabled', instanceId: label, disabled })
}
+16 -2
View File
@@ -1493,6 +1493,14 @@ export const useSceneStore = defineStore('scene', {
},
actionRuntime(): ActionRuntime {
const self = this
const instances = self.scene.buttonInstances ?? []
const byId = new Set(instances.map((bi) => bi.id))
const byLabel = new Map<string, string>()
for (const bi of instances) if (bi.label) byLabel.set(bi.label, bi.id)
function resolve(ref: string): string | null {
if (byId.has(ref)) return ref
return byLabel.get(ref) ?? null
}
return {
fps: self.scene.fps,
playbackIn: self.playbackIn,
@@ -1512,8 +1520,14 @@ export const useSceneStore = defineStore('scene', {
// 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)
setButtonDisabled(ref: string, disabled: boolean) {
const id = resolve(ref)
if (!id) {
// eslint-disable-next-line no-console
console.warn('[animatr] unknown button ref:', ref)
return
}
self.setButtonInstanceDisabled(id, disabled)
},
warn(msg: string) {
// eslint-disable-next-line no-console