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
+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 })
|
||||
}
|
||||
Reference in new issue
Block a user