openvisengine/src/lib/ava/script.ts
2026-11-04 05:50:06 +00:00

233 lines
7.9 KiB
TypeScript
Executable file

// AVA — visualization script format parser
//
// A script is a plain-text file:
//
// # comment lines start with '#' or '//'
// name: Gap The Mind <- header keys before the first [section]
// author: synth-c (adapted)
// mode: lines <- lines | dots | solid
// blend: additive <- additive | alpha
// persist: 0.9 <- motion trail persistence 0..0.98
// linewidth: 2
//
// [init] runs once when the script loads
// [frame] runs once per frame
// [beat] runs on frames where a beat fired (after [frame])
// [point] runs once per point (n times per frame)
import { compileProgram, ExpressionError, type Env, type Program } from './expression'
import type { BlendMode, RenderMode } from './types'
export interface ScriptMeta {
name: string
author: string
description: string
mode: RenderMode
blend: BlendMode
persist: number
linewidth: number
/** +1 (default, y is up) or -1 (AVS convention, y is down — used by auto-ported presets). */
ysign: number
}
export interface ScriptError {
message: string
section: string
line: number
}
export class AvaScriptError extends Error {
section: string
line: number
constructor(err: ScriptError) {
super(err.message)
this.name = 'AvaScriptError'
this.section = err.section
this.line = err.line
}
}
export interface AvaScript {
meta: ScriptMeta
initProg: Program | null
frameProg: Program | null
beatProg: Program | null
pointProg: Program | null
}
const SECTION_NAMES = new Set(['init', 'frame', 'beat', 'point'])
const HEADER_KEYS = new Set(['name', 'author', 'description', 'mode', 'blend', 'persist', 'linewidth', 'ysign'])
const MODES = new Set(['lines', 'dots', 'solid'])
const BLENDS = new Set(['additive', 'alpha'])
const DEFAULT_META: ScriptMeta = {
name: 'Untitled',
author: '',
description: '',
mode: 'lines',
blend: 'additive',
persist: 0.85,
linewidth: 2,
ysign: 1,
}
/** Parse + compile a script. Throws AvaScriptError on the first problem found. */
export function parseScript(text: string): AvaScript {
const result = tryParse(text)
if (result.error) throw new AvaScriptError(result.error)
return result.script!
}
/** Never throws — returns the error (first only) instead. */
export function validateScript(text: string): { ok: boolean; script: AvaScript | null; error: ScriptError | null; meta: ScriptMeta | null } {
const result = tryParse(text)
return { ok: !result.error, script: result.script, error: result.error, meta: result.script?.meta ?? result.meta ?? null }
}
function tryParse(text: string): { script: AvaScript | null; meta?: ScriptMeta; error: ScriptError | null } {
const meta: ScriptMeta = { ...DEFAULT_META }
const sections: Record<string, { text: string; startLine: number }> = {}
const lines = text.split('\n')
let currentSection: string | null = null
let sectionBuffer: string[] = []
const flushSection = () => {
if (currentSection) {
sections[currentSection] = {
text: sectionBuffer.join('\n'),
startLine: currentSectionStartLine,
}
}
}
let currentSectionStartLine = 1
for (let idx = 0; idx < lines.length; idx++) {
const raw = lines[idx]
const lineNo = idx + 1
const trimmed = raw.trim()
// section headers
const sectionMatch = trimmed.match(/^\[([a-zA-Z]+)\]$/)
if (sectionMatch) {
flushSection()
const name = sectionMatch[1].toLowerCase()
if (!SECTION_NAMES.has(name)) {
return { script: null, meta: { ...meta }, error: { message: `Unknown section '[${name}]' -- valid sections: init, frame, beat, point`, section: name, line: lineNo } }
}
if (sections[name]) {
return { script: null, meta: { ...meta }, error: { message: `Duplicate section '[${name}]'`, section: name, line: lineNo } }
}
currentSection = name
currentSectionStartLine = lineNo + 1
sectionBuffer = []
continue
}
if (currentSection) {
sectionBuffer.push(raw)
continue
}
// header area
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue
const headerMatch = trimmed.match(/^([a-zA-Z]+)\s*:\s*(.*)$/)
if (!headerMatch) {
return { script: null, meta: { ...meta }, error: { message: `Expected 'key: value' header or a [section] -- got '${trimmed}'`, section: 'header', line: lineNo } }
}
const key = headerMatch[1].toLowerCase()
const value = headerMatch[2].trim()
if (!HEADER_KEYS.has(key)) {
return { script: null, meta: { ...meta }, error: { message: `Unknown header key '${key}' -- valid keys: name, author, description, mode, blend, persist, linewidth`, section: 'header', line: lineNo } }
}
// v7.2: string headers may be written quoted (name: "Bass Bloom") —
// strip one layer of matching surrounding quotes so the footer reads
// SCRIPT: Bass Bloom, not SCRIPT: "Bass Bloom".
const str = (): string => {
const q = value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
return q ? value.slice(1, -1).trim() : value
}
switch (key) {
case 'name': meta.name = str() || 'Untitled'; break
case 'author': meta.author = str(); break
case 'description': meta.description = str(); break
case 'mode':
if (!MODES.has(value)) {
return { script: null, meta: { ...meta }, error: { message: `mode must be one of: lines, dots, solid (got '${value}')`, section: 'header', line: lineNo } }
}
meta.mode = value as RenderMode
break
case 'blend':
if (!BLENDS.has(value)) {
return { script: null, meta: { ...meta }, error: { message: `blend must be 'additive' or 'alpha' (got '${value}')`, section: 'header', line: lineNo } }
}
meta.blend = value as BlendMode
break
case 'persist': {
const v = parseFloat(value)
if (Number.isNaN(v)) {
return { script: null, meta: { ...meta }, error: { message: `persist must be a number 0..0.98 (got '${value}')`, section: 'header', line: lineNo } }
}
meta.persist = Math.min(Math.max(v, 0), 0.98)
break
}
case 'linewidth': {
const v = parseFloat(value)
if (Number.isNaN(v)) {
return { script: null, meta: { ...meta }, error: { message: `linewidth must be a number (got '${value}')`, section: 'header', line: lineNo } }
}
meta.linewidth = Math.min(Math.max(v, 0.5), 16)
break
}
case 'ysign': {
const v = parseInt(value, 10)
if (v !== 1 && v !== -1) {
return { script: null, meta: { ...meta }, error: { message: `ysign must be 1 or -1 (got '${value}')`, section: 'header', line: lineNo } }
}
meta.ysign = v
break
}
}
}
flushSection()
// compile sections
const compiled: Partial<Record<string, Program>> = {}
for (const name of ['init', 'frame', 'beat', 'point']) {
const sec = sections[name]
if (!sec || !sec.text.trim()) continue
try {
compiled[name] = compileProgram(sec.text, sec.startLine)
} catch (err) {
if (err instanceof ExpressionError) {
return { script: null, meta: { ...meta }, error: { message: err.message, section: name, line: err.line } }
}
throw err
}
}
return {
script: {
meta,
initProg: compiled.init ?? null,
frameProg: compiled.frame ?? null,
beatProg: compiled.beat ?? null,
pointProg: compiled.point ?? null,
},
error: null,
}
}
/** Run helpers — all safely no-op when a section is absent. */
export function execInit(script: AvaScript, env: Env): void {
script.initProg?.exec(env)
}
export function execFrame(script: AvaScript, env: Env): void {
script.frameProg?.exec(env)
}
export function execBeat(script: AvaScript, env: Env): void {
script.beatProg?.exec(env)
}
export function execPoint(script: AvaScript, env: Env): void {
script.pointProg?.exec(env)
}