225 lines
12 KiB
TypeScript
Executable file
225 lines
12 KiB
TypeScript
Executable file
// AVA — script editor help panel: variables, functions, operators, examples
|
||
// and the .ava file format. Collapsible, opened from the editor toolbar.
|
||
|
||
'use client'
|
||
|
||
import type { ReactNode } from 'react'
|
||
import { BLEND_SPECS, BLEND_GROUP_LABELS, type BlendGroup } from '@/lib/ava/blend'
|
||
|
||
const FRAME_VARS: [string, string][] = [
|
||
['n', 'point count (4–4096, default 400 -- set in [init] or [frame])'],
|
||
['t', 'your own counter (do with it whatever you want)'],
|
||
['time', 'seconds since the script started'],
|
||
['dt', 'frame delta in seconds'],
|
||
['frame', 'frame number'],
|
||
['bass / mid / treb', 'instant band levels, 0..1 and above'],
|
||
['bass_att / mid_att / treb_att', 'smoothed bands (fast attack, slow release) -- use these for beat detection logic'],
|
||
['vol / vol_att', 'overall level, instant + smoothed'],
|
||
['beat', '1 on the frame a beat fired, else 0'],
|
||
['bpm', 'detected tempo'],
|
||
['beatphase', '0..1 position within the beat period'],
|
||
['beatconf', '0..1 confidence of the tempo lock'],
|
||
['beats', 'total beat count since load'],
|
||
['beatage', 'seconds since the last beat (0 on the beat frame) -- exp(-beatage*4) is a kick envelope'],
|
||
['silent', '1 while silence holds the beat grid (v7.15.1) -- no beats fire and beatphase freezes; releases the moment real audio returns. Waveform / band data still flows, it is just near zero'],
|
||
['w / h', 'canvas size in pixels'],
|
||
['aspect', 'canvas width / height -- divide x by it for perfect circles'],
|
||
['persist', 'motion-trail persistence 0..1, writable per frame -- 1 = the framebuffer never fades (old content then leaves only through a bg* transform or a blend: replace repaint); at 0.5 and above the 8-bit fade used to leave a faint gray wash -- the engine now sweeps that to pure black automatically (see trueblack)'],
|
||
['blend', 'per-frame override of the blend: header -- set it to a mode\'s number from the Blend modes table below (1 = normal, 2 = additive, 3 = replace, 6 = subtract, ...); read after [frame]/[beat], before drawing'],
|
||
['bgrot', 'background spin in radians per frame -- + clockwise, - counter-clockwise; 0 = off (set in [init] / [frame] / [beat]; persists like any variable)'],
|
||
['bgx / bgy', 'background drift per frame, the -1..1 screen convention (y is up) -- slide the whole picture up, down, left or right'],
|
||
['bgzoom', 'background scale per frame -- below 1 draw-in (shrinks toward the center), above 1 blow-out (expands); 1 = off'],
|
||
]
|
||
|
||
const POINT_VARS: [string, string][] = [
|
||
['i', '0..1 point index'],
|
||
['v', 'waveform sample −1..1'],
|
||
['sv', 'spectrum magnitude 0..1 (≈12 Hz to 16 kHz, log-ish -- the range music actually occupies)'],
|
||
['x / y', 'position −1..1 (x = sin(a)*rad / aspect keeps circles round)'],
|
||
['red / green / blue', 'point color 0..1'],
|
||
['skip', '> 0.5 hides the point / breaks the line'],
|
||
['size', 'dot radius in pixels (dots mode)'],
|
||
['alpha', 'opacity 0..1'],
|
||
]
|
||
|
||
const FUNCS: [string, string][] = [
|
||
['sin cos tan asin acos atan', 'trigonometry (radians)'],
|
||
['atan2(x, y)', 'arctangent of y/x keeping quadrant'],
|
||
['abs sqrt sqr pow(x, y) exp log log10', 'powers, roots & logs'],
|
||
['floor ceil round int frac sign', 'rounding & sign'],
|
||
['min max', 'pairwise extrema'],
|
||
['rand(seed)', 'deterministic per-seed random 0..1 -- rand(i) is stable per point'],
|
||
['hsvr(h, s?, v?) hsvg hsvb', 'HSV→RGB channels (hue wraps 0..1; s, v default 1) -- red = hsvr(hue) + green = hsvg(hue) + blue = hsvb(hue) paints the rainbow'],
|
||
['smoothstep(e0, e1, x)', 'hermite-smooth 0..1 step between e0 and e1'],
|
||
['lerp(a, b, t) clamp(x, lo, hi)', 'interpolate & constrain'],
|
||
['inrange(x, lo, hi)', '1 when lo ≤ x ≤ hi'],
|
||
['above(a, b) below(a, b) equal(a, b)', 'comparisons returning 1/0'],
|
||
['band(a, b) bor(a, b) bnot(a)', 'logical and / or / not (also && || !)'],
|
||
['sigmoid(x, y)', '1 / (1 + exp(-x·y))'],
|
||
['if(cond, a, b)', 'lazy conditional -- only the taken branch runs, so if(beat, flash=1, 0) works'],
|
||
]
|
||
|
||
const EXAMPLES: [string, string][] = [
|
||
['hue = frac(hue + beat * 0.125)', 'rotate the color wheel 1/8 turn on every beat'],
|
||
['red = hsvr(hue); green = hsvg(hue); blue = hsvb(hue)', 'full rainbow from one hue variable'],
|
||
['flash = exp(-beatage * 5)', 'kick envelope -- 1 on the beat, fades fast'],
|
||
['if(equal(beats % 4, 0), hue = frac(hue + 0.25), 0)', 'shift color once per 4-beat bar'],
|
||
['rad = 0.2 + bass * 0.6', 'bass pulse'],
|
||
['rot = rot + dt * 1.2 + beat * 0.4', 'beat kick'],
|
||
['ang = rand(i) * 6.28318', 'stable per-point random'],
|
||
['x = sin(i * 6.28318 + t) * rad / aspect', 'circle'],
|
||
['bgrot = dir * (0.004 + exp(-beatage * 3) * 0.02)', 'the background spins to the music -- every beat accelerates it, then it relaxes (slow persist blends the trails)'],
|
||
['if(above(bass, bass_att * 1.25), dir = -dir, 0)', 'a hard hit flips the background spin direction -- put this in [beat]'],
|
||
['bgzoom = 1 - 0.001', 'draw-in -- the picture slowly spirals toward the center'],
|
||
]
|
||
|
||
function HelpTable({ rows, mono = true }: { rows: [string, string][]; mono?: boolean }) {
|
||
return (
|
||
<table className="w-full border-collapse text-left">
|
||
<tbody>
|
||
{rows.map(([name, desc]) => (
|
||
<tr key={name} className="border-b border-zinc-800/60 last:border-0 align-top">
|
||
<td
|
||
className={`py-1 pr-3 whitespace-nowrap text-emerald-400/90 ${mono ? 'font-mono text-[11px]' : 'text-xs'}`}
|
||
>
|
||
{name}
|
||
</td>
|
||
<td className="py-1 text-[11px] leading-relaxed text-zinc-400">{desc}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)
|
||
}
|
||
|
||
function HelpSection({ title, children }: { title: string; children: ReactNode }) {
|
||
return (
|
||
<section>
|
||
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-[0.15em] text-zinc-400">{title}</h4>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export function HelpPanel() {
|
||
return (
|
||
<div className="space-y-5 rounded-lg border border-zinc-800/80 bg-zinc-950/60 p-4">
|
||
<HelpSection title="Quick start">
|
||
<ol className="list-inside list-decimal space-y-1 text-[11px] leading-relaxed text-zinc-400">
|
||
<li>Write header keys (name, mode, blend, persist, linewidth) at the top.</li>
|
||
<li>
|
||
Put setup code in <span className="font-mono text-emerald-400/90">[init]</span>, per-frame code in{' '}
|
||
<span className="font-mono text-emerald-400/90">[frame]</span>, reactions in{' '}
|
||
<span className="font-mono text-emerald-400/90">[beat]</span>, and the geometry in{' '}
|
||
<span className="font-mono text-emerald-400/90">[point]</span>.
|
||
</li>
|
||
<li>
|
||
Set <span className="font-mono text-emerald-400/90">n</span> (point count) in [init], drive it with{' '}
|
||
<span className="font-mono text-emerald-400/90">bass</span>,{' '}
|
||
<span className="font-mono text-emerald-400/90">beat</span> and <span className="font-mono text-emerald-400/90">v</span>.
|
||
</li>
|
||
<li>Hit Apply ▶ -- errors are flagged next to the exact line.</li>
|
||
</ol>
|
||
</HelpSection>
|
||
|
||
<HelpSection title="Variables -- everywhere">
|
||
<HelpTable rows={FRAME_VARS} />
|
||
</HelpSection>
|
||
|
||
<HelpSection title="Variables -- [point] only">
|
||
<HelpTable rows={POINT_VARS} />
|
||
</HelpSection>
|
||
|
||
<HelpSection title="Functions">
|
||
<HelpTable rows={FUNCS} />
|
||
<p className="mt-2 text-[11px] text-zinc-500">
|
||
Constants: <span className="font-mono text-emerald-400/90">pi tau e</span>
|
||
</p>
|
||
</HelpSection>
|
||
|
||
<HelpSection title="Operators">
|
||
<p className="font-mono text-[11px] leading-relaxed text-zinc-400">
|
||
+ - * / % ^ (power) · == != < <= > >= · && || ! ·
|
||
ternary <span className="text-emerald-400/90">c ? a : b</span> · assignment{' '}
|
||
<span className="text-emerald-400/90">=</span> (works inside if() and ternary branches)
|
||
</p>
|
||
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
|
||
Statements are separated by newlines or semicolons. Comments start with <span className="font-mono">{'#'}</span> or{' '}
|
||
<span className="font-mono">{'//'}</span>.
|
||
</p>
|
||
</HelpSection>
|
||
|
||
<HelpSection title="Example formulas">
|
||
<div className="space-y-1.5">
|
||
{EXAMPLES.map(([code, note]) => (
|
||
<div key={code} className="flex flex-col gap-0.5 rounded border border-zinc-800/70 bg-zinc-800/40 px-2.5 py-1.5">
|
||
<code className="font-mono text-[11px] text-zinc-200">{code}</code>
|
||
<span className="text-[10px] text-zinc-500">{note}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</HelpSection>
|
||
|
||
<HelpSection title="Blend modes (blend:)">
|
||
<p className="mb-2 text-[11px] leading-relaxed text-zinc-400">
|
||
The <span className="font-mono text-emerald-400/90">blend:</span> header picks how fresh strokes mix with what is
|
||
already on screen (base = the picture, blend = the stroke). Names are case- and separator-insensitive —{' '}
|
||
<span className="font-mono">color burn</span> = <span className="font-mono">colorburn</span>. The{' '}
|
||
<span className="font-mono text-emerald-400/90">blend</span> variable can switch the mode per frame by its number.
|
||
</p>
|
||
<div className="space-y-3">
|
||
{(
|
||
['core', 'darken', 'lighten', 'contrast', 'invert', 'component', 'mask'] as BlendGroup[]
|
||
).map((g) => (
|
||
<div key={g}>
|
||
<p className="mb-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-500">
|
||
{BLEND_GROUP_LABELS[g]}
|
||
</p>
|
||
<HelpTable
|
||
rows={BLEND_SPECS.map((s, i) => [s, i] as const)
|
||
.filter(([s]) => s.group === g)
|
||
.map(([s, i]) => [
|
||
`${i + 1} · ${s.key}`,
|
||
s.aliases?.length ? `${s.note} [also: ${s.aliases.join(', ')}]` : s.note,
|
||
])}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</HelpSection>
|
||
|
||
<HelpSection title="File format (.ava)">
|
||
<p className="mb-2 text-[11px] leading-relaxed text-zinc-400">
|
||
A plain-text file: optional header keys before the first section, then any of the four sections in any order.
|
||
</p>
|
||
<pre className="overflow-x-auto rounded border border-zinc-800/70 bg-zinc-800/40 p-2.5 font-mono text-[11px] leading-relaxed text-zinc-300">
|
||
{`# header keys
|
||
name: My Visualization
|
||
author: you
|
||
description: shown in the preset list
|
||
mode: lines # lines | dots | solid
|
||
blend: additive # any of the 41 keywords -- see the Blend modes table
|
||
persist: 0.85 # trail persistence 0..1 (1 = never fades)
|
||
trueblack: auto # on | off | auto (default auto): subtractive fade tail
|
||
# so trails reach PURE black even at high persist --
|
||
# auto runs it exactly in the sticky zone (persist >= 0.5)
|
||
linewidth: 2
|
||
|
||
[init] # runs once on load
|
||
[frame] # runs every frame
|
||
[beat] # runs on beat frames (after [frame])
|
||
[point] # runs n times per frame`}
|
||
</pre>
|
||
<p className="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||
<span className="font-mono text-emerald-400/90">trueblack: auto</span> keeps every trail honest: the fade multiplies the framebuffer in 8-bit
|
||
steps, so a persist of 0.5 or more leaves a stuck residue (round(v·p) = v for the smallest values) -- a faint
|
||
colored wash that never fully fades. auto (the default) subtracts 1/255 per frame exactly in that sticky zone,
|
||
so everything ends in true black while cheap low-persist scripts pay nothing. The sweep runs on the GPU and
|
||
preserves transparency, so <span className="font-mono">behind</span>/<span className="font-mono">clear</span>{' '}
|
||
art keeps its holes (the no-WebGL fallback stands down for it instead). <span className="font-mono">on</span>{' '}
|
||
forces the sweep at any persist below 1, <span className="font-mono">off</span> keeps the wash for looks.
|
||
</p>
|
||
</HelpSection>
|
||
</div>
|
||
)
|
||
}
|