1514 lines
52 KiB
TypeScript
Executable file
1514 lines
52 KiB
TypeScript
Executable file
// 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))
|
||
}
|