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:
1 parent
94ebbac238
commit
fb58cd18f7
5 files changed
+368
-10
No files matched your search
@@ -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
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user