233 lines
7.9 KiB
TypeScript
Executable file
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)
|
|
}
|