// 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 = {} 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> = {} 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) }