Compare commits

..

10 commits

Author SHA1 Message Date
90b6c6ea2e Initial commit of openvisengine 2026-11-05 22:18:10 -06:00
Z User
9cea959a52 v7.13.1 — Beat Matrix endpoint fix: full 16x10 grid, no phantom dot past the wall 2026-11-05 08:44:59 +00:00
Z User
05d2c626ca worklog: Task 40 — v7.13 merge of the parallel v7.12 line into the OVE-Directory build 2026-11-05 08:23:21 +00:00
Z User
b161685026 v7.13 — merge the parallel v7.12 line into the OVE-Directory build
Three-way merge (base d59d398, verified identical trees). Brings in:
- 7.10.1: spectrum edge fix (SPEC_TOP_HZ 16 kHz cap)
- 7.10.2 (was 7.11 there): wake lock, respectful Demo, one-click Save
- 7.12: background movement (bgrot/bgx/bgy/bgzoom) + osc-rotations preset

Hand-merged: script-tab.tsx toolbar (one-click Save + Upload side by side),
version.ts + release-notes.ts single timeline, new 7.13 entries.
2026-11-05 08:09:31 +00:00
Z User
40c6d2887e b8fd4912-690d-432c-9235-71b5108014df 2026-11-05 06:53:07 +00:00
Z User
f72eae5ecb 03ddf852-dedd-41e2-89a2-337bd4bd6a89 2026-11-05 03:40:34 +00:00
Z User
1ea335a2fe 62fe0e7e-3025-47cc-bab0-d72875f1698e 2026-11-04 23:55:48 +00:00
Z User
401963b9f3 a30cfb39-1b53-4097-990f-27d01fb42f1f 2026-11-04 18:48:09 +00:00
Z User
1c1105da45 e7e98d75-c472-45a0-8363-2cc17e4e3f4e 2026-11-04 16:14:36 +00:00
Z User
fd71ca41aa ca373082-0cfe-4fd4-89b2-0a9b9c85453b 2026-11-04 15:27:56 +00:00
66 changed files with 6094 additions and 95 deletions

2
.env
View file

@ -1 +1,3 @@
DATABASE_URL=file:/home/z/my-project/db/custom.db DATABASE_URL=file:/home/z/my-project/db/custom.db
OVE_DIRECTORY_URL=https://s3374dir.openvisengine.com
OVE_DIRECTORY_API_KEY=+7MkRB+hkV0UBhPqM1haTQ0f8JGKqkuXwoBTdTSAouU=

64
.gitignore vendored
View file

@ -1,54 +1,22 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies
node_modules/
.pnp
.pnp.js
# dependencies # Next.js & Bun build outputs
node_modules .next/
/.pnp out/
.pnp.* build/
.yarn/* dist/
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing # Environment & secrets
/coverage .env
.env.*
!.env.example
# next.js # Debug & OS artifacts
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .DS_Store
*.pem
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
local-*
.claude
.z-ai-config
*.log
dev.log
dev.out.log
test
prompt
server.log
# Skills directory
/skills/

Binary file not shown.

0
public/demo-tracks/demo-01.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-02.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-03.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-04.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-05.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-06.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-07.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-08.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-09.mp3 Normal file → Executable file
View file

0
public/demo-tracks/demo-10.mp3 Normal file → Executable file
View file

View file

@ -15,6 +15,12 @@ use rustfft::{Fft, FftPlanner};
/// engine's AnalyserNode. /// engine's AnalyserNode.
pub const FFT_SIZE: usize = 8192; pub const FFT_SIZE: usize = 8192;
/// Top of the script-visible spectrum, in Hz. Mirrors SPEC_TOP_HZ in
/// src/lib/ava/audio.ts (v7.10.1): the bar field stops at 16 kHz instead of
/// Nyquist because music and every lossy codec are silent above that, which
/// used to freeze the right edge of spectrum visuals.
pub const SPEC_TOP_HZ: f32 = 16000.0;
/// Waveform history kept for the script `v` variable (matches the web /// Waveform history kept for the script `v` variable (matches the web
/// engine's "most recent 2048 samples" window). /// engine's "most recent 2048 samples" window).
pub const WAVE_SIZE: usize = 2048; pub const WAVE_SIZE: usize = 2048;
@ -48,13 +54,18 @@ impl Spectrum {
} }
/// Spectrum magnitude at position 0..1 → 0..1 with the web engine's /// Spectrum magnitude at position 0..1 → 0..1 with the web engine's
/// log-ish frequency mapping. Mirrors `AudioEngine.getSpectrum()`. /// log-ish frequency mapping. Mirrors `AudioEngine.getSpectrum()` in
/// audio.ts: the field tops out at `SPEC_TOP_HZ` (16 kHz), not Nyquist.
pub fn spectrum_at(&self, pos: f64) -> f64 { pub fn spectrum_at(&self, pos: f64) -> f64 {
if self.mags.is_empty() { if self.mags.is_empty() {
return 0.0; return 0.0;
} }
let p = pos.clamp(0.0, 1.0); let p = pos.clamp(0.0, 1.0);
let bin = 2.0 + (p.powf(1.6) * (self.mags.len() as f64 - 3.0)).floor(); let bh = bin_hz(self.sample_rate, self.mags.len() * 2);
let hi = ((SPEC_TOP_HZ / bh).ceil() as usize)
.min(self.mags.len() - 1)
.max(3);
let bin = 2.0 + (p.powf(1.6) * (hi as f64 - 2.0)).floor();
self.mags[(bin as usize).min(self.mags.len() - 1)] as f64 self.mags[(bin as usize).min(self.mags.len() - 1)] as f64
} }
} }

View file

@ -0,0 +1,71 @@
// OVE — /api/ove-directory/scripts/[id]/ratings: cast a 1–5 rating.
//
// The visitor's anonymous voter id is minted HERE (cookie "ove_voter",
// HttpOnly, one year) and forwarded as the opaque `voter` string the
// directory dedupes on — one vote per script per visitor, enforced upstream.
// 200 and 409 DUPLICATE_VOTE both pass through with the live aggregates.
import { NextRequest, NextResponse } from 'next/server'
import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
const VOTER_COOKIE = 'ove_voter'
const VOTER_MAX_AGE = 365 * 24 * 60 * 60
function mintVoter(): string {
return crypto.randomUUID()
}
/** Read the existing voter id, or mint one (the response then sets it). */
function voterFor(req: NextRequest): { voter: string; minted: boolean } {
const existing = req.cookies.get(VOTER_COOKIE)?.value
if (existing && existing.trim().length >= 1 && existing.trim().length <= 64) {
return { voter: existing.trim(), minted: false }
}
return { voter: mintVoter(), minted: true }
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const { id } = await params
if (!/^\d+$/.test(id) || Number.parseInt(id, 10) < 1) {
return jsonError(400, 'VALIDATION', 'id must be a positive integer.', 'id')
}
const ct = req.headers.get('content-type') ?? ''
if (!ct.toLowerCase().startsWith('application/json')) {
return jsonError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json.')
}
let body: unknown
try {
body = await req.json()
} catch {
return jsonError(400, 'INVALID_JSON', 'The request body is not valid JSON.')
}
const rating = (body as { rating?: unknown } ?? {}).rating
if (typeof rating !== 'number' || !Number.isInteger(rating) || rating < 1 || rating > 5) {
return jsonError(400, 'VALIDATION', 'rating must be an integer from 1 to 5.', 'rating')
}
const { voter, minted } = voterFor(req)
const r = await dirFetch(`/v1/scripts/${id}/ratings`, {
method: 'POST',
body: { rating, voter },
})
const res = NextResponse.json(r.data, { status: r.status })
if (minted && (r.status === 200 || r.status === 409)) {
res.cookies.set(VOTER_COOKIE, voter, {
httpOnly: true,
sameSite: 'lax',
maxAge: VOTER_MAX_AGE,
path: '/',
})
}
return res
}

View file

@ -0,0 +1,23 @@
// OVE — /api/ove-directory/scripts/[id]: fetch one full record (including
// the hash string) by numeric id — the "Get" flow's first hop.
import { NextRequest, NextResponse } from 'next/server'
import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const { id } = await params
if (!/^\d+$/.test(id) || Number.parseInt(id, 10) < 1) {
return jsonError(400, 'VALIDATION', 'id must be a positive integer.', 'id')
}
const r = await dirFetch(`/v1/scripts/${id}`)
return NextResponse.json(r.data, { status: r.status })
}

View file

@ -0,0 +1,98 @@
// OVE — /api/ove-directory/scripts: the directory's list + submit endpoints,
// proxied server-to-server (the API key never leaves this process).
//
// GET ?sort=name|type|date|rating&order=asc|desc → meta records (no hash)
// POST { name, type, hash } → 201 full record, or
// 409 DUPLICATE_HASH with the existing row (treated as success by the
// UI). Before forwarding, the proxy re-checks what it is about to
// publish: the share string must decode, and the decoded script must
// compile — only working scripts join the community directory.
import { NextRequest, NextResponse } from 'next/server'
import { decodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
const SORTS = new Set(['name', 'type', 'date', 'rating'])
const ORDERS = new Set(['asc', 'desc'])
/** Unicode code points after trimming (how the upstream counts name/type). */
function cpLen(s: string): number {
return [...s].length
}
export async function GET(req: NextRequest) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const url = new URL(req.url)
const sort = url.searchParams.get('sort') ?? 'date'
const order = url.searchParams.get('order') ?? 'desc'
if (!SORTS.has(sort)) {
return jsonError(400, 'VALIDATION', 'sort must be one of: name, type, date, rating.', 'sort')
}
if (!ORDERS.has(order)) {
return jsonError(400, 'VALIDATION', 'order must be asc or desc.', 'order')
}
const r = await dirFetch(`/v1/scripts?sort=${encodeURIComponent(sort)}&order=${encodeURIComponent(order)}`)
return NextResponse.json(r.data, { status: r.status })
}
export async function POST(req: NextRequest) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const ct = req.headers.get('content-type') ?? ''
if (!ct.toLowerCase().startsWith('application/json')) {
return jsonError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json.')
}
let body: unknown
try {
body = await req.json()
} catch {
return jsonError(400, 'INVALID_JSON', 'The request body is not valid JSON.')
}
const b = (body ?? {}) as Record<string, unknown>
if (typeof b.name !== 'string' || typeof b.type !== 'string' || typeof b.hash !== 'string') {
return jsonError(400, 'VALIDATION', 'name, type and hash are required strings.')
}
const name = b.name.trim()
if (cpLen(name) < 1 || cpLen(name) > 100) {
return jsonError(400, 'VALIDATION', 'name must be 1–100 characters (after trimming).', 'name')
}
const type = b.type.trim()
if (cpLen(type) < 1 || cpLen(type) > 50) {
return jsonError(400, 'VALIDATION', 'type must be 1–50 characters (after trimming).', 'type')
}
const hash = b.hash
const hashBytes = Buffer.byteLength(hash, 'utf8')
if (hashBytes < 1 || hashBytes > 100_000) {
return jsonError(400, 'VALIDATION', 'hash must be 1–100,000 bytes.', 'hash')
}
// Integrity gate: the share string must decode back into a script…
const decoded = decodeScriptHash(hash)
if (!decoded.ok) {
return jsonError(400, 'INVALID_HASH', `The share string did not decode: ${decoded.error}`)
}
// …and that script must compile before it joins the directory.
const v = validateScript(decoded.script)
if (!v.ok) {
const where = v.error ? ` [${v.error.section}] line ${v.error.line}: ${v.error.message}` : ''
return jsonError(400, 'VALIDATION', `Only scripts that compile can be published -- fix it first.${where}`, 'script')
}
const r = await dirFetch('/v1/scripts', { method: 'POST', body: { name, type, hash } })
return NextResponse.json(r.data, { status: r.status })
}

View file

@ -0,0 +1,19 @@
// OVE — /api/ove-directory/status: forward the directory's public health
// probe (no API key needed upstream; harmless to send one). Used by the
// Directory tab's status line.
import { NextResponse } from 'next/server'
import { dirConfig, dirFetch } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
export async function GET() {
if (!dirConfig()) {
return NextResponse.json(
{ error: { code: 'NOT_CONFIGURED', message: 'OVE-Directory is not configured on this server.' } },
{ status: 500 },
)
}
const r = await dirFetch('/status')
return NextResponse.json(r.data, { status: r.status })
}

View file

@ -17,8 +17,9 @@ import { VisualStage } from '@/components/ava/visual-stage'
import { ControlPanel } from '@/components/ava/control-panel' import { ControlPanel } from '@/components/ava/control-panel'
import { FooterBar } from '@/components/ava/footer-bar' import { FooterBar } from '@/components/ava/footer-bar'
import { QueueBar } from '@/components/ava/queue-bar' import { QueueBar } from '@/components/ava/queue-bar'
import { applyScriptText, tapBeat } from '@/components/ava/audio-source' import { applyScriptText, setBootScriptSnapshot, tapBeat } from '@/components/ava/audio-source'
import { STARTER_TEMPLATE } from '@/lib/ava/presets' import { STARTER_TEMPLATE } from '@/lib/ava/presets'
import { releaseWakeLock, syncWakeLock, watchWakeLockVisibility } from '@/lib/ava/wake-lock'
import { import {
lsGetString, lsGetString,
readStoredBandConfig, readStoredBandConfig,
@ -105,6 +106,7 @@ function useInitialLoad() {
// (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers // (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers
// every beat's hue) → any Zai-Magic preset → template // every beat's hue) → any Zai-Magic preset → template
let text: string | null = lsGetString('ava.defaultScript') let text: string | null = lsGetString('ava.defaultScript')
const usedSavedDefault = text !== null && text.trim() !== ''
let startupPreset: (typeof presetsList)[number] | undefined let startupPreset: (typeof presetsList)[number] | undefined
if (!text) { if (!text) {
startupPreset = startupPreset =
@ -117,6 +119,12 @@ function useInitialLoad() {
} }
if (!text) text = STARTER_TEMPLATE if (!text) text = STARTER_TEMPLATE
applyScriptText(text) applyScriptText(text)
// v7.11: remember the untouched startup script — the Demo showcase
// only auto-engages while the editor still matches it (any difference
// is the visitor's own material). A SAVED default is the visitor's
// own material from the start, so it counts as their choice.
setBootScriptSnapshot(text)
if (usedSavedDefault) store.markManualChoice()
if (startupPreset) store.setActivePresetId(startupPreset.id) if (startupPreset) store.setActivePresetId(startupPreset.id)
// club mode: bring back the random-preset timer if it was on // club mode: bring back the random-preset timer if it was on
@ -146,10 +154,28 @@ function useSpaceToTap() {
}, []) }, [])
} }
/**
* v7.11 — keep the phone's screen on while music is playing. Any source but
* idle holds a screen wake lock (see lib/ava/wake-lock.ts); the moment the
* engine goes idle the lock is released and the device's normal inactivity
* dimming / sleep takes over again. Browsers silently drop the sentinel when
* the page is hidden, so a visibilitychange watcher re-acquires it on return
* while music is still going. Unsupported browsers degrade silently.
*/
function useWakeLockOnPlayback() {
const source = useAvaStore((s) => s.source)
useEffect(() => {
syncWakeLock(source !== 'idle')
}, [source])
useEffect(() => watchWakeLockVisibility(), [])
useEffect(() => () => releaseWakeLock(), [])
}
export default function Home() { export default function Home() {
useInitialLoad() useInitialLoad()
useBeatWatcher() useBeatWatcher()
useSpaceToTap() useSpaceToTap()
useWakeLockOnPlayback()
return ( return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:h-screen lg:overflow-hidden"> <div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:h-screen lg:overflow-hidden">

View file

@ -11,6 +11,7 @@ import { getEngine } from '@/lib/ava/engine'
import type { TapResult } from '@/lib/ava/beat' import type { TapResult } from '@/lib/ava/beat'
import { validateScript } from '@/lib/ava/script' import { validateScript } from '@/lib/ava/script'
import type { ScriptError } from '@/lib/ava/script' import type { ScriptError } from '@/lib/ava/script'
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
import { DEMO_TRACK_COUNT, demoTrackTitle } from '@/lib/ava/audio' import { DEMO_TRACK_COUNT, demoTrackTitle } from '@/lib/ava/audio'
import { useAvaStore } from '@/lib/store' import { useAvaStore } from '@/lib/store'
import { toast, useToast } from '@/hooks/use-toast' import { toast, useToast } from '@/hooks/use-toast'
@ -325,16 +326,56 @@ function fileErrorMessage(err: unknown): string {
/** The read-only folder that ships the hand-authored Zai-Magic presets. */ /** The read-only folder that ships the hand-authored Zai-Magic presets. */
const ZAI_MAGIC_FOLDER = 'root/embedded/Zai-Magic' const ZAI_MAGIC_FOLDER = 'root/embedded/Zai-Magic'
// ---------------------------------------------------------------------------
// v7.11 — the visitor's own selection vs. the welcome showcase
// ---------------------------------------------------------------------------
/** The script text this visit booted with (saved default / Bass Bloom /
* template). Any later difference = the visitor's own material. */
let bootScriptSnapshot = ''
/** Capture the startup script (page boot) — the "untouched editor" baseline. */
export function setBootScriptSnapshot(text: string): void {
bootScriptSnapshot = text.trim()
}
/**
* v7.11 — does this visitor have their OWN thing going? True when they
* loaded a preset (tree Load / double-click / Copy to my presets / created
* one), brought their own script material into the editor (typed, pasted,
* hash-imported, file-loaded — anything that differs from the untouched
* startup text), saved their own default startup script, or are running
* their own mix mode. The Demo showcase auto-engages ONLY while this is
* false; otherwise the Demo button just plays music and touches nothing —
* a custom script can be tested and debugged against the demo track
* without any fear of it being replaced.
*/
export function visitorHasOwnSelection(): boolean {
const s = useAvaStore.getState()
if (s.manualChoice) return true
// their own club-mode mix is running — the demo must not re-scope it
if (s.randomize || s.sequenceMode) return true
const cur = s.scriptText.trim()
if (!cur) return false
// the untouched starter template (fresh visit / New button) is not own material
if (cur === STARTER_TEMPLATE.trim()) return false
// anything else that differs from the untouched startup text is theirs
return cur !== bootScriptSnapshot
}
/** /**
* Music just started but no preset is active (the visitor never picked one — * Music just started but no preset is active (the visitor never picked one —
* e.g. they are running their own untitled draft): load the default preset, * e.g. they are running their own untitled draft): load the default preset,
* Zai-Magic "Bass Bloom", so something beat-reactive is on screen the moment * Zai-Magic "Bass Bloom", so something beat-reactive is on screen the moment
* audio arrives. Engine-only — the editor's unsaved text is never touched * audio arrives. Engine-only — the editor's unsaved text is never touched
* (the same courtesy random switching pays). * (the same courtesy random switching pays). v7.11: a visitor with their OWN
* material (custom draft / saved default) is left completely alone — the
* forced default used to replace their running script.
*/ */
function ensureDefaultPreset(): void { function ensureDefaultPreset(): void {
const s = useAvaStore.getState() const s = useAvaStore.getState()
if (s.activePresetId !== null) return if (s.activePresetId !== null) return
if (visitorHasOwnSelection()) return
const preset = s.presets.find( const preset = s.presets.find(
(p) => p.folderPath === ZAI_MAGIC_FOLDER && p.name === 'Bass Bloom', (p) => p.folderPath === ZAI_MAGIC_FOLDER && p.name === 'Bass Bloom',
) )
@ -353,7 +394,8 @@ function ensureDefaultPreset(): void {
* expand it so the visitor sees what is playing), then start the sequence — * expand it so the visitor sees what is playing), then start the sequence —
* v7.4: every Zai-Magic preset IN ORDER, one every 20 seconds, wrapping * v7.4: every Zai-Magic preset IN ORDER, one every 20 seconds, wrapping
* around, for the one play-through (Sequence, not Random — the demo pass * around, for the one play-through (Sequence, not Random — the demo pass
* should walk the whole family, not repeat picks). * should walk the whole family, not repeat picks). v7.11: only ever called
* for a visit that made no choice of their own (see visitorHasOwnSelection).
*/ */
function startShowcaseMix(): void { function startShowcaseMix(): void {
const s = useAvaStore.getState() const s = useAvaStore.getState()
@ -370,7 +412,8 @@ function startShowcaseMix(): void {
* Stop button, or switching to another source) unwind the showcase state: * Stop button, or switching to another source) unwind the showcase state:
* the Demo flow force-checked the mix mode (Sequence), so it gets unchecked * the Demo flow force-checked the mix mode (Sequence), so it gets unchecked
* again. The visitor's own selection (the last preset that was running) * again. The visitor's own selection (the last preset that was running)
* stays put. * stays put. v7.11: only called when the showcase actually engaged — a
* visitor whose own Random/Sequence mix was already running keeps it.
*/ */
function retireDemoShowcase(): void { function retireDemoShowcase(): void {
const s = useAvaStore.getState() const s = useAvaStore.getState()
@ -430,27 +473,37 @@ export function useAudioSource() {
const index = readDemoNext() const index = readDemoNext()
try { try {
await engine.audio.startDemo(index) await engine.audio.startDemo(index)
// v7.11: the welcome showcase (Bass Bloom + Zai-Magic Sequence) only
// engages for a visit that made NO choice of their own — no preset
// loaded (double-click / Load / a copy), no custom script material in
// the editor, no own mix running. Everyone else keeps their selection:
// Demo just plays the track.
const showcase = !visitorHasOwnSelection()
// v7.3/v7.10: single pass — when the track runs out (or is stopped, or // v7.3/v7.10: single pass — when the track runs out (or is stopped, or
// replaced by another source) the engine fires onDemoEnded exactly // replaced by another source) the engine fires onDemoEnded exactly
// once, WITH the reason: sync the store to idle (the landing overlay // once, WITH the reason: sync the store to idle (the landing overlay
// returns), uncheck the randomizer the showcase had checked, and — // returns), uncheck the showcase mix ONLY when the showcase engaged,
// when the pass truly ENDED (natural finish or the Stop button) — // and — when the pass truly ENDED (natural finish or the Stop button) —
// advance the collection place so the next Demo click plays the next // advance the collection place so the next Demo click plays the next
// track. A source switch keeps the visitor's place. // track. A source switch keeps the visitor's place.
engine.audio.onDemoEnded = (reason) => { engine.audio.onDemoEnded = (reason) => {
syncSource() syncSource()
retireDemoShowcase() if (showcase) retireDemoShowcase()
if (reason === 'ended' || reason === 'stop') writeDemoNext(index + 1) if (reason === 'ended' || reason === 'stop') writeDemoNext(index + 1)
} }
syncSource() syncSource()
// showcase flow (v7.2/v7.4): nothing active → Bass Bloom, then the // showcase flow (v7.2/v7.4/v7.11): nothing active and nothing of the
// Zai-Magic sequence takes over — every preset in order, 20 s each, // visitor's own → Bass Bloom, then the Zai-Magic sequence takes over —
// for this one play-through // every preset in order, 20 s each, for this one play-through. With
// the visitor's own selection, BOTH are skipped (ensureDefaultPreset
// holds its own guard as well).
ensureDefaultPreset() ensureDefaultPreset()
startShowcaseMix() if (showcase) startShowcaseMix()
toast({ toast({
title: 'Demo track playing', title: 'Demo track playing',
description: `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.`, description: showcase
? `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.`
: `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · your selection stays active -- the demo never touches your preset or the editor.`,
}) })
} catch (err) { } catch (err) {
syncSource() syncSource()

View file

@ -1,4 +1,4 @@
// OVE — right-hand control panel: the five tabs. // OVE — right-hand control panel: the six tabs.
// //
// Desktop: fixed width, content scrolls inside the panel. Mobile: natural // Desktop: fixed width, content scrolls inside the panel. Mobile: natural
// height (the page scrolls and the footer is pushed down naturally). // height (the page scrolls and the footer is pushed down naturally).
@ -9,6 +9,7 @@ import { motion } from 'framer-motion'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PresetsTab } from '@/components/ava/presets-tab' import { PresetsTab } from '@/components/ava/presets-tab'
import { ScriptTab } from '@/components/ava/script-tab' import { ScriptTab } from '@/components/ava/script-tab'
import { DirectoryTab } from '@/components/ava/directory-tab'
import { DetectTab } from '@/components/ava/detect-tab' import { DetectTab } from '@/components/ava/detect-tab'
import { BeatTab } from '@/components/ava/beat-tab' import { BeatTab } from '@/components/ava/beat-tab'
import { NativeTab } from '@/components/ava/native-tab' import { NativeTab } from '@/components/ava/native-tab'
@ -19,8 +20,12 @@ import { cn } from '@/lib/utils'
// v6.5 — the Native tab is PARKED (hidden) so the Settings tab can take its // v6.5 — the Native tab is PARKED (hidden) so the Settings tab can take its
// slot; it returns unchanged when the project nears completion. Flip this // slot; it returns unchanged when the project nears completion. Flip this
// flag to bring it back — the tab row widens to 6 columns automatically and // flag to bring it back — the tab row widens to 7 columns automatically and
// the component + /api/rust-scaffold endpoint are untouched. // the component + /api/rust-scaffold endpoint are untouched.
//
// v7.11 — the Directory tab (community script browser) joined between Script
// and Detect; the row is 3 columns on phones (two stacked rows) and one row
// from sm up.
const NATIVE_TAB_ENABLED = false const NATIVE_TAB_ENABLED = false
function TabBody({ children }: { children: React.ReactNode }) { function TabBody({ children }: { children: React.ReactNode }) {
@ -45,7 +50,7 @@ export function ControlPanel() {
<TabsList <TabsList
className={cn( className={cn(
'h-11 w-full shrink-0 bg-zinc-900/80', 'h-11 w-full shrink-0 bg-zinc-900/80',
NATIVE_TAB_ENABLED ? 'grid-cols-6' : 'grid-cols-5', NATIVE_TAB_ENABLED ? 'grid-cols-3 sm:grid-cols-7' : 'grid-cols-3 sm:grid-cols-6',
)} )}
> >
<TabsTrigger value="presets" className="text-xs"> <TabsTrigger value="presets" className="text-xs">
@ -54,6 +59,9 @@ export function ControlPanel() {
<TabsTrigger value="script" className="text-xs"> <TabsTrigger value="script" className="text-xs">
Script Script
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="directory" className="text-xs">
Directory
</TabsTrigger>
<TabsTrigger value="detect" className="text-xs"> <TabsTrigger value="detect" className="text-xs">
Detect Detect
</TabsTrigger> </TabsTrigger>
@ -81,6 +89,11 @@ export function ControlPanel() {
<ScriptTab /> <ScriptTab />
</TabBody> </TabBody>
</TabsContent> </TabsContent>
<TabsContent value="directory" className="flex min-h-0 flex-1 flex-col outline-none">
<TabBody>
<DirectoryTab />
</TabBody>
</TabsContent>
<TabsContent value="detect" className="flex min-h-0 flex-1 flex-col outline-none"> <TabsContent value="detect" className="flex min-h-0 flex-1 flex-col outline-none">
<TabBody> <TabBody>
<DetectTab /> <DetectTab />

View file

@ -0,0 +1,496 @@
// OVE — Directory tab (v7.11): the community script browser.
//
// Lists every script published to the OVE-Directory (its own VPS, reached
// through this server's backend proxy) with the name its author chose, its
// type, its date, and its star rating. Visitors rate entries 1–5 (one vote
// per script per visitor, enforced upstream), see the live average to the
// right of the stars, sort the whole list by Name / Type / Date / Rating,
// and "Get" any entry: the hash string is fetched, decoded back into the
// exact script, and filed in this browser's preset tree under
// "root/user created/OVE-Directory" -- from where it opens in the editor.
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
Check,
CloudUpload,
Copy,
Download,
FileCode2,
Loader2,
RefreshCw,
Star,
TriangleAlert,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { decodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { useAvaStore } from '@/lib/store'
import { useToast } from '@/hooks/use-toast'
import {
DEFAULT_ORDER,
DEFAULT_SORT,
DIR_SORTS,
DOWNLOAD_FOLDER,
fetchDirList,
fetchDirScript,
fetchDirStatus,
formatDirDate,
loadMyVotes,
rateDirScript,
rememberMyVote,
saveDownloadedPreset,
type DirMeta,
type DirOrder,
type DirSortKey,
type DirStatus,
} from '@/lib/ove-directory'
import { cn } from '@/lib/utils'
/** Copy text to the clipboard with a legacy fallback (same trick as the share dialogs). */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
ta.remove()
return ok
} catch {
return false
}
}
}
interface GotPanel {
hash: string
script: string
compiles: boolean
savedAs: string
alreadyHad: boolean
}
// ── one directory row ─────────────────────────────────────────────────────
function StarRow({
entry,
myVote,
busy,
onVote,
}: {
entry: DirMeta
myVote: number
busy: boolean
onVote: (stars: number) => void
}) {
const [hover, setHover] = useState(0)
const active = hover || myVote
const locked = myVote > 0
return (
<span
className="flex items-center"
onMouseLeave={() => setHover(0)}
role="group"
aria-label={`Rating for ${entry.name}`}
>
{[1, 2, 3, 4, 5].map((n) => {
const filled = n <= active
return (
<button
key={n}
type="button"
disabled={busy || locked}
aria-label={
locked ? `You rated this ${myVote} of 5` : `Rate ${entry.name}: ${n} of 5 stars`
}
title={locked ? `You rated this ${myVote}\u2605` : `Rate ${n} of 5`}
onMouseEnter={() => setHover(n)}
onFocus={() => setHover(n)}
onClick={() => onVote(n)}
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md transition-colors',
locked ? 'cursor-default' : 'hover:bg-zinc-800/70',
)}
>
<Star
aria-hidden
className={cn(
'size-4 transition-colors',
filled ? 'fill-current text-amber-400' : 'text-zinc-600',
locked && 'opacity-70',
)}
/>
</button>
)
})}
</span>
)
}
function DirRow({
entry,
myVote,
voting,
getting,
got,
onVote,
onGet,
}: {
entry: DirMeta
myVote: number
voting: boolean
getting: boolean
got: GotPanel | null
onVote: (stars: number) => void
onGet: () => void
}) {
const [copied, setCopied] = useState(false)
async function copyHash() {
if (!got) return
const ok = await copyText(got.hash)
if (ok) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
function openInEditor() {
if (!got) return
useAvaStore.getState().setScriptText(got.script)
useAvaStore.getState().setTab('script')
}
return (
<article className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
<div className="flex items-baseline gap-2">
<h4 className="min-w-0 truncate text-sm font-medium text-zinc-100" title={entry.name}>
{entry.name}
</h4>
<span
className="max-w-32 shrink-0 truncate rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-400"
title={entry.type}
>
{entry.type}
</span>
<time className="ml-auto shrink-0 font-mono text-[11px] text-zinc-500" dateTime={entry.created_at}>
{formatDirDate(entry.created_at)}
</time>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<StarRow entry={entry} myVote={myVote} busy={voting} onVote={onVote} />
<span className="font-mono text-[11px] text-zinc-400" role="status">
Average: <span className="text-amber-300">{entry.average.toFixed(1)}</span>
<span className="text-zinc-600"> · {entry.votes} {entry.votes === 1 ? 'vote' : 'votes'}</span>
</span>
<span className="ml-auto">
{getting ? (
<Button size="sm" disabled className="h-8 gap-1.5 border-zinc-700 text-xs">
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Getting…
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={onGet}
className="h-8 gap-1.5 border-zinc-700 text-xs"
title="Fetch this script: it's saved to this browser's presets and can open in the editor"
>
{got ? <Check className="size-3.5 text-emerald-400" aria-hidden /> : <Download className="size-3.5" aria-hidden />}
{got ? 'Get again' : 'Get'}
</Button>
)}
</span>
</div>
{got && (
<div className="mt-3 space-y-2 border-t border-zinc-800 pt-3">
<p className="text-[11px] leading-relaxed text-zinc-400">
{got.alreadyHad ? (
<>You already had this exact script -- no duplicate was made.</>
) : (
<>
Saved to your presets as <span className="text-emerald-300">{got.savedAs}</span> in{' '}
<span className="font-mono">{DOWNLOAD_FOLDER}</span>.
</>
)}
</p>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[11px] text-zinc-500">Share string (the stored hash):</span>
<Button
size="sm"
variant="outline"
onClick={() => void copyHash()}
className="h-7 gap-1 border-zinc-700 px-2 text-[11px]"
title="Copy the full share string"
>
{copied ? <Check className="size-3" aria-hidden /> : <Copy className="size-3" aria-hidden />}
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
<p className="max-h-20 overflow-y-auto break-all rounded-md border border-zinc-800 bg-zinc-950 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-emerald-200/70 select-all">
{got.hash}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p
className={cn(
'flex items-center gap-1.5 font-mono text-[11px]',
got.compiles ? 'text-emerald-400' : 'text-amber-400',
)}
>
<FileCode2 className="size-3.5 shrink-0" aria-hidden />
{got.compiles ? 'Decodes and compiles OK' : "Decodes, but doesn't compile (yet)"}
</p>
<Button
size="sm"
onClick={openInEditor}
className="h-8 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
title="Load this script into the editor"
>
<FileCode2 className="size-3.5" aria-hidden /> Open in editor
</Button>
</div>
</div>
)}
</article>
)
}
// ── the tab ───────────────────────────────────────────────────────────────
export function DirectoryTab() {
const { toast } = useToast()
const [status, setStatus] = useState<DirStatus | null>(null)
const [scripts, setScripts] = useState<DirMeta[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [sort, setSort] = useState<DirSortKey>(DEFAULT_SORT)
const [order, setOrder] = useState<DirOrder>(DEFAULT_ORDER)
const [myVotes, setMyVotes] = useState<Record<string, number>>({})
const [voting, setVoting] = useState<number | null>(null)
const [getting, setGetting] = useState<number | null>(null)
const [got, setGot] = useState<Record<number, GotPanel>>({})
useEffect(() => {
setMyVotes(loadMyVotes())
void (async () => {
const s = await fetchDirStatus()
setStatus(s)
})()
}, [])
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
const list = await fetchDirList(sort, order)
setScripts(list)
} catch (e) {
setError(e instanceof Error ? e.message : 'The directory could not be listed.')
} finally {
setLoading(false)
}
}, [sort, order])
useEffect(() => {
void reload()
}, [reload])
const vote = useCallback(
async (entry: DirMeta, stars: number) => {
if (voting !== null) return
setVoting(entry.id)
try {
const r = await rateDirScript(entry.id, stars)
rememberMyVote(entry.id, stars)
setMyVotes(loadMyVotes())
setScripts((prev) =>
prev.map((s) => (s.id === entry.id ? { ...s, average: r.average, votes: r.votes } : s)),
)
if (r.duplicate) {
toast({
title: 'Already rated',
description: 'One vote per script per visitor -- the directory kept the first one.',
})
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Rating failed.'
if (msg.includes('no longer in the directory')) {
setScripts((prev) => prev.filter((s) => s.id !== entry.id))
}
toast({ title: 'Rating failed', description: msg, variant: 'destructive' })
} finally {
setVoting(null)
}
},
[voting, toast],
)
const get = useCallback(
async (entry: DirMeta) => {
if (getting !== null) return
setGetting(entry.id)
try {
const full = await fetchDirScript(entry.id)
const decoded = decodeScriptHash(full.hash)
if (!decoded.ok) throw new Error('The stored share string did not decode -- it may be corrupted.')
const v = validateScript(decoded.script)
const save = saveDownloadedPreset(decoded.script, entry.name)
if (!save.ok) throw new Error(save.error ?? 'Could not save to this browser.')
setGot((prev) => ({
...prev,
[entry.id]: {
hash: full.hash,
script: decoded.script,
compiles: v.ok,
savedAs: save.preset?.name ?? entry.name,
alreadyHad: Boolean(save.alreadyHad),
},
}))
toast({
title: save.alreadyHad ? 'Already in your presets' : 'Saved to your presets',
description: `"${save.preset?.name ?? entry.name}" -- open it in the editor from the panel below, or hit Apply after switching to the Script tab.`,
})
} catch (e) {
toast({
title: 'Get failed',
description: e instanceof Error ? e.message : 'Get failed.',
variant: 'destructive',
})
} finally {
setGetting(null)
}
},
[getting, toast],
)
const online = status?.status === 'ok'
return (
<div className="space-y-3">
{/* header: service status + refresh */}
<div className="flex items-center gap-2">
<span
aria-hidden
className={cn(
'size-2 shrink-0 rounded-full',
status === null ? 'bg-zinc-600' : online ? 'bg-emerald-400' : 'bg-amber-400',
)}
/>
<h3 className="text-sm font-semibold text-zinc-100">OVE-Directory</h3>
<span className="truncate font-mono text-[11px] text-zinc-500">
{status === null
? 'connecting…'
: online
? `${status.scripts_total} ${status.scripts_total === 1 ? 'script' : 'scripts'} · v${status.version}`
: 'unreachable'}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
void reload()
void fetchDirStatus().then(setStatus)
}}
className="ml-auto h-8 w-8 p-0 text-zinc-400 hover:text-zinc-100"
title="Refresh the list"
aria-label="Refresh the directory list"
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} aria-hidden />
</Button>
</div>
{/* sort controls: Name / Type / Date / Rating + direction */}
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label="Sort the directory">
{DIR_SORTS.map((s) => (
<Button
key={s.key}
size="sm"
variant="outline"
onClick={() => setSort(s.key)}
className={cn(
'h-8 border-zinc-700 px-2.5 text-xs',
sort === s.key && 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
)}
aria-pressed={sort === s.key}
>
{s.label}
</Button>
))}
<Button
size="sm"
variant="outline"
onClick={() => setOrder((o) => (o === 'asc' ? 'desc' : 'asc'))}
className="h-8 border-zinc-700 px-2.5 text-xs"
title="Toggle sort direction"
>
{order === 'asc' ? '↑ asc' : '↓ desc'}
</Button>
<span className="ml-auto hidden font-mono text-[11px] text-zinc-600 sm:inline" aria-hidden>
{sort}/{order}
</span>
</div>
{/* list / states */}
{error ? (
<div className="space-y-3 rounded-lg border border-rose-500/25 bg-rose-500/5 p-4" role="alert">
<p className="flex items-start gap-2 text-sm text-rose-300">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
{error}
</p>
<Button size="sm" variant="outline" onClick={() => void reload()} className="h-8 border-zinc-700 text-xs">
<RefreshCw className="size-3.5" aria-hidden /> Try again
</Button>
</div>
) : loading ? (
<div className="space-y-2" aria-busy>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-20 animate-pulse rounded-lg border border-zinc-800 bg-zinc-900/40" />
))}
</div>
) : scripts.length === 0 ? (
<div className="space-y-2 rounded-lg border border-zinc-800 bg-zinc-900/40 p-4 text-center">
<p className="text-sm text-zinc-300">Nothing published yet.</p>
<p className="text-xs leading-relaxed text-zinc-500">
Build a script in the Script tab, make sure it shows{' '}
<span className="font-mono text-emerald-400">✔ Compiled OK</span>, then hit{' '}
<span className="text-emerald-300">Upload to OVE-Directory</span> -- published scripts appear
here for everyone to browse, rate, and Get.
</p>
</div>
) : (
<div className="space-y-2">
{scripts.map((entry) => (
<DirRow
key={entry.id}
entry={entry}
myVote={myVotes[String(entry.id)] ?? 0}
voting={voting === entry.id}
getting={getting === entry.id}
got={got[entry.id] ?? null}
onVote={(stars) => void vote(entry, stars)}
onGet={() => void get(entry)}
/>
))}
<p className="flex items-center justify-center gap-1.5 pt-1 text-center font-mono text-[11px] text-zinc-600">
<CloudUpload className="size-3" aria-hidden />
{scripts.length} {scripts.length === 1 ? 'entry' : 'entries'} -- publish yours from the Script tab
</p>
</div>
)}
</div>
)
}

View file

@ -23,12 +23,15 @@ const FRAME_VARS: [string, string][] = [
['w / h', 'canvas size in pixels'], ['w / h', 'canvas size in pixels'],
['aspect', 'canvas width / height -- divide x by it for perfect circles'], ['aspect', 'canvas width / height -- divide x by it for perfect circles'],
['persist', 'motion-trail persistence 0..0.98 (writable per frame)'], ['persist', 'motion-trail persistence 0..0.98 (writable per frame)'],
['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][] = [ const POINT_VARS: [string, string][] = [
['i', '0..1 point index'], ['i', '0..1 point index'],
['v', 'waveform sample −1..1'], ['v', 'waveform sample −1..1'],
['sv', 'spectrum magnitude 0..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)'], ['x / y', 'position −1..1 (x = sin(a)*rad / aspect keeps circles round)'],
['red / green / blue', 'point color 0..1'], ['red / green / blue', 'point color 0..1'],
['skip', '> 0.5 hides the point / breaks the line'], ['skip', '> 0.5 hides the point / breaks the line'],
@ -62,6 +65,9 @@ const EXAMPLES: [string, string][] = [
['rot = rot + dt * 1.2 + beat * 0.4', 'beat kick'], ['rot = rot + dt * 1.2 + beat * 0.4', 'beat kick'],
['ang = rand(i) * 6.28318', 'stable per-point random'], ['ang = rand(i) * 6.28318', 'stable per-point random'],
['x = sin(i * 6.28318 + t) * rad / aspect', 'circle'], ['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 }) { function HelpTable({ rows, mono = true }: { rows: [string, string][]; mono?: boolean }) {

View file

@ -167,6 +167,9 @@ function useLoadPreset() {
if (result.ok) { if (result.ok) {
st.setActivePresetId(preset.id) st.setActivePresetId(preset.id)
st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath }) st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath })
// v7.11: an explicitly loaded preset (double-click / Load button) is
// the visitor's own choice — the Demo showcase must never override it
st.markManualChoice()
// No "Preset loaded" toast (v7.3): the footer's SCRIPT: label // No "Preset loaded" toast (v7.3): the footer's SCRIPT: label
// already echoes the running preset — the popup was intrusive and // already echoes the running preset — the popup was intrusive and
// only compile errors genuinely need the attention grab. // only compile errors genuinely need the attention grab.
@ -527,6 +530,7 @@ export function PresetsTab() {
applyScriptText(STARTER_TEMPLATE) applyScriptText(STARTER_TEMPLATE)
st.setActivePresetId(res.preset.id) st.setActivePresetId(res.preset.id)
st.selectPreset(res.preset.id) st.selectPreset(res.preset.id)
st.markManualChoice() // v7.11 — a created preset is the visitor's own choice
st.setTab('script') st.setTab('script')
toast({ toast({
title: 'Preset created', title: 'Preset created',
@ -671,6 +675,7 @@ export function PresetsTab() {
const st = useAvaStore.getState() const st = useAvaStore.getState()
st.toggleFolder(USER_ROOT, true) st.toggleFolder(USER_ROOT, true)
st.selectPreset(res.preset.id) st.selectPreset(res.preset.id)
st.markManualChoice() // v7.11 — a copied preset is the visitor's own choice
toast({ toast({
title: 'Copied to your presets', title: 'Copied to your presets',
description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`, description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`,

View file

@ -1,12 +1,15 @@
// AVA — Script tab: the custom visualization script editor. // AVA — Script tab: the custom visualization script editor.
// //
// Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save // Toolbar: Apply, Save (to the preset picked in the Presets tab when a save
// target is set), Save As…, Save as Default, Save to File, Load from File, // target is set; v7.11 also with NO target — one click stores the current
// script as a new preset under "user created", auto-named from its name:
// header), Save As…, Save as Default, Save to File, Load from File,
// Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode // Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode
// string and paste one back), New, Help. Editor: line-number gutter + // string and paste one back), Upload to OVE-Directory (v7.11 — publish the
// monospace textarea (synced scroll, Tab inserts two spaces), error lines // compiled script to the community directory), New, Help. Editor: line-number
// flagged in the gutter. Status line under the editor shows compile status // gutter + monospace textarea (synced scroll, Tab inserts two spaces), error
// or runtime errors. // lines flagged in the gutter. Status line under the editor shows compile
// status or runtime errors.
'use client' 'use client'
@ -15,6 +18,7 @@ import {
Check, Check,
CircleHelp, CircleHelp,
ClipboardPaste, ClipboardPaste,
CloudUpload,
Copy, Copy,
Download, Download,
FilePlus2, FilePlus2,
@ -44,11 +48,12 @@ import {
import { applyScriptText } from '@/components/ava/audio-source' import { applyScriptText } from '@/components/ava/audio-source'
import { HelpPanel } from '@/components/ava/help-panel' import { HelpPanel } from '@/components/ava/help-panel'
import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs' import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs'
import { UploadToDirectoryDialog } from '@/components/ava/upload-dialog'
import { sanitizeFileName } from '@/components/ava/shared' import { sanitizeFileName } from '@/components/ava/shared'
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths' import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths'
import { createUserPreset, presetIsLocal, updateUserPreset } from '@/lib/ava/user-presets' import { createUserPreset, listUserPresets, presetIsLocal, renameUserPreset, updateUserPreset } from '@/lib/ava/user-presets'
/** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */ /** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */
function formatSavePath(folderPath: string, name: string): string { function formatSavePath(folderPath: string, name: string): string {
@ -58,6 +63,16 @@ function formatSavePath(folderPath: string, name: string): string {
return `${segs.slice(0, 2).join('/')}/…/${name}` return `${segs.slice(0, 2).join('/')}/…/${name}`
} }
/** First free name in a folder: "Base", "Base (1)", "Base (2)" … */
function uniqueName(base: string, taken: Set<string>): string {
if (!taken.has(base)) return base
for (let i = 1; i < 1000; i++) {
const candidate = `${base} (${i})`
if (!taken.has(candidate)) return candidate
}
return `${base} (${Date.now().toString(36).slice(-4)})`
}
export function ScriptTab() { export function ScriptTab() {
const { toast } = useToast() const { toast } = useToast()
const scriptText = useAvaStore((s) => s.scriptText) const scriptText = useAvaStore((s) => s.scriptText)
@ -80,6 +95,23 @@ export function ScriptTab() {
// v7.7 — share-string dialogs // v7.7 — share-string dialogs
const [shareOutOpen, setShareOutOpen] = useState(false) const [shareOutOpen, setShareOutOpen] = useState(false)
const [shareInOpen, setShareInOpen] = useState(false) const [shareInOpen, setShareInOpen] = useState(false)
// v7.11 — publish to the community OVE-Directory. Gated on the same line
// the status line shows: the editor script must pass ✔ Compiled OK and the
// engine must not be sitting on a runtime error (a stale crash from an
// earlier Apply keeps this off until Apply runs clean).
const [uploadOpen, setUploadOpen] = useState(false)
const canUpload = validation?.ok === true && !runtimeError
// v7.11 (parallel line, merged in v7.13) — the display name a Hash⇢Script
// import arrived with (the script's name: header, else the share-string
// label); the one-click Save uses it when the script itself carries no name
const [pendingName, setPendingName] = useState<string | null>(null)
// v7.11 (parallel line, merged in v7.13) — "Give This Preset a Name" dialog
// (renames the just-saved auto-named preset: nameless scripts / duplicates)
const [renameOpen, setRenameOpen] = useState(false)
const [renameValue, setRenameValue] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameBusy, setRenameBusy] = useState(false)
const [renameTarget, setRenameTarget] = useState<{ id: string; name: string; folderPath: string } | null>(null)
// debounced live validation (compile is cheap, scripts are small) // debounced live validation (compile is cheap, scripts are small)
useEffect(() => { useEffect(() => {
@ -159,6 +191,111 @@ export function ScriptTab() {
} }
} }
/**
* v7.11 — one-click save with NO target picked: create a new preset under
* "user created" from the current editor script. Auto-named from the
* script's name: header (e.g. a hash import of "Spectrum Bars:[▼…]" saves
* as "Spectrum Bars"); a nameless script lands as "Empty Name", a
* conflict as "Base (1)", "Base (2)"… — and whenever the name had to be
* invented, the "Give This Preset a Name" dialog opens right after so the
* just-saved preset can be renamed in one extra step. Nothing is ever
* lost to a naming problem: the preset is saved FIRST, renamed second.
*/
const saveAsNewPreset = async () => {
if (saving) return
setSaving(true)
try {
const v = validateScript(scriptText)
if (!v.ok) {
toast({
title: 'Fix the compile errors first',
description: 'The script must compile before it can be saved as a preset.',
variant: 'destructive',
})
return
}
// candidate name: the script's own name: header, else the share-string
// label it arrived with (Hash⇢Script import)
let base: string | null = null
const metaName = v.meta?.name?.trim()
if (metaName && metaName !== 'Untitled') base = metaName
if (!base && pendingName) base = pendingName.trim() || null
if (base) {
// preset names must stay path-safe (no slashes / control characters)
base = base.replace(/[/\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120).trim()
if (!base) base = null
}
const taken = new Set(
listUserPresets().filter((p) => p.folderPath === USER_ROOT).map((p) => p.name),
)
const autoName = uniqueName(base ?? 'Empty Name', taken)
const invented = base === null || autoName !== base
const res = createUserPreset({
name: autoName,
content: scriptText,
folderPath: USER_ROOT,
author: v.meta?.author || 'you',
})
if (!res.ok) {
toast({ title: 'Save failed', description: res.error, variant: 'destructive' })
return
}
applyUserPresets()
setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
const st = useAvaStore.getState()
st.selectPreset(res.preset.id)
st.toggleFolder(USER_ROOT, true)
toast({
title: `Saved as "${res.preset.name}"`,
description: invented
? 'Stored under "user created" in this browser -- give it a better name in the dialog.'
: 'Stored under "user created" in this browser.',
})
if (invented) {
// the name had to be invented (nameless script / duplicate conflict)
// — the preset is SAFE; offer the rename as one extra step
setRenameTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
setRenameValue(base ?? '')
setRenameError(null)
setRenameOpen(true)
}
} finally {
setSaving(false)
}
}
/** v7.11 — confirm the "Give This Preset a Name" dialog: rename the
* just-saved preset to the visitor's name (stays open on conflicts). */
const applyRename = async () => {
if (!renameTarget || renameBusy) return
const trimmed = renameValue.trim()
if (!trimmed) {
setRenameError('Enter a name first.')
return
}
setRenameBusy(true)
try {
const res = renameUserPreset(renameTarget.id, trimmed)
if (!res.ok) {
setRenameError(res.error)
return
}
applyUserPresets()
const st = useAvaStore.getState()
if (st.editTarget?.id === res.preset.id) {
st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
}
st.selectPreset(res.preset.id)
setRenameOpen(false)
toast({
title: 'Preset renamed',
description: `Saved as "${res.preset.name}" in ${res.preset.folderPath} (this browser).`,
})
} finally {
setRenameBusy(false)
}
}
const openSaveAs = () => { const openSaveAs = () => {
if (!editTarget) return if (!editTarget) return
setSaveAsName(editTarget.name) setSaveAsName(editTarget.name)
@ -226,6 +363,9 @@ export function ScriptTab() {
const text = await file.text() const text = await file.text()
const result = applyScriptText(text) const result = applyScriptText(text)
if (result.ok) { if (result.ok) {
// v7.11: a file from disk is the visitor's own material — the Demo
// showcase must never clobber it
useAvaStore.getState().markManualChoice()
const meta = validateScript(text).meta const meta = validateScript(text).meta
toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` }) toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` })
} else if (result.error) { } else if (result.error) {
@ -301,7 +441,7 @@ export function ScriptTab() {
> >
<Play className="size-3.5" /> Apply <Play className="size-3.5" /> Apply
</Button> </Button>
{editTarget && ( {editTarget ? (
<Button <Button
size="sm" size="sm"
onClick={() => void saveToTarget()} onClick={() => void saveToTarget()}
@ -311,6 +451,18 @@ export function ScriptTab() {
> >
<Save className="size-3.5" /> Save <Save className="size-3.5" /> Save
</Button> </Button>
) : (
validation?.ok && (
<Button
size="sm"
onClick={() => void saveAsNewPreset()}
disabled={saving}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-emerald-950 hover:bg-emerald-300"
title='Save the current script as a NEW preset under "user created" -- auto-named from the script name: header'
>
<Save className="size-3.5" /> Save
</Button>
)
)} )}
{editTarget && ( {editTarget && (
<Button <Button
@ -368,12 +520,28 @@ export function ScriptTab() {
> >
<ClipboardPaste className="size-3.5 text-emerald-400/90" /> Hash⇢Script <ClipboardPaste className="size-3.5 text-emerald-400/90" /> Hash⇢Script
</Button> </Button>
<Button
size="sm"
variant="outline"
disabled={!canUpload}
onClick={() => setUploadOpen(true)}
className="h-9 gap-1.5 border-zinc-700"
title={
canUpload
? 'Publish this compiled script to the community OVE-Directory (it travels as its share string)'
: 'Publishing needs the script to pass ✔ Compiled OK with no runtime error -- fix it, then Apply'
}
>
<CloudUpload className="size-3.5 text-emerald-400/90" aria-hidden />{' '}
<span className="hidden sm:inline">Upload to </span>OVE-Directory
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setScriptText(STARTER_TEMPLATE) setScriptText(STARTER_TEMPLATE)
setEditTarget(null) setEditTarget(null)
setPendingName(null)
}} }}
className="h-9 gap-1.5 border-zinc-700" className="h-9 gap-1.5 border-zinc-700"
title="Start over from the template (clears the save target)" title="Start over from the template (clears the save target)"
@ -486,14 +654,78 @@ export function ScriptTab() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* v7.11 -- rename the just-auto-named preset (nameless script /
duplicate conflict): the preset is already saved under "user
created"; this dialog only gives it the visitor's own name */}
<Dialog
open={renameOpen}
onOpenChange={(o) => {
setRenameOpen(o)
if (o) setRenameError(null)
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Give This Preset a Name</DialogTitle>
<DialogDescription>
The script is already saved as “{renameTarget?.name ?? 'Untitled'}” under “user created” in this
browser. Name it anything you like -- the rename applies instantly.
</DialogDescription>
</DialogHeader>
<div>
<Input
value={renameValue}
onChange={(e) => {
setRenameValue(e.target.value)
setRenameError(null)
}}
placeholder="Preset name"
autoFocus
aria-label="New preset name"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === 'Enter') void applyRename()
}}
/>
{renameError && <p className="mt-1.5 text-[11px] text-rose-400">{renameError}</p>}
</div>
<DialogFooter>
<Button variant="outline" className="h-9 border-zinc-700" onClick={() => setRenameOpen(false)}>
Keep as-is
</Button>
<Button
className="h-9 gap-1.5 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
disabled={renameBusy}
onClick={() => void applyRename()}
>
<Save className="size-3.5" /> {renameBusy ? 'Renaming…' : 'Save Name'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* v7.7 -- share-string dialogs */} {/* v7.7 -- share-string dialogs */}
<ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} /> <ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} />
<HashToScriptDialog <HashToScriptDialog
open={shareInOpen} open={shareInOpen}
onOpenChange={setShareInOpen} onOpenChange={setShareInOpen}
onImport={(text) => setScriptText(text)} onImport={(text, name) => {
setScriptText(text)
setPendingName(name)
// the editor now holds FOREIGN content — the previous save target
// no longer matches what is in the editor, so a later Save must not
// overwrite it (v7.11: Save creates a new preset instead)
setEditTarget(null)
// imported material is the visitor's own — the Demo showcase must
// never clobber it (v7.11)
useAvaStore.getState().markManualChoice()
}}
/> />
{/* v7.11 -- publish to the community directory */}
<UploadToDirectoryDialog open={uploadOpen} onOpenChange={setUploadOpen} scriptText={scriptText} />
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View file

@ -164,8 +164,10 @@ export function HashToScriptDialog({
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
/** Load the decoded script into the editor (script-tab wires this). */ /** Load the decoded script into the editor (script-tab wires this).
onImport: (script: string) => void * v7.11: also hands back the script's display name (its name: header,
* else the share-string label) so Save can auto-name the new preset. */
onImport: (script: string, name: string | null) => void
}) { }) {
const { toast } = useToast() const { toast } = useToast()
const [input, setInput] = useState('') const [input, setInput] = useState('')
@ -219,12 +221,12 @@ export function HashToScriptDialog({
function load() { function load() {
if (!decoded) return if (!decoded) return
onImport(decoded.script) onImport(decoded.script, decoded.name ?? null)
onOpenChange(false) onOpenChange(false)
toast({ toast({
title: 'Script loaded from share string', title: 'Script loaded from share string',
description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${ description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${
decoded.compiles ? ' Hit Apply to run it.' : ' Fix the errors, then Apply.' decoded.compiles ? ' Hit Apply to run it, or Save to keep it as a preset.' : ' Fix the errors, then Apply.'
}`, }`,
}) })
} }

View file

@ -0,0 +1,216 @@
// AVA — "Upload to OVE-Directory" dialog (v7.11).
//
// Publishes the current editor script to the community directory (its own
// VPS, reached through this server's backend proxy). The script travels as
// its compact share string -- the same Script⇢Hash encoding -- so the
// directory stores a few hundred characters instead of the full source;
// anyone "Getting" the entry later decodes the exact script back.
//
// Only scripts that pass ✔ Compiled OK can be published (the toolbar button
// is disabled otherwise, and the backend re-checks the hash decodes AND
// compiles before forwarding). Entries are permanent: the directory has no
// update and no delete, so the name deserves a second look.
'use client'
import { useMemo, useState } from 'react'
import { CloudUpload, TriangleAlert, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { encodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { uploadDirScript } from '@/lib/ove-directory'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
const HASH_PREVIEW = 160
export function UploadToDirectoryDialog({
open,
onOpenChange,
scriptText,
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** The current editor script (already gate-checked: it compiles). */
scriptText: string
}) {
const { toast } = useToast()
const [name, setName] = useState('')
const [type, setType] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Derived once from the script: its name header and mode.
const meta = useMemo(() => validateScript(scriptText).meta, [scriptText])
const encoded = useMemo(() => encodeScriptHash(scriptText), [scriptText])
// Reset whenever the dialog is (re)opened — same render-time pattern as
// HashToScriptDialog (the toolbar button toggles `open` directly).
const [wasOpen, setWasOpen] = useState(false)
if (open && !wasOpen) {
setWasOpen(true)
const defaultName =
meta?.name && meta.name !== 'Untitled' ? meta.name : encoded.name && encoded.name !== 'OVE' ? encoded.name : 'Untitled'
setName(defaultName)
setType(meta?.mode ? meta.mode.charAt(0).toUpperCase() + meta.mode.slice(1) : 'Lines')
setBusy(false)
setError(null)
} else if (!open && wasOpen) {
setWasOpen(false)
}
const nameLen = [...name.trim()].length
const typeLen = [...type.trim()].length
const hashPreview =
encoded.hash.length > HASH_PREVIEW ? `${encoded.hash.slice(0, HASH_PREVIEW)}…` : encoded.hash
async function publish() {
if (busy) return
const trimmedName = name.trim()
const trimmedType = type.trim()
if (!trimmedName || nameLen > 100) {
setError('The name must be 1–100 characters.')
return
}
if (!trimmedType || typeLen > 50) {
setError('The type must be 1–50 characters (e.g. Lines, Dots, Solid).')
return
}
setBusy(true)
setError(null)
try {
const r = await uploadDirScript({ name: trimmedName, type: trimmedType, hash: encoded.hash })
if (r.duplicate) {
toast({
title: 'Already in the directory',
description: `A script with this exact share string was already published${
r.id ? ` (entry #${r.id})` : ''
} -- nothing was changed.`,
})
} else {
toast({
title: 'Published to OVE-Directory',
description: `"${trimmedName}" (${trimmedType}) joined the community directory -- it's live in the Directory tab.`,
})
}
onOpenChange(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.')
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CloudUpload className="size-4 text-emerald-400" aria-hidden />
Upload to OVE-Directory
</DialogTitle>
<DialogDescription>
Publish this script to the community directory. It travels as its compact share string
(Script⇢Hash encoding), so the directory stores a fraction of the source -- anyone
&ldquo;Getting&rdquo; it later decodes your exact script back.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_140px]">
<div className="space-y-1.5">
<label htmlFor="ove-up-name" className="text-xs font-medium text-zinc-300">
Script name
</label>
<Input
id="ove-up-name"
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
autoFocus
maxLength={120}
placeholder="Spectrum Bars"
aria-label="Name shown in the directory"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === 'Enter') void publish()
}}
/>
<p className="font-mono text-[11px] text-zinc-500">1–100 characters -- this is what the directory lists.</p>
</div>
<div className="space-y-1.5">
<label htmlFor="ove-up-type" className="text-xs font-medium text-zinc-300">
Type
</label>
<Input
id="ove-up-type"
value={type}
onChange={(e) => {
setType(e.target.value)
setError(null)
}}
maxLength={60}
placeholder="Lines"
aria-label="Type / category"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') void publish()
}}
/>
<p className="font-mono text-[11px] text-zinc-500">1–50 characters -- e.g. Lines, Dots, Solid.</p>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-zinc-300">Share string</p>
<p className="break-all rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-emerald-200/80 select-all">
{hashPreview}
</p>
<p className="font-mono text-[11px] text-zinc-500" role="status">
{encoded.hashLength} characters (the script itself is {encoded.scriptLength}).
</p>
</div>
<p className="flex items-start gap-1.5 rounded-md border border-amber-400/25 bg-amber-400/5 px-2.5 py-2 text-[11px] leading-relaxed text-amber-300/90">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
Directory entries are permanent -- there is no edit and no delete. Double-check the name
before publishing.
</p>
{error && (
<p className="flex items-start gap-1.5 break-words font-mono text-[11px] leading-relaxed text-rose-400" role="alert">
<X className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{error}
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => void publish()}
disabled={busy}
className={cn('h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300')}
>
<CloudUpload className="size-3.5" /> {busy ? 'Publishing…' : 'Publish'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -25,6 +25,19 @@ import type { AudioSourceKind, BandConfig, BandLevels } from './types'
import { ZERO_LEVELS } from './types' import { ZERO_LEVELS } from './types'
const FFT_SIZE = 8192 const FFT_SIZE = 8192
/**
* Top of the script-visible spectrum, in Hz (v7.10.1). The bar field
* (getspec()/sv) tops out HERE instead of at Nyquist (22/24 kHz): real
* music carries almost nothing above ~16 kHz, and every lossy source
* low-passes even lower (192 kbps MP3 ≈ 18.6 kHz, YouTube AAC ≈ 15.5 kHz;
* measured across the demo collection: 0.000% of total magnitude above
* 18.6 kHz). A full-range mapping therefore left the rightmost ~20% of
* every spectrum visual frozen at zero -- bars that never moved. 16 kHz
* matches the spectral-flux cap in getBands(), and puts the right edge on
* the 12-16 kHz "air" band where cymbals, hats and synth sparkle live.
* Mirrored by SPEC_TOP_HZ in rust-agent/src/spectrum.rs.
*/
const SPEC_TOP_HZ = 16000
const ATTACK_TAU = 0.035 // seconds const ATTACK_TAU = 0.035 // seconds
const RELEASE_TAU = 0.22 // seconds const RELEASE_TAU = 0.22 // seconds
@ -375,11 +388,18 @@ export class AudioEngine {
return (this.timeData[idx] - 128) / 128 return (this.timeData[idx] - 128) / 128
} }
/** Spectrum magnitude at position 0..1 → 0..1 (log-ish frequency mapping). */ /**
* Spectrum magnitude at position 0..1 → 0..1 (log-ish frequency mapping).
* The field spans bin 2 (~12 Hz) to the SPEC_TOP_HZ bin (~16 kHz), so
* every position lands on frequencies music actually occupies -- see
* SPEC_TOP_HZ above for why it must not stretch to Nyquist.
*/
getSpectrum(pos: number): number { getSpectrum(pos: number): number {
if (this.freqData.length === 0 || this.kind === 'idle') return 0 if (this.freqData.length === 0 || this.kind === 'idle') return 0
const p = Math.min(1, Math.max(0, pos)) const p = Math.min(1, Math.max(0, pos))
const bin = 2 + Math.floor(Math.pow(p, 1.6) * (this.freqData.length - 3)) const binHz = this.sampleRate / 2 / this.freqData.length
const hiBin = Math.max(3, Math.min(this.freqData.length - 1, Math.ceil(SPEC_TOP_HZ / binHz)))
const bin = 2 + Math.floor(Math.pow(p, 1.6) * (hiBin - 2))
return this.freqData[bin] / 255 return this.freqData[bin] / 255
} }
} }

View file

@ -7,16 +7,20 @@
// for originals that rely on effects the script engine cannot run. // for originals that rely on effects the script engine cannot run.
// //
// The "Zai-Magic" folder at the END of EMBEDDED_LIBRARY is the exception to // The "Zai-Magic" folder at the END of EMBEDDED_LIBRARY is the exception to
// "do not edit": ten presets HAND-AUTHORED for this engine (v7.00) to show // "do not edit": eleven presets HAND-AUTHORED for this engine (v7.00 + the
// off beat-synced color — they use hsvr/hsvg/hsvb, beatage, megabuf particle // v7.12 osc-rotations addition) to show off beat-synced color and the v7.12
// physics, getspec() and loop(). If the converter ever regenerates this // background transform -- they use hsvr/hsvg/hsvb, beatage, megabuf particle
// file, keep that section verbatim. // physics, getspec(), loop() and the bg* framebuffer controls. If the
// converter ever regenerates this file, keep that section verbatim.
// //
// EMBEDDED_SEED_VERSION: bump whenever this library is regenerated — the // EMBEDDED_SEED_VERSION: bump whenever this library is regenerated — the
// GET /api/presets route re-seeds the root/embedded subtree when it changes // GET /api/presets route re-seeds the root/embedded subtree when it changes
// (v7.10: em dashes in descriptions/script comments replaced with '--'). // (v7.10: em dashes in descriptions/script comments replaced with '--';
// v7.12: eleventh Zai-Magic preset "osc-rotations" added; v7.13.1: Beat
// Matrix grid endpoint fix — the last point no longer lands one column past
// the wall and the rightmost column keeps its bottom dot).
export const EMBEDDED_SEED_VERSION = '7.10' export const EMBEDDED_SEED_VERSION = '7.13.1'
export interface EmbeddedPreset { export interface EmbeddedPreset {
folder: string folder: string
@ -842,11 +846,13 @@ export const EMBEDDED_LIBRARY: EmbeddedPreset[] = [
}, },
// ========================================================================== // ==========================================================================
// Zai-Magic — ten presets hand-authored for this engine (v7.00). // Zai-Magic — eleven presets hand-authored for this engine (v7.00; the
// Every one is built around BEAT-SYNCED COLOR: the color wheel turns on // eleventh, "osc-rotations", shipped with v7.12). Every one is built around
// the kick, palettes swap per beat or per 4-beat bar, and kick envelopes // BEAT-SYNCED COLOR: the color wheel turns on the kick, palettes swap per
// (beatage) drive brightness. Techniques span all three render modes, // beat or per 4-beat bar, and kick envelopes (beatage) drive brightness;
// megabuf particle physics, direct getspec() calls and loop() setup. // osc-rotations adds the v7.12 background transform (bgrot/bgzoom) to the
// toolkit. Techniques span all three render modes, megabuf particle
// physics, direct getspec() calls and loop() setup.
// KEEP VERBATIM when regenerating this file with the converter. // KEEP VERBATIM when regenerating this file with the converter.
// ========================================================================== // ==========================================================================
{ {
@ -1300,8 +1306,14 @@ chroma = frac(chroma + beat * 0.166)
flash = exp(-beatage * 5) flash = exp(-beatage * 5)
[point] [point]
col = floor(i * cols) # i spans 0..1 INCLUSIVE (AVS convention), so floor(i*cols) sends the very
row = floor(frac(i * cols) * rows) # last point to col 16 -- one column past the wall -- and leaves the
# rightmost column without its bottom dot. Mapping through CELL CENTERS
# (k + 0.5) puts every floor() half a cell away from an integer boundary,
# so no point can ever spill or shuffle, even under float dust.
cell = i * (cols * rows - 1) + 0.5
col = floor(cell / rows)
row = floor(cell - col * rows)
lvl = getspec(col / cols, 0, 0) lvl = getspec(col / cols, 0, 0)
frombottom = (rows - 1 - row) / rows frombottom = (rows - 1 - row) / rows
lit = if(below(frombottom, lvl * 1.15), 1, 0) lit = if(below(frombottom, lvl * 1.15), 1, 0)
@ -1314,6 +1326,46 @@ red = hsvr(hue, sat, lum + flash * lit * 0.55)
green = hsvg(hue, sat, lum + flash * lit * 0.55) green = hsvg(hue, sat, lum + flash * lit * 0.55)
blue = hsvb(hue, sat, lum + flash * lit * 0.55) blue = hsvb(hue, sat, lum + flash * lit * 0.55)
size = 2.4 + lit * 1.7 + treb * 1.2 size = 2.4 + lit * 1.7 + treb * 1.2
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "osc-rotations",
author: "Zai",
description:
"A stationary centered oscilloscope over a background that rotates to the music -- every beat temporarily accelerates the spin, a hard hit flips it between clockwise and counter-clockwise, and the fade to black stays slow so the colors blend. The left and right points of the line color-shift together while the center points hold their own hue. Hand-authored for OVE (v7.12 background transform + mirror-symmetric hsv color).",
content: `name: "osc-rotations"
author: "Zai"
mode: lines
blend: additive
persist: 0.965
linewidth: 2.2
[init]
n = 512
hue = 0.58
dir = 1
[frame]
punch = exp(-beatage * 3.2)
bgrot = dir * (0.0032 + punch * (0.014 + vol_att * 0.012))
bgzoom = 1 - 0.0009 - punch * 0.0011
hue = frac(hue + dt * 0.012 + beat * 0.02)
[beat]
if(above(bass, bass_att * 1.25), dir = -dir, 0)
[point]
x = i * 2 - 1
y = v * (0.3 + vol_att * 0.8)
d = abs(i - 0.5) * 2
h = hue + d * 0.34
lum = 0.5 + 0.38 * vol_att + punch * 0.22
red = hsvr(h, 0.95, lum)
green = hsvg(h, 0.95, lum)
blue = hsvb(h, 0.95, lum)
size = 2 + punch * 1.6
alpha = 0.92
`, `,
}, },
] ]

View file

@ -16,6 +16,15 @@ import { DEFAULT_BAND_CONFIG, DEFAULT_BEAT_CONFIG } from './types'
const MAX_POINTS = 8192 const MAX_POINTS = 8192
const POINT_STRIDE = 8 // x, y, red, green, blue, skip, size, alpha const POINT_STRIDE = 8 // x, y, red, green, blue, skip, size, alpha
/** Read a script bg* control value. Non-finite (NaN / Infinity from a bad
* script computation) and missing values fall back to the neutral value --
* a NaN must never reach a canvas transform (it poisons the matrix and every
* later draw becomes a silent no-op: a black screen with no error). */
function bgNum(v: number | undefined, neutral: number): number {
const x = typeof v === 'number' ? v : neutral
return Number.isFinite(x) ? x : neutral
}
export interface EngineStats { export interface EngineStats {
fps: number fps: number
levels: BandLevels levels: BandLevels
@ -54,6 +63,12 @@ export class AvaEngine {
/** ctx.filter support (older Safari lacks it → plain crossfade fallback). */ /** ctx.filter support (older Safari lacks it → plain crossfade fallback). */
private canFilter = false private canFilter = false
/** v7.12 background-feedback buffer. Same pixel size as the live canvas;
* recreated automatically on resize. Null until a script first asks for a
* background transform (zero cost for every preset that doesn't). */
private bgBuffer: HTMLCanvasElement | null = null
private bgBufCtx: CanvasRenderingContext2D | null = null
constructor() { constructor() {
// AVS-eel compatibility hooks — the expression engine reads audio/time // AVS-eel compatibility hooks — the expression engine reads audio/time
// through these (getspec/getosc/gettime). Headless tests can override. // through these (getspec/getosc/gettime). Headless tests can override.
@ -141,6 +156,60 @@ export class AvaEngine {
return this.script?.meta.name ?? '--' return this.script?.meta.name ?? '--'
} }
/**
* v7.12 — background feedback transform (the AVS movement family).
*
* The whole framebuffer is moved BEFORE this frame's points are drawn:
* 1. snapshot the live canvas into the offscreen buffer;
* 2. wipe the canvas black;
* 3. draw the snapshot back through the frame's transform.
*
* bgrot radians per frame — + rotates the picture CLOCKWISE on screen
* (canvas y is down, so ctx.rotate(+θ) spins cw); - = ccw.
* bgx/bgy drift per frame in the -1..1 screen convention (y is UP,
* matching script x/y) — slide up, down, left, right.
* bgzoom per-frame scale — <1 draw-in (shrinks toward center),
* >1 blow-out (expands outward); 1 = neutral.
*
* Uncovered regions (rotated corners, slid-off edges) read black, and the
* persistence fade (persist header / env.persist) blends the trails —
* which is exactly how the classic AVS plugins made "speed accelerates to
* the beat, a hard hit flips the spin, fade to black stays slow" work:
* the rate is just a script variable driven by beatage envelopes, and a
* [beat] section flips the sign on a hard hit (bass vs bass_att).
*/
private applyBackground(w: number, h: number, rot: number, tx: number, ty: number, zoom: number): void {
const canvas = this.canvas
const ctx = this.ctx
if (!canvas || !ctx) return
if (!this.bgBuffer || this.bgBuffer.width !== w || this.bgBuffer.height !== h) {
const buf = document.createElement('canvas')
buf.width = w
buf.height = h
this.bgBuffer = buf
this.bgBufCtx = buf.getContext('2d')
}
const bctx = this.bgBufCtx
if (!bctx || !this.bgBuffer) return
// 1. snapshot the live canvas ('copy' replaces the buffer wholesale)
bctx.globalCompositeOperation = 'copy'
bctx.drawImage(canvas, 0, 0)
// 2. wipe — the transformed snapshot must REPLACE the old content, not
// double-expose over it
ctx.globalCompositeOperation = 'source-over'
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, w, h)
// 3. draw the snapshot back through this frame's transform. Screen y is
// down; script y is up — bgy > 0 moves the picture UP.
ctx.save()
ctx.imageSmoothingEnabled = true
ctx.translate(w / 2 + tx * (w / 2), h / 2 - ty * (h / 2))
if (rot !== 0) ctx.rotate(rot)
if (zoom !== 1) ctx.scale(zoom, zoom)
ctx.drawImage(this.bgBuffer, -w / 2, -h / 2)
ctx.restore()
}
private resize(): void { private resize(): void {
if (!this.canvas) return if (!this.canvas) return
const parent = this.canvas.parentElement const parent = this.canvas.parentElement
@ -227,6 +296,20 @@ export class AvaEngine {
} catch (err) { } catch (err) {
this.scriptError = `runtime error: ${String(err)}` this.scriptError = `runtime error: ${String(err)}`
} }
// ---- background feedback transform (v7.12) --------------------------
// AVS-style whole-framebuffer movement, read AFTER [frame]/[beat] ran
// so this frame's values apply this frame. Set from [init] (persists
// forever), [frame] or [beat] (every frame / on the kick). Values live
// in env like any variable: a script that sets them only on beats gets
// on-beat jumps. Neutral readings skip the pass entirely.
const bgRot = Math.max(-0.6, Math.min(0.6, bgNum(env.bgrot, 0)))
const bgX = Math.max(-0.25, Math.min(0.25, bgNum(env.bgx, 0)))
const bgY = Math.max(-0.25, Math.min(0.25, bgNum(env.bgy, 0)))
const bgZoom = Math.max(0.5, Math.min(2, bgNum(env.bgzoom, 1)))
if (bgRot !== 0 || bgX !== 0 || bgY !== 0 || bgZoom !== 1) {
this.applyBackground(w, h, bgRot, bgX, bgY, bgZoom)
}
} }
// ---- point evaluation --------------------------------------------------- // ---- point evaluation ---------------------------------------------------

128
src/lib/ava/wake-lock.ts Normal file
View file

@ -0,0 +1,128 @@
// OVE — Screen Wake Lock manager (v7.11).
//
// Mobile browsers dim and eventually turn the screen off after a few seconds
// of inactivity — correct for reading, fatal for a music visualizer propped
// on a table. While audio is actually playing (demo / mic / file / link —
// anything but idle) the app holds a screen wake lock so the visuals stay
// visible; the moment playback stops the lock is released and the phone's
// normal inactivity timeout takes over again.
//
// API notes (developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API):
// • navigator.wakeLock.request('screen') → Promise<WakeLockSentinel>
// • The browser SILENTLY releases the sentinel when the page becomes
// hidden (app switch / screen lock) — so a 'visibilitychange' listener
// re-acquires it when the visitor comes back and music is still going.
// • request() rejects when unsupported (older iOS Safari, Firefox Android
// pre-126), when the document is hidden, or when a platform setting
// refuses — every rejection is swallowed: the lock is a nice-to-have,
// never a blocker.
// • Wake locks are free — no battery drain beyond the screen itself, and
// the page already renders at full tilt while playing.
/** Minimal structural type — lib.dom versions vary in Wake Lock coverage. */
interface WakeLockSentinelLike {
readonly released: boolean
release: () => Promise<void>
addEventListener: (type: 'release', listener: () => void) => void
removeEventListener: (type: 'release', listener: () => void) => void
}
interface WakeLockLike {
request: (type: 'screen') => Promise<WakeLockSentinelLike>
}
/** The held sentinel (null = not held / browser released it). */
let sentinel: WakeLockSentinelLike | null = null
/** Whether a lock is currently WANTED (music playing). */
let wantActive = false
/** Guards against overlapping request() calls. */
let requesting = false
/** The sentinel's own release listener (kept so it can be detached). */
let releaseListener: (() => void) | null = null
function navigatorWakeLock(): WakeLockLike | null {
if (typeof navigator === 'undefined') return null
const nav = navigator as Navigator & { wakeLock?: unknown }
const wl = nav.wakeLock
if (!wl || typeof wl !== 'object') return null
const typed = wl as Partial<WakeLockLike>
return typeof typed.request === 'function' ? (typed as WakeLockLike) : null
}
/** Drop our reference when the browser auto-releases (page hidden). */
function onSentinelRelease(): void {
sentinel = null
releaseListener = null
}
async function acquire(): Promise<void> {
if (sentinel !== null || requesting) return
const wl = navigatorWakeLock()
if (!wl) return
requesting = true
try {
const s = await wl.request('screen')
// state may have flipped to "stopped" while the promise was in flight
if (!wantActive) {
try {
await s.release()
} catch {
/* already gone */
}
return
}
sentinel = s
releaseListener = onSentinelRelease
s.addEventListener('release', releaseListener)
} catch {
/* unsupported / hidden / refused — degrade silently */
} finally {
requesting = false
}
}
function dropSentinel(): void {
const s = sentinel
sentinel = null
if (s) {
if (releaseListener) s.removeEventListener('release', releaseListener)
releaseListener = null
s.release().catch(() => {
/* already released */
})
}
}
/**
* Hold or release the screen wake lock according to playback. Called with
* `true` whenever a source starts, `false` whenever the engine goes idle.
*/
export function syncWakeLock(active: boolean): void {
wantActive = active
if (!active) {
dropSentinel()
return
}
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
void acquire()
}
// hidden right now (rare — playback needs a gesture, but a queued playlist
// could land here): the visibilitychange listener below acquires on return.
}
/** Explicit teardown (hook unmount / pagehide). */
export function releaseWakeLock(): void {
syncWakeLock(false)
}
/**
* Re-acquire the lock when the page becomes visible again while music is
* still playing (the browser always drops it on hide). Returns a cleanup.
*/
export function watchWakeLockVisibility(): () => void {
const onVisibility = () => {
if (document.visibilityState === 'visible' && wantActive) void acquire()
}
document.addEventListener('visibilitychange', onVisibility)
return () => document.removeEventListener('visibilitychange', onVisibility)
}

274
src/lib/ove-directory.ts Normal file
View file

@ -0,0 +1,274 @@
// OVE — OVE-Directory client library (v7.11).
//
// The community script directory lives on its own server
// (https://s3374dir.openvisengine.com) behind an API key. Browsers NEVER
// talk to it directly: every call goes through this app's backend proxy at
// /api/ove-directory/* (src/app/api/ove-directory/), which holds the key in
// server env and forwards the request. This module is the browser-side
// twin: typed wrappers for each proxied endpoint, the per-visitor vote
// bookkeeping (localStorage), and the "Get" flow that decodes a fetched
// hash string back into a script and files it in this browser's preset
// tree under "root/user created/OVE-Directory".
//
// Wire shapes mirror the upstream directory 1:1 (the proxy forwards bodies
// verbatim), so the reference that matters is the OVE-Directory API card:
// meta record { id, name, type, created_at, average, votes } (lists)
// full record meta + { hash } (fetch)
// errors { error: { code, message, field? }, hint?, … }
import type { PresetItem } from '@/lib/store'
import { applyUserPresets } from '@/lib/store'
import { createUserPreset, listUserPresets } from '@/lib/ava/user-presets'
import { validateScript } from '@/lib/ava/script'
// ── types & constants ─────────────────────────────────────────────────────
export type DirSortKey = 'name' | 'type' | 'date' | 'rating'
export type DirOrder = 'asc' | 'desc'
/** Meta record — one row of a directory listing (no hash). */
export interface DirMeta {
id: number
name: string
type: string
created_at: string
average: number
votes: number
}
/** Full record — a fetched script (meta + the hash string). */
export interface DirScript extends DirMeta {
hash: string
}
export interface DirStatus {
status: string
service: string
version: string
uptime_seconds: number
scripts_total: number
timestamp: string
}
export const DIR_SORTS: ReadonlyArray<{ key: DirSortKey; label: string }> = [
{ key: 'rating', label: 'Rating' },
{ key: 'name', label: 'Name' },
{ key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' },
]
/** Default listing: top rated first; unrated sink to the bottom (upstream rule). */
export const DEFAULT_SORT: DirSortKey = 'rating'
export const DEFAULT_ORDER: DirOrder = 'desc'
/** Where "Get" files downloaded scripts in this browser's preset tree. */
export const DOWNLOAD_FOLDER = 'root/user created/OVE-Directory'
// ── low-level proxy call ──────────────────────────────────────────────────
interface ProxyErrorBody {
error?: { code?: string; message?: string; field?: string } | string
hint?: string
}
/** Pull a human-readable message out of a proxy/upstream error body. */
function errorText(body: unknown, fallback: string): string {
if (!body || typeof body !== 'object') return fallback
const e = (body as ProxyErrorBody).error
if (typeof e === 'string') return e
if (e && typeof e === 'object') {
if (typeof e.message === 'string' && e.message) return e.message
}
return fallback
}
async function proxyJson<T>(path: string, init?: RequestInit): Promise<{ status: number; body: T | null; message: string | null }> {
let res: Response
try {
res = await fetch(path, { ...init, headers: { Accept: 'application/json', ...(init?.headers ?? {}) } })
} catch {
return { status: 0, body: null, message: 'Could not reach this server (network error).' }
}
let body: T | null = null
try {
body = (await res.json()) as T
} catch {
/* non-JSON body — keep null */
}
if (!res.ok) {
const message = body ? errorText(body, `Request failed (${res.status}).`) : `Request failed (${res.status}).`
return { status: res.status, body, message }
}
return { status: res.status, body, message: null }
}
// ── endpoint wrappers ─────────────────────────────────────────────────────
/** Health probe. Null when the proxy itself is unreachable. */
export async function fetchDirStatus(): Promise<DirStatus | null> {
const r = await proxyJson<DirStatus>('/api/ove-directory/status')
return r.body ?? null
}
/** List scripts, sorted upstream. Throws Error(message) on failure. */
export async function fetchDirList(sort: DirSortKey, order: DirOrder): Promise<DirMeta[]> {
const qs = `?sort=${encodeURIComponent(sort)}&order=${encodeURIComponent(order)}`
const r = await proxyJson<{ count: number; scripts: DirMeta[] }>(`/api/ove-directory/scripts${qs}`)
if (!r.body || !Array.isArray(r.body.scripts)) throw new Error(r.message ?? 'The directory response was malformed.')
return r.body.scripts
}
/** Fetch one full record (with its hash string). Throws Error(message). */
export async function fetchDirScript(id: number): Promise<DirScript> {
const r = await proxyJson<DirScript>(`/api/ove-directory/scripts/${id}`)
if (!r.body || typeof r.body.hash !== 'string') throw new Error(r.message ?? 'The directory response was malformed.')
return r.body
}
export interface UploadResult {
id: number
duplicate: boolean
record: DirMeta
}
/**
* Publish {name, type, hash} to the directory. Both the 201 (new) and the
* 409 DUPLICATE_HASH (already there — nothing changed) paths resolve as
* success; everything else throws Error(message).
*/
export async function uploadDirScript(input: { name: string; type: string; hash: string }): Promise<UploadResult> {
const r = await proxyJson<DirMeta & { existing?: DirMeta }>('/api/ove-directory/scripts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: input.name, type: input.type, hash: input.hash }),
})
if (r.status === 201 && r.body) {
return { id: r.body.id, duplicate: false, record: r.body as DirMeta }
}
if (r.status === 409 && r.body) {
// duplicate-hash bodies carry the row as `existing`
const body = r.body as DirMeta & { existing?: DirMeta }
const existing = body.existing ?? body
return { id: existing.id, duplicate: true, record: existing }
}
throw new Error(r.message ?? 'Upload failed.')
}
export interface RateResult {
average: number
votes: number
duplicate: boolean
}
/**
* Cast a 1–5 rating for script `id` as this visitor (the backend mints the
* anonymous voter id). 200 and 409 DUPLICATE_VOTE both resolve as success —
* on duplicates the directory returns the unchanged aggregates.
*/
export async function rateDirScript(id: number, rating: number): Promise<RateResult> {
const r = await proxyJson<{ average: number; votes: number }>(`/api/ove-directory/scripts/${id}/ratings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating }),
})
if ((r.status === 200 || r.status === 409) && r.body && typeof r.body.average === 'number') {
return { average: r.body.average, votes: r.body.votes ?? 0, duplicate: r.status === 409 }
}
if (r.status === 404) throw new Error('That script is no longer in the directory.')
throw new Error(r.message ?? 'Rating failed.')
}
// ── per-visitor vote bookkeeping (this browser) ───────────────────────────
const VOTES_KEY = 'ove.directory.votes.v1'
/** { scriptId: stars } as remembered from this browser. */
export function loadMyVotes(): Record<string, number> {
try {
const raw = localStorage.getItem(VOTES_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return {}
const out: Record<string, number> = {}
for (const [k, v] of Object.entries(parsed)) {
const n = typeof v === 'number' ? v : Number(v)
if (Number.isInteger(n) && n >= 1 && n <= 5 && /^\d{1,10}$/.test(k)) out[k] = n
}
return out
} catch {
return {}
}
}
/** Remember that this browser rated `id` with `stars` (1–5). */
export function rememberMyVote(id: number, stars: number): void {
try {
const votes = loadMyVotes()
votes[String(id)] = stars
localStorage.setItem(VOTES_KEY, JSON.stringify(votes))
} catch {
/* private mode — voting still worked, we just can't remember it */
}
}
// ── "Get" flow: decode a fetched hash and file it in this browser ─────────
export interface SaveDownloadResult {
ok: boolean
error?: string
/** The preset as stored (newly created or the already-present twin). */
preset?: PresetItem
/** True when a preset with identical content was already there (no copy made). */
alreadyHad?: boolean
}
/**
* Pick a name for a downloaded script inside DOWNLOAD_FOLDER: the exact
* name first, then "Name (1)", "Name (2)", … mirroring the directory's own
* duplicate-numbering style.
*/
function uniqueDownloadName(base: string, taken: Set<string>): string {
if (!taken.has(base)) return base
for (let i = 1; i < 100; i++) {
const candidate = `${base} (${i})`
if (!taken.has(candidate)) return candidate
}
return `${base} (${Date.now() % 1000})`
}
/**
* Save a script fetched from the directory into this browser's preset tree
* (localStorage), deduped by exact content. Does NOT touch the engine —
* the caller decides whether to also drop it into the editor.
*/
export function saveDownloadedPreset(script: string, fallbackName: string): SaveDownloadResult {
const v = validateScript(script)
const meta = v.ok ? v.meta : null
const base = (meta?.name && meta.name !== 'Untitled' ? meta.name : fallbackName).trim() || 'Untitled'
const existing = listUserPresets().filter((p) => p.folderPath === DOWNLOAD_FOLDER)
const twin = existing.find((p) => p.content === script)
if (twin) return { ok: true, preset: twin, alreadyHad: true }
const taken = new Set(existing.map((p) => p.name))
const name = uniqueDownloadName(base, taken)
const res = createUserPreset({
name,
content: script,
folderPath: DOWNLOAD_FOLDER,
author: meta?.author || 'OVE-Directory',
description: `Downloaded from the community directory as "${fallbackName}".`,
})
if (!res.ok) return { ok: false, error: res.error }
applyUserPresets()
return { ok: true, preset: res.preset, alreadyHad: false }
}
// ── display helpers ───────────────────────────────────────────────────────
/** "2026-11-05T18:40:12Z" → "Nov 5, 2026" (falls back to the raw date). */
export function formatDirDate(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(0, 10)
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}

View file

@ -39,6 +39,94 @@ export interface ReleaseNote {
} }
export const RELEASE_NOTES: ReleaseNote[] = [ export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '7.13.1',
name: 'Beat Matrix grid fixed',
changes: [
{
kind: 'fixed',
text: 'The rightmost column of Beat Matrix no longer sits one row short. Its last dot used to land one column past the wall\u2019s right edge while the column itself was left without its bottom dot -- a classic off-by-one between the grid math and the engine\u2019s inclusive 0-to-1 point range. I rebuilt the mapping through cell centers, so all 16 columns now carry their full 10 dots, perfectly aligned.',
},
],
},
{
version: '7.13',
name: 'One build, every feature',
changes: [
{
kind: 'improved',
text: 'The two development lines are merged into one build. Everything from the parallel line -- the unfrozen spectrum edge, the screen wake lock, the respectful Demo button, one-click saving, the background movement family and the "osc-rotations" preset -- now lives alongside the community OVE-Directory in this release.',
},
{
kind: 'improved',
text: 'The Script tab toolbar carries the one-click Save and "Upload to OVE-Directory" side by side, and the release list below now reads as one timeline (the parallel line\u2019s wake-lock release is folded in as 7.10.2).',
},
],
},
{
version: '7.12',
name: 'Background movement (AVS-style) + osc-rotations',
changes: [
{
kind: 'added',
text: 'Scripts can move the whole picture now: bgrot spins the background clockwise or counter-clockwise, bgx / bgy slide it up, down, left or right, and bgzoom draws it in or blows it out -- the movement family the classic Winamp AVS plugins were built on. The speed can ride the music: accelerate on every beat, flip the spin direction on a hard hit, and let a slow fade to black blend the colors. All four variables are documented in the script editor\'s help panel.',
},
{
kind: 'added',
text: 'I added an eleventh Zai-Magic preset: "osc-rotations" -- a stationary centered oscilloscope whose left and right points color-shift together while the center points hold their own hue, over a background that rotates clockwise and counter-clockwise to the music and temporarily accelerates with the beat. Hard hits flip the spin; the fade stays slow so the colors blend.',
},
],
},
{
version: '7.11',
name: 'The community OVE-Directory',
changes: [
{
kind: 'added',
text: 'A community script directory on its own server -- a sixth tab, Directory, lists every published script with its name, type and date, a 1–5 star rater with the live "Average: #.#" beside the stars, and sorting by Name, Type, Date or Rating. I reached it through this site\u2019s own backend, so the two servers stay fully independent: either can go down and the other keeps running.',
},
{
kind: 'added',
text: '"Upload to OVE-Directory" in the Script tab (enabled while the script shows \u2714 Compiled OK with no runtime error). The script travels as its compact Script\u21e2Hash string -- the directory stores a fraction of the source -- and every upload is re-checked server-side: the string must decode back into a script that still compiles.',
},
{
kind: 'added',
text: '"Get" on any directory entry fetches the exact script back, files it in this browser\u2019s preset tree under "user created/OVE-Directory" (identical copies are never duplicated; name collisions number themselves "(1) (2) (3)\u2026"), and offers a one-click "Open in editor".',
},
{
kind: 'added',
text: 'Ratings are one vote per script per visitor, enforced by the directory itself; a second attempt just shows the stored average. The anonymous voter id is a cookie minted by this site\u2019s backend -- no accounts, no identity stored.',
},
],
},
{
version: '7.10.2',
name: 'Wake lock, a respectful Demo, one-click save',
changes: [
{
kind: 'added',
text: 'The screen stays on while music plays: on phones and tablets the site now holds a screen wake lock for as long as any audio source is live, then lets go the moment playback stops so the device\'s normal screen timeout takes over again.',
},
{
kind: 'improved',
text: 'The Demo Track button stopped hijacking the preset selection. When a preset is already loaded (tree double-click, Load, a copied preset) or the editor holds custom script material, Demo just plays the track and leaves everything untouched -- the Zai-Magic Sequence showcase now only starts for a visit that never picked or typed anything, so a custom script can be built and debugged against the demo track without fear of it being replaced.',
},
{
kind: 'improved',
text: 'One-click saving in the Script tab: a valid script shows the Save button even with no save target picked, and one click stores it under "user created", auto-named from the script\'s name header (a hash import of "Spectrum Bars:[▼…]" saves as "Spectrum Bars"). Nameless scripts land as "Empty Name", collisions as "(1)", "(2)"… -- and in those cases a "Give This Preset a Name" dialog opens immediately so the just-saved preset can be renamed on the spot.',
},
],
},
{
version: '7.10.1',
name: 'Spectrum edge unfrozen',
changes: [
{
kind: 'fixed',
text: 'Spectrum visuals used to sit dead along the right edge: the bar field stretched all the way to the top of the FFT (~24 kHz), where music carries almost nothing and MP3/YouTube audio is already cut off, so the rightmost bars read permanent silence no matter how loud the track got. I capped the field at 16 kHz -- the range where music actually lives -- so every bar has something to react to.',
},
],
},
{ {
version: '7.10', version: '7.10',
name: 'Ten demo tracks', name: 'Ten demo tracks',

View file

@ -0,0 +1,145 @@
// OVE — OVE-Directory backend proxy helpers (v7.11).
//
// The community script directory runs on its own VPS
// (https://s3374dir.openvisengine.com) behind an API key. Browsers never see
// that key: every /api/ove-directory/* route on THIS server forwards the
// request server-to-server, so the directory's address and credentials stay
// in env config here. The two VPSs stay fully independent -- either can go
// down without touching the other.
//
// What lives here:
// dirConfig() — env: OVE_DIRECTORY_URL + OVE_DIRECTORY_API_KEY
// dirFetch() — authenticated upstream call (X-API-Key, 10 s timeout),
// returns { status, data } with upstream bodies verbatim;
// network failures become 502 BAD_GATEWAY envelopes
// oveRateCheck()— a small per-visitor-IP throttle for the proxy itself
// (the upstream's 240/min bucket is shared by every visitor
// behind this server's IP, so local policing matters)
// jsonError() — the shared error envelope for local rejections
import { NextRequest, NextResponse } from 'next/server'
const UPSTREAM_TIMEOUT_MS = 10_000
// ── configuration ─────────────────────────────────────────────────────────
export interface DirConfig {
url: string
key: string
}
/** Env config; null when the proxy is not set up (500 to callers). */
export function dirConfig(): DirConfig | null {
const url = (process.env.OVE_DIRECTORY_URL ?? '').trim().replace(/\/+$/, '')
const key = (process.env.OVE_DIRECTORY_API_KEY ?? '').trim()
if (!url || !key) return null
return { url, key }
}
// ── upstream calls ────────────────────────────────────────────────────────
export interface DirFetchResult {
status: number
/** Parsed upstream JSON body (envelope, record, list — verbatim). */
data: unknown
}
/**
* Call the directory. `path` is the upstream path (with query), `body` is
* JSON-encoded when present. Adds the X-API-Key header on every call; the
* upstream's one public route (/status) just ignores it.
*/
export async function dirFetch(path: string, init?: { method?: string; body?: unknown }): Promise<DirFetchResult> {
const cfg = dirConfig()
if (!cfg) {
return {
status: 500,
data: { error: { code: 'NOT_CONFIGURED', message: 'OVE-Directory is not configured on this server.' } },
}
}
let res: Response
try {
res = await fetch(`${cfg.url}${path}`, {
method: init?.method ?? 'GET',
headers: {
'X-API-Key': cfg.key,
Accept: 'application/json',
...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
cache: 'no-store',
})
} catch {
return {
status: 502,
data: {
error: {
code: 'BAD_GATEWAY',
message: 'Could not reach the OVE-Directory server. Try again in a moment.',
},
},
}
}
let data: unknown = null
try {
data = await res.json()
} catch {
data = null
}
if (data === null) {
return {
status: 502,
data: {
error: {
code: 'BAD_UPSTREAM',
message: 'The OVE-Directory server sent a malformed response.',
},
},
}
}
return { status: res.status, data }
}
// ── local error envelope (matches the upstream shape) ─────────────────────
export function jsonError(status: number, code: string, message: string, field?: string): NextResponse {
return NextResponse.json(
{ error: { code, message, ...(field !== undefined ? { field } : {}) } },
{ status },
)
}
// ── per-visitor throttle for the proxy ────────────────────────────────────
const RL_WINDOW_MS = 60_000
const RL_LIMIT = 30
const rlBuckets = new Map<string, { start: number; count: number }>()
/**
* Count this visitor IP against the proxy's own limit (30 requests/min).
* Returns null when allowed, or the seconds to wait when throttled.
*/
export function oveRateCheck(ip: string): number | null {
const now = Date.now()
let b = rlBuckets.get(ip)
if (!b || now - b.start >= RL_WINDOW_MS) {
b = { start: now, count: 0 }
rlBuckets.set(ip, b)
}
b.count++
if (rlBuckets.size > 512) {
for (const [k, v] of rlBuckets) {
if (now - v.start >= RL_WINDOW_MS) rlBuckets.delete(k)
}
}
if (b.count > RL_LIMIT) return Math.max(1, Math.ceil((b.start + RL_WINDOW_MS - now) / 1000))
return null
}
/** Best-effort visitor IP for the throttle (the gateway sets X-Forwarded-For). */
export function visitorIp(req: NextRequest): string {
const fwd = req.headers.get('x-forwarded-for')
const first = fwd?.split(',')[0]?.trim()
return first && first.length > 0 ? first : 'local'
}

View file

@ -187,6 +187,14 @@ interface AvaState {
editTarget: EditTarget | null editTarget: EditTarget | null
/** Preset currently running on the engine (tree indicator). */ /** Preset currently running on the engine (tree indicator). */
activePresetId: string | null activePresetId: string | null
/** v7.11 — true once THIS visit made an explicit choice: loaded a preset
* (tree Load / double-click / copy-to-my-presets / created one), brought
* the visitor's own script material into the editor (typed / pasted /
* hash-imported / file-loaded), saved a default startup script, or runs
* their own mix mode. While false the Demo button may still run the
* welcome showcase; once true, Demo never touches the selection.
* Deliberately NOT persisted — every visit starts fresh. */
manualChoice: boolean
/** Timed preset mixing (club mode): Random (no repeats) or, v7.4, Sequence /** Timed preset mixing (club mode): Random (no repeats) or, v7.4, Sequence
* (in-order cycling with wrap-around). The two are mutually exclusive — * (in-order cycling with wrap-around). The two are mutually exclusive —
* ticking one unticks the other; both share one delay + folder scope and * ticking one unticks the other; both share one delay + folder scope and
@ -219,6 +227,8 @@ interface AvaState {
selectPreset: (id: string | null) => void selectPreset: (id: string | null) => void
setEditTarget: (t: EditTarget | null) => void setEditTarget: (t: EditTarget | null) => void
setActivePresetId: (id: string | null) => void setActivePresetId: (id: string | null) => void
/** v7.11 — flag this visitor as having made their own choice (see field). */
markManualChoice: () => void
setRandomize: (on: boolean) => void setRandomize: (on: boolean) => void
/** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */ /** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */
setSequenceMode: (on: boolean) => void setSequenceMode: (on: boolean) => void
@ -371,6 +381,7 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
expandedFolders: {}, expandedFolders: {},
editTarget: null, editTarget: null,
activePresetId: null, activePresetId: null,
manualChoice: false,
randomize: false, randomize: false,
sequenceMode: false, sequenceMode: false,
randomIntervalSec: 10, randomIntervalSec: 10,
@ -481,6 +492,8 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
setActivePresetId: (id) => set({ activePresetId: id }), setActivePresetId: (id) => set({ activePresetId: id }),
markManualChoice: () => set({ manualChoice: true }),
setRandomize: (on) => { setRandomize: (on) => {
// v7.4 mutual exclusion: ticking Random unticks Sequence (turning one // v7.4 mutual exclusion: ticking Random unticks Sequence (turning one
// OFF never lights the other — the visitor's explicit choice) // OFF never lights the other — the visitor's explicit choice)

View file

@ -12,6 +12,138 @@
// warns in the console when the two drift apart. // warns in the console when the two drift apart.
// //
// History: // History:
// 7.13.1 — Beat Matrix endpoint fix (user report): the rightmost column
// of dots sat one row short -- its bottom dot missing -- while a
// phantom dot spilled one column past the wall's right edge at
// the top. Root cause: the engine's point position i spans 0..1
// INCLUSIVE (classic AVS Superscope convention, engine.ts: i =
// k/(n-1)), but Beat Matrix's grid math assumed exclusive i = k/n:
// floor(i*cols) turned the last point into col 16 (one past the
// 16-column wall) at row 0, and column 15 was left with nine
// dots (rows 0-8, no bottom). Fix: the script now maps through
// cell centers -- cell = i*(cols*rows-1)+0.5, col =
// floor(cell/rows), row = floor(cell-col*rows) -- which puts
// every floor() half a cell away from an integer boundary, so
// the endpoint can never spill and float dust can never shuffle
// a dot. Full 16x10 bijection verified headlessly (all 160
// points in 160 distinct cells, col<=15, row<=9, bottom-right
// cell present). Sweep of the other 100 presets: every other
// floor(i*M) use is benign -- angular presets close their loop
// (bar=bars lands exactly on bar 0's angle), Aurora Veil's
// phantom lands off-screen (x=1.19), the megabuf particle
// presets draw their phantom slot at alpha 0, and the converted
// Winamp classics rendered identically under original AVS
// (which also uses inclusive i). EMBEDDED_SEED_VERSION 7.12 ->
// 7.13.1 re-seeds the library so every visitor gets the fixed
// Beat Matrix.
// 7.13 — one build, every feature: the parallel development lines are
// merged. This tree now carries BOTH the community OVE-Directory
// (v7.11 here) AND the parallel line's additions — the spectrum
// 16 kHz cap (its 7.10.1), the screen wake lock + respectful
// Demo button + one-click Save (its 7.11, folded into this
// history as 7.10.2 to keep one clean timeline), and the
// AVS-style background movement family (bgrot / bgx / bgy /
// bgzoom) with the eleventh Zai-Magic preset "osc-rotations"
// (its 7.12). Git three-way merge against the shared v7.10
// ancestor (identical tree hashes verified on both sides); the
// only hand-merged surface was the Script tab toolbar, which
// now carries the one-click Save and "Upload to OVE-Directory"
// side by side, and the two changelogs (here + release-notes).
// 7.12 — background movement + osc-rotations. Scripts can now move the
// whole framebuffer every frame, the way the classic Winamp AVS
// plugins did: bgrot (radians/frame, + clockwise on screen),
// bgx / bgy (per-frame drift in the -1..1 screen convention, y
// up), bgzoom (< 1 draw-in, > 1 blow-out) -- read after [frame]/
// [beat] so this frame's values apply this frame. The engine
// snapshots the canvas into an offscreen buffer, wipes, and
// draws the snapshot back through the transform, so the
// persistence fade blends the moving trails: "speed accelerates
// to the beat, a hard hit flips the spin, fade to black stays
// slow" is pure script (a beatage envelope drives the rate; a
// [beat] section flips a dir variable on hard hits via bass vs
// bass_att). Neutral readings (0/0/0/1) skip the pass entirely
// (zero cost for every preset that doesn't move); non-finite
// values fall back to neutral (a NaN in a canvas transform
// matrix would black the screen with no error); per-frame
// clamps: rot ±0.6 rad, drift ±0.25, zoom 0.5..2. New 11th
// Zai-Magic preset "osc-rotations" (user-requested): a
// stationary centered oscilloscope (time-domain v) whose color
// is mirror-symmetric -- points equidistant left/right of
// center shift together, the center points hold their own hue
// -- over a background rotating cw/ccw to the music that
// temporarily accelerates on the beat; hard hits
// (bass > 1.25 × bass_att) flip the spin; a whisper of draw-in
// (bgzoom ~0.999) spirals the trails inward; persist 0.965
// keeps the fade slow so colors blend. Help panel: bg* variable
// rows + three new example formulas. EMBEDDED_SEED_VERSION
// 7.10 -> 7.12 (library re-seed adds the preset; the share
// codec's dictionary stays frozen per its compatibility
// contract -- bg* identifiers ride the pair packing like any
// custom variable). Headless validation of the preset's
// compiled programs: quiet 0.004 rad/frame, hard beat 0.026
// (6.5×) + direction flip, soft beat no flip, mirrored points
// share hue / center distinct
// 7.11 — the community OVE-Directory. A sixth tab (Directory) lists every
// script published to the community directory — a separate VPS at
// s3374dir.openvisengine.com, reached ONLY through this server's
// backend proxy (/api/ove-directory/*), which holds the API key in
// env and re-validates every upload (the share string must decode
// AND compile). Publishing: the Script tab gains "Upload to
// OVE-Directory" (enabled only while the editor shows ✔ Compiled OK
// with no runtime error); the script travels as its Script⇢Hash
// string, so the directory stores a fraction of the source. The
// listing carries each entry's name, type and date, a 1–5 star
// widget (the voter id is an HttpOnly cookie minted by the proxy;
// one vote per script per visitor, enforced upstream), the live
// "Average: #.#" to the right of the stars, and sorting by Name /
// Type / Date / Rating (done upstream, both directions). "Get"
// fetches the full record, decodes the hash back into the exact
// script, files it in this browser's preset tree under
// root/user created/OVE-Directory (content-deduped, names numbered
// "(1) (2) (3)…" on collision) and offers "Open in editor".
// Duplicate uploads resolve as success (409 DUPLICATE_HASH carries
// the existing row); duplicate votes show the stored aggregates.
// 7.10.2 — wake lock, a respectful Demo button, one-click save. While any
// audio source is live the site holds a Screen Wake Lock so phone
// and tablet screens stay on (released the moment the engine goes
// idle so the normal inactivity dimming returns; browsers silently
// drop the sentinel on hide, so a visibilitychange watcher
// re-acquires it on return while music is still going;
// unsupported browsers degrade silently). The Demo Track button no
// longer force-engages the Sequence checkbox: a visitor who loaded
// a preset (tree double-click / Load / Copy to my presets / created
// one), brought their own script material into the editor (typed,
// pasted, Hash⇢Script, file), saved their own default startup
// script, or runs their own Random/Sequence mix keeps their
// selection -- Demo just plays the track (the showcase's tree
// re-scope, 20 s interval and retire-on-end all skip too; a new
// per-visit manualChoice flag + a boot-script snapshot decide, and
// ensureDefaultPreset gained the same guard so mic/file/link never
// clobber a custom draft either; the showcase now only starts for
// a visit that never picked or typed anything). Script tab: a valid
// script shows the Save button with NO save target needed -- one
// click creates a preset under "user created", auto-named from the
// script's name: header (a hash import of "Spectrum Bars:[▼…]"
// saves as "Spectrum Bars"), "Empty Name" when nameless, " (1)"/
// " (2)"… suffixes on conflicts, and whenever the name had to be
// invented a "Give This Preset a Name" dialog opens to rename the
// just-saved preset in one step (the preset is always saved FIRST,
// renamed second, so nothing is ever lost to a naming problem).
// Hash⇢Script imports clear the stale save target (the editor now
// holds foreign content) and pass the decoded label through as the
// save-name fallback when the script has no name: header
// 7.10.1 — the frozen right edge of spectrum visuals. getspec()/sv mapped
// the bar field across the whole FFT (bin 2 ... Nyquist, ~12 Hz to
// 24 kHz), but music carries almost nothing above ~16 kHz and every
// lossy source low-passes even lower (192 kbps MP3 at ~18.6 kHz,
// YouTube AAC at ~15.5 kHz; measured across the demo collection:
// 0.000% of total magnitude above 18.6 kHz), so the rightmost ~20%
// of every spectrum visual read a permanent zero -- bars that never
// moved. getSpectrum() now tops the field out at 16 kHz
// (SPEC_TOP_HZ, matching the spectral-flux cap in getBands()), so
// the right edge lands on the 12-16 kHz "air" band where cymbals,
// hats and synth sparkle live. rust-agent's spectrum_at() mirrors
// the change, and the help panel's sv entry now states the span.
// 7.10 — ten demo tracks + first-person changelog + declutter. The demo // 7.10 — ten demo tracks + first-person changelog + declutter. The demo
// source is now a COLLECTION: ten mastered tracks in // source is now a COLLECTION: ten mastered tracks in
// public/demo-tracks/ (demo-01.mp3 … demo-10.mp3, filename order // public/demo-tracks/ (demo-01.mp3 … demo-10.mp3, filename order
@ -299,4 +431,4 @@
export const APP_NAME = 'Open Visualization Engine' export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE' export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.10' export const APP_VERSION = '7.13.1'

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View file

@ -2213,3 +2213,146 @@ BROWSER E2E (agent-browser + VLM, ALL PASS):
Stage Summary: Stage Summary:
- v7.7 shipped: scripts are now shareable as single Unicode strings — ~half the length of the script, self-checking, copy/paste-survivable, lossless for ANY text (compiling not required) — and the Settings tab is decluttered while the house account keeps Stream-a-Link working for everyone. The shared dialog height cap protects every dialog app-wide from tall content on short screens. - v7.7 shipped: scripts are now shareable as single Unicode strings — ~half the length of the script, self-checking, copy/paste-survivable, lossless for ANY text (compiling not required) — and the Settings tab is decluttered while the house account keeps Stream-a-Link working for everyone. The shared dialog height cap protects every dialog app-wide from tall content on short screens.
---
Task ID: 37
Agent: main (Z.ai Code)
Task: v7.10.1 (user report while testing their new "Spectrum Bars" script): a section on the very right of spectrum visuals is inactive and never reacts to the music -- same dead right edge on other spectrum scripts. Explain why, then fix it engine-side so every script benefits.
DIAGNOSIS (why the right edge is dead):
- getspec()/sv mapped bar position 0..1 across the WHOLE FFT: bin = 2 + floor(p^1.6 * 4093) at 8192-point FFT, so pos 1.0 = bin 4095 = Nyquist (22.05/24 kHz). pos 0.8 already lands at ~16.8 kHz.
- Real music carries almost nothing up there, and every lossy source low-passes even lower: the demo collection is 192 kbps MP3 @48 kHz (LAME low-pass ~18.6 kHz), YouTube AAC ~15.5 kHz. Measured on demo-01 (20 s, 8192-FFT): 0.000% of total magnitude above 18.6 kHz; content above -40 dB rel. peak ends at ~13.4 kHz, above -60 dB at ~17.8 kHz.
- So bar positions above ~0.82 read byte 0 every frame -- the rightmost ~20% of every spectrum visual was permanently frozen at the floor. Not a script bug: every script sampling getspec over 0..1 (all 10 converted getspec presets included) showed the same dead edge, exactly like the Winamp AVS originals. The engine's own flux detector already caps at 16 kHz for the same reason (audio.ts getBands).
Work Log:
- src/lib/ava/audio.ts: new SPEC_TOP_HZ = 16000 const (with the full why-comment); getSpectrum() now maps the field from bin 2 (~12 Hz) to the 16 kHz bin instead of Nyquist: hiBin = clamp(ceil(16000/binHz)), bin = 2 + floor(p^1.6 * (hiBin - 2)). Same log-ish curve, every position now lands on frequencies music occupies; right edge = 12-16 kHz "air" band (cymbals/hats/sparkle). Matches the flux cap.
- rust-agent/src/spectrum.rs: SPEC_TOP_HZ const + spectrum_at() mirrors the new mapping exactly (source parity; not compiled locally, no cargo).
- help-panel.tsx: sv entry now states the span: "spectrum magnitude 0..1 (≈12 Hz to 16 kHz, log-ish -- the range music actually occupies)".
- version.ts 7.10.1 + history entry; release-notes.ts 7.10.1 "Spectrum edge unfrozen" (fixed, first-person voice). lint clean; tsc clean apart from the pre-existing base-target share-codec BigInt literals.
VERIFICATION (three independent measurements):
- Offline full-track emulation (Blackman window + AnalyserNode dB byte mapping, every 21 ms of demo-01): under the NEW mapping every one of 48 bars is >15/255 in 76-100% of frames and >60/255 in 68-99% -- nothing is structurally dead. Under the old mapping positions >0.82 read literal zero.
- Fresh in-page chain (eval: fetch demo-01 -> decodeAudioData -> own AnalyserNode at t=150 s): top positions dance (pos 1.0 = 16 kHz fluctuates 4..118/255 frame to frame).
- Live E2E with the USER'S OWN "Spectrum Bars" script (found in the test browser's localStorage from the previous session, 28 bars x 4 pts, running on Demo Track 2 at 60 FPS): per-bar pixel measurements across 6 frames show the rightmost bars (f 0.74-1.0 = 9.9-16 kHz) swinging 4%..76% with the music -- under the old mapping those bars sampled 16.8-24 kHz and sat pinned at 0 (the script's own below(lvl,0.004)?0 snap would hold them perfectly flat). Also verified with a purpose-built 48-bar "Edge Check" script earlier in the session.
- Anomaly chased and closed: one 5-sample window showed bars 36-48 at floor while bass danced -- turned out to be the demo track's outro (offline analysis: t=187-191 s has a legitimately dead 10-16 kHz band while lows keep going); the demo ended right after (SRC: IDLE, place advanced), matching the timeline. Not a mapping issue.
- What's New dialog: v7.10.1 "Spectrum edge unfrozen" first with CURRENT badge + FIXED tag; drift guard silent after a clean reload (one transient guard warning was hot-reload event ordering + stale console history); the one hydration error was agent-browser's own data-__ab-ci instrumentation, absent on a clean reload. Console otherwise clean; dev.log clean.
- Test-browser localStorage cleared afterwards (convention); scratch PCM deleted.
Stage Summary:
- v7.10.1 shipped: the frozen right edge of every spectrum visual is unfrozen. The bar field now spans ~12 Hz to 16 kHz (the range music actually occupies) instead of stretching to Nyquist, so the right bars land on the 10-16 kHz air band and dance with the hats/cymbals. The user's own Spectrum Bars script (whose 0.78+0.45*f treble tilt now has something real to boost) verified live: rightmost bars swing 4..76%.
---
Task ID: 38
Agent: main (Z.ai Code)
Task: v7.11 (user request, three parts). RESTORED RETROACTIVELY — the entry was lost in a session cutoff (same as Task 35 once was); scope reconstructed from the version.ts 7.11 history and the tool-results/v711-* evidence screenshots (v711-01 through v711-22, all PASS). (1) Mobile wake lock: while any audio source is live the site holds a Screen Wake Lock so phone/tablet screens stay on; released the moment the engine goes idle so normal inactivity dimming returns. (2) A respectful Demo Track button: no longer force-engages the Sequence checkbox when the visitor has loaded a preset (tree double-click / Load / copied preset) or brought their own script material into the editor — Demo just plays the track; the Zai-Magic showcase only starts for a visit that never picked or typed anything. (3) Script tab one-click Save: a valid script shows the Save button with no save target needed; auto-names from the script's name header (a hash import of "Spectrum Bars:[▼…]" saves as "Spectrum Bars"), "Empty Name" when nameless, " (1)"/" (2)"… suffixes on conflicts — and when the name had to be invented, a "Give This Preset a Name" dialog opens to rename the just-saved preset in one step (saved FIRST, renamed second, so nothing is ever lost).
Work Log (reconstructed):
- src/lib/ava/wake-lock.ts (NEW): Screen Wake Lock sentinel manager — acquire on source-live, release on idle, visibilitychange re-acquire (browsers silently drop the sentinel on hide), silent degradation where unsupported.
- audio-source.ts / control-panel / page.tsx: per-visit manualChoice flag + boot-script snapshot decide whether the Demo showcase may engage; ensureDefaultPreset gained the same guard (mic/file/link never clobber a custom draft either). Demo teardown paths (ended/stop/switch) all respect it.
- script-tab.tsx: Save button visible on any valid script with no target; auto-naming ladder (script name header -> hash import label -> "Empty Name" -> conflict suffixes); "Give This Preset a Name" dialog after any invented save. Hash⇢Script imports clear the stale save target and pass the decoded label through as the fallback name.
- version.ts 7.11 + history; release-notes.ts 7.11 entry.
E2E (evidence: tool-results/v711-01..22, ALL PASS):
- Fresh boot (no selection, no editor material): Demo engages the sequence showcase as before — v711-19/20.
- Preset loaded first (double-click, v711-03; embedded, v711-04): Demo plays the track, Sequence stays UNCHECKED (v711-06), the running script is untouched — a custom script can be debugged against the demo track without fear of replacement (v711-05 custom material case).
- Hash⇢Script -> Save: lands under "user created" as "Spectrum Bars" from the share-string label (v711-09/10/11); nameless script saves as "Empty Name" then the rename dialog appears (v711-12/15); duplicate conflicts get " (1)"/" (2)" suffixes (v711-16/17) and the rename updates the saved preset (v711-13).
- Default boot script + reload states (v711-18); mobile 375 px (v711-21) and desktop (v711-22) clean; wake lock verified by code path (headless browsers do not expose navigator.wakeLock — degrade-silent branch exercised).
Stage Summary:
- v7.11 shipped: the screen stays on while music plays, the Demo button stopped hijacking the preset selection, and one click saves any valid script with a smart name. This restored entry closes the gap in the build diary.
---
Task ID: 39
Agent: main (Z.ai Code)
Task: v7.12 (user request): add the classic-AVS background-movement family to the scripting — the background shifts up/down/left/right, spins clockwise/counter-clockwise, draws in and blows out, with the speed accelerating to the beat and hard hits flipping the spin direction while the fade to black stays slow (so colors blend) — then ship a permanent Zai-Magic preset "osc-rotations": a stationary centered oscilloscope whose left/right points color-shift together and whose center points hold their own hue, over a background rotating cw/ccw to the music with temporary beat acceleration.
Work Log:
- src/lib/ava/engine.ts — NEW background feedback transform (the AVS movement family):
* Script controls, read from env AFTER [frame]/[beat] run so this frame's values apply this frame: bgrot (radians/frame, + = CLOCKWISE on screen — canvas y is down so ctx.rotate(+) spins cw), bgx/bgy (per-frame drift, the -1..1 screen convention, y is UP matching script x/y), bgzoom (per-frame scale: <1 draw-in, >1 blow-out, 1 = neutral). Values persist in env like any variable — set from [init] (persists forever), [frame] (every frame) or [beat] (on-beat jumps); neutral readings skip the pass entirely (zero cost for every preset that doesn't move).
* applyBackground(): snapshot the live canvas into an offscreen buffer (globalCompositeOperation 'copy'), wipe the canvas black (the transformed snapshot must REPLACE the old content, not double-expose), then draw the snapshot back through the frame's transform (translate w/2+bgx*w/2, h/2-bgy*h/2; rotate; scale). Uncovered regions (rotated corners, slid-off edges) read black and the persistence fade blends the trails — exactly how the classic AVS plugins made "speed accelerates to the beat, a hard hit flips the spin, fade stays slow" work: the rate is just a script variable driven by a beatage envelope, and a [beat] section flips a dir variable on hard hits (bass vs bass_att).
* Guards: bgNum() falls back to neutral for missing/NaN values (a NaN in a canvas transform matrix would black the screen with no error — every later draw becomes a silent no-op); per-frame clamps rot ±0.6 rad, drift ±0.25, zoom 0.5..2. Buffer recreated automatically on resize; null until first use (zero cost otherwise). Gated on this.script so a stale env after clearScript can never spin a blank canvas.
- src/lib/ava/embedded-presets.ts — eleventh Zai-Magic preset "osc-rotations" (user-named, lowercase so it sorts last in the tree): persist 0.965 (slow fade — colors blend), [frame] drives bgrot = dir * (0.0032 + punch * (0.014 + vol_att * 0.012)) with punch = exp(-beatage*3.2) (temporary beat acceleration that relaxes with the kick ring), bgzoom = 1 - 0.0009 - punch*0.0011 (a whisper of draw-in so trails spiral inward instead of ringing), hue drifts dt*0.012 + beat*0.02; [beat] flips dir on hard hits only (bass > 1.25 * bass_att — soft beats leave the spin alone); [point] draws the stationary centered scope x = i*2-1, y = v*(0.3+vol_att*0.8) with mirror-symmetric color h = hue + |i-0.5|*2 * 0.34 (points equidistant left/right of center share one hue, the center holds its own). EMBEDDED_SEED_VERSION 7.10 -> 7.12 (route re-seeds the subtree; fingerprint self-heal would have caught it too). "ten presets" comments updated to eleven (both the file-top and the Zai-Magic section header).
- src/components/ava/help-panel.tsx — bgrot / bgx/bgy / bgzoom rows in the variables table + three example formulas (beat-accelerated spin, hard-hit direction flip in [beat], draw-in).
- src/lib/version.ts 7.12 + history; src/lib/release-notes.ts 7.12 entry (2x added, first-person voice). Share codec deliberately untouched: its dictionary is a frozen compatibility contract (adding entries needs a new scheme marker) — bg* identifiers ride the pair packing like any custom variable.
HEADLESS VALIDATION (/home/z/tmp-validate/bg-transform/validate-osc.ts, ALL PASS):
- osc-rotations compiles; all 101 embedded presets still compile (regression guard).
- Compiled-program behavior: quiet frame bgrot 0.00397 rad/frame cw + bgzoom 0.99906; hard beat bgrot 0.0256 (6.5x) AND dir flips to -1; soft beat no flip; point pass x spans ±1 with |y| symmetric, mirrored positions share hue exactly (0.7906 both) while center is distinct (0.6206).
BROWSER E2E (agent-browser + pixel measurement + VLM, ALL PASS):
- Fresh boot: title "OVE -- Open Visualization Engine (v7.12)"; tree shows "Zai-Magic -- 11"; /api/presets re-seeded on the version gate and serves osc-rotations (106 presets total; sorts last in Zai-Magic as designed).
- Preset live on the demo collection (manual load first, then Demo Track — v7.11's respectful-Demo still holds: Random/Sequence stayed UNCHECKED with a preset loaded): footer "SCRIPT: osc-rotations · Demo Track 1/2 · FPS 59-60 · PTS 512 · BPM 132.3/138.8 locked". Mid-band (scope zone) mean 133/255; outer zones lit only by rotation-carried trails.
- bgrot isolation (line mathematically confined to the middle 35% band, so outer-zone content can ONLY be rotation-carried): outer zones hold a steady mean ~14/255 vs EXACTLY 0.00 in the no-bg control — rotation puts content where the line cannot reach.
- bgx sign: +0.012 drifts the bright cloud right (mean x 0.518); -0.012 mirrors it exactly (0.480) — the sign convention verified by symmetry.
- bgy sign: +0.02 pulls the bright cloud UP (mean y-from-top 0.368 vs 0.5 neutral).
- bgzoom: at 1.05/frame the outer zones went from dead 0.00 to ~38/255 (blow-out pushes the confined line's trails outward); at 1.003/frame with persist 0.92 trails die before traveling far (consistent with the fade math — equilibrium analysis, not a defect).
- What's New dialog: 7.12 "Background movement (AVS-style) + osc-rotations" first with CURRENT badge, drift guard silent. Help panel: bg* rows + examples all present.
- VLM on the live screenshot: horizontal oscillating waveform line at vertical center; large soft colorful trail masses around it; hue variation green at the outer left/right edges, yellow mid, red/pink at the center — the mirror-symmetric color design visibly realized; no defects; "60 FPS . 512 PTS" overlay normal.
- Console clean (0 errors/0 warnings); dev.log clean (59 successful requests, no compile errors). Footer: sticks to the viewport bottom on short pages (1280x800 Beat tab: docH 800, footer bottom 800) and pushes to the document end on long pages (375 px Presets tab: docH 1469, footer at doc end, no overlap); 375 px scrollWidth 375 = clientWidth (no horizontal overflow). Test-browser localStorage cleared afterwards.
Stage Summary:
- v7.12 shipped: scripts can move the whole picture — bgrot spins, bgx/bgy slide, bgzoom draws in / blows out — with the speed riding the music exactly the way the classic AVS plugins did it (beatage envelopes drive the rate, [beat] flips the spin on hard hits, persist keeps the fade slow so colors blend). The eleventh Zai-Magic preset "osc-rotations" demonstrates the full recipe: a stationary centered oscilloscope with mirror-symmetric color (left/right shift together, center holds its own hue) over a beat-accelerated, hard-hit-flipping rotating background.
---
Task ID: 40
Agent: main (Z.ai Code)
Task: v7.13 (user request): merge the two parallel development lines. The user supplied the v7.12 build ("updated script editor variables and the osc-rotations preset") as an encrypted zip on Google Drive (password: z.ai) and asked to compare it with this tree's "Upload to OVE-Directory" version (v7.11) and merge the improvements into one build.
MERGE ARCHAEOLOGY:
- The Drive zip (openvisengine.zip, 374 MB, AES-encrypted, compression type 99 — Python zipfile can't read it; pyzipper could) extracted to /home/z/drive-712/v712-full: APP_VERSION 7.12, worklog Tasks 37-39 (v7.10.1 spectrum fix, v7.11 wake lock/respectful-Demo/one-click-Save, v7.12 bg movement + osc-rotations). The partial ove-src12/ extraction from the earlier session verified identical (114/115 sha256; the one mismatch was a Drive-fetch artifact named "globals.css CSS").
- Divergence map via the zip's own .git: BOTH repos share commit d59d398 (tree 953bae5... — IDENTICAL hash on both sides, a true common ancestor at v7.10). After it: this tree shipped v7.11 = the OVE-Directory (directory-tab.tsx, upload-dialog.tsx, /api/ove-directory proxy, lib/ove-directory.ts); the zip tree shipped 7.10.1 + 7.11-wake-lock + 7.12. The ONLY overlapping files were script-tab.tsx, version.ts, release-notes.ts (+ demo-track binaries, identical on both sides).
Work Log:
- Git 3-way merge: zip repo fetched as remote v712, `git merge 1ea335a` — everything auto-merged (engine.ts applyBackground + bgrot/bgx/bgy/bgzoom, audio.ts SPEC_TOP_HZ 16 kHz cap, rust-agent/spectrum.rs mirror, embedded-presets.ts osc-rotations + EMBEDDED_SEED_VERSION 7.12, wake-lock.ts NEW, audio-source.ts + page.tsx manualChoice/respectful-Demo, presets-tab.tsx, share-dialogs.tsx, store.ts markManualChoice, help-panel.tsx bg* rows + sv span) except three conflicts.
- script-tab.tsx (hand-merged): both sides' state blocks kept side by side — ours' uploadOpen/canUpload (Upload to OVE-Directory, gated on ✔ Compiled OK + no runtime error) + theirs' pendingName/rename* states (one-click Save + "Give This Preset a Name"); toolbar now carries the dual Save (target save / one-click new) AND the Upload button; Hash⇢Script import sets pendingName + clears editTarget + markManualChoice; UploadToDirectoryDialog mounted. Verified byte-lossless: diff vs the v7.12 original removes ONLY comment lines (theirs' code 100% intact; ours' upload code added).
- version.ts + release-notes.ts (hand-merged into ONE timeline): NEW 7.13 entry ("one build, every feature") on top, then 7.12 (bg movement + osc-rotations), 7.11 (the OVE-Directory), 7.10.2 (the parallel line's wake-lock/respectful-Demo/one-click-Save release — RENUMBERED from its original 7.11 so the list reads monotonically; noted in the 7.13 text), 7.10.1 (spectrum edge), 7.10 and below untouched. APP_VERSION = '7.13'. Drift guard green.
- engine.ts / audio.ts / embedded-presets.ts / wake-lock.ts / help-panel.tsx / audio-source.ts verified BYTE-IDENTICAL to the validated v7.12 originals (cmp) — the parallel line's E2E-proven behavior carries over by construction.
ENVIRONMENT RECOVERY (sandbox quirks, not code):
- The merge swapped db/custom.db underneath the running server → Prisma "readonly database" (dead inode); fixed by restart.
- Turbopack's persistent cache panicked twice ("Failed to restore task data — corrupted database") after file replacements/kills; fixed with rm -rf .next + clean start.
- Directly-spawned background servers were reaped when the Bash session ended; the stable path is the system's own .zscripts/dev.sh (disowned bun run dev). Server now up: / 200, /api/presets 200, /api/ove-directory/* 200.
- EMBEDDED_SEED_VERSION gate fired on first hit (7.10 -> 7.12): DB re-seeded to 106 presets incl. osc-rotations (verified in SQLite: seed=7.12, count=106, osc-rotations=1).
E2E (agent-browser + pixel measurement + VLM, ALL PASS):
- Fresh boot: title "OVE -- Open Visualization Engine (v7.13)"; tree root/embedded 106 = Amp Picks 66 + Dynamics 9 + Envision 15 + Synth-C 5 + Zai-Magic 11; osc-rotations sorts last in Zai-Magic as designed.
- osc-rotations live on Demo Track (dblclick-load via the treeitem's inner button): footer "SRC: DEMO · Demo Track · FPS 59-60 · PTS 512 · BPM 139.9 locked · SCRIPT: osc-rotations". Two frames 1.2 s apart: 20% of sampled pixels changed (animating). Zone means: mid-band 69.7/255 (the scope) vs corners 3.3-13.8/255 — outer-zone content can ONLY be rotation-carried (the line is confined to the middle), i.e. applyBackground is rotating the picture in the merged build.
- VLM on the clean capture: (1) bright horizontal oscillating waveform at the vertical center ✓; (2) colorful smeared trail masses at corners/edges "consistent with a rotating background layer smearing trails around the stationary center waveform" ✓; (3) mirror-symmetric hues — yellow/gold at BOTH extremes, warm pink/magenta in the middle ✓; (4) no UI defects ✓.
- Script tab: Apply / Save / Script⇢Hash / Hash⇢Script / Upload to OVE-Directory / New / Help ALL present; with a compiling script both Save and Upload enabled (upload title carries the publish/fix-then-apply guidance); Upload dialog opens with the share-string description.
- One-click Save flow: New (template) -> strip the name header -> Save -> saved as "Empty Name" -> "Give This Preset a Name" dialog opened automatically -> renamed to "Merge Test v713" -> "Preset renamed" toast; test preset removed from localStorage afterwards.
- Directory tab: live listing via the proxy (1 script, sort controls, "Spectrum Bars · LINES · Average: 4.5 · 2 votes", Get button).
- Help panel: bgrot / bgx / bgy / bgzoom rows + example formulas all present.
- What's New dialog: 7.13 "One build, every feature" FIRST with CURRENT badge; 7.12, 7.10.2 in the merged timeline; drift guard silent.
- Responsive: 375x812 no horizontal overflow (scrollWidth 375 = clientWidth), footer pushed to document end (docH 1651); 1280x800 footer sticks to viewport bottom (800 = 800).
- Console: 0 errors, 0 warnings (wake lock degrades silently in headless — no navigator.wakeLock); dev.log 0 errors. Test-browser localStorage cleaned afterwards.
Stage Summary:
- v7.13 shipped: the parallel line is merged — the spectrum 16 kHz cap, the screen wake lock, the respectful Demo button, the one-click Save with auto-naming + rename dialog, the AVS-style background movement family (bgrot/bgx/bgy/bgzoom) and the eleventh Zai-Magic preset "osc-rotations" all run in the same build as the community OVE-Directory, with one clean version timeline (the parallel wake-lock release folded in as 7.10.2). Only three files needed hand-merging; every engine-side change is byte-identical to its validated original.
---
Task ID: 41
Agent: main (Z.ai Code)
Task: v7.13.1 (user report): in Zai-Magic "Beat Matrix" the rightmost vertical line of dots sat offset +1 upwards -- its bottom dot did not line up with the other columns, and its top dot spilled over onto the next section to the right. Diagnose, explain, fix.
DIAGNOSIS (why the right column was short and a phantom dot spilled):
- The engine's point position i spans 0..1 INCLUSIVE (classic AVS Superscope convention; engine.ts: i = k/(n-1)). Beat Matrix's grid math assumed exclusive i = k/n: col = floor(i * cols) sends the LAST point (i = 1 exactly) to col 16 -- one column past the 16-column wall (x = 0.986/aspect vs the wall edge 0.87/aspect) at row 0 (top), reading getspec(1.0); and column 15 was left with only 9 dots (rows 0-8, no bottom: its lowest dot sat at y = -0.62 while every other column reaches y = -0.81). Verified headlessly before the fix: col 15 = 9 dots rows 0-8, col 16 = 1 dot, bottom-row y spread col 14 vs 15 = 0.19 script units.
- Sweep of all other floor(i*M) uses in the library (62 occurrences): every other one is benign -- angular presets close their loop (bar = bars lands exactly on bar 0's angle: seg*tau/segs with seg = segs = 2pi = 0), Aurora Veil's phantom lands off-screen (x = 1.19 > 1), the megabuf particle presets (Bass Bloom, Supernova) draw their phantom slot at alpha 0 (uninitialized megabuf reads 0), Strobe Lattice/Tunnel Vision/Prism Chandelier hide one endpoint dot in rotating/mirrored chaos, and the converted Winamp classics rendered identically under original AVS (which also uses inclusive i) -- their authors saw and accepted exactly this behavior. Beat Matrix was the only hand-authored ALIGNED grid where the defect is obvious, so it was the only one that needed the fix.
Work Log:
- embedded-presets.ts Beat Matrix [point]: the grid mapping now goes through CELL CENTERS -- cell = i * (cols * rows - 1) + 0.5, col = floor(cell / rows), row = floor(cell - col * rows). Because i = k/(n-1) over n = cols*rows points, cell = k + 0.5 exactly (up to 1-ulp dust), so every floor() sits half a cell away from an integer boundary: the endpoint can never spill and float dust can never shuffle a dot into a neighboring cell. getspec/x/y/hue all read col/row as before (col 0..15, row 0..9; the last point now lands at col 15 row 9, the author's original intent under i = k/n). A 6-line comment in the script documents the why.
- EMBEDDED_SEED_VERSION 7.12 -> 7.13.1 (library re-seed replaces the DB's Beat Matrix; verified in SQLite: seed = 7.13.1, 106 presets, fixed content present, old mapping gone).
- version.ts 7.13.1 + history entry; release-notes.ts 7.13.1 "Beat Matrix grid fixed" (fixed, first-person voice).
HEADLESS VALIDATION (/home/z/tmp-validate/beatmatrix/validate.mts, ALL PASS — REAL parser/compiler, not a re-implementation):
- Regression guard: all 101 embedded presets still compile.
- The fixed Beat Matrix [point] program executed over all 160 points with the engine's exact i = k/(n-1): 160/160 cells occupied, ZERO duplicates, max col 15, max row 9, bottom-right cell (15,9) present, all 16 columns carry their full 10 rows, 16 distinct x symmetric about 0, 10 distinct y rows aligned.
BROWSER E2E (agent-browser + 8-frame max-brightness composite + VLM, ALL PASS):
- Fresh load: title "OVE -- Open Visualization Engine (v7.13.1)"; /api/presets re-seeded on the version gate; Beat Matrix loads (PTS 160 = 16x10) and plays on the demo track (FPS 57-60, BPM 133.5 locked).
- Pixel lattice analysis of an 8-frame composite (captures the treble moments when the high-band columns light): exactly 16 dot columns detected, even 38-39 px gaps; the BOTTOM dot of every one of the 16 columns sits at the SAME pixel row (y = 619, spread 0 px) -- the row-9 position that only exists under the fix (the old code's col-15 lowest dot sat ~80 px higher).
- Phantom-dot check: the old spill position (x ~722, top row) shows ZERO bright pixels across the composite (x 710-750 all 0); the only brightness right of the wall is column 15's own glow decaying monotonically to nothing by x = 710 (175 -> 45 -> 0 bright-pixel counts), i.e. additive-blend glow, not a dot. The fullscreen-overlay icon (x >= 757) is outside the lattice zone.
- VLM on the composite: "16 columns"; bottom row fully lit across all 16 columns at the same height (clean straight bottom edge); no stray dot right of the rightmost column; no alignment defects.
- What's New dialog: 7.13.1 "Beat Matrix grid fixed" first with CURRENT badge; drift guard silent (0 console errors / 0 warnings across the session); dev.log clean (200s only). Test-browser localStorage cleared afterwards.
Stage Summary:
- v7.13.1 shipped: Beat Matrix's wall is whole -- every column carries its full 10 dots, the bottom edge is straight across all 16 columns, and the phantom dot that spilled past the right edge is gone. The fix lives in the preset's grid math (cell-center mapping, immune to the inclusive-endpoint and float dust alike); the engine's AVS-correct i convention is untouched, and the library re-seed delivers the fixed preset to every visitor.