diff --git a/src/app/page.tsx b/src/app/page.tsx index 8246215..ecd80c9 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,13 @@ -// AVA — Advanced Visualization Agent · main page. +// OVE — Open Visualization Engine · main page. // // Assembles the whole app: header (source controls + beat pairing), the // visual stage (canvas + engine loop), the control panel (Presets / Script / // Detect / Beat / Native) and the sticky footer status bar. // // Engine lifecycle lives in (the canvas owner); this page owns -// initial data loading (presets + settings), the beat-flash watcher and the -// Space-to-tap shortcut. +// initial data loading (merged preset tree + per-visitor settings from this +// browser's localStorage), the beat-flash watcher and the Space-to-tap +// shortcut. 'use client' @@ -18,14 +19,12 @@ import { FooterBar } from '@/components/ava/footer-bar' import { applyScriptText, tapBeat } from '@/components/ava/audio-source' import { STARTER_TEMPLATE } from '@/lib/ava/presets' import { - isBandConfig, - isBeatConfig, lsGetString, readStoredBandConfig, readStoredBeatConfig, + refreshPresets, restoreRandomSettings, useAvaStore, - type PresetItem, } from '@/lib/store' /** @@ -75,42 +74,29 @@ function useBeatWatcher() { }, [stats]) } -/** First load: presets + settings (server first, localStorage fallback). */ +/** + * First load: the merged preset tree (read-only embedded library from the + * server ∪ this browser's user presets) + per-visitor settings, which now + * come exclusively from localStorage — the server stores nothing about a + * visitor, so nobody can change what anybody else sees (v6.2). + */ function useInitialLoad() { useEffect(() => { let cancelled = false ;(async () => { - const [presetsRes, settingsRes] = await Promise.all([ - fetch('/api/presets') - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null), - fetch('/api/settings') - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null), - ]) + const presetsList = await refreshPresets() if (cancelled) return const store = useAvaStore.getState() - const presetsList = (presetsRes?.presets ?? []) as PresetItem[] - if (presetsRes?.presets) store.setPresets(presetsRes.presets) - const folders: string[] = presetsRes?.folders ?? [] - if (folders.length) store.setFolders(folders) - const settings = (settingsRes ?? {}) as { - defaultScript?: string | null - bandConfig?: unknown - beatConfig?: unknown - } - - const band = isBandConfig(settings.bandConfig) ? settings.bandConfig : readStoredBandConfig() + const band = readStoredBandConfig() if (band) store.applyBandConfig(band) - const beat = isBeatConfig(settings.beatConfig) ? settings.beatConfig : readStoredBeatConfig() + const beat = readStoredBeatConfig() if (beat) store.applyBeatConfig(beat) - // startup script: saved default → localStorage backup → first classic → template - let text: string | null = typeof settings.defaultScript === 'string' ? settings.defaultScript : null - if (!text) text = lsGetString('ava.defaultScript') - let startupPreset: PresetItem | undefined + // startup script: saved default (this browser) → first classic → template + let text: string | null = lsGetString('ava.defaultScript') + let startupPreset: (typeof presetsList)[number] | undefined if (!text) { startupPreset = presetsList.find((p) => p.folderPath === 'root/embedded/Synth-C') ?? presetsList[0] @@ -121,7 +107,7 @@ function useInitialLoad() { if (startupPreset) store.setActivePresetId(startupPreset.id) // club mode: bring back the random-preset timer if it was on - restoreRandomSettings(folders.length ? folders : store.folders) + restoreRandomSettings(useAvaStore.getState().folders) })() return () => { cancelled = true diff --git a/src/lib/ava/preset-io.ts b/src/lib/ava/preset-io.ts new file mode 100644 index 0000000..f975310 --- /dev/null +++ b/src/lib/ava/preset-io.ts @@ -0,0 +1,159 @@ +// OVE — client-side preset zip export / import (browser-only, JSZip). +// +// v6.2 security model: presets never travel to the server for writing. +// Export zips are built in the browser from the merged library (embedded + +// this visitor's user presets); import zips are unpacked in the browser and +// land ONLY inside "root/user created" — the embedded library stays +// untouched no matter what a zip contains. + +import JSZip from 'jszip' +import type { PresetItem } from '@/lib/store' +import { USER_ROOT, isUserAreaPath, isValidUserName } from '@/lib/preset-paths' +import { importUserPresets } from '@/lib/ava/user-presets' + +const MAX_ZIP_BYTES = 50 * 1024 * 1024 +const MAX_ZIP_ENTRIES = 2000 + +function subtreeOf(path: string): (p: string) => boolean { + return (p) => p === path || p.startsWith(`${path}/`) +} + +// --------------------------------------------------------------------------- +// export +// --------------------------------------------------------------------------- + +/** + * Build + download a .zip of everything under `folderPath` (presets from the + * merged store: embedded + user). Mirrors the historical server export + * format — full virtual paths ("root/…/Name.ava") — so zips round-trip. + */ +export async function exportPresetsZip( + folderPath: string, + presets: PresetItem[], + folders: string[], +): Promise<{ ok: true; presets: number } | { ok: false; error: string }> { + try { + const inScope = presets.filter((p) => subtreeOf(folderPath)(p.folderPath)) + if (inScope.length === 0) { + return { ok: false, error: 'That folder is empty — nothing to export.' } + } + const folderSet = new Set(folders.filter(subtreeOf(folderPath))) + for (const p of inScope) { + const segs = p.folderPath.split('/') + for (let i = 1; i <= segs.length; i++) { + const partial = segs.slice(0, i).join('/') + if (subtreeOf(folderPath)(partial)) folderSet.add(partial) + } + } + + const zip = new JSZip() + for (const fp of folderSet) zip.folder(fp) + for (const p of inScope) zip.file(`${p.folderPath}/${p.name}.ava`, p.content) + + const blob = await zip.generateAsync({ type: 'blob' }) + const url = URL.createObjectURL(blob) + const label = (folderPath.split('/').pop() ?? 'root').replace(/["\u0000-\u001f\u007f]/g, '').trim() || 'root' + const a = document.createElement('a') + a.href = url + a.download = `OVE presets - ${label}.zip` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + return { ok: true, presets: inScope.length } + } catch { + return { ok: false, error: 'Could not build the export zip.' } + } +} + +// --------------------------------------------------------------------------- +// import +// --------------------------------------------------------------------------- + +/** + * Remap a zip entry's directory into the user area: + * - "root/user created/…" → kept as-is (round-trip fidelity) + * - "root//…" → "root/user created//…" + * (imported copies of embedded presets become YOUR editable copies) + * - anything without a root/ prefix → placed inside the selected target + */ +function remapEntryFolder(dir: string, target: string): string { + if (dir === 'root/user created' || dir.startsWith('root/user created/')) return dir + if (dir === 'root') return target + if (dir.startsWith('root/')) return `${USER_ROOT}/${dir.slice('root/'.length)}` + return `${target}/${dir}` +} + +/** + * Unpack a .zip of .ava/.txt preset scripts in the browser and import them + * into this visitor's user library. `selectedFolder` is the target when it + * lives in the user area; anything else (embedded, root) falls back to + * "root/user created" — imports can never touch the embedded library. + */ +export async function importPresetsZip( + file: File, + selectedFolder: string, +): Promise< + { ok: true; imported: number; skipped: number; skippedOther: number; foldersCreated: number } | { ok: false; error: string } +> { + if (file.size > MAX_ZIP_BYTES) { + return { ok: false, error: 'That zip is too large (50 MB limit).' } + } + let zip: JSZip + try { + zip = await JSZip.loadAsync(await file.arrayBuffer()) + } catch { + return { ok: false, error: 'That file is not a valid zip archive.' } + } + + const entries = Object.values(zip.files).filter((e) => !e.dir) + if (entries.length === 0) return { ok: false, error: 'That zip is empty.' } + if (entries.length > MAX_ZIP_ENTRIES) { + return { ok: false, error: `That zip has too many entries (${entries.length} — limit ${MAX_ZIP_ENTRIES}).` } + } + + const target = isUserAreaPath(selectedFolder) ? selectedFolder : USER_ROOT + const imports: { name: string; content: string; folderPath: string; author?: string }[] = [] + let skippedOther = 0 + for (const entry of entries) { + const rawPath = entry.name.replace(/\\/g, '/') + if (rawPath.includes('__MACOSX') || rawPath.split('/').some((s) => s.startsWith('.'))) continue + if (!/\.(ava|txt)$/i.test(rawPath)) { + skippedOther++ + continue + } + const segs = rawPath + .split('/') + .map((s) => s.trim()) + .filter((s) => s.length > 0 && s !== '.' && s !== '..') + if (segs.length === 0) continue + const name = isValidUserName(segs[segs.length - 1].replace(/\.(ava|txt)$/i, '')) + if (!name) { + skippedOther++ + continue + } + const dirSegs = segs.slice(0, -1) + const folderPath = remapEntryFolder(dirSegs.join('/'), target) + const content = await entry.async('string') + const author = + /^author:\s*(.+)$/m + .exec(content)?.[1] + ?.replace(/^["']|["']$/g, '') + .trim() + .slice(0, 120) ?? '' + imports.push({ name, content, folderPath, author: author || undefined }) + } + + if (imports.length === 0) { + return { ok: false, error: 'No .ava preset files found in that zip.' } + } + const result = importUserPresets(imports) + if (!result.ok) return result + return { + ok: true, + imported: result.imported, + skipped: result.skipped, + skippedOther, + foldersCreated: result.foldersCreated, + } +} diff --git a/src/lib/ava/user-presets.ts b/src/lib/ava/user-presets.ts new file mode 100644 index 0000000..5866fae --- /dev/null +++ b/src/lib/ava/user-presets.ts @@ -0,0 +1,424 @@ +// OVE — per-visitor user presets, stored in THIS browser's localStorage. +// +// Security model (v6.2): the server hosts only the read-only embedded +// library. Everything a visitor creates / imports / edits lives in their own +// browser ("user created" area), so no visitor can ever write anything +// another visitor sees — spam, garbage presets and vandalism are impossible +// by construction. localStorage is exactly "the visitor's temporary files +// that the site can access and load". +// +// Caps keep a single visitor from bricking their own browser storage: +// 200 presets / 100 folders / 128 KB per script. Every write is quota-guarded. + +import type { PresetItem } from '@/lib/store' +import { + USER_ROOT, + isUserAreaPath, + isValidUserName, + normalizeUserFolderPath, +} from '@/lib/preset-paths' + +const STORAGE_KEY = 'ove.userPresets.v1' + +export const MAX_USER_PRESETS = 200 +export const MAX_USER_FOLDERS = 100 +export const MAX_CONTENT_CHARS = 128 * 1024 + +/** A user preset as persisted in localStorage. */ +export interface StoredUserPreset { + /** Always starts with "local-" — distinguishes browser presets from server rows. */ + id: string + name: string + author: string + description: string + content: string + /** Full virtual path, always inside "root/user created". */ + folderPath: string + createdAt: number + updatedAt: number +} + +interface UserStorePayload { + v: 1 + /** Explicitly created (possibly empty) folders. Preset paths derive more. */ + folders: string[] + presets: StoredUserPreset[] +} + +export type UserResult = + | ({ ok: true } & (T extends undefined ? Record : T)) + | { ok: false; error: string } + +// --------------------------------------------------------------------------- +// low-level storage access (guarded — SSR / private mode / quota safe) +// --------------------------------------------------------------------------- + +function emptyPayload(): UserStorePayload { + return { v: 1, folders: [], presets: [] } +} + +function isStoredPreset(v: unknown): v is StoredUserPreset { + if (!v || typeof v !== 'object') return false + const p = v as Record + return ( + typeof p.id === 'string' && + typeof p.name === 'string' && + typeof p.author === 'string' && + typeof p.description === 'string' && + typeof p.content === 'string' && + typeof p.folderPath === 'string' && + typeof p.createdAt === 'number' && + typeof p.updatedAt === 'number' + ) +} + +/** Read + shape-check + self-heal (invalid / out-of-area entries are dropped). */ +function readUserStore(): UserStorePayload { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return emptyPayload() + const parsed = JSON.parse(raw) as Partial | null + if (!parsed || typeof parsed !== 'object') return emptyPayload() + const presets = Array.isArray(parsed.presets) + ? parsed.presets.filter( + (p): p is StoredUserPreset => + isStoredPreset(p) && + isUserAreaPath(p.folderPath) && + p.folderPath === normalizeUserFolderPath(p.folderPath) && + isValidUserName(p.name) !== null, + ) + : [] + const folders = Array.isArray(parsed.folders) + ? parsed.folders.filter( + (f): f is string => + typeof f === 'string' && + f !== USER_ROOT && + isUserAreaPath(f) && + f === normalizeUserFolderPath(f), + ) + : [] + return { v: 1, folders: [...new Set(folders)], presets } + } catch { + return emptyPayload() + } +} + +/** Persist; surfaces browser-storage failures (quota / private mode). */ +function writeUserStore(payload: UserStorePayload): UserResult { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) + return { ok: true } + } catch { + return { + ok: false, + error: + 'Browser storage is full or unavailable — delete some user presets (they live only in this browser).', + } + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +export function presetIsLocal(id: string): boolean { + return id.startsWith('local-') +} + +function newLocalId(): string { + const uuid = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + return `local-${uuid}` +} + +/** Validate a destination folder for user presets (must be inside user area). */ +function validUserFolder(raw: unknown): string | null { + const path = normalizeUserFolderPath(raw) + if (!path || !isUserAreaPath(path)) return null + return path +} + +/** + * Every folder path the user tree should show: explicitly created folders ∪ + * all ancestors of every stored preset's folder. + */ +export function allUserFolderPaths(): string[] { + const { folders, presets } = readUserStore() + const set = new Set([USER_ROOT, ...folders]) + for (const p of presets) { + const segs = p.folderPath.split('/') + for (let i = 2; i <= segs.length; i++) set.add(segs.slice(0, i).join('/')) + } + set.delete('root') + return [...set] +} + +/** Map a stored user preset onto the shape the UI store consumes. */ +export function userPresetToItem(p: StoredUserPreset): PresetItem { + return { + id: p.id, + name: p.name, + author: p.author, + description: p.description, + content: p.content, + isBuiltIn: false, + approx: false, + folderPath: p.folderPath, + originalFile: null, + updatedAt: new Date(p.updatedAt).toISOString(), + } +} + +export function listUserPresets(): PresetItem[] { + return readUserStore().presets.map(userPresetToItem) +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +export function createUserPreset(input: { + name: unknown + content: unknown + folderPath: unknown + author?: unknown + description?: unknown +}): UserResult<{ preset: PresetItem }> { + const name = isValidUserName(input.name) + if (!name) return { ok: false, error: 'Invalid preset name (no slashes, max 120 characters).' } + if (typeof input.content !== 'string' || !input.content.trim()) { + return { ok: false, error: 'Preset content is empty.' } + } + if (input.content.length > MAX_CONTENT_CHARS) { + return { ok: false, error: `Script too large (over ${Math.floor(MAX_CONTENT_CHARS / 1024)} KB).` } + } + const folderPath = validUserFolder(input.folderPath ?? USER_ROOT) + if (!folderPath) return { ok: false, error: 'Presets can only be created inside "user created".' } + + const store = readUserStore() + if (store.presets.some((p) => p.folderPath === folderPath && p.name === name)) { + return { ok: false, error: `A preset named "${name}" already exists in ${folderPath}.` } + } + if (store.presets.length >= MAX_USER_PRESETS) { + return { ok: false, error: `User preset limit reached (${MAX_USER_PRESETS} in this browser).` } + } + const now = Date.now() + const preset: StoredUserPreset = { + id: newLocalId(), + name, + author: typeof input.author === 'string' ? input.author.trim().slice(0, 120) : '', + description: typeof input.description === 'string' ? input.description.trim().slice(0, 500) : '', + content: input.content, + folderPath, + createdAt: now, + updatedAt: now, + } + const written = writeUserStore({ ...store, presets: [...store.presets, preset] }) + if (!written.ok) return written + return { ok: true, preset: userPresetToItem(preset) } +} + +export function updateUserPreset( + id: string, + patch: { content?: unknown; author?: unknown; description?: unknown }, +): UserResult<{ preset: PresetItem }> { + const store = readUserStore() + const idx = store.presets.findIndex((p) => p.id === id) + if (idx === -1) return { ok: false, error: 'That preset is not in your browser library.' } + const preset = { ...store.presets[idx] } + if (patch.content !== undefined) { + if (typeof patch.content !== 'string' || !patch.content.trim()) { + return { ok: false, error: 'Preset content is empty.' } + } + if (patch.content.length > MAX_CONTENT_CHARS) { + return { ok: false, error: `Script too large (over ${Math.floor(MAX_CONTENT_CHARS / 1024)} KB).` } + } + preset.content = patch.content + } + if (patch.author !== undefined) { + preset.author = typeof patch.author === 'string' ? patch.author.trim().slice(0, 120) : '' + } + if (patch.description !== undefined) { + preset.description = + typeof patch.description === 'string' ? patch.description.trim().slice(0, 500) : '' + } + preset.updatedAt = Date.now() + const presets = [...store.presets] + presets[idx] = preset + const written = writeUserStore({ ...store, presets }) + if (!written.ok) return written + return { ok: true, preset: userPresetToItem(preset) } +} + +export function renameUserPreset(id: string, rawName: unknown): UserResult<{ preset: PresetItem }> { + const name = isValidUserName(rawName) + if (!name) return { ok: false, error: 'Invalid preset name (no slashes, max 120 characters).' } + const store = readUserStore() + const idx = store.presets.findIndex((p) => p.id === id) + if (idx === -1) return { ok: false, error: 'That preset is not in your browser library.' } + const preset = store.presets[idx] + if (store.presets.some((p) => p.id !== id && p.folderPath === preset.folderPath && p.name === name)) { + return { ok: false, error: `A preset named "${name}" already exists in ${preset.folderPath}.` } + } + const updated = { ...preset, name, updatedAt: Date.now() } + const presets = [...store.presets] + presets[idx] = updated + const written = writeUserStore({ ...store, presets }) + if (!written.ok) return written + return { ok: true, preset: userPresetToItem(updated) } +} + +export function deleteUserPreset(id: string): UserResult { + const store = readUserStore() + const presets = store.presets.filter((p) => p.id !== id) + if (presets.length === store.presets.length) { + return { ok: false, error: 'That preset is not in your browser library.' } + } + return writeUserStore({ ...store, presets }) +} + +export function createUserFolder(rawPath: unknown): UserResult<{ path: string }> { + const path = validUserFolder(rawPath) + if (!path) return { ok: false, error: 'Folders can only be created inside "user created".' } + if (path === USER_ROOT) return { ok: false, error: 'That folder already exists.' } + const store = readUserStore() + if (store.folders.includes(path)) return { ok: false, error: 'That folder already exists.' } + if (store.folders.length >= MAX_USER_FOLDERS) { + return { ok: false, error: `User folder limit reached (${MAX_USER_FOLDERS} in this browser).` } + } + // ancestor folders are implied by this row — drop redundant explicit ones + const folders = store.folders.filter((f) => !f.startsWith(`${path}/`)) + const written = writeUserStore({ ...store, folders: [...folders, path] }) + if (!written.ok) return written + return { ok: true, path } +} + +export function renameUserFolder( + rawPath: unknown, + rawName: unknown, +): UserResult<{ to: string; presets: number; folders: number }> { + const path = validUserFolder(rawPath) + const name = isValidUserName(rawName, 80) + if (!path) return { ok: false, error: 'Folders can only be renamed inside "user created".' } + if (!name) return { ok: false, error: 'Invalid folder name (no slashes, max 80 characters).' } + if (path === USER_ROOT) { + return { ok: false, error: '"user created" is the root of your library and cannot be renamed.' } + } + const store = readUserStore() + const parent = path.slice(0, path.lastIndexOf('/')) + const to = `${parent}/${name}` + const prefix = `${path}/` + if (to === path) return { ok: true, to, presets: 0, folders: 0 } + if ( + store.folders.some((f) => f === to || f.startsWith(`${to}/`)) || + store.presets.some((p) => p.folderPath === to || p.folderPath.startsWith(`${to}/`)) + ) { + return { ok: false, error: `"${name}" already exists there.` } + } + // reject renames that would push any path beyond the depth cap + const tooDeep = + to.split('/').length > 8 || + [...store.folders, ...store.presets.map((p) => p.folderPath)].some( + (f) => f.startsWith(prefix) && `${to}${f.slice(path.length)}`.split('/').length > 8, + ) + if (tooDeep) return { ok: false, error: 'That rename would nest folders too deep (max 8 levels).' } + const folders = store.folders.map((f) => (f === path ? to : f.startsWith(prefix) ? to + f.slice(path.length) : f)) + const presets = store.presets.map((p) => + p.folderPath === path + ? { ...p, folderPath: to, updatedAt: Date.now() } + : p.folderPath.startsWith(prefix) + ? { ...p, folderPath: to + p.folderPath.slice(path.length), updatedAt: Date.now() } + : p, + ) + const written = writeUserStore({ ...store, folders, presets }) + if (!written.ok) return written + return { + ok: true, + to, + presets: store.presets.filter((p) => p.folderPath === path || p.folderPath.startsWith(prefix)).length, + folders: store.folders.filter((f) => f === path || f.startsWith(prefix)).length, + } +} + +export function deleteUserFolder( + rawPath: unknown, +): UserResult<{ presets: number; folders: number }> { + const path = validUserFolder(rawPath) + if (!path) return { ok: false, error: 'Folders can only be deleted inside "user created".' } + if (path === USER_ROOT) { + return { ok: false, error: '"user created" is the root of your library and cannot be deleted.' } + } + const store = readUserStore() + const prefix = `${path}/` + const keptPresets = store.presets.filter( + (p) => p.folderPath !== path && !p.folderPath.startsWith(prefix), + ) + const keptFolders = store.folders.filter((f) => f !== path && !f.startsWith(prefix)) + const removedPresets = store.presets.length - keptPresets.length + const removedFolders = store.folders.length - keptFolders.length + if (removedPresets === 0 && removedFolders === 0) { + // maybe an implied folder (only ever held presets that are gone now) + return { ok: true, presets: 0, folders: 0 } + } + const written = writeUserStore({ ...store, presets: keptPresets, folders: keptFolders }) + if (!written.ok) return written + return { ok: true, presets: removedPresets, folders: removedFolders } +} + +// --------------------------------------------------------------------------- +// zip import (path remapping happens in preset-io; entries arrive pre-mapped) +// --------------------------------------------------------------------------- + +export function importUserPresets( + entries: { name: string; content: string; folderPath: string; author?: string }[], +): UserResult<{ imported: number; skipped: number; foldersCreated: number }> { + const store = readUserStore() + let imported = 0 + let skipped = 0 + const folders = new Set(store.folders) + const presets = [...store.presets] + const now = Date.now() + for (const entry of entries) { + if (presets.length >= MAX_USER_PRESETS) { + return { ok: false, error: `User preset limit reached (${MAX_USER_PRESETS} in this browser) — import stopped.` } + } + const folderPath = validUserFolder(entry.folderPath) + const name = isValidUserName(entry.name) + const content = typeof entry.content === 'string' ? entry.content : '' + if (!folderPath || !name || !content.trim() || content.length > MAX_CONTENT_CHARS) { + skipped++ + continue + } + if (presets.some((p) => p.folderPath === folderPath && p.name === name)) { + skipped++ + continue + } + if (folderPath !== USER_ROOT) folders.add(folderPath) + presets.push({ + id: newLocalId(), + name, + author: (entry.author ?? '').slice(0, 120), + description: '', + content, + folderPath, + createdAt: now, + updatedAt: now, + }) + imported++ + } + const foldersCreated = folders.size - store.folders.length + const written = writeUserStore({ v: 1, folders: [...folders], presets }) + if (!written.ok) return written + return { ok: true, imported, skipped, foldersCreated } +} + +/** Debug/escape hatch: wipe the browser library (used by nothing by default). */ +export function clearUserPresets(): void { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + /* ignore */ + } +} diff --git a/src/lib/preset-paths.ts b/src/lib/preset-paths.ts new file mode 100644 index 0000000..f82db8a --- /dev/null +++ b/src/lib/preset-paths.ts @@ -0,0 +1,65 @@ +// OVE — shared preset-tree path constants (PURE module: no server imports). +// +// Imported by BOTH server routes and client components, so it must stay +// dependency-free. The preset library has two structural areas: +// +// root/embedded — the shipped, curated library. READ-ONLY for visitors +// (the server refuses every mutation; the UI shows a +// lock). Only the maintainer's seed pipeline writes it. +// root/user created — every visitor's OWN presets, stored in their browser +// (localStorage) — never on the server. No cross-visitor +// spam or vandalism is possible by construction. + +export const ROOT = 'root' +export const EMBEDDED_ROOT = 'root/embedded' +export const USER_ROOT = 'root/user created' + +export const MAX_DEPTH = 8 +export const MAX_SEGMENT = 80 +export const MAX_NAME = 120 + +/** True for "root/embedded" and every path below it. */ +export function isEmbeddedPath(path: string): boolean { + return path === EMBEDDED_ROOT || path.startsWith(`${EMBEDDED_ROOT}/`) +} + +/** True for "root/user created" and every path below it. */ +export function isUserAreaPath(path: string): boolean { + return path === USER_ROOT || path.startsWith(`${USER_ROOT}/`) +} + +/** + * Validate + normalize a folder path ("root/a/b"). Returns null when the path + * is malformed (wrong root, too deep, bad characters, empty segments). + * Client-side twin of the server-side normalizeFolderPath (which cannot be + * imported here — it lives next to the Prisma client). + */ +export function normalizeUserFolderPath(raw: unknown): string | null { + if (typeof raw !== 'string') return null + const trimmed = raw.trim().replace(/\/+$/, '') + if (!trimmed) return null + const parts = trimmed + .split('/') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + if (parts.length === 0 || parts.length > MAX_DEPTH) return null + if (parts[0] !== ROOT) return null + for (const seg of parts) { + if (seg.length > MAX_SEGMENT) return null + if (/[\u0000-\u001f\u007f]/.test(seg)) return null + if (seg === '.' || seg === '..') return null + } + return parts.join('/') +} + +/** + * Validate a preset / folder display name (no separators, sane length). + * Client-side twin of the server-side isValidName. + */ +export function isValidUserName(raw: unknown): string | null { + if (typeof raw !== 'string') return null + const name = raw.trim() + if (!name || name.length > MAX_NAME) return null + if (/[/\u0000-\u001f\u007f]/.test(name)) return null + return name +} diff --git a/src/lib/server/preset-tree.ts b/src/lib/server/preset-tree.ts index d4f0dbb..fc04f0e 100755 --- a/src/lib/server/preset-tree.ts +++ b/src/lib/server/preset-tree.ts @@ -1,17 +1,18 @@ -// AVA — server-side preset folder-tree helpers. +// OVE — server-side preset folder-tree helpers. // // The preset library is a virtual file tree rooted at "root" with two -// structural areas: "root/embedded" (the shipped Winamp AVS ports) and -// "root/user created" (the user's own space). Every folder below those is -// fully manageable (create / rename / delete / export / import). +// structural areas: "root/embedded" (the shipped, READ-ONLY library — only +// the seed pipeline writes it) and "root/user created" (each visitor's own +// presets, which live in their BROWSER via localStorage — never on the +// server). All public mutation endpoints are therefore refused. import { db } from '@/lib/db' +import { EMBEDDED_ROOT, MAX_DEPTH, MAX_SEGMENT, ROOT, USER_ROOT } from '@/lib/preset-paths' -export const MAX_DEPTH = 8 -export const MAX_SEGMENT = 80 +export { EMBEDDED_ROOT, USER_ROOT, isEmbeddedPath, isUserAreaPath } from '@/lib/preset-paths' /** Folders that anchor the tree — they can never be renamed or deleted. */ -export const PROTECTED_FOLDERS = new Set(['root', 'root/embedded', 'root/user created']) +export const PROTECTED_FOLDERS = new Set([ROOT, EMBEDDED_ROOT, USER_ROOT]) export interface ParsedPath { path: string @@ -31,7 +32,7 @@ export function normalizeFolderPath(raw: unknown): ParsedPath | null { .map((s) => s.trim()) .filter((s) => s.length > 0) if (parts.length === 0 || parts.length > MAX_DEPTH) return null - if (parts[0] !== 'root') return null + if (parts[0] !== ROOT) return null for (const seg of parts) { if (seg.length > MAX_SEGMENT) return null if (/[\u0000-\u001f\u007f]/.test(seg)) return null diff --git a/src/lib/store.ts b/src/lib/store.ts index 26f9b0b..97d1161 100755 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1,14 +1,19 @@ -// AVA — client state store (zustand) +// OVE — client state store (zustand) // // Single source of truth for the UI. The engine is the render authority; this // store mirrors the pieces the UI needs (throttled stats, configs, script // text, presets, source state) and applies config changes to the engine live. -// Config changes persist to localStorage immediately and to /api/settings -// debounced (~600 ms). +// Config + user presets persist to THIS browser's localStorage only — the +// server hosts nothing per-visitor (v6.2 security model). +// +// The preset list is a MERGED view: the read-only embedded library from the +// server + this visitor's own presets from localStorage ("user created"). import { create } from 'zustand' import type { EngineStats } from '@/lib/ava/engine' import { getEngine } from '@/lib/ava/engine' +import { allUserFolderPaths, listUserPresets } from '@/lib/ava/user-presets' +import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths' import type { TapResult } from '@/lib/ava/beat' import type { BandConfig, BeatConfig, BeatSource } from '@/lib/ava/types' import { DEFAULT_BAND_CONFIG, DEFAULT_BEAT_CONFIG } from '@/lib/ava/types' @@ -53,7 +58,8 @@ export interface BeatFlash { } // --------------------------------------------------------------------------- -// localStorage helpers (all guarded — SSR / private mode safe) +// localStorage persistence (per-visitor; the server stores NOTHING about a +// visitor — no cross-visitor pollution is possible) // --------------------------------------------------------------------------- const LS_BAND = 'ava.bandConfig' @@ -94,38 +100,16 @@ export function lsSetString(key: string, value: string): void { } } -// --------------------------------------------------------------------------- -// debounced /api/settings persistence -// --------------------------------------------------------------------------- - -let bandPersistTimer: ReturnType | null = null -let beatPersistTimer: ReturnType | null = null - -function putSettings(payload: Record): void { - fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => { - /* offline — localStorage copy already saved */ - }) -} - function persistBandConfig(cfg: BandConfig): void { lsSetJSON(LS_BAND, cfg) - if (bandPersistTimer) clearTimeout(bandPersistTimer) - bandPersistTimer = setTimeout(() => putSettings({ bandConfig: cfg }), 600) } function persistBeatConfig(cfg: BeatConfig): void { lsSetJSON(LS_BEAT, cfg) - if (beatPersistTimer) clearTimeout(beatPersistTimer) - beatPersistTimer = setTimeout(() => putSettings({ beatConfig: cfg }), 600) } export function persistDefaultScript(text: string): void { lsSetString(LS_SCRIPT, text) - putSettings({ defaultScript: text }) } // --------------------------------------------------------------------------- @@ -460,42 +444,66 @@ export const useAvaStore = create()((set, get) => ({ })) // --------------------------------------------------------------------------- -// preset tree fetching (shared by Presets tab + initial load) +// preset tree fetching + merge (server embedded library ∪ browser user presets) // --------------------------------------------------------------------------- +/** Last server response — the read-only embedded half of the merged tree. */ +let serverPresetsCache: PresetItem[] = [] +let serverFoldersCache: string[] = ['root', 'root/embedded', 'root/user created'] + +/** + * Merge the cached server library with this visitor's localStorage presets, + * push the combined tree into the store, and prune stale selections. + */ +function setMergedPresets(): PresetItem[] { + // Defensive: the user area is ALWAYS browser-owned — anything the server + // might still report there is ignored (the server also purges its copy). + const embedded = serverPresetsCache.filter((p) => !isUserAreaPath(p.folderPath)) + const local = listUserPresets() + const presets = [...embedded, ...local] + + const folders = [...new Set([...serverFoldersCache, ...allUserFolderPaths()])] + const s = useAvaStore.getState() + s.setPresets(presets) + s.setFolders(folders) + + // prune selections that no longer exist (after delete / import / rename) + const patch: Partial = {} + if (!folders.includes(s.selectedFolder)) patch.selectedFolder = 'root' + if (s.selectedPresetId && !presets.some((p) => p.id === s.selectedPresetId)) { + patch.selectedPresetId = null + } + if (s.activePresetId && !presets.some((p) => p.id === s.activePresetId)) { + patch.activePresetId = null + } + if (s.editTarget && !presets.some((p) => p.id === s.editTarget?.id)) { + patch.editTarget = null + } + const scope = s.randomScope + if (scope !== 'root' && !folders.includes(scope)) { + patch.randomScope = USER_ROOT + persistRandomSettings() + syncRandomTimer() + } + if (Object.keys(patch).length > 0) useAvaStore.setState(patch) + return presets +} + +/** Re-merge after a browser-side user-preset change (create/rename/delete/import). */ +export function applyUserPresets(): PresetItem[] { + return setMergedPresets() +} + export async function refreshPresets(): Promise { try { const res = await fetch('/api/presets') if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = (await res.json()) as { presets?: PresetItem[]; folders?: string[] } - const presets = data.presets ?? [] - const folders = + serverPresetsCache = data.presets ?? [] + serverFoldersCache = data.folders && data.folders.length ? data.folders : ['root', 'root/embedded', 'root/user created'] - const s = useAvaStore.getState() - s.setPresets(presets) - s.setFolders(folders) - - // prune selections that no longer exist (after delete / import / rename) - const patch: Partial = {} - if (!folders.includes(s.selectedFolder)) patch.selectedFolder = 'root' - if (s.selectedPresetId && !presets.some((p) => p.id === s.selectedPresetId)) { - patch.selectedPresetId = null - } - if (s.activePresetId && !presets.some((p) => p.id === s.activePresetId)) { - patch.activePresetId = null - } - if (s.editTarget && !presets.some((p) => p.id === s.editTarget?.id)) { - patch.editTarget = null - } - const scope = s.randomScope - if (scope !== 'root' && !folders.includes(scope)) { - patch.randomScope = s.selectedFolder - persistRandomSettings() - syncRandomTimer() - } - if (Object.keys(patch).length > 0) useAvaStore.setState(patch) - return presets + return setMergedPresets() } catch { - return [] + return setMergedPresets() } } diff --git a/tool-results/read_1793082276516_65b3b8ecd40e.txt b/tool-results/read_1793082276516_65b3b8ecd40e.txt new file mode 100644 index 0000000..695a0f7 --- /dev/null +++ b/tool-results/read_1793082276516_65b3b8ecd40e.txt @@ -0,0 +1,834 @@ + 1→# Worklog — AVA (Advanced Visualization Agent) + 2→ + 3→Project: Audio-reactive visualization agent (AVS-style scripting, 3-band spectrum detection, + 4→predictive beat tracking). Built as a Next.js 16 web app (reference implementation) plus a + 5→Rust/Vulkan scaffold for the future native cross-platform export. + 6→ + 7→User requirements: + 8→- 3-band detection: Bass 20–100 Hz, Mid 700 Hz–2 kHz, High 7 kHz–16 kHz (user-adjustable) + 9→- Beat detection from bass + beat PREDICTION (keeps beat when bass drops out) + 10→- Green dot beat indicator + "Set Beat" tap-tempo button + 11→- AVS-style custom visualization script editor: edit, save as default, save to file, load from file + 12→- Help section with variables/functions reference + 13→- 5 preset scripts (user provided real Winamp AVS presets on Google Drive) + 14→- Rust + Vulkan target for Linux/Windows/macOS export (scaffold + blueprint delivered here) + 15→ + 16→--- + 17→Task ID: 1 + 18→Agent: main (Z.ai Code) + 19→Task: Project inspection + Google Drive preset retrieval + 20→ + 21→Work Log: + 22→- Verified dev server running on port 3000 (Next.js 16.1.3, dev.log clean) + 23→- Fetched Google Drive folder (public) — extracted 5 file IDs and downloaded all files: + 24→ - "Synth-C - Gap The Mind.avs" (12.5 KB) + 25→ - "zamuz - wonder 'bout.avs" (34 KB) + 26→ - "Synth-C - Quantization.avs" (14.8 KB) + 27→ - "zamuz - keep tryin'.avs" (19 KB) + 28→ - "zamuz - vorticity (brain activity remix).avs" (17 KB) + 29→- Parsed binary AVS format (null-terminated strings): extracted Superscope code, Texer II + 30→ configs, movement expressions. These are scene classics from visbot.net. + 31→- Decided engine design: AVS-eel-flavored expression language (if/above/below/equal/rand, + 32→ getspec/getosc-equivalents sv/v, reg-free flat variables) so the presets port naturally. + 33→ + 34→Stage Summary: + 35→- All 5 original presets secured and understood; will be adapted to the AVA script dialect + 36→ (originals preserved as downloadable files in public/avs-originals/). + 37→- Core engine architecture defined: expression compiler, script parser, BeatTracker (PLL-style + 38→ predictor + tap tempo), AudioEngine (WebAudio FFT, mic/file/demo synth), canvas renderer. + 39→ + 40→--- + 41→Task ID: 2 + 42→Agent: main (Z.ai Code) + 43→Task: Project setup — deps, Prisma schema, DB, original preset files + 44→ + 45→Work Log: + 46→- Installed jszip (for the Rust scaffold zip download API) + 47→- Prisma schema rewritten: `Preset` (name/author/description/content/isBuiltIn, unique name) + 48→ and `Settings` (defaultScript + bandConfig + beatConfig JSON) — ran `bun run db:push` OK + 49→- Copied the 5 original Winamp .avs binaries to public/avs-originals/ (downloadable from UI) + 50→ + 51→Stage Summary: + 52→- DB ready; jszip available; originals served at /avs-originals/*.avs + 53→ + 54→--- + 55→Task ID: 3 + 56→Agent: main (Z.ai Code) + 57→Task: AVA core engine libraries (src/lib/ava/*) + 58→ + 59→Work Log: + 60→- types.ts — BandConfig / BeatConfig / BandLevels / BeatFrame + defaults + 61→ (bass 20–100 Hz, mid 700–2000 Hz, high 7000–16000 Hz as requested) + 62→- expression.ts — full expression compiler: tokenizer → recursive-descent parser → closure + 63→ tree. AVS-eel flavored: if() is LAZY, && / || short-circuit, ternary, ^ power (right-assoc), + 64→ assignment valid inside call args + ternary branches (if(cond, x=1, x=2) works), + 65→ newline & ';' statement separators (newlines inside parens ignored), '#'/'//' comments, + 66→ deterministic hash rand(seed). 30+ functions incl. above/below/equal/band/bor/bnot/frac. + 67→- script.ts — text format parser: header keys (name/author/description/mode/blend/persist/ + 68→ linewidth) + [init]/[frame]/[beat]/[point] sections, error objects with section+line, + 69→ validateScript() never throws. + 70→- beat.ts — BeatTracker: rolling-average energy detection w/ threshold+minInterval+floor, + 71→ interval learning (median + EMA period lock, spread gating), PLL-style phase predictor that + 72→ keeps firing beats when bass drops (confidence-gated, slowly decays), tap() tempo learning. + 73→- audio.ts — AudioEngine: WebAudio AnalyserNode FFT 8192 (~5.4 Hz bins), byte-spectrum band + 74→ averaging with attack 35ms / release 220ms smoothing; sources: demo (synthesized techno: + 75→ kick 160→43Hz sweep, snare bandpass 1.8k, hats HP 8.2k, saw bassline — audible + analyzable), + 76→ mic (never routed to speakers), file (loops, audible). getWave/getSpectrum for scripts. + 77→- presets.ts — 5 built-in presets ADAPTED from the user's real AVS files: + 78→ Gap The Mind (radial warp rings), wonder 'bout (bass-spawned expanding rings), + 79→ Quantization (grid-snapped dot rings), keep tryin' (bass burst dots + swirl), + 80→ vorticity (twisting spiral arms) + STARTER_TEMPLATE. + 81→- engine.ts — AvaEngine: rAF loop (bands → beat → env → frame/beat progs → point prog ×N → + 82→ draw), canvas 2d renderer with additive blending, per-point color, 2-pass glow, + 83→ persistence trails, lines/dots/solid modes, DPR-aware resize via ResizeObserver, + 84→ throttled stats callback for the UI. Singleton via getEngine(). + 85→- SMOKE TESTED via bun script: all 5 presets parse & execute 120 frames; lazy-if/ternary/pow + 86→ OK; beat sim locks 124.1 BPM from 124 BPM kick train; predictor sustains beats through 10s + 87→ of silence; tap tempo locks 120 BPM from 500ms taps. (2 parser bugs found & fixed: assignment + 88→ inside call args; newline statement separators with paren-awareness.) + 89→ + 90→Stage Summary: + 91→- Core engine complete and verified. Env variables exposed to scripts: n, t, time, dt, frame, + 92→ bass/mid/treb (+ _att), vol (+ _att), beat, bpm, beatphase, beatconf, beats, w, h, aspect, + 93→ persist, i, v, sv, x, y, red, green, blue, skip, size, alpha. + 94→ + 95→--- + 96→Task ID: 4 + 97→Agent: main (Z.ai Code) + 98→Task: API routes + 99→ + 100→Work Log: + 101→- GET/POST /api/presets — GET seeds built-ins on first run and lists all; POST upserts user + 102→ presets by name (built-ins protected, 409 on name clash with built-in) + 103→- PUT/DELETE /api/presets/[id] — user presets only + 104→- GET/PUT /api/settings — defaultScript + bandConfig + beatConfig (upsert, JSON strings in DB) + 105→- Created src/lib/ava/blueprint.ts STUB (interface Blueprint + placeholder) for Task 3-b + 106→ + 107→Stage Summary: + 108→- Backend complete. Frontend contract: GET /api/presets → { presets: [...] }; + 109→ POST /api/presets {name, content, author?, description?} → {preset}; + 110→ GET /api/settings → { defaultScript, bandConfig, beatConfig }; + 111→ PUT /api/settings { defaultScript? , bandConfig?, beatConfig? }. + 112→ + 113→ + 114→--- + 115→Task ID: 3-a + 116→Agent: full-stack-developer (UI) + 117→Task: Complete UI for AVA — single-page app (dark club/VJ aesthetic) with header + 118→(audio source controls, BPM readout, green beat dot + Set Beat pairing), visual + 119→stage (canvas + engine loop), 5-tab control panel (Presets / Script / Detect / + 120→Beat / Native) and sticky footer status bar. + 121→ + 122→Work Log: + 123→- Read worklog + all engine APIs (types/engine/script/presets/blueprint/audio/beat) + 124→ and the existing API routes before writing any code. + 125→- src/lib/store.ts — zustand store: throttled stats (AvaStats = EngineStats + + 126→ beatCount), beatFlash pulses, bandConfig/beatConfig mirrors that write the + 127→ engine live + persist (localStorage instant, PUT /api/settings debounced 600 ms + 128→ with shape guards), preset list + refreshPresets(), script text, active tab. + 129→- src/components/ava/audio-source.ts — tapBeat() (engine.beat.tap + dot flash + + 130→ lastTap), applyScriptText() (validate → setScript, engine.setScript), and + 131→ useAudioSource() hook wrapping startDemo/startMic/startFile/stop with error + 132→ mapping (mic permission / no device / decode failure → destructive toast) and + 133→ store sync from engine.audio.kind (authoritative). + 134→- src/components/ava/beat-dot.tsx — the green indicator: SVG phase ring + 135→ (stroke-dashoffset follows beat.phase, 150 ms linear transition), emerald dot + 136→ that scales 1.9× + glows 180 ms on audio/tap beats, and for predicted beats an + 137→ expanding outline ring (framer-motion, 450 ms) + "PRED" badge. Flash state is + 138→ derived (seq vs clearedSeq) — no setState-in-effect. + 139→- src/app/page.tsx — useInitialLoad (presets + settings; server → localStorage → + 140→ first preset → STARTER_TEMPLATE fallback chain; applies configs + script), + 141→ useBeatWatcher (see decisions below), useSpaceToTap (Space outside + 142→ inputs/textarea/select/button → tap, preventDefault). Root layout: + 143→ min-h-screen flex-col, lg:h-screen with internal panel scroll (footer always + 144→ at viewport bottom; mobile pushes footer naturally). + 145→- src/components/ava/header.tsx — sticky, backdrop-blur; emerald logo square + + 146→ AVA title + subtitle (hidden on mobile); segmented Demo/Mic/File/Stop control + 147→ (44 px targets, active state emerald, hidden file input); demo BPM slider + 148→ (60–200, only while demo active); right cluster = BPM readout (mono, emerald, + 149→ '—' when unknown) + BeatDot + Set Beat button, always visible, wraps to row 1 + 150→ on mobile (source controls wrap to row 2). + 151→- src/components/ava/visual-stage.tsx — owns engine lifecycle (engine.start/ + 152→ stop + onStats → store), idle glass overlay with 3 big source buttons + 153→ (AnimatePresence fade), top-left FPS/PTS readout (pointer-events-none), top- + 154→ right fullscreen toggle (requestFullscreen on container). Canvas absolute + 155→ fill; engine's ResizeObserver handles DPR. + 156→- src/components/ava/presets-tab.tsx — preset cards (name/author/description + 157→ line-clamp-3, mode + built-in/custom badges), Load (applies + toasts, jumps + 158→ to Script tab on compile error), ".avs original" download link for built-ins + 159→ via originalFileUrl(name), two-step Delete (3 s confirm timeout) for customs, + 160→ "New Script" (STARTER_TEMPLATE + switch tab), save-as-preset section (POST, + 161→ 409 handled, refresh + toast). List: max-h-96 + thin styled scrollbar. + 162→- src/components/ava/script-tab.tsx — toolbar (Apply ▶ / Save as Default 💾 / + 163→ Save to File ⬇ Blob download .ava / Load from File 📂 (.ava,.avs, + 164→ .txt, auto-applies when valid) / New / Help ?), gutter+textarea editor + 165→ (synced scroll, Tab → 2 spaces, min-h-320, wrap=off), rose gutter line on the + 166→ error line, live debounced validation (250 ms), status line "✔ Compiled OK — + 167→ mode: lines, n=480" (n from running [init] on a scratch env) / compile error + 168→ / stats.error runtime errors. + 169→- src/components/ava/help-panel.tsx — Quick Start, full variables table (frame + 170→ + point-only), functions table, operators, 5 example formulas, .ava file + 171→ format doc. + 172→- src/components/ava/detect-tab.tsx — Bass/Mid/High cards: Low/High Hz sliders + 173→ (bass 10–300/20–500, mid 100–5000/300–8000, high 1000–12000/4000–20000; low < + 174→ high enforced with min gaps 10/50/200 in the store), Gain 0.1–4, mono Hz + 175→ readouts, live VU meters (instant fill emerald/amber/rose + _att peak-hold + 176→ tick), per-band reset. + 177→- src/components/ava/beat-tab.tsx — 80 px BeatDot + source badge (AUDIO/PRED/ + 178→ TAP), big BPM readout, large SET BEAT button (framer-motion whileTap), tap + 179→ feedback ("Tap 3… keep tapping" → "Locked: 128.0 BPM"), confidence bar, beat + 180→ count, sensitivity (threshold 1.1–2.5, "lower = more sensitive"), min + 181→ interval 150–600 ms, prediction switch ("Keeps the beat alive when the bass + 182→ drops out"), how-it-works explainer. + 183→- src/components/ava/native-tab.tsx — renders BLUEPRINT (title/intro/sections/ + 184→ bullets/code blocks in scrollable pre), emerald "Download Rust Scaffold + 185→ (.zip)" , identical-language note. + 186→- src/components/ava/footer-bar.tsx — sticky mt-auto status bar: SRC/FPS/PTS/ + 187→ BPM/SCRIPT micro-stats (mono), green "engine ok" / pulsing red "script error". + 188→- src/app/layout.tsx — dark class on , title "AVA — Advanced + 189→ Visualization Agent", body bg-zinc-950. + 190→- FIXED BUGS during browser testing: (1) Detect tab crashed — levels key for + 191→ the high band is `treb` not `high` (added levelKey/attKey mapping to BandSpec); + 192→ (2) lint: setState-in-effect in BeatDot (derived flash state instead); + 193→ (3) lint: // text node in help panel. + 194→- Tuned src/hooks/use-toast.ts: TOAST_LIMIT 1→3, TOAST_REMOVE_DELAY 100 s→5 s + 195→ (lingering toasts were covering header/panel buttons during testing). + 196→- VERIFIED in headless browser: initial load seeds presets + applies Gap The + 197→ Mind (480 pts @60 fps); demo start/stop + DEMO slider; beat dot flashing live + 198→ (scale 1↔1.9); tap tempo lock ("Locked: 119.9 BPM" from 4×500 ms taps, Space + 199→ key too); after Stop the PRED badge/outline ring shows prediction carrying + 200→ the beat; preset load/save/delete (two-step) round-trip via API; Save as + 201→ Default → PUT /api/settings + localStorage; band slider → debounced PUT; + 202→ .avs originals serve 200; script upload (auto-apply, n=300) + export to + 203→ Upload Test.ava; mic error toast ("No microphone found" in this headless + 204→ env); no blue/indigo anywhere; lint clean; tsc clean for owned files. + 205→ + 206→Stage Summary: + 207→- Files created: src/lib/store.ts; src/components/ava/{shared.ts, audio-source.ts, + 208→ beat-dot.tsx, header.tsx, visual-stage.tsx, presets-tab.tsx, script-tab.tsx, + 209→ help-panel.tsx, detect-tab.tsx, beat-tab.tsx, native-tab.tsx, footer-bar.tsx, + 210→ control-panel.tsx}. Modified: src/app/page.tsx (replaced), src/app/layout.tsx, + 211→ src/hooks/use-toast.ts (toast duration/limit only). + 212→- KEY DECISION — beat flash sampling: engine stats are throttled (~8 Hz) but + 213→ beat=true lasts one frame, so most beats are invisible in stats. The watcher + 214→ in page.tsx ALSO fires on beat.phase running backwards (real beats reset + 215→ phase to 0; the predictor grid wraps at 1). Classification: stats.beat.beat + 216→ sampled → exact source; phase drop + prediction on + confidence > 0.35 + + 217→ bassAtt < 0.06 → 'predicted'; phase drop + bassAtt > 0.05 → 'audio'; silent + 218→ no-lock → no flash (idle dot doesn't pulse). Tap flashes suppressed for + 219→ 300 ms to avoid double pulses. Verified working in-browser. + 220→- KEY DECISION — desktop uses lg:h-screen + internal panel scroll so the footer + 221→ stays at the viewport bottom and the canvas fills the height; mobile uses + 222→ natural flow (min-h-screen + mt-auto footer) so the footer is pushed down. + 223→- Store is the only place configs touch the engine (applyBandConfig/ + 224→ setBandValue etc.), keeping live-update + persistence consistent everywhere. + 225→- NOTE for Task 5 (integration): /api/rust-scaffold is linked from the Native + 226→ tab but the route does not exist yet (Task 3-b owns it — expect 404 until + 227→ then). BLUEPRINT is still the stub; the Native tab renders whatever 3-b puts + 228→ in it, no UI changes needed. Settings row 'main' currently holds + 229→ defaultScript="Gap The Mind" + default bandConfig from testing (both equal + 230→ defaults — safe to leave or clear). Pre-existing tsc errors in + 231→ src/lib/ava/{audio,script}.ts and examples/ are NOT from this task + 232→ (ignoreBuildErrors is set). + 233→ + 234→--- + 235→Task ID: 3-b + 236→Agent: general-purpose (Rust scaffold + blueprint) — [timed out at the very end; + 237→ deliverables verified complete by the main agent, which appends this entry] + 238→ + 239→Task: Rust/Vulkan native-export scaffold + architecture blueprint + zip download API + 240→ + 241→Work Log: + 242→- Built rust-agent/ cargo crate (ava-agent, edition 2021): winit 0.30 (ApplicationHandler + 243→ API), wgpu 22 (Vulkan-first; `vulkan-force` feature for MoltenVK on macOS), cpal 0.15, + 244→ rustfft 6.2, pollster, anyhow + 245→- src/audio.rs — cpal default-input capture, ring buffer, AudioProcessor::poll() per frame + 246→- src/spectrum.rs — Hann-window 8192 FFT, band levels w/ same attack(35ms)/release(220ms) + 247→ smoothing as the web engine + 248→- src/beat.rs — 1:1 port of beat.ts (same constants, median+EMA lock, PLL predictor, taps) + 249→- src/script/{lexer,parser,env,mod}.rs — expression engine + .ava format parser with the + 250→ same lazy-if / assignment-in-args / deterministic-rand semantics + 251→- src/engine.rs — script runtime host (init/frame/beat/point stepping, shared state) + 252→- src/render/{mod.rs,shader.wgsl} — wgpu pipelines: audio-reactive fullscreen background + + 253→ additive point cloud, #[repr(C)] uniforms, persistence via TODO'd feedback texture + 254→- presets/*.ava — all six scripts (5 classics + starter) portable across engines + 255→- Rewrote src/lib/ava/blueprint.ts with 8 real sections (architecture map, wgpu/Vulkan + 256→ strategy, audio pipeline, beat engine, script engine, rendering, per-platform builds, + 257→ roadmap) + 258→- Built src/app/api/rust-scaffold/route.ts (jszip walk, skip target/.git/node_modules) + 259→ + 260→Stage Summary: + 261→- Scaffold ~1,700 lines, all files present; zip route verified 200 in dev.log; lint clean. + 262→- NOT compiled (no cargo in sandbox) — scaffold quality, clearly TODO-marked where partial. + 263→ + 264→--- + 265→Task ID: 5 + 266→Agent: main (Z.ai Code) + 267→Task: Integration, lint, agent-browser E2E verification & fixes + 268→ + 269→Work Log: + 270→- bun run lint → zero errors across the whole project + 271→- dev.log clean: / 200, /api/presets 200, /api/settings 200, /api/rust-scaffold 200, + 272→ no runtime errors, no hydration warnings + 273→- Agent-browser E2E golden path, all VERIFIED: + 274→ * initial load: idle overlay, 5 preset cards + .avs original links, tabs render + 275→ * Demo Track → canvas lit (30.7% pixels @60fps, 480 pts), footer SRC/FPS/PTS/BPM live + 276→ * footer shows detected 120.0 BPM from the demo kick pattern + 277→ * Set Beat ×4 taps → "Locked: 99.8 BPM" (matches real ~600ms tap intervals), confidence 65% + 278→ * Beat tab: AUDIO badge, beat count, sensitivity ×1.40, min interval 250ms, prediction switch + 279→ * Stop audio → badge switches to PRED, beat count 20→35 (predictor keeps the beat), + 280→ confidence decays 65%→35% — core requirement verified + 281→ * Script editor: broken script → "✘ [point] line 8: Unknown function 'badfunc'"; + 282→ fixed script → "✔ Compiled OK — mode: lines, n=300" + 283→ * Help panel: variables table, functions, operators, examples + 284→ * Save as preset → "Smoke Test Ring" listed + toast; two-step delete verified + 285→ * Save as Default → PUT /api/settings verified via curl; reload auto-loads the default + 286→ * Detect tab: Bass 20–100 Hz / Mid 700–2000 Hz / High 7k–16 kHz cards, gains, live + 287→ meters reading real audio (0.78/0.60/0.32) + 288→ * Native tab: blueprint sections render + zip download (116 KB, 25 files, valid zip) + 289→ * Mobile 390×844: responsive, footer pushed naturally, sticks to viewport bottom when scrolled + 290→- Cleanup: deleted the test preset, cleared test defaultScript (DB + localStorage) so a + 291→ fresh visit loads "Gap The Mind" + 292→ + 293→Stage Summary: + 294→- ALL acceptance criteria verified in-browser. Zero console errors. The application is + 295→ fully interactive: 3-band detection, beat detection + prediction + tap learning, AVS-style + 296→ scripting with editor/help/save/load, 5 authentic presets (adapted), Rust scaffold export. + 297→ + 298→--- + 299→Task ID: 6 + 300→Agent: general-purpose + 301→Task: "Stream a link" backend — yt-dlp link resolution + same-origin audio byte proxy (POST /api/resolve, GET /api/audio/stream) + 302→ + 303→Work Log: + 304→- Read worklog (Tasks 1–5) and existing routes; confirmed dev server already running on :3000 + 305→ (Node, next-server v16.1.3) — never restarted it; no build, no new deps, no git + 306→- Installed yt-dlp standalone binary: bin/yt-dlp (GitHub latest yt-dlp_linux), chmod +x, + 307→ --version prints 2026.10.21 + 308→- src/lib/server/ssrf.ts — assertPublicHttpUrl(raw): http(s) only, explicit port must be 80/443, + 309→ localhost/.localhost/.local/.internal banned, IPv4 privates (0/8, 10/8, 100.64/10, 127/8, + 310→ 169.254/16, 172.16/12, 192.168/16), IPv6 ::/::1/fc00::/7/fe80::/10 plus IPv4-mapped + 311→ ::ffff:a.b.c.d (embedded v4 checked; hand-written IPv6 expander handles v4 tails + zone ids), + 312→ then dns.promises.lookup(all:true) with EVERY result re-checked by the same rules (kills + 313→ nip.io / DNS-rebind tricks). Throws user-safe messages; returns the parsed URL. + 314→- src/lib/server/stream-store.ts — Map on globalThis.__avaStreams via a typed interface with an + 315→ optional property + `??=` (no `any`): token = randomBytes(16).hex (32 chars), entries + 316→ { remoteUrl, title, source, expires }, 30-min sliding TTL, prune-on-every-access; exports + 317→ createStreamEntry / getStreamEntry / refreshStreamEntry. + 318→- src/app/api/resolve/route.ts — JSON body { url } (≤2048, trim) → 400s; SSRF gate → 400; + 319→ 10-min success cache on globalThis.__avaResolveCache (repeat links replay instantly); + 320→ direct-audio bypass for .mp3 .m4a .aac .ogg .oga .opus .wav .flac .mp4 .webm (source "direct", + 321→ title = decoded last path segment, fallback hostname); otherwise yt-dlp + 322→ -J --no-warnings --no-playlist --no-progress --socket-timeout 15 + 323→ -f "bestaudio[protocol^=http]/bestaudio/best" with a 30 s kill → 504; non-zero exit → + 324→ ANSI/control-stripped stderr tail (300 chars) → 422 ("Unsupported URL" gets its own message); + 325→ stream URL pick = data.url ?? requested_formats[0].url ?? last http-protocol format; + 326→ .m3u8/.mpd path or m3u8/mpd/dash protocol → 422 adaptive message; SSRF re-check on the + 327→ RESOLVED stream URL (public CDNs like googlevideo pass) → 422 if not; title control-stripped + + 328→ clamped 140 (fallback "Stream"), source = extractor_key|extractor|hostname lowercase, + 329→ durationSec = numeric duration or null. + 330→- src/app/api/audio/stream/route.ts — token → entry (prune first) else 404; SSRF re-check of the + 331→ stored remoteUrl; forwards the client Range header, Chrome UA, redirect follow, no-store; + 332→ !ok → 502 `Upstream failed (HTTP n).`; content-type must be audio/*, video/*, + 333→ application/octet-stream or application/ogg else 502 "Upstream did not return audio."; + 334→ refreshes sliding TTL, then passes through the upstream status with content-type/-length/ + 335→ -range/accept-ranges (only when upstream provided them) + Cache-Control: public, max-age=1800. + 336→- FIX found while testing: the upstream host intermittently RST/ETIMEDOUTs full (non-ranged) + 337→ fetches — verified against raw node fetch (1/6 ETIMEDOUT) — added ONE immediate retry on + 338→ network-level failure only (HTTP error statuses are never retried); after that: 2× full GET + 339→ 200 audio/mpeg 8,945,229 bytes. + 340→- tsc --noEmit: zero errors in the new files (the project's 8 pre-existing errors are all in + 341→ src/lib/ava, examples/, skills/, rust-scaffold — untouched). + 342→ + 343→Stage Summary: + 344→- yt-dlp: /home/z/my-project/bin/yt-dlp, version 2026.10.21 (runtime order: $AVA_YTDLP → + 345→ /bin/yt-dlp if existsSync → "yt-dlp" on PATH) + 346→- Files created (nothing else touched, no frontend files): bin/yt-dlp, src/lib/server/ssrf.ts, + 347→ src/lib/server/stream-store.ts, src/app/api/resolve/route.ts, src/app/api/audio/stream/route.ts + 348→- Test 1 MUST-PASS direct MP3 (soundhelix Song-1): resolve → ok:true, source:"direct", + 349→ title:"SoundHelix-Song-1.mp3"; full stream GET → 200 audio/mpeg 8,945,229 B (>1 MB; takes ~30 s + 350→ because the origin throttles at ~300 KB/s — proxy adds ~0); Range 0-1023 → 206 with + 351→ content-range "bytes 0-1023/8945229", accept-ranges, cache-control all verified + 352→- Test 2 MUST-PASS SSRF: http://127.0.0.1/x → 400, http://169.254.169.254/latest/meta-data → 400, + 353→ ftp://example.com/a.mp3 → 400. Extras: localhost:8080 → 400 (port), foo.bar.local → 400, + 354→ [::1] → 400, [::ffff:10.0.0.5] → 400 (v4-mapped), 127.0.0.1.nip.io → 400 (DNS-result check), + 355→ "not a url" / {} / {"url":5} / bad JSON → 400 "Expected { url } JSON body." + 356→- Test 3 YouTube jNQXAC9IVRw: bot-blocked from this datacenter IP ("Sign in to confirm you're not + 357→ a bot"). Tried the 2 extractor-args variants (youtube:player_client=web_embedded, then =tv) + 358→ within the 5-minute budget — still blocked. Surfaces as a clean 422 with the sanitized tail: + 359→ "Could not extract audio from that link: …Sign in to confirm you're not a bot…". Honest + 360→ outcome: blocked here, ACCEPTABLE per spec (would work from residential IPs / with cookies). + 361→- Test 4 SoundCloud forss/flickermood: yt-dlp extracts fine (title "Flickermood", 213.9 s) but + 362→ this serve only exposes m3u8_native formats (hls_aac_96k/160k — confirmed in raw -J JSON) → + 363→ clean 422 adaptive(HLS/DASH) message, exactly the manifest-only contract case. + 364→- Test 5 https://example.com/ → clean 422 "That site is not supported — try YouTube, SoundCloud, + 365→ or a direct MP3 link."; example.com/stream.m3u8 → 422 (does NOT hit the direct bypass). + 366→- Extractor SUCCESS path proven end-to-end with archive.org: resolve → ok:true, + 367→ title:"mp3 test file", source:"archiveorg", durationSec:12.42; stream → 200 audio/mpeg + 368→ 198,658 B + 206 ranged; cache replay 16 ms. + 369→- Stream negatives: unknown/missing token → 404 "Unknown or expired stream token"; upstream 500 + 370→ (missing file) → 502 "Upstream failed (HTTP 500)." + 371→- lint: my 4 files pass eslint clean; `bun run lint` overall reports exactly 1 remaining error in + 372→ src/components/ava/link-dialog.tsx:72 (set-state-in-effect) — that file is owned by the parallel + 373→ frontend agent and was NOT touched by me per the ownership rules. + 374→- dev.log: no exceptions/stack traces from the new routes — only expected + 375→ 200/206/400/404/422/502 request lines. + 376→- Deviations from spec (all deliberate): (1) one network-error retry in the proxy (upstream RSTs, + 377→ never retries HTTP statuses); (2) third store helper refreshStreamEntry() — the two required + 378→ helpers plus this one implements the specified "refresh sliding expiry AFTER upstream success"; + 379→ (3) proxy fetch uses cache:'no-store'; (4) 500 wording "Audio extractor unavailable (yt-dlp not + 380→ found or failed to start)."; (5) success cache keyed by the normalized URL href (target.href). + 381→ + 382→--- + 383→Task ID: 7 + 384→Agent: main (Z.ai Code) + 385→Task: Frontend integration of the "Link" audio source (YouTube / SoundCloud / direct MP3) + 386→ + 387→Work Log: + 388→- Read worklog + all relevant files (audio.ts, store.ts, audio-source.ts, header.tsx, + 389→ visual-stage.tsx, footer-bar.tsx, types.ts) before editing. + 390→- src/lib/ava/types.ts — AudioSourceKind += 'link' + 391→- src/lib/ava/audio.ts — new startUrl(streamUrl, title): same-origin proxied