1039 lines
No EOL
44 KiB
Text
1039 lines
No EOL
44 KiB
Text
1→// AVA — .avs (Winamp AVS binary preset) → .ava auto-converter
|
|
2→//
|
|
3→// Parses the legacy .avs binary format (reverse-engineered against the
|
|
4→// vis_avs reference source), extracts the primary SuperScope / Texer II
|
|
5→// point program plus any AVSTrans #define macros and Jheriko Global
|
|
6→// register-setter code, translates it to the AVA script dialect, validates
|
|
7→// and smoke-runs every generated script through the real engine parser, and
|
|
8→// emits src/lib/ava/embedded-presets.ts.
|
|
9→//
|
|
10→// Run: bun tools/convert-avs.ts [inputRoot] (inputRoot defaults to /tmp/avsdrive/raw)
|
|
11→//
|
|
12→// Format reference (per grandchild/vis_avs source):
|
|
13→// magic "Nullsoft AVS Preset 0.2\x1a" (24 bytes)
|
|
14→// byte clearFlag
|
|
15→// effects: [int32 id] [if id>=0x4000: 32-byte apeId] [int32 len] [blob]
|
|
16→// id 36 SuperScope: [byte ver==1][point][frame][beat][init][int chsrc]
|
|
17→// [int ncolors][colors...][int drawmode]
|
|
18→// id -2 EffectList: [byte mode0|0x80][byte mode0][byte in][byte out]
|
|
19→// [byte extsize][ext ints while pos<extsize+5][effects...]
|
|
20→// child apeId "AVS 2.8+ Effect List Config" =
|
|
21→// [int useCode][init][frame]
|
|
22→// ape "Acko.net: Texer II": [int ver][260b image][int resize][int wrap]
|
|
23→// [int colorize][int unused][init][frame][beat][point]
|
|
24→// ape "Misc: AVSTrans Automation": [int x4][null-term code]
|
|
25→// ape "Jheriko: Global": [int loadTime][24b pad][nt init][nt frame][nt beat]
|
|
26→// id 21 Comment: [string]
|
|
27→// strings: [int32 len][len bytes] (len includes the null when present;
|
|
28→// empty string = length 0)
|
|
29→
|
|
30→import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs'
|
|
31→import { join, basename, dirname, relative } from 'node:path'
|
|
32→import { validateScript } from '../src/lib/ava/script'
|
|
33→import { AVA_HOOKS, resetEelBuffers } from '../src/lib/ava/expression'
|
|
34→
|
|
35→// ---------------------------------------------------------------------------
|
|
36→// binary reader
|
|
37→// ---------------------------------------------------------------------------
|
|
38→
|
|
39→class Reader {
|
|
40→ pos = 0
|
|
41→ constructor(readonly data: Uint8Array) {}
|
|
42→ get remaining(): number {
|
|
43→ return this.data.length - this.pos
|
|
44→ }
|
|
45→ int32(): number {
|
|
46→ if (this.remaining < 4) throw new Error('out of bytes (int32)')
|
|
47→ const v = this.data[this.pos] | (this.data[this.pos + 1] << 8) | (this.data[this.pos + 2] << 16) | (this.data[this.pos + 3] << 24)
|
|
48→ this.pos += 4
|
|
49→ return v
|
|
50→ }
|
|
51→ byte(): number {
|
|
52→ if (this.remaining < 1) throw new Error('out of bytes (byte)')
|
|
53→ return this.data[this.pos++]
|
|
54→ }
|
|
55→ bytes(n: number): Uint8Array {
|
|
56→ if (n < 0 || this.remaining < n) throw new Error('out of bytes')
|
|
57→ const out = this.data.subarray(this.pos, this.pos + n)
|
|
58→ this.pos += n
|
|
59→ return out
|
|
60→ }
|
|
61→ /** Length-prefixed eel string: [int len][len bytes], cut at first null. */
|
|
62→ lpString(): string {
|
|
63→ const len = this.int32()
|
|
64→ if (len <= 0) return ''
|
|
65→ return latin1(this.bytes(len)).split('\0')[0]
|
|
66→ }
|
|
67→ /** Null-terminated string occupying the rest of the blob. */
|
|
68→ ntRest(): string {
|
|
69→ return latin1(this.bytes(this.remaining)).split('\0')[0]
|
|
70→ }
|
|
71→}
|
|
72→
|
|
73→function latin1(b: Uint8Array): string {
|
|
74→ let s = ''
|
|
75→ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
|
|
76→ return s
|
|
77→}
|
|
78→
|
|
79→// ---------------------------------------------------------------------------
|
|
80→// effect tree
|
|
81→// ---------------------------------------------------------------------------
|
|
82→
|
|
83→const ID_SUPERSCOPE = 36
|
|
84→const ID_COMMENT = 21
|
|
85→const ID_EFFECTLIST = -2
|
|
86→const APE_TEXER2 = 'Acko.net: Texer II'
|
|
87→const APE_EELTRANS = 'Misc: AVSTrans Automation'
|
|
88→const APE_GLOBALVARS = 'Jheriko: Global'
|
|
89→
|
|
90→interface Effect {
|
|
91→ id: number
|
|
92→ apeId: string | null
|
|
93→ kind: 'superscope' | 'texer2' | 'effectlist' | 'eeltrans' | 'globalvars' | 'comment' | 'other'
|
|
94→ enabled: boolean
|
|
95→ // superscope / texer2
|
|
96→ code?: { init: string; frame: string; beat: string; point: string }
|
|
97→ spectrumSource?: boolean
|
|
98→ drawMode?: number // 0 dots, 1 lines
|
|
99→ colors?: number[] // 0xRRGGBB
|
|
100→ // eeltrans / globalvars
|
|
101→ transCode?: string
|
|
102→ gv?: { init: string; frame: string; beat: string }
|
|
103→ // comment
|
|
104→ comment?: string
|
|
105→ children?: Effect[]
|
|
106→}
|
|
107→
|
|
108→function parseEffectListBody(blob: Uint8Array): { enabled: boolean; children: Effect[] } {
|
|
109→ const r = new Reader(blob)
|
|
110→ let enabled = true
|
|
111→ let ext = 5
|
|
112→ const first = r.byte()
|
|
113→ enabled = (first & 0b10) === 0
|
|
114→ if (first & 0x80) {
|
|
115→ r.byte() // mode0 copy
|
|
116→ r.byte() // input blend
|
|
117→ r.byte() // output blend
|
|
118→ ext = r.byte() + 5
|
|
119→ }
|
|
120→ // extended config ints while pos < ext
|
|
121→ while (r.pos < ext && r.remaining >= 4) {
|
|
122→ const v = r.int32()
|
|
123→ if (r.pos > ext - 4) break
|
|
124→ void v
|
|
125→ }
|
|
126→ const children: Effect[] = []
|
|
127→ while (r.remaining > 8) {
|
|
128→ const id = r.int32()
|
|
129→ let apeId: string | null = null
|
|
130→ if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0]
|
|
131→ const len = r.int32()
|
|
132→ if (len < 0 || len > r.remaining) break
|
|
133→ const childBlob = r.bytes(len)
|
|
134→ // special case: the v2.81+ codeable EffectList's own init/frame code
|
|
135→ // section, stored as a child entry with the Effect List APE id.
|
|
136→ if (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config') {
|
|
137→ const cr = new Reader(childBlob)
|
|
138→ if (cr.remaining >= 4) cr.int32() // use_code
|
|
139→ const gv = { init: cr.lpString(), frame: cr.lpString(), beat: '' }
|
|
140→ if (gv.init.trim() || gv.frame.trim()) {
|
|
141→ children.push({ id, apeId, kind: 'globalvars', enabled: true, gv })
|
|
142→ }
|
|
143→ continue
|
|
144→ }
|
|
145→ children.push(parseEffect(id, apeId, childBlob))
|
|
146→ }
|
|
147→ return { enabled, children }
|
|
148→}
|
|
149→
|
|
150→function parseEffect(id: number, apeId: string | null, blob: Uint8Array): Effect {
|
|
151→ const base = { id, apeId, enabled: true } as Effect
|
|
152→ if (id === ID_SUPERSCOPE) {
|
|
153→ const r = new Reader(blob)
|
|
154→ const code = { init: '', frame: '', beat: '', point: '' }
|
|
155→ let spectrumSource = false
|
|
156→ let drawMode = 1
|
|
157→ const colors: number[] = []
|
|
158→ const ver = r.byte()
|
|
159→ if (ver === 1) {
|
|
160→ code.point = r.lpString()
|
|
161→ code.frame = r.lpString()
|
|
162→ code.beat = r.lpString()
|
|
163→ code.init = r.lpString()
|
|
164→ } else {
|
|
165→ // ancient fixed 1024-byte block
|
|
166→ if (blob.length >= 1025) {
|
|
167→ const buf = latin1(blob.subarray(1, 1025))
|
|
168→ code.point = buf.slice(0, 256).split('\0')[0]
|
|
169→ code.frame = buf.slice(256, 512).split('\0')[0]
|
|
170→ code.beat = buf.slice(512, 768).split('\0')[0]
|
|
171→ code.init = buf.slice(768, 1024).split('\0')[0]
|
|
172→ }
|
|
173→ }
|
|
174→ if (r.remaining >= 4) {
|
|
175→ const chsrc = r.int32()
|
|
176→ spectrumSource = (chsrc & 0b100) !== 0
|
|
177→ if (r.remaining >= 4) {
|
|
178→ const ncolors = r.int32()
|
|
179→ if (ncolors <= 16) {
|
|
180→ for (let i = 0; i < ncolors && r.remaining >= 4; i++) colors.push(r.int32() >>> 0)
|
|
181→ }
|
|
182→ if (r.remaining >= 4) drawMode = r.int32()
|
|
183→ }
|
|
184→ }
|
|
185→ return { ...base, kind: 'superscope', code, spectrumSource, drawMode, colors }
|
|
186→ }
|
|
187→ if (id >= 0x4000 && apeId === APE_TEXER2) {
|
|
188→ const r = new Reader(blob)
|
|
189→ r.int32() // version
|
|
190→ r.bytes(260) // image path
|
|
191→ r.int32() // resize
|
|
192→ r.int32() // wrap
|
|
193→ r.int32() // colorize
|
|
194→ r.int32() // unused
|
|
195→ const code = { init: r.lpString(), frame: r.lpString(), beat: r.lpString(), point: r.lpString() }
|
|
196→ return { ...base, kind: 'texer2', code, spectrumSource: false, drawMode: 0, colors: [] }
|
|
197→ }
|
|
198→ if (id === ID_EFFECTLIST || (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config')) {
|
|
199→ const { enabled, children } = parseEffectListBody(blob)
|
|
200→ return { ...base, kind: 'effectlist', enabled, children }
|
|
201→ }
|
|
202→ if (id >= 0x4000 && apeId === APE_EELTRANS) {
|
|
203→ const r = new Reader(blob)
|
|
204→ if (r.remaining >= 16) {
|
|
205→ r.int32(); r.int32(); r.int32(); r.int32()
|
|
206→ return { ...base, kind: 'eeltrans', transCode: r.ntRest() }
|
|
207→ }
|
|
208→ return { ...base, kind: 'other' }
|
|
209→ }
|
|
210→ if (id >= 0x4000 && apeId === APE_GLOBALVARS) {
|
|
211→ const r = new Reader(blob)
|
|
212→ if (r.remaining >= 28) {
|
|
213→ r.int32() // load time
|
|
214→ r.bytes(24) // unused
|
|
215→ const gv = { init: ntAt(r), frame: ntAt(r), beat: ntAt(r) }
|
|
216→ return { ...base, kind: 'globalvars', gv }
|
|
217→ }
|
|
218→ return { ...base, kind: 'other' }
|
|
219→ }
|
|
220→ if (id === ID_COMMENT) {
|
|
221→ const r = new Reader(blob)
|
|
222→ return { ...base, kind: 'comment', comment: r.lpString() }
|
|
223→ }
|
|
224→ return { ...base, kind: 'other' }
|
|
225→}
|
|
226→
|
|
227→function ntAt(r: Reader): string {
|
|
228→ // null-terminated string starting at reader position (within the blob)
|
|
229→ const start = r.pos
|
|
230→ const d = r.data
|
|
231→ let end = start
|
|
232→ while (end < d.length && d[end] !== 0) end++
|
|
233→ r.pos = Math.min(end + 1, d.length)
|
|
234→ return latin1(d.subarray(start, end))
|
|
235→}
|
|
236→
|
|
237→function parseAvsFile(path: string): Effect[] {
|
|
238→ const data = new Uint8Array(readFileSync(path))
|
|
239→ const magic = 'Nullsoft AVS Preset 0.2\x1a'
|
|
240→ const head = latin1(data.subarray(0, magic.length))
|
|
241→ if (head !== magic) throw new Error(`bad magic: ${JSON.stringify(head.slice(0, 30))}`)
|
|
242→ const r = new Reader(data.subarray(magic.length))
|
|
243→ r.byte() // clear flag
|
|
244→ const effects: Effect[] = []
|
|
245→ while (r.remaining > 8) {
|
|
246→ const id = r.int32()
|
|
247→ let apeId: string | null = null
|
|
248→ if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0]
|
|
249→ const len = r.int32()
|
|
250→ if (len < 0 || len > r.remaining) break
|
|
251→ effects.push(parseEffect(id, apeId, r.bytes(len)))
|
|
252→ }
|
|
253→ return effects
|
|
254→}
|
|
255→
|
|
256→// ---------------------------------------------------------------------------
|
|
257→// AVSTrans #define macro preprocessor
|
|
258→// ---------------------------------------------------------------------------
|
|
259→
|
|
260→interface Macro {
|
|
261→ name: string
|
|
262→ params: string[] | null // null = object-like
|
|
263→ body: string
|
|
264→}
|
|
265→
|
|
266→/** AVSTrans built-in constants (available even without an AVSTrans module). */
|
|
267→const AVSTRANS_BUILTINS: Macro[] = [
|
|
268→ { name: '$pi', params: null, body: '3.14159265358979' },
|
|
269→ { name: '$twopi', params: null, body: '6.28318530717959' },
|
|
270→ { name: '$e', params: null, body: '2.71828182845905' },
|
|
271→ { name: '$phi', params: null, body: '1.61803398874989' },
|
|
272→]
|
|
273→
|
|
274→function extractDefines(sources: string[]): { macros: Macro[]; failNote: string | null } {
|
|
275→ const macros: Macro[] = []
|
|
276→ let failNote: string | null = null
|
|
277→ const defineRe = /^[ \t]*#define\s+(.+)$/gm
|
|
278→ for (const src of sources) {
|
|
279→ if (!src.includes('#define')) continue
|
|
280→ // join backslash line-continuations first
|
|
281→ const joined = src.replace(/\\\r?\n/g, ' ')
|
|
282→ const matches = joined.match(defineRe) ?? []
|
|
283→ for (const m of matches) {
|
|
284→ const rest = m.replace(/^[ \t]*#define\s+/, '').trim()
|
|
285→ const fn = rest.match(/^([A-Za-z0-9_$]+)\s*\(([^)]*)\)\s*([\s\S]*)$/)
|
|
286→ if (fn) {
|
|
287→ const params = fn[2].split(',').map((p) => p.trim()).filter(Boolean)
|
|
288→ macros.push({ name: fn[1], params, body: fn[3] })
|
|
289→ } else {
|
|
290→ const obj = rest.match(/^([A-Za-z0-9_$]+)\s+([\s\S]*)$/)
|
|
291→ if (obj) macros.push({ name: obj[1], params: null, body: obj[2] })
|
|
292→ else failNote = 'unparsed #define'
|
|
293→ }
|
|
294→ }
|
|
295→ }
|
|
296→ // user defines override the AVSTrans built-ins
|
|
297→ const byName = new Map(macros.map((m) => [m.name, m]))
|
|
298→ for (const b of AVSTRANS_BUILTINS) if (!byName.has(b.name)) macros.push(b)
|
|
299→ return { macros, failNote }
|
|
300→}
|
|
301→
|
|
302→/** Split a macro CALL starting at `openPos` (must be '('). */
|
|
303→function readCallArgs(src: string, openPos: number): { args: string[]; end: number } | null {
|
|
304→ if (src[openPos] !== '(') return null
|
|
305→ let depth = 0
|
|
306→ let start = openPos + 1
|
|
307→ const args: string[] = []
|
|
308→ for (let i = openPos; i < src.length; i++) {
|
|
309→ const c = src[i]
|
|
310→ if (c === '(') depth++
|
|
311→ else if (c === ')') {
|
|
312→ depth--
|
|
313→ if (depth === 0) {
|
|
314→ args.push(src.slice(start, i))
|
|
315→ return { args, end: i + 1 }
|
|
316→ }
|
|
317→ } else if (c === ',' && depth === 1) {
|
|
318→ args.push(src.slice(start, i))
|
|
319→ start = i + 1
|
|
320→ }
|
|
321→ }
|
|
322→ return null
|
|
323→}
|
|
324→
|
|
325→function applyMacros(code: string, macros: Macro[], depth = 0): string {
|
|
326→ if (depth > 24 || !macros.length) return code
|
|
327→ let out = code
|
|
328→ let changed = false
|
|
329→ for (const macro of macros) {
|
|
330→ const re = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.name)}($|[^A-Za-z0-9_(]|\\()`, 'g')
|
|
331→ let result = ''
|
|
332→ let last = 0
|
|
333→ let m: RegExpExecArray | null
|
|
334→ let macroChanged = false
|
|
335→ while ((m = re.exec(out))) {
|
|
336→ const at = m.index + m[1].length
|
|
337→ // skip #define lines themselves
|
|
338→ const lineStart = out.lastIndexOf('\n', at) + 1
|
|
339→ const lineHead = out.slice(lineStart, at).trimStart()
|
|
340→ if (lineHead.startsWith('#')) continue
|
|
341→ if (macro.params) {
|
|
342→ const call = readCallArgs(out, at + macro.name.length)
|
|
343→ if (!call || call.args.length !== macro.params.length) continue
|
|
344→ let body = macro.body
|
|
345→ // token-concat ##param## first, then plain params
|
|
346→ for (let i = 0; i < macro.params.length; i++) {
|
|
347→ body = body.split(`##${macro.params[i]}##`).join(call.args[i].trim())
|
|
348→ }
|
|
349→ for (let i = 0; i < macro.params.length; i++) {
|
|
350→ body = body.replace(new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.params[i])}($|[^A-Za-z0-9_])`, 'g'),
|
|
351→ (_s, pre, post) => `${pre}(${call.args[i].trim()})${post}`)
|
|
352→ }
|
|
353→ result += out.slice(last, at) + `(${body})`
|
|
354→ last = call.end
|
|
355→ } else {
|
|
356→ result += out.slice(last, at) + `(${macro.body})`
|
|
357→ last = at + macro.name.length
|
|
358→ }
|
|
359→ macroChanged = true
|
|
360→ re.lastIndex = last
|
|
361→ }
|
|
362→ if (macroChanged) {
|
|
363→ result += out.slice(last)
|
|
364→ out = result
|
|
365→ changed = true
|
|
366→ }
|
|
367→ }
|
|
368→ if (changed) return applyMacros(out, macros, depth + 1)
|
|
369→ return out
|
|
370→}
|
|
371→
|
|
372→function escapeRe(s: string): string {
|
|
373→ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
374→}
|
|
375→
|
|
376→function stripDirectives(code: string): string {
|
|
377→ return code
|
|
378→ .split('\n')
|
|
379→ .filter((l) => !/^\s*#/.test(l))
|
|
380→ .join('\n')
|
|
381→}
|
|
382→
|
|
383→// ---------------------------------------------------------------------------
|
|
384→// code translation
|
|
385→// ---------------------------------------------------------------------------
|
|
386→
|
|
387→function translateCode(code: string, spectrumSource: boolean): string {
|
|
388→ let out = code
|
|
389→ // linesize -> size (AVS per-point line width)
|
|
390→ out = out.replace(/\blinesize\b/g, 'size')
|
|
391→ // AVS rand(n) returns an integer 0..n-1 — our deterministic rand(seed) differs
|
|
392→ out = out.replace(/\brand\b/g, 'rnd')
|
|
393→ // spectrum-source scopes: v should read the spectrum (AVS: byte/128-1)
|
|
394→ if (spectrumSource) out = out.replace(/\bv\b/g, '(sv*2-1)')
|
|
395→ return out
|
|
396→}
|
|
397→
|
|
398→function assignsVar(code: string, name: string): boolean {
|
|
399→ return new RegExp(`(^|[^A-Za-z0-9_])${name}\\s*=[^=]`).test(code)
|
|
400→}
|
|
401→
|
|
402→// ---------------------------------------------------------------------------
|
|
403→// candidate collection
|
|
404→// ---------------------------------------------------------------------------
|
|
405→
|
|
406→interface Candidates {
|
|
407→ scopes: Array<Effect & { kind: 'superscope' | 'texer2' }>
|
|
408→ eeltrans: string[]
|
|
409→ gv: Array<{ init: string; frame: string; beat: string }>
|
|
410→ comment: string | null
|
|
411→ otherKinds: Set<string>
|
|
412→}
|
|
413→
|
|
414→function collect(effects: Effect[], into: Candidates): void {
|
|
415→ for (const e of effects) {
|
|
416→ if (!e.enabled) continue
|
|
417→ switch (e.kind) {
|
|
418→ case 'superscope':
|
|
419→ case 'texer2':
|
|
420→ into.scopes.push(e as Effect & { kind: 'superscope' | 'texer2' })
|
|
421→ break
|
|
422→ case 'eeltrans':
|
|
423→ if (e.transCode) into.eeltrans.push(e.transCode)
|
|
424→ break
|
|
425→ case 'globalvars':
|
|
426→ if (e.gv) into.gv.push(e.gv)
|
|
427→ break
|
|
428→ case 'comment':
|
|
429→ if (!into.comment && e.comment) into.comment = e.comment
|
|
430→ break
|
|
431→ case 'effectlist':
|
|
432→ if (e.children) collect(e.children, into)
|
|
433→ break
|
|
434→ default:
|
|
435→ into.otherKinds.add(describeEffect(e))
|
|
436→ }
|
|
437→ }
|
|
438→}
|
|
439→
|
|
440→const BUILTIN_NAMES: Record<number, string> = {
|
|
441→ 0: 'Simple', 1: 'DotPlane', 2: 'OscilloscopeStar', 3: 'Fadeout', 4: 'BlitterFeedback',
|
|
442→ 5: 'OnBeatClear', 6: 'Blur', 7: 'BassSpin', 8: 'MovingParticle', 9: 'RotoBlitter', 10: 'SVP',
|
|
443→ 11: 'Colorfade', 12: 'ColorClip', 13: 'RotStar', 14: 'Ring', 15: 'Movement', 16: 'Scatter',
|
|
444→ 17: 'DotGrid', 18: 'BufferSave', 19: 'DotFountain', 20: 'Water', 22: 'Brightness', 23: 'Interleave',
|
|
445→ 24: 'Grain', 25: 'ClearScreen', 26: 'Mirror', 27: 'Starfield', 29: 'Bump', 30: 'Mosaic',
|
|
446→ 31: 'WaterBump', 32: 'Video', 33: 'CustomBPM', 34: 'Picture', 35: 'DynamicDistanceModifier',
|
|
447→ 37: 'Invert', 38: 'UniqueTone', 39: 'Timescope', 40: 'SetRenderMode', 41: 'Interferences',
|
|
448→ 42: 'DynamicShift', 43: 'DynamicMovement', 44: 'FastBright', 45: 'ColorModifier',
|
|
449→}
|
|
450→
|
|
451→function describeEffect(e: Effect): string {
|
|
452→ if (e.apeId) return e.apeId
|
|
453→ return BUILTIN_NAMES[e.id] ?? `effect#${e.id}`
|
|
454→}
|
|
455→
|
|
456→// ---------------------------------------------------------------------------
|
|
457→// fallback templates (for presets whose visuals are movement/APE driven)
|
|
458→// ---------------------------------------------------------------------------
|
|
459→
|
|
460→function fallbackTemplate(seed: number, name: string): { content: string; style: string } {
|
|
461→ const styles = [
|
|
462→ {
|
|
463→ style: 'pulse rings',
|
|
464→ mode: 'lines',
|
|
465→ content: `name: ${JSON.stringify(name)}
|
|
466→mode: lines
|
|
467→blend: additive
|
|
468→persist: 0.88
|
|
469→linewidth: 2
|
|
470→
|
|
471→[init]
|
|
472→n = 720
|
|
473→
|
|
474→[frame]
|
|
475→hue = hue + dt * (0.06 + mid * 0.2)
|
|
476→travel = travel + dt * (0.3 + bass_att * 1.4)
|
|
477→
|
|
478→[beat]
|
|
479→flash = 1
|
|
480→
|
|
481→[point]
|
|
482→g = floor(i * 9)
|
|
483→gi = frac(i * 9)
|
|
484→ph = frac(rand(g * 5.1 + 1) + travel * (0.6 + rand(g + 17) * 0.8))
|
|
485→a = gi * 6.28318
|
|
486→rad = ph * (0.25 + bass * 0.6 + flash * 0.08)
|
|
487→x = sin(a) * rad / aspect
|
|
488→y = cos(a) * rad
|
|
489→fade = sin(ph * 3.14159)
|
|
490→red = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7))
|
|
491→green = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7 + 2.09))
|
|
492→blue = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7 + 4.19))
|
|
493→skip = if(above(ph, 0.95), 1, 0)
|
|
494→`,
|
|
495→ },
|
|
496→ {
|
|
497→ style: 'tunnel',
|
|
498→ mode: 'lines',
|
|
499→ content: `name: ${JSON.stringify(name)}
|
|
500→mode: lines
|
|
501→blend: additive
|
|
502→persist: 0.9
|
|
503→linewidth: 1.6
|
|
504→
|
|
505→[init]
|
|
506→n = 900
|
|
507→
|
|
508→[frame]
|
|
509→t = t + dt * (0.25 + bass_att * 1.5)
|
|
510→hue = hue + dt * 0.1
|
|
511→
|
|
512→[point]
|
|
513→ring = floor(i * 24)
|
|
514→ri = frac(i * 24)
|
|
515→z = frac(ring / 24 + t)
|
|
516→rad = 0.06 + z * 0.9
|
|
517→a = ri * 6.28318 + ring * 0.35 + sin(t * 0.7) * 2
|
|
518→x = sin(a) * rad * (0.55 + sv * 0.9) / aspect
|
|
519→y = cos(a) * rad * (0.55 + sv * 0.9)
|
|
520→f = (1 - z) * (0.35 + bass * 0.65)
|
|
521→red = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4))
|
|
522→green = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4 + 2.09))
|
|
523→blue = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4 + 4.19))
|
|
524→`,
|
|
525→ },
|
|
526→ {
|
|
527→ style: 'starfield',
|
|
528→ mode: 'dots',
|
|
529→ content: `name: ${JSON.stringify(name)}
|
|
530→mode: dots
|
|
531→blend: additive
|
|
532→persist: 0.82
|
|
533→linewidth: 2.5
|
|
534→
|
|
535→[init]
|
|
536→n = 850
|
|
537→
|
|
538→[frame]
|
|
539→travel = travel + dt * (0.2 + vol_att * 1.6)
|
|
540→hue = hue + dt * 0.05
|
|
541→
|
|
542→[point]
|
|
543→a = rand(i * 7.3) * 6.28318
|
|
544→z = frac(rand(i * 3.1 + 5) + travel * (0.5 + rand(i + 9) * 0.7))
|
|
545→rad = z * z * 1.1
|
|
546→x = sin(a) * rad / aspect
|
|
547→y = cos(a) * rad
|
|
548→tw = 0.4 + 0.6 * sin(z * 40 + time * 3) * (0.5 + treb)
|
|
549→red = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3))
|
|
550→green = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3 + 2.09))
|
|
551→blue = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3 + 4.19))
|
|
552→size = 1 + z * 3 * (0.5 + bass)
|
|
553→skip = if(above(z, 0.97), 1, 0)
|
|
554→`,
|
|
555→ },
|
|
556→ {
|
|
557→ style: 'lattice',
|
|
558→ mode: 'dots',
|
|
559→ content: `name: ${JSON.stringify(name)}
|
|
560→mode: dots
|
|
561→blend: additive
|
|
562→persist: 0.78
|
|
563→linewidth: 2.5
|
|
564→
|
|
565→[init]
|
|
566→cols = 36
|
|
567→n = cols * cols
|
|
568→
|
|
569→[frame]
|
|
570→rot = rot + dt * (0.05 + mid * 0.4)
|
|
571→hue = hue + dt * (0.04 + treb * 0.1)
|
|
572→
|
|
573→[point]
|
|
574→cx = floor(i * cols) / (cols - 1)
|
|
575→cy = frac(i * cols)
|
|
576→h = sv * 0.35
|
|
577→ux = (cx - 0.5) * 1.6
|
|
578→uy = (cy - 0.5) * 1.6
|
|
579→rx = ux * cos(rot) - uy * sin(rot)
|
|
580→ry = ux * sin(rot) + uy * cos(rot)
|
|
581→x = (rx / aspect) * (1 - h * 0.7)
|
|
582→y = ry * (1 - h * 0.7)
|
|
583→lum = 0.25 + h * 2.2
|
|
584→red = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3))
|
|
585→green = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3 + 2.09))
|
|
586→blue = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3 + 4.19))
|
|
587→size = 1.2 + h * 5
|
|
588→`,
|
|
589→ },
|
|
590→ {
|
|
591→ style: 'swirl',
|
|
592→ mode: 'lines',
|
|
593→ content: `name: ${JSON.stringify(name)}
|
|
594→mode: lines
|
|
595→blend: additive
|
|
596→persist: 0.9
|
|
597→linewidth: 1.6
|
|
598→
|
|
599→[init]
|
|
600→n = 1100
|
|
601→
|
|
602→[frame]
|
|
603→t = t + dt * (0.3 + bass_att * 1.8)
|
|
604→hue = hue + dt * 0.08 + beat * 0.01
|
|
605→
|
|
606→[point]
|
|
607→arm = floor(i * 5)
|
|
608→fa = frac(i * 5)
|
|
609→d = fa
|
|
610→ang = arm * 1.2566 + d * (1.4 + mid * 3) * 3.14159 + t
|
|
611→rad = (0.05 + d * d * 1.15) * (0.7 + bass * 0.5)
|
|
612→x = sin(ang) * rad / aspect
|
|
613→y = cos(ang) * rad
|
|
614→red = (0.3 + 0.7 * d) * (0.3 + bass)
|
|
615→green = (0.3 + 0.7 * d) * (0.25 + mid)
|
|
616→blue = (0.3 + 0.7 * d) * (0.2 + treb + hue * 0.2)
|
|
617→skip = if(below(fa, 0.04), 1, 0)
|
|
618→`,
|
|
619→ },
|
|
620→ {
|
|
621→ style: 'kaleido',
|
|
622→ mode: 'lines',
|
|
623→ content: `name: ${JSON.stringify(name)}
|
|
624→mode: lines
|
|
625→blend: additive
|
|
626→persist: 0.86
|
|
627→linewidth: 1.8
|
|
628→
|
|
629→[init]
|
|
630→n = 560
|
|
631→segs = 8
|
|
632→
|
|
633→[frame]
|
|
634→rot = rot + dt * (0.08 + mid_att * 0.5)
|
|
635→hue = hue + dt * (0.07 + treb * 0.08)
|
|
636→
|
|
637→[beat]
|
|
638→flash = 1
|
|
639→
|
|
640→[point]
|
|
641→seg = floor(i * segs)
|
|
642→si = frac(i * segs)
|
|
643→a0 = si * 6.28318 / segs + rot
|
|
644→amp = 0.12 + sv * 0.55 + bass * 0.25 + flash * 0.05
|
|
645→rad = 0.15 + amp * (0.4 + 0.6 * sin(a0 * 6 + time * 2))
|
|
646→a = seg * 6.28318 / segs + a0 * if(below(seg % 2, 1), 1, -1)
|
|
647→x = sin(a) * rad / aspect
|
|
648→y = cos(a) * rad
|
|
649→lum = 0.35 + amp * 1.2
|
|
650→red = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg))
|
|
651→green = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg + 2.09))
|
|
652→blue = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg + 4.19))
|
|
653→skip = if(above(si, 0.96), 1, 0)
|
|
654→`,
|
|
655→ },
|
|
656→ ]
|
|
657→ return styles[seed % styles.length]
|
|
658→}
|
|
659→
|
|
660→// ---------------------------------------------------------------------------
|
|
661→// smoke test — runs the generated script headlessly through the real engine env
|
|
662→// ---------------------------------------------------------------------------
|
|
663→
|
|
664→function smokeTest(content: string): { ok: boolean; error?: string; n: number; ms: number } {
|
|
665→ const t0 = Date.now()
|
|
666→ const v = validateScript(content)
|
|
667→ 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 }
|
|
668→ resetEelBuffers()
|
|
669→ AVA_HOOKS.spectrumAt = (p) => 0.25 + 0.25 * Math.sin(p * 18 + 1)
|
|
670→ AVA_HOOKS.waveAt = (p) => Math.sin(p * 12.56) * 0.8
|
|
671→ const env: Record<string, number> = { n: 400, t: 0, time: 0, frame: 0 }
|
|
672→ const script = v.script
|
|
673→ try {
|
|
674→ script.initProg?.exec(env)
|
|
675→ } catch (err) {
|
|
676→ return { ok: false, error: `init: ${String(err)}`, n: 0, ms: Date.now() - t0 }
|
|
677→ }
|
|
678→ const n = Math.min(512, Math.max(4, Math.round(env.n ?? 400)))
|
|
679→ let nan = 0
|
|
680→ try {
|
|
681→ for (let f = 0; f < 36; f++) {
|
|
682→ env.dt = 1 / 60
|
|
683→ env.time = (f + 1) / 60
|
|
684→ env.frame = f + 1
|
|
685→ AVA_HOOKS.now = () => env.time ?? 0
|
|
686→ env.beat = f % 15 === 0 ? 1 : 0
|
|
687→ env.bass = 0.3 + 0.3 * Math.sin(f * 0.4)
|
|
688→ env.mid = 0.3
|
|
689→ env.treb = 0.25
|
|
690→ env.bass_att = env.bass
|
|
691→ env.mid_att = 0.3
|
|
692→ env.treb_att = 0.25
|
|
693→ env.vol = 0.4
|
|
694→ env.vol_att = 0.4
|
|
695→ env.w = 1280
|
|
696→ env.h = 720
|
|
697→ env.aspect = 1280 / 720
|
|
698→ script.frameProg?.exec(env)
|
|
699→ if (env.beat) script.beatProg?.exec(env)
|
|
700→ for (let k = 0; k < n; k++) {
|
|
701→ env.i = k / (n - 1 || 1)
|
|
702→ env.v = AVA_HOOKS.waveAt(env.i)
|
|
703→ env.sv = AVA_HOOKS.spectrumAt(env.i)
|
|
704→ env.x = 0; env.y = 0; env.red = 1; env.green = 1; env.blue = 1; env.skip = 0; env.size = 2; env.alpha = 1
|
|
705→ script.pointProg?.exec(env)
|
|
706→ if (!Number.isFinite(env.x) || !Number.isFinite(env.y)) nan++
|
|
707→ }
|
|
708→ }
|
|
709→ } catch (err) {
|
|
710→ return { ok: false, error: `runtime: ${String(err)}`, n, ms: Date.now() - t0 }
|
|
711→ }
|
|
712→ const ms = Date.now() - t0
|
|
713→ // some presets deliberately NaN points (skip via NaN) — allow up to 40%
|
|
714→ if (nan > n * 36 * 0.4) return { ok: false, error: `${nan} NaN points of ${n * 36}`, n, ms }
|
|
715→ return { ok: true, n, ms }
|
|
716→}
|
|
717→
|
|
718→// ---------------------------------------------------------------------------
|
|
719→// name / author extraction
|
|
720→// ---------------------------------------------------------------------------
|
|
721→
|
|
722→function extractNameAuthor(filename: string): { name: string; author: string } {
|
|
723→ let base = filename.replace(/\.avs$/i, '')
|
|
724→ base = base.replace(/^\d{1,2}\s+/, '') // "01 dynamic duo - ..." numbering
|
|
725→ // "Author - Name" (spaces around dash)
|
|
726→ const spaced = base.match(/^(.+?)\s+-\s+(.+)$/)
|
|
727→ if (spaced) return { author: spaced[1].trim(), name: spaced[2].trim() }
|
|
728→ // "Author-Name" (no spaces — e.g. "zxe-Urantia", "Degnic-Adrealine rush")
|
|
729→ const tight = base.match(/^([A-Za-z0-9_&']+)-([A-Za-z].+)$/)
|
|
730→ if (tight) return { author: tight[1].trim(), name: tight[2].trim() }
|
|
731→ return { author: '', name: base }
|
|
732→}
|
|
733→
|
|
734→function hashStr(s: string): number {
|
|
735→ let h = 2166136261
|
|
736→ for (let i = 0; i < s.length; i++) {
|
|
737→ h ^= s.charCodeAt(i)
|
|
738→ h = Math.imul(h, 16777619)
|
|
739→ }
|
|
740→ return Math.abs(h)
|
|
741→}
|
|
742→
|
|
743→// ---------------------------------------------------------------------------
|
|
744→// the converter
|
|
745→// ---------------------------------------------------------------------------
|
|
746→
|
|
747→export { parseAvsFile, collect, applyMacros, extractDefines, translateCode, smokeTest, extractNameAuthor, fallbackTemplate, convertOne }
|
|
748→export type { Effect, Candidates, OutputPreset, Macro }
|
|
749→
|
|
750→interface OutputPreset {
|
|
751→ folder: string
|
|
752→ name: string
|
|
753→ author: string
|
|
754→ description: string
|
|
755→ originalFile: string
|
|
756→ content: string
|
|
757→}
|
|
758→
|
|
759→function convertOne(file: string, relDir: string, absPath: string): { preset: OutputPreset; status: 'ported' | 'fallback'; note: string } {
|
|
760→ const { name, author } = extractNameAuthor(file)
|
|
761→ const effects = parseAvsFile(absPath)
|
|
762→ const cands: Candidates = { scopes: [], eeltrans: [], gv: [], comment: null, otherKinds: new Set() }
|
|
763→ collect(effects, cands)
|
|
764→
|
|
765→ const folder = 'root/embedded/' + relDir
|
|
766→ const originalFile = `/avs-originals/embedded/${relDir}/${file}`
|
|
767→ const desc = (extra: string): string => {
|
|
768→ const by = author ? ` by ${author}` : ''
|
|
769→ const src = `Auto-ported from the Winamp AVS preset "${file}"${by}.`
|
|
770→ return extra ? `${src} ${extra}` : src
|
|
771→ }
|
|
772→
|
|
773→ const makeFallback = (reason: string) => {
|
|
774→ const tpl = fallbackTemplate(hashStr(name), name)
|
|
775→ return {
|
|
776→ preset: {
|
|
777→ folder,
|
|
778→ name,
|
|
779→ author,
|
|
780→ description: desc(`The original's visuals are driven by ${reason}, which the AVA script engine does not support — this is a generated stand-in that keeps the beat & band reactivity.`),
|
|
781→ originalFile,
|
|
782→ content: tpl.content,
|
|
783→ },
|
|
784→ status: 'fallback' as const,
|
|
785→ note: `${reason} → ${tpl.style} stand-in`,
|
|
786→ }
|
|
787→ }
|
|
788→
|
|
789→ // pick the richest point program
|
|
790→ const scored = cands.scopes
|
|
791→ .map((s) => ({ s, score: (s.code?.point.length ?? 0) + (s.code?.frame.length ?? 0) * 0.3 }))
|
|
792→ .sort((a, b) => b.score - a.score)
|
|
793→ if (!scored.length || (scored[0].s.code?.point.length ?? 0) < 12) {
|
|
794→ const reason = cands.otherKinds.size
|
|
795→ ? Array.from(cands.otherKinds).slice(0, 3).join(', ')
|
|
796→ : 'no codeable scope'
|
|
797→ return makeFallback(reason)
|
|
798→ }
|
|
799→ const scope = scored[0].s
|
|
800→ const code = scope.code!
|
|
801→
|
|
802→ // reject trivial scopes (e.g. a 2-point checker) — they are not the real
|
|
803→ // visual. If init pins a tiny literal n and the frame code never sets n,
|
|
804→ // the preset's meat is elsewhere (movements / APEs).
|
|
805→ const initNLit = code.init.match(/(^|[^A-Za-z0-9_])n\s*=\s*([0-9.]+)\s*;/)
|
|
806→ const frameSetsN = assignsVar(code.frame, 'n')
|
|
807→ if (initNLit && !frameSetsN && parseFloat(initNLit[2]) < 8) {
|
|
808→ return makeFallback('a trivial scope (the real work is movement/APE driven)')
|
|
809→ }
|
|
810→
|
|
811→ // macro preprocessing (AVSTrans defines + local defines)
|
|
812→ const macroSources = [...cands.eeltrans, code.init, code.frame, code.beat, code.point]
|
|
813→ const { macros } = extractDefines(macroSources)
|
|
814→ let init = code.init
|
|
815→ let frame = code.frame
|
|
816→ let beat = code.beat
|
|
817→ let point = code.point
|
|
818→ let gvNote = ''
|
|
819→ if (macros.length) {
|
|
820→ init = applyMacros(init, macros)
|
|
821→ frame = applyMacros(frame, macros)
|
|
822→ beat = applyMacros(beat, macros)
|
|
823→ point = applyMacros(point, macros)
|
|
824→ }
|
|
825→ // merge Jheriko Global register-setters as a preamble (sets reg00=dt etc.)
|
|
826→ if (cands.gv.length) {
|
|
827→ const gv = cands.gv[0]
|
|
828→ const gvi = stripDirectives(applyMacros(gv.init, macros))
|
|
829→ const gvf = stripDirectives(applyMacros(gv.frame, macros))
|
|
830→ const gvb = stripDirectives(applyMacros(gv.beat, macros))
|
|
831→ if (gvi.trim()) init = `${gvi}\n${init}`
|
|
832→ if (gvf.trim()) frame = `${gvf}\n${frame}`
|
|
833→ if (gvb.trim()) beat = `${gvb}\n${beat}`
|
|
834→ gvNote = ' + global register code'
|
|
835→ }
|
|
836→ init = stripDirectives(init)
|
|
837→ frame = stripDirectives(frame)
|
|
838→ beat = stripDirectives(beat)
|
|
839→ point = stripDirectives(point)
|
|
840→
|
|
841→ // token rewrites
|
|
842→ const spectrum = scope.spectrumSource === true
|
|
843→ init = translateCode(init, spectrum)
|
|
844→ frame = translateCode(frame, spectrum)
|
|
845→ beat = translateCode(beat, spectrum)
|
|
846→ point = translateCode(point, spectrum)
|
|
847→
|
|
848→ // AVS `t` is auto-advancing time — if the code never assigns t, map reads to `time`
|
|
849→ const allCode = `${init}\n${frame}\n${beat}\n${point}`
|
|
850→ if (!assignsVar(allCode, 't')) {
|
|
851→ init = init.replace(/\bt\b/g, 'time')
|
|
852→ frame = frame.replace(/\bt\b/g, 'time')
|
|
853→ beat = beat.replace(/\bt\b/g, 'time')
|
|
854→ point = point.replace(/\bt\b/g, 'time')
|
|
855→ }
|
|
856→
|
|
857→ // default n if never set
|
|
858→ if (!assignsVar(allCode, 'n')) init = `n = 400\n${init}`
|
|
859→
|
|
860→ // color cycling fallback when the point code never sets colors
|
|
861→ const setsColors = assignsVar(point, 'red') || assignsVar(point, 'green') || assignsVar(point, 'blue')
|
|
862→ let colorFrame = ''
|
|
863→ let colorPoint = ''
|
|
864→ if (!setsColors && scope.colors && scope.colors.length) {
|
|
865→ const cols = scope.colors.map((c) => ({
|
|
866→ r: ((c >> 16) & 255) / 255,
|
|
867→ g: ((c >> 8) & 255) / 255,
|
|
868→ b: (c & 255) / 255,
|
|
869→ }))
|
|
870→ const gen = cols
|
|
871→ .map((c, i) => `if(avsi==${i}, avsr=${c.r.toFixed(3)}; avsg=${c.g.toFixed(3)}; avsb=${c.b.toFixed(3)};)`)
|
|
872→ .join('\n')
|
|
873→ colorFrame = `avsc = avsc + 1;\navsp = avsc % ${cols.length * 64};\navsi = floor(avsp / 64);\navsf = (avsp % 64) / 64;\n${gen}`
|
|
874→ const c1 = cols[0]
|
|
875→ colorFrame += `\nif(avsi == ${cols.length - 1}, avsr2=${c1.r.toFixed(3)}; avsg2=${c1.g.toFixed(3)}; avsb2=${c1.b.toFixed(3)};)`
|
|
876→ for (let i = 0; i < cols.length - 1; i++) {
|
|
877→ 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)};)`
|
|
878→ }
|
|
879→ colorFrame += `\navsr = avsr * (1 - avsf) + avsr2 * avsf;\navsg = avsg * (1 - avsf) + avsg2 * avsf;\navsb = avsb * (1 - avsf) + avsb2 * avsf;`
|
|
880→ colorPoint = 'red = avsr;\ngreen = avsg;\nblue = avsb;'
|
|
881→ } else if (!setsColors) {
|
|
882→ colorFrame = 'avsh = avsh + dt * 0.15;'
|
|
883→ 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);'
|
|
884→ }
|
|
885→
|
|
886→ const mode = scope.kind === 'texer2' ? 'dots' : scope.drawMode === 0 ? 'dots' : 'lines'
|
|
887→ const linewidth = mode === 'dots' ? 3 : 2
|
|
888→
|
|
889→ const content = `# Auto-ported from "${file}" (Winamp AVS${author ? ` / ${author}` : ''})
|
|
890→# ${scope.kind === 'texer2' ? 'Texer II' : 'SuperScope'} point program${gvNote}${spectrum ? ', spectrum source' : ''}${macros.length ? `, ${macros.length} AVSTrans macros expanded` : ''}.
|
|
891→name: ${JSON.stringify(name)}
|
|
892→author: ${JSON.stringify(author)}
|
|
893→mode: ${mode}
|
|
894→blend: additive
|
|
895→persist: ${mode === 'dots' ? 0.78 : 0.88}
|
|
896→linewidth: ${linewidth}
|
|
897→ysign: -1
|
|
898→
|
|
899→[init]
|
|
900→${init.trim() || 'n = 400'}
|
|
901→
|
|
902→[frame]
|
|
903→${[frame.trim(), colorFrame].filter(Boolean).join('\n')}
|
|
904→
|
|
905→[beat]
|
|
906→${beat.trim() || 'flash = 1'}
|
|
907→
|
|
908→[point]
|
|
909→${[point.trim(), colorPoint].filter(Boolean).join('\n')}
|
|
910→`
|
|
911→
|
|
912→ const smoke = smokeTest(content)
|
|
913→ if (!smoke.ok) {
|
|
914→ return makeFallback(`unportable scope code (${smoke.error})`)
|
|
915→ }
|
|
916→ const perf = smoke.ms > 900 ? ' (heavy script — point count clamped)' : ''
|
|
917→ if (smoke.ms > 900) {
|
|
918→ // clamp n for heavy scripts so the browser keeps 60fps
|
|
919→ const patched = content.replace(/\[init\]\n/, `[init]\nn = if(above(n, 2048), 2048, n)\n`)
|
|
920→ const recheck = smokeTest(patched)
|
|
921→ if (recheck.ok) {
|
|
922→ return {
|
|
923→ preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.${perf}`), originalFile, content: patched },
|
|
924→ status: 'ported',
|
|
925→ note: `ok (n clamped, ${smoke.ms}ms → ${recheck.ms}ms)`,
|
|
926→ }
|
|
927→ }
|
|
928→ }
|
|
929→ return {
|
|
930→ preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.`), originalFile, content },
|
|
931→ status: 'ported',
|
|
932→ note: `ok (${smoke.ms}ms, n=${smoke.n})`,
|
|
933→ }
|
|
934→}
|
|
935→
|
|
936→// ---------------------------------------------------------------------------
|
|
937→// main
|
|
938→// ---------------------------------------------------------------------------
|
|
939→
|
|
940→const inputRoot = process.argv[2] ?? '/tmp/avsdrive/raw'
|
|
941→const outDir = 'tools/converted'
|
|
942→const originalsDir = 'public/avs-originals/embedded'
|
|
943→
|
|
944→const SKIP_FOLDERS = new Set(['Synth-C']) // hand-adapted versions ship in BUILTIN_PRESETS
|
|
945→
|
|
946→function walk(dir: string): string[] {
|
|
947→ const out: string[] = []
|
|
948→ for (const entry of readdirSync(dir)) {
|
|
949→ const p = join(dir, entry)
|
|
950→ if (statSync(p).isDirectory()) {
|
|
951→ if (SKIP_FOLDERS.has(entry)) continue
|
|
952→ out.push(...walk(p))
|
|
953→ } else if (entry.toLowerCase().endsWith('.avs')) out.push(p)
|
|
954→ }
|
|
955→ return out
|
|
956→}
|
|
957→
|
|
958→const isMain = typeof process !== 'undefined' && process.argv[1] && process.argv[1].includes('convert-avs')
|
|
959→if (!isMain) {
|
|
960→ // imported as a library — skip main
|
|
961→} else {
|
|
962→main()
|
|
963→}
|
|
964→
|
|
965→function main() {
|
|
966→
|
|
967→const files = walk(inputRoot).sort()
|
|
968→console.log(`converting ${files.length} .avs files from ${inputRoot}`)
|
|
969→const results: OutputPreset[] = []
|
|
970→const report: Array<{ file: string; status: string; note: string }> = []
|
|
971→let ported = 0
|
|
972→let fallback = 0
|
|
973→
|
|
974→for (const abs of files) {
|
|
975→ const rel = relative(inputRoot, abs)
|
|
976→ const relDir = dirname(rel) === '.' ? '' : dirname(rel)
|
|
977→ const file = basename(rel)
|
|
978→ try {
|
|
979→ const r = convertOne(file, relDir, abs)
|
|
980→ results.push(r.preset)
|
|
981→ report.push({ file: rel, status: r.status, note: r.note })
|
|
982→ if (r.status === 'ported') ported++
|
|
983→ else fallback++
|
|
984→ console.log(`${r.status === 'ported' ? '✔' : '○'} ${rel} — ${r.note}`)
|
|
985→ // keep a copy of the generated .ava for reference
|
|
986→ const destAva = join(outDir, relDir, file.replace(/\.avs$/i, '.ava'))
|
|
987→ mkdirSync(dirname(destAva), { recursive: true })
|
|
988→ writeFileSync(destAva, r.preset.content)
|
|
989→ // copy the original binary
|
|
990→ const destOrig = join(originalsDir, relDir, file)
|
|
991→ mkdirSync(dirname(destOrig), { recursive: true })
|
|
992→ copyFileSync(abs, destOrig)
|
|
993→ } catch (err) {
|
|
994→ report.push({ file: rel, status: 'error', note: String(err) })
|
|
995→ fallback++
|
|
996→ console.log(`✘ ${rel} — ${String(err)}`)
|
|
997→ }
|
|
998→}
|
|
999→
|
|
1000→// emit the TS module
|
|
1001→const seen = new Set<string>()
|
|
1002→for (const p of results) {
|
|
1003→ const key = `${p.folder}//${p.name}`
|
|
1004→ if (seen.has(key)) console.log(`!! duplicate name in folder: ${key}`)
|
|
1005→ seen.add(key)
|
|
1006→}
|
|
1007→
|
|
1008→const tsHeader = `// AVA — embedded preset library (AUTO-GENERATED by tools/convert-avs.ts — do not edit)
|
|
1009→//
|
|
1010→// ${results.length} Winamp AVS presets from the user's Google Drive collection,
|
|
1011→// auto-ported to the AVA script dialect by the converter. Ported presets run
|
|
1012→// the original SuperScope / Texer II code (machine-translated); stand-in
|
|
1013→// presets are generated visuals for originals that rely on effects the script
|
|
1014→// engine does not support (movements, APEs).
|
|
1015→
|
|
1016→export interface EmbeddedPreset {
|
|
1017→ folder: string
|
|
1018→ name: string
|
|
1019→ author: string
|
|
1020→ description: string
|
|
1021→ originalFile?: string
|
|
1022→ content: string
|
|
1023→}
|
|
1024→
|
|
1025→`
|
|
1026→
|
|
1027→const lines: string[] = []
|
|
1028→for (const p of results) {
|
|
1029→ 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 content: ${JSON.stringify(p.content)},\n },`)
|
|
1030→}
|
|
1031→const ts = tsHeader + `export const EMBEDDED_LIBRARY: EmbeddedPreset[] = [\n${lines.join('\n')}\n]\n`
|
|
1032→writeFileSync('src/lib/ava/embedded-presets.ts', ts)
|
|
1033→
|
|
1034→console.log(`\nDone: ${ported} ported, ${fallback} fallback, ${results.length} total`)
|
|
1035→console.log(`→ src/lib/ava/embedded-presets.ts (${(ts.length / 1024).toFixed(0)} KB)`)
|
|
1036→console.log(`→ ${originalsDir}/ (${results.length} .avs originals)`)
|
|
1037→writeFileSync('tools/convert-report.json', JSON.stringify(report, null, 1))
|
|
1038→}
|
|
1039→ |