openvisengine/tools/convert-avs.ts

1514 lines
52 KiB
TypeScript
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// AVA — .avs (Winamp AVS binary preset) → .ava auto-converter
//
// Parses the legacy .avs binary format (reverse-engineered against the
// vis_avs reference source), extracts the primary SuperScope / Texer II
// point program plus any AVSTrans #define macros and Jheriko Global
// register-setter code, translates it to the AVA script dialect, validates
// and smoke-runs every generated script through the real engine parser, and
// emits src/lib/ava/embedded-presets.ts.
//
// Run: bun tools/convert-avs.ts [inputRoot] (inputRoot defaults to /tmp/avsdrive/raw)
//
// Format reference (per grandchild/vis_avs source):
// magic "Nullsoft AVS Preset 0.2\x1a" (24 bytes)
// byte clearFlag
// effects: [int32 id] [if id>=0x4000: 32-byte apeId] [int32 len] [blob]
// id 36 SuperScope: [byte ver==1][point][frame][beat][init][int chsrc]
// [int ncolors][colors...][int drawmode]
// id -2 EffectList: [byte mode0|0x80][byte mode0][byte in][byte out]
// [byte extsize][ext ints while pos<extsize+5][effects...]
// child apeId "AVS 2.8+ Effect List Config" =
// [int useCode][init][frame]
// ape "Acko.net: Texer II": [int ver][260b image][int resize][int wrap]
// [int colorize][int unused][init][frame][beat][point]
// ape "Misc: AVSTrans Automation": [int x4][null-term code]
// ape "Jheriko: Global": [int loadTime][24b pad][nt init][nt frame][nt beat]
// id 21 Comment: [string]
// strings: [int32 len][len bytes] (len includes the null when present;
// empty string = length 0)
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs'
import { join, basename, dirname, relative, resolve } from 'node:path'
import { validateScript } from '../src/lib/ava/script'
import { AVA_HOOKS, resetEelBuffers } from '../src/lib/ava/expression'
// ---------------------------------------------------------------------------
// binary reader
// ---------------------------------------------------------------------------
class Reader {
pos = 0
constructor(readonly data: Uint8Array) {}
get remaining(): number {
return this.data.length - this.pos
}
int32(): number {
if (this.remaining < 4) throw new Error('out of bytes (int32)')
const v = this.data[this.pos] | (this.data[this.pos + 1] << 8) | (this.data[this.pos + 2] << 16) | (this.data[this.pos + 3] << 24)
this.pos += 4
return v
}
byte(): number {
if (this.remaining < 1) throw new Error('out of bytes (byte)')
return this.data[this.pos++]
}
bytes(n: number): Uint8Array {
if (n < 0 || this.remaining < n) throw new Error('out of bytes')
const out = this.data.subarray(this.pos, this.pos + n)
this.pos += n
return out
}
/** Length-prefixed eel string: [int len][len bytes], cut at first null. */
lpString(): string {
const len = this.int32()
if (len <= 0) return ''
return latin1(this.bytes(len)).split('\0')[0]
}
/** Null-terminated string occupying the rest of the blob. */
ntRest(): string {
return latin1(this.bytes(this.remaining)).split('\0')[0]
}
}
function latin1(b: Uint8Array): string {
let s = ''
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
return s
}
// ---------------------------------------------------------------------------
// effect tree
// ---------------------------------------------------------------------------
const ID_SUPERSCOPE = 36
const ID_COMMENT = 21
const ID_EFFECTLIST = -2
const APE_TEXER2 = 'Acko.net: Texer II'
const APE_EELTRANS = 'Misc: AVSTrans Automation'
const APE_GLOBALVARS = 'Jheriko: Global'
interface Effect {
id: number
apeId: string | null
kind: 'superscope' | 'texer2' | 'effectlist' | 'eeltrans' | 'globalvars' | 'comment' | 'other'
enabled: boolean
// superscope / texer2
code?: { init: string; frame: string; beat: string; point: string }
spectrumSource?: boolean
drawMode?: number // 0 dots, 1 lines
colors?: number[] // 0xRRGGBB
// eeltrans / globalvars
transCode?: string
gv?: { init: string; frame: string; beat: string }
// comment
comment?: string
children?: Effect[]
}
function parseEffectListBody(blob: Uint8Array): { enabled: boolean; children: Effect[] } {
const r = new Reader(blob)
let enabled = true
let ext = 5
const first = r.byte()
enabled = (first & 0b10) === 0
if (first & 0x80) {
r.byte() // mode0 copy
r.byte() // input blend
r.byte() // output blend
ext = r.byte() + 5
}
// extended config ints while pos < ext
while (r.pos < ext && r.remaining >= 4) {
const v = r.int32()
if (r.pos > ext - 4) break
void v
}
const children: Effect[] = []
while (r.remaining > 8) {
const id = r.int32()
let apeId: string | null = null
if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0]
const len = r.int32()
if (len < 0 || len > r.remaining) break
const childBlob = r.bytes(len)
// special case: the v2.81+ codeable EffectList's own init/frame code
// section, stored as a child entry with the Effect List APE id.
if (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config') {
const cr = new Reader(childBlob)
if (cr.remaining >= 4) cr.int32() // use_code
const gv = { init: cr.lpString(), frame: cr.lpString(), beat: '' }
if (gv.init.trim() || gv.frame.trim()) {
children.push({ id, apeId, kind: 'globalvars', enabled: true, gv })
}
continue
}
children.push(parseEffect(id, apeId, childBlob))
}
return { enabled, children }
}
function parseEffect(id: number, apeId: string | null, blob: Uint8Array): Effect {
const base = { id, apeId, enabled: true } as Effect
if (id === ID_SUPERSCOPE) {
const r = new Reader(blob)
const code = { init: '', frame: '', beat: '', point: '' }
let spectrumSource = false
let drawMode = 1
const colors: number[] = []
const ver = r.byte()
if (ver === 1) {
code.point = r.lpString()
code.frame = r.lpString()
code.beat = r.lpString()
code.init = r.lpString()
} else {
// ancient fixed 1024-byte block
if (blob.length >= 1025) {
const buf = latin1(blob.subarray(1, 1025))
code.point = buf.slice(0, 256).split('\0')[0]
code.frame = buf.slice(256, 512).split('\0')[0]
code.beat = buf.slice(512, 768).split('\0')[0]
code.init = buf.slice(768, 1024).split('\0')[0]
}
}
if (r.remaining >= 4) {
const chsrc = r.int32()
spectrumSource = (chsrc & 0b100) !== 0
if (r.remaining >= 4) {
const ncolors = r.int32()
if (ncolors <= 16) {
for (let i = 0; i < ncolors && r.remaining >= 4; i++) colors.push(r.int32() >>> 0)
}
if (r.remaining >= 4) drawMode = r.int32()
}
}
return { ...base, kind: 'superscope', code, spectrumSource, drawMode, colors }
}
if (id >= 0x4000 && apeId === APE_TEXER2) {
const r = new Reader(blob)
r.int32() // version
r.bytes(260) // image path
r.int32() // resize
r.int32() // wrap
r.int32() // colorize
r.int32() // unused
const code = { init: r.lpString(), frame: r.lpString(), beat: r.lpString(), point: r.lpString() }
return { ...base, kind: 'texer2', code, spectrumSource: false, drawMode: 0, colors: [] }
}
if (id === ID_EFFECTLIST || (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config')) {
const { enabled, children } = parseEffectListBody(blob)
return { ...base, kind: 'effectlist', enabled, children }
}
if (id >= 0x4000 && apeId === APE_EELTRANS) {
const r = new Reader(blob)
if (r.remaining >= 16) {
r.int32(); r.int32(); r.int32(); r.int32()
return { ...base, kind: 'eeltrans', transCode: r.ntRest() }
}
return { ...base, kind: 'other' }
}
if (id >= 0x4000 && apeId === APE_GLOBALVARS) {
const r = new Reader(blob)
if (r.remaining >= 28) {
r.int32() // load time
r.bytes(24) // unused
const gv = { init: ntAt(r), frame: ntAt(r), beat: ntAt(r) }
return { ...base, kind: 'globalvars', gv }
}
return { ...base, kind: 'other' }
}
if (id === ID_COMMENT) {
const r = new Reader(blob)
return { ...base, kind: 'comment', comment: r.lpString() }
}
return { ...base, kind: 'other' }
}
function ntAt(r: Reader): string {
// null-terminated string starting at reader position (within the blob)
const start = r.pos
const d = r.data
let end = start
while (end < d.length && d[end] !== 0) end++
r.pos = Math.min(end + 1, d.length)
return latin1(d.subarray(start, end))
}
function parseAvsFile(path: string): Effect[] {
const data = new Uint8Array(readFileSync(path))
const magic = 'Nullsoft AVS Preset 0.2\x1a'
const head = latin1(data.subarray(0, magic.length))
if (head !== magic) throw new Error(`bad magic: ${JSON.stringify(head.slice(0, 30))}`)
const r = new Reader(data.subarray(magic.length))
r.byte() // clear flag
const effects: Effect[] = []
while (r.remaining > 8) {
const id = r.int32()
let apeId: string | null = null
if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0]
const len = r.int32()
if (len < 0 || len > r.remaining) break
effects.push(parseEffect(id, apeId, r.bytes(len)))
}
return effects
}
// ---------------------------------------------------------------------------
// AVSTrans #define macro preprocessor
// ---------------------------------------------------------------------------
interface Macro {
name: string
params: string[] | null // null = object-like
body: string
}
/** AVSTrans built-in constants (available even without an AVSTrans module). */
const AVSTRANS_BUILTINS: Macro[] = [
{ name: '$pi', params: null, body: '3.14159265358979' },
{ name: '$twopi', params: null, body: '6.28318530717959' },
{ name: '$e', params: null, body: '2.71828182845905' },
{ name: '$phi', params: null, body: '1.61803398874989' },
]
function extractDefines(sources: string[]): { macros: Macro[]; failNote: string | null } {
const macros: Macro[] = []
let failNote: string | null = null
const defineRe = /^[ \t]*#define\s+(.+)$/gm
for (const src of sources) {
if (!src.includes('#define')) continue
// join backslash line-continuations first
const joined = src.replace(/\\\r?\n/g, ' ')
const matches = joined.match(defineRe) ?? []
for (const m of matches) {
const rest = m.replace(/^[ \t]*#define\s+/, '').trim()
const fn = rest.match(/^([A-Za-z0-9_$]+)\s*\(([^)]*)\)\s*([\s\S]*)$/)
if (fn) {
const params = fn[2].split(',').map((p) => p.trim()).filter(Boolean)
macros.push({ name: fn[1], params, body: fn[3] })
} else {
const obj = rest.match(/^([A-Za-z0-9_$]+)\s+([\s\S]*)$/)
if (obj) macros.push({ name: obj[1], params: null, body: obj[2] })
else failNote = 'unparsed #define'
}
}
}
// user defines override the AVSTrans built-ins
const byName = new Map(macros.map((m) => [m.name, m]))
for (const b of AVSTRANS_BUILTINS) if (!byName.has(b.name)) macros.push(b)
return { macros, failNote }
}
/** Split a macro CALL starting at `openPos` (must be '('). */
function readCallArgs(src: string, openPos: number): { args: string[]; end: number } | null {
if (src[openPos] !== '(') return null
let depth = 0
let start = openPos + 1
const args: string[] = []
for (let i = openPos; i < src.length; i++) {
const c = src[i]
if (c === '(') depth++
else if (c === ')') {
depth--
if (depth === 0) {
args.push(src.slice(start, i))
return { args, end: i + 1 }
}
} else if (c === ',' && depth === 1) {
args.push(src.slice(start, i))
start = i + 1
}
}
return null
}
function applyMacros(code: string, macros: Macro[], depth = 0): string {
if (depth > 24 || !macros.length) return code
let out = code
let changed = false
for (const macro of macros) {
const re = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.name)}($|[^A-Za-z0-9_(]|\\()`, 'g')
let result = ''
let last = 0
let m: RegExpExecArray | null
let macroChanged = false
while ((m = re.exec(out))) {
const at = m.index + m[1].length
// skip #define lines themselves
const lineStart = out.lastIndexOf('\n', at) + 1
const lineHead = out.slice(lineStart, at).trimStart()
if (lineHead.startsWith('#')) continue
if (macro.params) {
const call = readCallArgs(out, at + macro.name.length)
if (!call || call.args.length !== macro.params.length) continue
let body = macro.body
// token-concat ##param## first, then plain params
for (let i = 0; i < macro.params.length; i++) {
body = body.split(`##${macro.params[i]}##`).join(call.args[i].trim())
}
for (let i = 0; i < macro.params.length; i++) {
body = body.replace(new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.params[i])}($|[^A-Za-z0-9_])`, 'g'),
(_s, pre, post) => `${pre}(${call.args[i].trim()})${post}`)
}
result += out.slice(last, at) + `(${body})`
last = call.end
} else {
result += out.slice(last, at) + `(${macro.body})`
last = at + macro.name.length
}
macroChanged = true
re.lastIndex = last
}
if (macroChanged) {
result += out.slice(last)
out = result
changed = true
}
}
if (changed) return applyMacros(out, macros, depth + 1)
return out
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function stripDirectives(code: string): string {
return code
.split('\n')
.filter((l) => !/^\s*#/.test(l))
.join('\n')
}
// ---------------------------------------------------------------------------
// code translation
// ---------------------------------------------------------------------------
function translateCode(code: string, spectrumSource: boolean): string {
let out = code
// linesize -> size (AVS per-point line width)
out = out.replace(/\blinesize\b/g, 'size')
// AVS rand(n) returns an integer 0..n-1 — our deterministic rand(seed) differs
out = out.replace(/\brand\b/g, 'rnd')
// spectrum-source scopes: v should read the spectrum (AVS: byte/128-1)
if (spectrumSource) out = out.replace(/\bv\b/g, '(sv*2-1)')
return out
}
function assignsVar(code: string, name: string): boolean {
return new RegExp(`(^|[^A-Za-z0-9_])${name}\\s*=[^=]`).test(code)
}
// ---------------------------------------------------------------------------
// candidate collection
// ---------------------------------------------------------------------------
interface Candidates {
scopes: Array<Effect & { kind: 'superscope' | 'texer2' }>
eeltrans: string[]
gv: Array<{ init: string; frame: string; beat: string }>
comment: string | null
otherKinds: Set<string>
}
function collect(effects: Effect[], into: Candidates): void {
for (const e of effects) {
if (!e.enabled) continue
switch (e.kind) {
case 'superscope':
case 'texer2':
into.scopes.push(e as Effect & { kind: 'superscope' | 'texer2' })
break
case 'eeltrans':
if (e.transCode) into.eeltrans.push(e.transCode)
break
case 'globalvars':
if (e.gv) into.gv.push(e.gv)
break
case 'comment':
if (!into.comment && e.comment) into.comment = e.comment
break
case 'effectlist':
if (e.children) collect(e.children, into)
break
default:
into.otherKinds.add(describeEffect(e))
}
}
}
const BUILTIN_NAMES: Record<number, string> = {
0: 'Simple', 1: 'DotPlane', 2: 'OscilloscopeStar', 3: 'Fadeout', 4: 'BlitterFeedback',
5: 'OnBeatClear', 6: 'Blur', 7: 'BassSpin', 8: 'MovingParticle', 9: 'RotoBlitter', 10: 'SVP',
11: 'Colorfade', 12: 'ColorClip', 13: 'RotStar', 14: 'Ring', 15: 'Movement', 16: 'Scatter',
17: 'DotGrid', 18: 'BufferSave', 19: 'DotFountain', 20: 'Water', 22: 'Brightness', 23: 'Interleave',
24: 'Grain', 25: 'ClearScreen', 26: 'Mirror', 27: 'Starfield', 29: 'Bump', 30: 'Mosaic',
31: 'WaterBump', 32: 'Video', 33: 'CustomBPM', 34: 'Picture', 35: 'DynamicDistanceModifier',
37: 'Invert', 38: 'UniqueTone', 39: 'Timescope', 40: 'SetRenderMode', 41: 'Interferences',
42: 'DynamicShift', 43: 'DynamicMovement', 44: 'FastBright', 45: 'ColorModifier',
}
function describeEffect(e: Effect): string {
if (e.apeId) return e.apeId
return BUILTIN_NAMES[e.id] ?? `effect#${e.id}`
}
// ---------------------------------------------------------------------------
// fallback stand-in generator (for presets whose visuals are movement/APE
// driven).
//
// The original converter squeezed every unportable preset into one of 6 fixed
// templates, so a third of the library looked identical. This generator
// instead builds a UNIQUE visual per preset:
// * 16 distinct geometric families (rings, tunnel, starfield, lattice,
// swirl, kaleido, lissajous, spectrum bars, moire, fountain, rose,
// orbits, ribbon, web, rain, helix)
// * 6 color palettes (rainbow / fire / ice / neon-band / mono / duotone)
// * every numeric parameter (counts, speeds, twists, couplings, widths,
// persistence) drawn from a deterministic PRNG seeded by the preset NAME
// — same name always regenerates the same visual, different names always
// differ
// * the family is chosen from the original's unportable components so the
// vibe roughly matches (BlitterFeedback → tunnels, Mirror → kaleido,
// Starfield → starfield, DotFountain → fountain, …)
// ---------------------------------------------------------------------------
/** Deterministic PRNG (mulberry32) — stable across runs/machines. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** float in [lo, hi], formatted for generated code */
const rr = (rng: () => number, lo: number, hi: number): string =>
String(Math.round((lo + rng() * (hi - lo)) * 1000) / 1000)
/** int in [lo, hi] */
const ri = (rng: () => number, lo: number, hi: number): number =>
Math.floor(lo + rng() * (hi - lo + 1))
const pick = <T,>(rng: () => number, arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)]
// --- palettes ---------------------------------------------------------------
type PaletteName = 'rainbow' | 'fire' | 'ice' | 'neon' | 'mono' | 'duotone'
const PALETTE_NAMES: readonly PaletteName[] = ['rainbow', 'fire', 'ice', 'neon', 'mono', 'duotone']
/** red/green/blue lines for a luminance + phase expression pair. */
function colorLines(palette: PaletteName, lum: string, phase: string): string {
switch (palette) {
case 'fire':
return `red = min(1, ${lum} * 1.3)\ngreen = ${lum} * 0.5\nblue = ${lum} * 0.12`
case 'ice':
return `red = ${lum} * 0.3\ngreen = ${lum} * 0.65\nblue = min(1, ${lum} * 1.3)`
case 'neon':
return `red = ${lum} * (0.25 + bass * 0.95)\ngreen = ${lum} * (0.25 + mid * 0.95)\nblue = ${lum} * (0.25 + treb * 0.95)`
case 'mono':
return `red = ${lum} * 0.85\ngreen = ${lum} * 0.92\nblue = ${lum}`
case 'duotone':
return `red = ${lum} * abs(sin(${phase}))\ngreen = ${lum} * 0.22\nblue = ${lum} * abs(cos(${phase}))`
default:
return `red = ${lum} * (0.45 + 0.55 * sin(hue * 6.28318 + ${phase}))\ngreen = ${lum} * (0.45 + 0.55 * sin(hue * 6.28318 + ${phase} + 2.09))\nblue = ${lum} * (0.45 + 0.55 * sin(hue * 6.28318 + ${phase} + 4.19))`
}
}
function scriptHeader(name: string, mode: string, persist: string, linewidth: string): string {
return `name: ${JSON.stringify(name)}\nmode: ${mode}\nblend: additive\npersist: ${persist}\nlinewidth: ${linewidth}`
}
interface FamilyCtx {
rng: () => number
name: string
palette: PaletteName
hueSpeed: string
}
type FamilyFn = (ctx: FamilyCtx) => { style: string; content: string }
// --- the 16 families --------------------------------------------------------
function famPulseRings({ rng, name, palette, hueSpeed }: FamilyCtx) {
const rings = ri(rng, 5, 13)
const mode = pick(rng, ['lines', 'dots'] as const)
const speed = rr(rng, 0.18, 0.5)
const radBase = rr(rng, 0.16, 0.34)
const radBass = rr(rng, 0.35, 0.85)
const content = `${scriptHeader(name, mode, rr(rng, 0.78, 0.9), rr(rng, 1.4, 2.6))}
[init]
n = ${rings * ri(rng, 40, 72)}
[frame]
hue = hue + dt * ${hueSpeed} + beat * 0.01
travel = travel + dt * (${speed} + bass_att * 1.4)
flash = flash * 0.86
[beat]
flash = 1
[point]
g = floor(i * ${rings})
gi = frac(i * ${rings})
ph = frac(rand(g * 5.1 + 1) + travel * (0.55 + rand(g + 17) * 0.85))
a = gi * 6.28318
rad = ph * (${radBase} + bass * ${radBass} + flash * 0.08)
x = sin(a) * rad / aspect
y = cos(a) * rad
fade = sin(ph * 3.14159)
${colorLines(palette, 'fade * (0.35 + flash * 0.4)', 'g * 0.7')}
skip = if(above(ph, 0.96), 1, 0)
`
return { style: 'pulse rings', content }
}
function famTunnel({ rng, name, palette, hueSpeed }: FamilyCtx) {
const rings = ri(rng, 16, 30)
const twist = rr(rng, 0.1, 0.6)
const speed = rr(rng, 0.15, 0.4)
const svC = rr(rng, 0.5, 1.3)
const mode = pick(rng, ['lines', 'dots'] as const)
const content = `${scriptHeader(name, mode, rr(rng, 0.84, 0.92), rr(rng, 1.3, 2.2))}
[init]
n = ${rings * ri(rng, 28, 48)}
[frame]
t = t + dt * (${speed} + bass_att * 1.5)
hue = hue + dt * ${hueSpeed}
[point]
ring = floor(i * ${rings})
ri2 = frac(i * ${rings})
z = frac(ring / ${rings} + t)
rad = 0.05 + z * ${rr(rng, 0.75, 1.05)}
a = ri2 * 6.28318 + ring * ${twist} + sin(t * 0.7) * 2
x = sin(a) * rad * (0.55 + sv * ${svC}) / aspect
y = cos(a) * rad * (0.55 + sv * ${svC})
${colorLines(palette, '(1 - z) * (0.3 + bass * 0.7)', `z * ${rr(rng, 2, 6)}`)}
`
return { style: 'tunnel', content }
}
function famStarfield({ rng, name, palette, hueSpeed }: FamilyCtx) {
const content = `${scriptHeader(name, 'dots', rr(rng, 0.76, 0.86), rr(rng, 2, 3))}
[init]
n = ${ri(rng, 650, 1100)}
[frame]
travel = travel + dt * (${rr(rng, 0.15, 0.35)} + vol_att * ${rr(rng, 1.2, 2)})
hue = hue + dt * ${hueSpeed}
[point]
a = rand(i * 7.3) * 6.28318
z = frac(rand(i * 3.1 + 5) + travel * (0.5 + rand(i + 9) * 0.7))
rad = z * z * ${rr(rng, 0.95, 1.25)}
x = sin(a) * rad / aspect
y = cos(a) * rad
tw = 0.4 + 0.6 * sin(z * 40 + time * 3) * (0.5 + treb)
${colorLines(palette, 'tw', `i * ${rr(rng, 2, 8)}`)}
size = 1 + z * 3 * (0.5 + bass)
skip = if(above(z, 0.97), 1, 0)
`
return { style: 'starfield', content }
}
function famLattice({ rng, name, palette, hueSpeed }: FamilyCtx) {
const cols = ri(rng, 22, 44)
const rotSpeed = rr(rng, 0.03, 0.12)
const hCoup = rr(rng, 0.2, 0.55)
const span = rr(rng, 1.4, 1.9)
const content = `${scriptHeader(name, 'dots', rr(rng, 0.74, 0.84), rr(rng, 2, 3))}
[init]
cols = ${cols}
n = cols * cols
[frame]
rot = rot + dt * (${rotSpeed} + mid * 0.4)
hue = hue + dt * ${hueSpeed}
[point]
cx = floor(i * cols) / (cols - 1)
cy = frac(i * cols)
h = sv * ${hCoup}
ux = (cx - 0.5) * ${span}
uy = (cy - 0.5) * ${span}
rx = ux * cos(rot) - uy * sin(rot)
ry = ux * sin(rot) + uy * cos(rot)
x = (rx / aspect) * (1 - h * 0.7)
y = ry * (1 - h * 0.7)
${colorLines(palette, '0.22 + h * 2.2', `cx * ${rr(rng, 2, 5)}`)}
size = 1.2 + h * 5
`
return { style: 'lattice', content }
}
function famSwirl({ rng, name, palette, hueSpeed }: FamilyCtx) {
const arms = ri(rng, 3, 9)
const twist = rr(rng, 0.8, 2.2)
const dExp = rr(rng, 1.6, 2.6)
const speed = rr(rng, 0.2, 0.45)
const content = `${scriptHeader(name, 'lines', rr(rng, 0.86, 0.92), rr(rng, 1.3, 2))}
[init]
n = ${ri(rng, 900, 1300)}
[frame]
t = t + dt * (${speed} + bass_att * 1.8)
hue = hue + dt * ${hueSpeed} + beat * 0.012
[point]
arm = floor(i * ${arms})
fa = frac(i * ${arms})
d = fa
ang = arm * ${rr(rng, 1.2, 1.6)} + d * ${twist} * 3.14159 + t
rad = (0.04 + pow(d, ${dExp}) * 1.15) * (0.7 + bass * 0.5)
x = sin(ang) * rad / aspect
y = cos(ang) * rad
${colorLines(palette, '(0.3 + 0.7 * d) * (0.3 + mid)', `d * ${rr(rng, 2, 5)}`)}
skip = if(below(fa, 0.04), 1, 0)
`
return { style: 'swirl', content }
}
function famKaleido({ rng, name, palette, hueSpeed }: FamilyCtx) {
const segs = ri(rng, 6, 14)
const lFreq = ri(rng, 4, 9)
const content = `${scriptHeader(name, 'lines', rr(rng, 0.82, 0.9), rr(rng, 1.5, 2.4))}
[init]
segs = ${segs}
n = ${ri(rng, 460, 660)}
[frame]
rot = rot + dt * (${rr(rng, 0.05, 0.15)} + mid_att * 0.5)
hue = hue + dt * ${hueSpeed}
flash = flash * 0.85
[beat]
flash = 1
[point]
seg = floor(i * segs)
si = frac(i * segs)
a0 = si * 6.28318 / segs + rot
amp = 0.1 + sv * 0.55 + bass * 0.25 + flash * 0.06
rad = 0.15 + amp * (0.4 + 0.6 * sin(a0 * ${lFreq} + time * 2))
a = seg * 6.28318 / segs + a0 * if(below(seg % 2, 1), 1, -1)
x = sin(a) * rad / aspect
y = cos(a) * rad
${colorLines(palette, '0.3 + amp * 1.3', 'seg * 0.8')}
skip = if(above(si, 0.96), 1, 0)
`
return { style: 'kaleido', content }
}
function famLissajous({ rng, name, palette, hueSpeed }: FamilyCtx) {
const fa0 = ri(rng, 2, 6)
const fb0 = ri(rng, 2, 7)
const phase = rr(rng, 0.5, 3)
const content = `${scriptHeader(name, 'lines', rr(rng, 0.86, 0.93), rr(rng, 1.4, 2.4))}
[init]
n = ${ri(rng, 600, 950)}
[frame]
t = t + dt * ${rr(rng, 0.2, 0.6)}
hue = hue + dt * ${hueSpeed}
[point]
ph = i * 6.28318 * ${rr(rng, 1, 3)}
fa = ${fa0} + mid * 0.35
fb = ${fb0} + bass * 0.45
x = sin(ph * fa + t) * (0.5 + bass * 0.3) / aspect
y = sin(ph * fb + t * 1.3 + ${phase}) * (0.5 + treb * 0.35)
${colorLines(palette, '0.3 + 0.45 * sin(ph * 2 + t) + abs(v) * 0.6', 'ph * 0.5')}
`
return { style: 'lissajous', content }
}
function famSpectrum({ rng, name, palette, hueSpeed }: FamilyCtx) {
const bars = ri(rng, 28, 64)
const inner = rr(rng, 0.1, 0.28)
const height = rr(rng, 0.5, 0.9)
const content = `${scriptHeader(name, pick(rng, ['lines', 'dots'] as const), rr(rng, 0.8, 0.9), rr(rng, 1.4, 2.6))}
[init]
bars = ${bars}
n = bars * ${ri(rng, 8, 14)}
[frame]
rot = rot + dt * (${rr(rng, 0.04, 0.12)} + mid * 0.3)
hue = hue + dt * ${hueSpeed}
[point]
bar = floor(i * bars)
bi = frac(i * bars)
lvl = getspec(bar / bars, 0, 0)
h = lvl * ${height}
a = bar * 6.28318 / bars + rot
rad = ${inner} + bi * h
x = sin(a) * rad / aspect
y = cos(a) * rad
${colorLines(palette, '0.25 + bi * 0.75', 'bar * 6.28318 / bars')}
skip = if(above(bi, 0.97), 1, 0)
`
return { style: 'spectrum bars', content }
}
function famMoire({ rng, name, palette, hueSpeed }: FamilyCtx) {
const f = (): string => rr(rng, 2, 7)
const content = `${scriptHeader(name, 'dots', rr(rng, 0.72, 0.84), rr(rng, 1.8, 2.8))}
[init]
n = ${ri(rng, 1100, 1600)}
[frame]
t = t + dt * ${rr(rng, 0.3, 0.8)}
hue = hue + dt * ${hueSpeed}
[point]
u = rand(i * 1.7) * 2 - 1
w = rand(i * 3.3 + 11) * 2 - 1
m = sin((u * ${f()} + w * ${f()} + t) * 3.14159) * sin((u * ${f()} - w * ${f()} - t * 0.7) * 3.14159)
x = u * ${rr(rng, 0.85, 1.0)} / aspect
y = w * ${rr(rng, 0.85, 1.0)}
lum = max(0, m) * (0.3 + bass * 0.9) + 0.06
${colorLines(palette, 'lum', `u * ${rr(rng, 2, 6)}`)}
size = 1 + abs(m) * ${rr(rng, 2, 4)}
`
return { style: 'moire', content }
}
function famFountain({ rng, name, palette, hueSpeed }: FamilyCtx) {
const jets = ri(rng, 2, 5)
const grav = rr(rng, 0.7, 1.4)
const spread = rr(rng, 0.5, 1.2)
const content = `${scriptHeader(name, 'dots', rr(rng, 0.74, 0.86), rr(rng, 1.8, 2.8))}
[init]
n = ${ri(rng, 750, 1200)}
[frame]
tt = tt + dt * (0.5 + bass_att * 1.1)
hue = hue + dt * ${hueSpeed}
burst = burst * 0.9
[beat]
burst = 1
[point]
jet = floor(i * ${jets})
life = frac(rand(i * 7.7 + jet) + tt * (0.35 + rand(i + 3) * 0.65))
a = jet * 6.28318 / ${jets} + rand(i * 5.1 + jet) * ${spread}
spd = 0.4 + rand(i * 9.2 + jet) * 0.8
px = cos(a) * spd * life
py = sin(a) * spd * life - ${grav} * life * life
x = px * (0.85 + bass * 0.35) / aspect
y = py
${colorLines(palette, 'max(0, 1 - life) * (0.35 + burst * 0.55)', 'life * 4')}
skip = if(above(life, 0.94), 1, 0)
`
return { style: 'fountain', content }
}
function famRose({ rng, name, palette, hueSpeed }: FamilyCtx) {
const k = ri(rng, 2, 7)
const loops = ri(rng, 1, 2)
const content = `${scriptHeader(name, 'lines', rr(rng, 0.84, 0.92), rr(rng, 1.5, 2.6))}
[init]
n = ${ri(rng, 700, 1000)}
[frame]
t = t + dt * (${rr(rng, 0.1, 0.3)} + mid * 0.5)
hue = hue + dt * ${hueSpeed}
flash = flash * 0.87
[beat]
flash = 1
[point]
th = i * 6.28318 * ${loops} + t
r = abs(cos(${k} * th + bass * ${rr(rng, 0.3, 0.9)}))
rad = r * (0.5 + bass * 0.3 + sv * 0.2 + flash * 0.08)
x = sin(th) * rad / aspect
y = cos(th) * rad
${colorLines(palette, '0.25 + 0.65 * r + v * 0.25', 'th * 0.5')}
`
return { style: 'rose', content }
}
function famOrbits({ rng, name, palette, hueSpeed }: FamilyCtx) {
const rings = ri(rng, 3, 7)
const sats = ri(rng, 2, 5)
const squashBase = rr(rng, 0.3, 0.6)
const content = `${scriptHeader(name, 'dots', rr(rng, 0.78, 0.88), rr(rng, 1.8, 3))}
[init]
rings = ${rings}
n = rings * ${ri(rng, 70, 110)}
[frame]
t = t + dt * (${rr(rng, 0.15, 0.4)} + bass_att * 0.6)
hue = hue + dt * ${hueSpeed}
squash = ${squashBase} + 0.25 * sin(t * ${rr(rng, 0.2, 0.5)})
[point]
ring = floor(i * rings)
ri3 = frac(i * rings)
r0 = 0.16 + ring * ${rr(rng, 0.1, 0.16)}
a = ri3 * 6.28318 + t * (1.2 - ring * 0.18) + ring * 2.4
x = sin(a) * r0 * (1 + bass * 0.18) / aspect
y = cos(a) * r0 * squash
sat = frac(ri3 * ${sats})
size = if(below(sat, 0.14), 3 + bass * 3, 1)
${colorLines(palette, '0.25 + bass * 0.5 + ring * 0.07', 'ring * 1.3')}
`
return { style: 'orbits', content }
}
function famRibbon({ rng, name, palette, hueSpeed }: FamilyCtx) {
const loops = ri(rng, 1, 3)
const wFreq = ri(rng, 2, 6)
const content = `${scriptHeader(name, 'lines', rr(rng, 0.88, 0.94), rr(rng, 1.4, 2.4))}
[init]
n = ${ri(rng, 800, 1200)}
[frame]
t = t + dt * (${rr(rng, 0.3, 0.7)} + bass_att * 0.8)
sway = sway + dt * (0.5 + treb)
hue = hue + dt * ${hueSpeed}
[point]
a = i * 6.28318 * ${loops}
wob = sin(a * ${wFreq} + t * 2) * (0.08 + treb * 0.3)
rad = (0.42 + mid * 0.25) * (0.85 + 0.15 * sin(a * 3 + sway)) + v * ${rr(rng, 0.15, 0.45)} - wob
x = sin(a) * rad / aspect
y = cos(a) * rad
${colorLines(palette, '0.3 + abs(v) * 1.2', 'a * 0.5')}
`
return { style: 'ribbon', content }
}
function famWeb({ rng, name, palette, hueSpeed }: FamilyCtx) {
const spokes = ri(rng, 14, 32)
const wFreq = ri(rng, 2, 5)
const content = `${scriptHeader(name, pick(rng, ['lines', 'dots'] as const), rr(rng, 0.8, 0.9), rr(rng, 1.3, 2.2))}
[init]
spokes = ${spokes}
n = spokes * ${ri(rng, 22, 40)}
[frame]
rot = rot + dt * (${rr(rng, 0.06, 0.16)} + mid * 0.4)
t = t + dt * ${rr(rng, 1.5, 3)}
hue = hue + dt * ${hueSpeed}
[point]
sp = floor(i * spokes)
si = frac(i * spokes)
a = sp * 6.28318 / spokes + rot
wave = sin(si * ${wFreq} * 3.14159 - t) * (0.03 + bass * 0.28)
rad = 0.12 + si * ${rr(rng, 0.75, 0.95)} + wave
x = sin(a) * rad / aspect
y = cos(a) * rad
${colorLines(palette, '(1 - si) * 0.45 + 0.15 + sv * 0.55', 'sp * 0.4')}
skip = if(above(si, 0.985), 1, 0)
`
return { style: 'web', content }
}
function famRain({ rng, name, palette, hueSpeed }: FamilyCtx) {
const cols = ri(rng, 40, 70)
const content = `${scriptHeader(name, 'dots', rr(rng, 0.7, 0.82), rr(rng, 1.6, 2.6))}
[init]
cols = ${cols}
n = ${ri(rng, 900, 1400)}
[frame]
fall = fall + dt * (${rr(rng, 0.4, 0.9)} + vol_att * ${rr(rng, 0.8, 1.5)})
hue = hue + dt * ${hueSpeed}
[point]
col = floor(i * cols)
ci = frac(i * cols)
xx = (col / cols - 0.5) * 1.9
yf = frac(rand(col * 3.7 + 1) + fall * (0.55 + rand(col + 5) * 0.5))
tail = rand(col * 1.3 + 9) * ${rr(rng, 0.25, 0.5)} + 0.04
yy = (yf * 2 - 1) * 1.05 + ci * tail
x = xx / aspect
y = yy
${colorLines(palette, '(1 - ci) * (0.2 + treb * 0.85) + 0.04', 'col * 0.3')}
skip = if(above(abs(yy), 1.02), 1, 0)
`
return { style: 'rain', content }
}
function famHelix({ rng, name, palette, hueSpeed }: FamilyCtx) {
const turns = ri(rng, 2, 6)
const content = `${scriptHeader(name, 'lines', rr(rng, 0.84, 0.92), rr(rng, 1.4, 2.4))}
[init]
n = ${ri(rng, 900, 1300)}
[frame]
t = t + dt * (${rr(rng, 0.15, 0.45)} + bass_att * 0.9)
hue = hue + dt * ${hueSpeed}
flash = flash * 0.86
[beat]
flash = 1
[point]
st = floor(i * 2)
si = frac(i * 2)
s = si * ${turns} * 6.28318 + t
ph = st * 3.14159
rad = (0.28 + bass * 0.32 + flash * 0.06) * (0.55 + 0.45 * (cos(s + ph) * 0.5 + 0.5))
x = sin(s + ph) * rad / aspect
y = (si - 0.5) * 1.8
${colorLines(palette, '0.25 + 0.55 * (cos(s + ph) * 0.5 + 0.5) + treb * 0.25', 's * 0.3')}
`
return { style: 'helix', content }
}
const FAMILIES: Record<string, FamilyFn> = {
'pulse rings': famPulseRings,
tunnel: famTunnel,
starfield: famStarfield,
lattice: famLattice,
swirl: famSwirl,
kaleido: famKaleido,
lissajous: famLissajous,
'spectrum bars': famSpectrum,
moire: famMoire,
fountain: famFountain,
rose: famRose,
orbits: famOrbits,
ribbon: famRibbon,
web: famWeb,
rain: famRain,
helix: famHelix,
}
const FAMILY_NAMES = Object.keys(FAMILIES)
/**
* Component → family affinity. The family is picked among the families hinted
* by the original's unportable components (union of all matches), so the
* stand-in's vibe roughly matches the original while same-component presets
* still spread across different families via the name-seeded RNG.
*/
const COMPONENT_FAMILY_HINTS: Array<[RegExp, string[]]> = [
[/blitterfeedback|rotoblitter|buffersave/i, ['tunnel', 'kaleido', 'moire']],
[/dynamicmovement|movement|dynamicshift/i, ['swirl', 'ribbon', 'web', 'moire']],
[/colormap|uniquetone|colorfade|colorclip|fastbright|colormodifier/i, ['spectrum bars', 'moire', 'orbits', 'lissajous']],
[/starfield/i, ['starfield', 'rain', 'helix']],
[/movingparticle|dotfountain/i, ['fountain', 'rain', 'starfield']],
[/water|bump|mosaic/i, ['pulse rings', 'web', 'moire', 'lattice']],
[/dotgrid|grain|scatter/i, ['lattice', 'rain', 'starfield', 'moire']],
[/mirror/i, ['kaleido', 'lissajous', 'rose']],
[/ring|rotstar/i, ['pulse rings', 'orbits', 'rose', 'tunnel']],
[/interference/i, ['moire', 'lissajous']],
[/bassspin/i, ['orbits', 'rose', 'swirl']],
[/oscilloscope|simple|svp/i, ['ribbon', 'lissajous', 'spectrum bars']],
]
function chooseFamilies(components: string[]): string[] {
const hits: string[] = []
for (const [re, fams] of COMPONENT_FAMILY_HINTS) {
if (components.some((c) => re.test(c))) {
for (const f of fams) if (!hits.includes(f)) hits.push(f)
}
}
return hits.length ? hits : FAMILY_NAMES
}
/** Known-good template used if a generated family somehow fails the smoke test. */
function safeFallback(name: string): string {
return `name: ${JSON.stringify(name)}
mode: lines
blend: additive
persist: 0.86
linewidth: 2
[init]
n = 640
[frame]
hue = hue + dt * 0.06 + beat * 0.01
travel = travel + dt * (0.3 + bass_att * 1.4)
flash = flash * 0.86
[beat]
flash = 1
[point]
g = floor(i * 8)
gi = frac(i * 8)
ph = frac(rand(g * 5.1 + 1) + travel * (0.55 + rand(g + 17) * 0.85))
a = gi * 6.28318
rad = ph * (0.24 + bass * 0.6 + flash * 0.08)
x = sin(a) * rad / aspect
y = cos(a) * rad
fade = sin(ph * 3.14159)
red = fade * (0.45 + 0.55 * sin(hue * 6.28318 + g * 0.7))
green = fade * (0.45 + 0.55 * sin(hue * 6.28318 + g * 0.7 + 2.09))
blue = fade * (0.45 + 0.55 * sin(hue * 6.28318 + g * 0.7 + 4.19))
skip = if(above(ph, 0.96), 1, 0)
`
}
/**
* Build the stand-in for one preset. Every parameter is drawn from a PRNG
* seeded by the preset name — the same name always regenerates the same
* visual; different names (even in the same family) look clearly different.
*/
/** Families already used by a stand-in in this converter run (full-coverage rotation). */
const usedStyles = new Set<string>()
function fallbackTemplate(
seed: number,
name: string,
components: string[] = [],
): { content: string; style: string } {
const rng = mulberry32(seed)
// 1) guarantee every family gets used at least once across a run (first
// fallbacks cycle through the unused ones — deterministic, files sorted)
// 2) otherwise mostly honor the component hints (vibe matches the original)
// 3) ~25% wild card so the archetype mix keeps rotating in large libraries
const hinted = chooseFamilies(components)
const unused = FAMILY_NAMES.filter((f) => !usedStyles.has(f))
const pool = unused.length ? unused : rng() < 0.25 ? FAMILY_NAMES : hinted
const style = pick(rng, pool)
usedStyles.add(style)
const palette = pick(rng, PALETTE_NAMES)
const hueSpeed = rr(rng, 0.03, 0.14)
const out = FAMILIES[style]({ rng, name, palette, hueSpeed })
return { content: out.content, style: out.style }
}
// ---------------------------------------------------------------------------
// smoke test — runs the generated script headlessly through the real engine env
// ---------------------------------------------------------------------------
function smokeTest(content: string): { ok: boolean; error?: string; n: number; ms: number } {
const t0 = Date.now()
const v = validateScript(content)
if (!v.ok || !v.script) return { ok: false, error: v.error ? `${v.error.section} line ${v.error.line}: ${v.error.message}` : 'parse failed', n: 0, ms: 0 }
resetEelBuffers()
AVA_HOOKS.spectrumAt = (p) => 0.25 + 0.25 * Math.sin(p * 18 + 1)
AVA_HOOKS.waveAt = (p) => Math.sin(p * 12.56) * 0.8
const env: Record<string, number> = { n: 400, t: 0, time: 0, frame: 0 }
const script = v.script
try {
script.initProg?.exec(env)
} catch (err) {
return { ok: false, error: `init: ${String(err)}`, n: 0, ms: Date.now() - t0 }
}
const n = Math.min(512, Math.max(4, Math.round(env.n ?? 400)))
let nan = 0
try {
for (let f = 0; f < 36; f++) {
env.dt = 1 / 60
env.time = (f + 1) / 60
env.frame = f + 1
AVA_HOOKS.now = () => env.time ?? 0
env.beat = f % 15 === 0 ? 1 : 0
env.bass = 0.3 + 0.3 * Math.sin(f * 0.4)
env.mid = 0.3
env.treb = 0.25
env.bass_att = env.bass
env.mid_att = 0.3
env.treb_att = 0.25
env.vol = 0.4
env.vol_att = 0.4
env.w = 1280
env.h = 720
env.aspect = 1280 / 720
script.frameProg?.exec(env)
if (env.beat) script.beatProg?.exec(env)
for (let k = 0; k < n; k++) {
env.i = k / (n - 1 || 1)
env.v = AVA_HOOKS.waveAt(env.i)
env.sv = AVA_HOOKS.spectrumAt(env.i)
env.x = 0; env.y = 0; env.red = 1; env.green = 1; env.blue = 1; env.skip = 0; env.size = 2; env.alpha = 1
script.pointProg?.exec(env)
if (!Number.isFinite(env.x) || !Number.isFinite(env.y)) nan++
}
}
} catch (err) {
return { ok: false, error: `runtime: ${String(err)}`, n, ms: Date.now() - t0 }
}
const ms = Date.now() - t0
// some presets deliberately NaN points (skip via NaN) — allow up to 40%
if (nan > n * 36 * 0.4) return { ok: false, error: `${nan} NaN points of ${n * 36}`, n, ms }
return { ok: true, n, ms }
}
// ---------------------------------------------------------------------------
// name / author extraction
// ---------------------------------------------------------------------------
function extractNameAuthor(filename: string): { name: string; author: string } {
let base = filename.replace(/\.avs$/i, '')
base = base.replace(/^\d{1,2}\s+/, '') // "01 dynamic duo - ..." numbering
// "Author - Name" (spaces around dash)
const spaced = base.match(/^(.+?)\s+-\s+(.+)$/)
if (spaced) return { author: spaced[1].trim(), name: spaced[2].trim() }
// "Author-Name" (no spaces — e.g. "zxe-Urantia", "Degnic-Adrealine rush")
const tight = base.match(/^([A-Za-z0-9_&']+)-([A-Za-z].+)$/)
if (tight) return { author: tight[1].trim(), name: tight[2].trim() }
return { author: '', name: base }
}
function hashStr(s: string): number {
let h = 2166136261
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return Math.abs(h)
}
// ---------------------------------------------------------------------------
// the converter
// ---------------------------------------------------------------------------
export { parseAvsFile, collect, applyMacros, extractDefines, translateCode, smokeTest, extractNameAuthor, fallbackTemplate, convertOne }
export type { Effect, Candidates, OutputPreset, Macro }
interface OutputPreset {
folder: string
name: string
author: string
description: string
originalFile: string
content: string
/** true = generated stand-in (original relies on unportable effects). */
approx?: boolean
}
function convertOne(file: string, relDir: string, absPath: string): { preset: OutputPreset; status: 'ported' | 'fallback'; note: string } {
const { name, author } = extractNameAuthor(file)
const effects = parseAvsFile(absPath)
const cands: Candidates = { scopes: [], eeltrans: [], gv: [], comment: null, otherKinds: new Set() }
collect(effects, cands)
// relDir already carries the clean tree ("root/embedded/Amp Picks") when the
// input mirrors the drive layout — only prefix when it does not.
const folder = relDir.startsWith('root/') ? relDir : 'root/embedded/' + relDir
const originalFile = `/avs-originals/embedded/${relDir}/${file}`
const desc = (extra: string): string => {
const by = author ? ` by ${author}` : ''
const src = `Auto-ported from the Winamp AVS preset "${file}"${by}.`
return extra ? `${src} ${extra}` : src
}
const makeFallback = (reason: string) => {
const tpl = fallbackTemplate(hashStr(name), name, Array.from(cands.otherKinds))
const smoke = smokeTest(tpl.content)
if (!smoke.ok) {
// should never happen — every family is smoke-tested code; still, never
// emit a broken script.
return {
preset: {
folder,
name,
author,
description: desc(`The original is driven by ${reason}, which the script engine cannot run — replaced with a generated stand-in (beat & band reactive).`),
originalFile,
content: safeFallback(name),
approx: true,
},
status: 'fallback' as const,
note: `${reason} → safe stand-in (family "${tpl.style}" failed smoke: ${smoke.error})`,
}
}
return {
preset: {
folder,
name,
author,
description: desc(`The original is driven by ${reason}, which the script engine cannot run — replaced with a generated "${tpl.style}"-style visual, uniquely parameterized for this preset (beat & band reactive).`),
originalFile,
content: tpl.content,
approx: true,
},
status: 'fallback' as const,
note: `${reason} → ${tpl.style} stand-in`,
}
}
// pick the richest point program
const scored = cands.scopes
.map((s) => ({ s, score: (s.code?.point.length ?? 0) + (s.code?.frame.length ?? 0) * 0.3 }))
.sort((a, b) => b.score - a.score)
if (!scored.length || (scored[0].s.code?.point.length ?? 0) < 12) {
const reason = cands.otherKinds.size
? Array.from(cands.otherKinds).slice(0, 3).join(', ')
: 'no codeable scope'
return makeFallback(reason)
}
const scope = scored[0].s
const code = scope.code!
// reject trivial scopes (e.g. a 2-point checker) — they are not the real
// visual. If init pins a tiny literal n and the frame code never sets n,
// the preset's meat is elsewhere (movements / APEs).
const initNLit = code.init.match(/(^|[^A-Za-z0-9_])n\s*=\s*([0-9.]+)\s*;/)
const frameSetsN = assignsVar(code.frame, 'n')
if (initNLit && !frameSetsN && parseFloat(initNLit[2]) < 8) {
return makeFallback('a trivial scope (the real work is movement/APE driven)')
}
// macro preprocessing (AVSTrans defines + local defines)
const macroSources = [...cands.eeltrans, code.init, code.frame, code.beat, code.point]
const { macros } = extractDefines(macroSources)
let init = code.init
let frame = code.frame
let beat = code.beat
let point = code.point
let gvNote = ''
if (macros.length) {
init = applyMacros(init, macros)
frame = applyMacros(frame, macros)
beat = applyMacros(beat, macros)
point = applyMacros(point, macros)
}
// merge Jheriko Global register-setters as a preamble (sets reg00=dt etc.)
if (cands.gv.length) {
const gv = cands.gv[0]
const gvi = stripDirectives(applyMacros(gv.init, macros))
const gvf = stripDirectives(applyMacros(gv.frame, macros))
const gvb = stripDirectives(applyMacros(gv.beat, macros))
if (gvi.trim()) init = `${gvi}\n${init}`
if (gvf.trim()) frame = `${gvf}\n${frame}`
if (gvb.trim()) beat = `${gvb}\n${beat}`
gvNote = ' + global register code'
}
init = stripDirectives(init)
frame = stripDirectives(frame)
beat = stripDirectives(beat)
point = stripDirectives(point)
// token rewrites
const spectrum = scope.spectrumSource === true
init = translateCode(init, spectrum)
frame = translateCode(frame, spectrum)
beat = translateCode(beat, spectrum)
point = translateCode(point, spectrum)
// AVS `t` is auto-advancing time — if the code never assigns t, map reads to `time`
const allCode = `${init}\n${frame}\n${beat}\n${point}`
if (!assignsVar(allCode, 't')) {
init = init.replace(/\bt\b/g, 'time')
frame = frame.replace(/\bt\b/g, 'time')
beat = beat.replace(/\bt\b/g, 'time')
point = point.replace(/\bt\b/g, 'time')
}
// default n if never set
if (!assignsVar(allCode, 'n')) init = `n = 400\n${init}`
// color cycling fallback when the point code never sets colors
const setsColors = assignsVar(point, 'red') || assignsVar(point, 'green') || assignsVar(point, 'blue')
let colorFrame = ''
let colorPoint = ''
if (!setsColors && scope.colors && scope.colors.length) {
const cols = scope.colors.map((c) => ({
r: ((c >> 16) & 255) / 255,
g: ((c >> 8) & 255) / 255,
b: (c & 255) / 255,
}))
const gen = cols
.map((c, i) => `if(avsi==${i}, avsr=${c.r.toFixed(3)}; avsg=${c.g.toFixed(3)}; avsb=${c.b.toFixed(3)};)`)
.join('\n')
colorFrame = `avsc = avsc + 1;\navsp = avsc % ${cols.length * 64};\navsi = floor(avsp / 64);\navsf = (avsp % 64) / 64;\n${gen}`
const c1 = cols[0]
colorFrame += `\nif(avsi == ${cols.length - 1}, avsr2=${c1.r.toFixed(3)}; avsg2=${c1.g.toFixed(3)}; avsb2=${c1.b.toFixed(3)};)`
for (let i = 0; i < cols.length - 1; i++) {
colorFrame += `\nif(avsi == ${i}, avsr2=${cols[i + 1].r.toFixed(3)}; avsg2=${cols[i + 1].g.toFixed(3)}; avsb2=${cols[i + 1].b.toFixed(3)};)`
}
colorFrame += `\navsr = avsr * (1 - avsf) + avsr2 * avsf;\navsg = avsg * (1 - avsf) + avsg2 * avsf;\navsb = avsb * (1 - avsf) + avsb2 * avsf;`
colorPoint = 'red = avsr;\ngreen = avsg;\nblue = avsb;'
} else if (!setsColors) {
colorFrame = 'avsh = avsh + dt * 0.15;'
colorPoint = 'red = 0.55 + 0.45 * sin(avsh * 6.28318);\ngreen = 0.55 + 0.45 * sin(avsh * 6.28318 + 2.09);\nblue = 0.55 + 0.45 * sin(avsh * 6.28318 + 4.19);'
}
const mode = scope.kind === 'texer2' ? 'dots' : scope.drawMode === 0 ? 'dots' : 'lines'
const linewidth = mode === 'dots' ? 3 : 2
const content = `# Auto-ported from "${file}" (Winamp AVS${author ? ` / ${author}` : ''})
# ${scope.kind === 'texer2' ? 'Texer II' : 'SuperScope'} point program${gvNote}${spectrum ? ', spectrum source' : ''}${macros.length ? `, ${macros.length} AVSTrans macros expanded` : ''}.
name: ${JSON.stringify(name)}
author: ${JSON.stringify(author)}
mode: ${mode}
blend: additive
persist: ${mode === 'dots' ? 0.78 : 0.88}
linewidth: ${linewidth}
ysign: -1
[init]
${init.trim() || 'n = 400'}
[frame]
${[frame.trim(), colorFrame].filter(Boolean).join('\n')}
[beat]
${beat.trim() || 'flash = 1'}
[point]
${[point.trim(), colorPoint].filter(Boolean).join('\n')}
`
const smoke = smokeTest(content)
if (!smoke.ok) {
return makeFallback(`unportable scope code (${smoke.error})`)
}
// a "ported" scope that renders a handful of points is not the real visual
// (the meat is in movements/APEs) — those presets get stand-ins too.
if (smoke.n < 16) {
return makeFallback(`a trivial scope (n=${smoke.n}; the real work is movement/APE driven)`)
}
const perf = smoke.ms > 900 ? ' (heavy script — point count clamped)' : ''
if (smoke.ms > 900) {
// clamp n for heavy scripts so the browser keeps 60fps
const patched = content.replace(/\[init\]\n/, `[init]\nn = if(above(n, 2048), 2048, n)\n`)
const recheck = smokeTest(patched)
if (recheck.ok) {
return {
preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.${perf}`), originalFile, content: patched, approx: false },
status: 'ported',
note: `ok (n clamped, ${smoke.ms}ms → ${recheck.ms}ms)`,
}
}
}
return {
preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.`), originalFile, content, approx: false },
status: 'ported',
note: `ok (${smoke.ms}ms, n=${smoke.n})`,
}
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
const inputRoot = process.argv[2] ?? '/tmp/avsdrive/raw'
const outDir = 'tools/converted'
const originalsDir = 'public/avs-originals/embedded'
/** Bump when regenerating — written into embedded-presets.ts for the seed check. */
const SEED_VERSION = '6.1'
const SKIP_FOLDERS = new Set(['Synth-C']) // hand-adapted versions ship in BUILTIN_PRESETS
function walk(dir: string): string[] {
const out: string[] = []
for (const entry of readdirSync(dir)) {
const p = join(dir, entry)
if (statSync(p).isDirectory()) {
if (SKIP_FOLDERS.has(entry)) continue
out.push(...walk(p))
} else if (entry.toLowerCase().endsWith('.avs')) out.push(p)
}
return out
}
const isMain = typeof process !== 'undefined' && process.argv[1] && process.argv[1].includes('convert-avs')
if (!isMain) {
// imported as a library — skip main
} else {
main()
}
function main() {
const files = walk(inputRoot).sort()
console.log(`converting ${files.length} .avs files from ${inputRoot}`)
const results: OutputPreset[] = []
const report: Array<{ file: string; status: string; note: string }> = []
let ported = 0
let fallback = 0
for (const abs of files) {
const rel = relative(inputRoot, abs)
const relDir = dirname(rel) === '.' ? '' : dirname(rel)
const file = basename(rel)
try {
const r = convertOne(file, relDir, abs)
results.push(r.preset)
report.push({ file: rel, status: r.status, note: r.note })
if (r.status === 'ported') ported++
else fallback++
console.log(`${r.status === 'ported' ? '✔' : '○'} ${rel} — ${r.note}`)
// keep a copy of the generated .ava for reference
const destAva = join(outDir, relDir, file.replace(/\.avs$/i, '.ava'))
mkdirSync(dirname(destAva), { recursive: true })
writeFileSync(destAva, r.preset.content)
// copy the original binary (skip when re-running in-place — src IS the dest)
const destOrig = join(originalsDir, relDir, file)
mkdirSync(dirname(destOrig), { recursive: true })
if (resolve(abs) !== resolve(destOrig)) copyFileSync(abs, destOrig)
} catch (err) {
report.push({ file: rel, status: 'error', note: String(err) })
fallback++
console.log(`✘ ${rel} — ${String(err)}`)
}
}
// emit the TS module
const seen = new Set<string>()
for (const p of results) {
const key = `${p.folder}//${p.name}`
if (seen.has(key)) console.log(`!! duplicate name in folder: ${key}`)
seen.add(key)
}
const tsHeader = `// AVA — embedded preset library (AUTO-GENERATED by tools/convert-avs.ts — do not edit)
//
// ${results.length} Winamp AVS presets from the user's Google Drive collection,
// auto-ported to the AVA script dialect by the converter. Ported presets run
// the original SuperScope / Texer II code (machine-translated); approx
// presets are UNIQUE generated stand-ins (16 families × seeded parameters)
// for originals that rely on effects the script engine cannot run.
//
// EMBEDDED_SEED_VERSION: bump whenever this library is regenerated — the
// GET /api/presets route re-seeds the root/embedded subtree when it changes.
export const EMBEDDED_SEED_VERSION = '${SEED_VERSION}'
export interface EmbeddedPreset {
folder: string
name: string
author: string
description: string
originalFile?: string
content: string
/** true = generated stand-in (original relies on unportable effects). */
approx?: boolean
}
`
const lines: string[] = []
for (const p of results) {
lines.push(` {\n folder: ${JSON.stringify(p.folder)},\n name: ${JSON.stringify(p.name)},\n author: ${JSON.stringify(p.author)},\n description: ${JSON.stringify(p.description)},\n originalFile: ${JSON.stringify(p.originalFile)},\n approx: ${p.approx === true},\n content: ${JSON.stringify(p.content)},\n },`)
}
const ts = tsHeader + `export const EMBEDDED_LIBRARY: EmbeddedPreset[] = [\n${lines.join('\n')}\n]\n`
writeFileSync('src/lib/ava/embedded-presets.ts', ts)
console.log(`\nDone: ${ported} ported, ${fallback} fallback, ${results.length} total`)
console.log(`→ src/lib/ava/embedded-presets.ts (${(ts.length / 1024).toFixed(0)} KB)`)
console.log(`→ ${originalsDir}/ (${results.length} .avs originals)`)
writeFileSync('tools/convert-report.json', JSON.stringify(report, null, 1))
}