8646bd4f-9dab-4f7f-a601-e6b446606285
This commit is contained in:
parent
f3fd5100d0
commit
682214e467
9 changed files with 3627 additions and 94 deletions
|
|
@ -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 <VisualStage> (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
|
||||
|
|
|
|||
159
src/lib/ava/preset-io.ts
Normal file
159
src/lib/ava/preset-io.ts
Normal file
|
|
@ -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/<anything else>/…" → "root/user created/<anything else>/…"
|
||||
* (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,
|
||||
}
|
||||
}
|
||||
424
src/lib/ava/user-presets.ts
Normal file
424
src/lib/ava/user-presets.ts
Normal file
|
|
@ -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<T = undefined> =
|
||||
| ({ ok: true } & (T extends undefined ? Record<string, never> : 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<string, unknown>
|
||||
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<UserStorePayload> | 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<string>([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 */
|
||||
}
|
||||
}
|
||||
65
src/lib/preset-paths.ts
Normal file
65
src/lib/preset-paths.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | null = null
|
||||
let beatPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function putSettings(payload: Record<string, unknown>): 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,17 +444,25 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
|
|||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// preset tree fetching (shared by Presets tab + initial load)
|
||||
// preset tree fetching + merge (server embedded library ∪ browser user presets)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function refreshPresets(): Promise<PresetItem[]> {
|
||||
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 =
|
||||
data.folders && data.folders.length ? data.folders : ['root', 'root/embedded', 'root/user created']
|
||||
/** 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)
|
||||
|
|
@ -489,13 +481,29 @@ export async function refreshPresets(): Promise<PresetItem[]> {
|
|||
}
|
||||
const scope = s.randomScope
|
||||
if (scope !== 'root' && !folders.includes(scope)) {
|
||||
patch.randomScope = s.selectedFolder
|
||||
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<PresetItem[]> {
|
||||
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[] }
|
||||
serverPresetsCache = data.presets ?? []
|
||||
serverFoldersCache =
|
||||
data.folders && data.folders.length ? data.folders : ['root', 'root/embedded', 'root/user created']
|
||||
return setMergedPresets()
|
||||
} catch {
|
||||
return []
|
||||
return setMergedPresets()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
834
tool-results/read_1793082276516_65b3b8ecd40e.txt
Normal file
834
tool-results/read_1793082276516_65b3b8ecd40e.txt
Normal file
|
|
@ -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 <meta.name>.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)" <a href="/api/rust-scaffold" download>, 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 <html>, 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→ <cwd>/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 <audio> element →
|
||||
392→ MediaElementAudioSourceNode → analyser (NOT cross-origin-tainted, so FFT works) → speakers;
|
||||
393→ loops by default; public sourceLabel field (file name / stream title) maintained by all
|
||||
394→ start* methods + stopSources
|
||||
395→- src/lib/store.ts — AudioSourceState += 'link'; new sourceTitle + linkDialogOpen state
|
||||
396→- src/components/ava/audio-source.ts — new resolveAndPlayLink(url): POST /api/resolve →
|
||||
397→ engine.audio.startUrl → store sync; never throws (returns {ok,title,source,error}); on
|
||||
398→ playback failure stops the engine and resets store; syncSource now also syncs sourceTitle
|
||||
399→- src/components/ava/link-dialog.tsx — NEW: dialog with URL input + Load button (spinner),
|
||||
400→ inline error box with YouTube bot-wall hint, RECENT LINKS chips (localStorage, max 5,
|
||||
401→ one-click reload); body REMOUNTS on every open (fresh state — no setState-in-effect, fixes
|
||||
402→ the lint error the Task 6 agent saw mid-build); results arriving after the user closed the
|
||||
403→ dialog surface as toasts
|
||||
404→- src/components/ava/header.tsx — "Link" segment button between File and Stop (opens dialog);
|
||||
405→ <LinkDialog /> mounted in the header
|
||||
406→- src/components/ava/visual-stage.tsx — idle overlay gains a 4th button "Stream a Link" +
|
||||
407→ updated copy
|
||||
408→- src/components/ava/footer-bar.tsx — SRC now shows "LINK · <title>" (truncated) for the
|
||||
409→ active source; title also shown for File sources
|
||||
410→- src/app/api/resolve/route.ts (main-agent touch-up, coordinated with Task 6's file): added
|
||||
411→ BOT_WALL_RE mapping so YouTube's "Sign in to confirm you're not a bot" stderr returns a
|
||||
412→ clean actionable 422 instead of a raw yt-dlp tail; client hint suppressed when the server
|
||||
413→ message already explains the fix (no duplication)
|
||||
414→
|
||||
415→Stage Summary:
|
||||
416→- Full link-source flow wired: header/idle-overlay → dialog → resolve → proxied playback →
|
||||
417→ band analysis + beat detection + visuals, all client-side after resolution. Zero layout
|
||||
418→ changes beyond the additive bits; lint clean.
|
||||
419→
|
||||
420→---
|
||||
421→Task ID: 8
|
||||
422→Agent: main (Z.ai Code)
|
||||
423→Task: E2E verification (agent-browser) of the link source + regression checks
|
||||
424→
|
||||
425→Work Log:
|
||||
426→- bun run lint → zero errors; dev.log clean (no exceptions)
|
||||
427→- Initial load: header shows Demo/Mic/File/LINK/Stop; idle overlay has 4 buttons incl.
|
||||
428→ "Stream a Link"; presets/tabs intact
|
||||
429→- Link dialog: opens from idle overlay AND header; invalid input ("not a url") → inline
|
||||
430→ client-side error "Paste a full http(s) link…"
|
||||
431→- Direct MP3 (SoundHelix Song 1): resolve → dialog closes → toast → footer shows
|
||||
432→ "SRC: LINK · SoundHelix-Song-1.mp3, FPS: 60, PTS: 480, BPM: 131.2" (real tempo ≈131 —
|
||||
433→ beat detection locks from the STREAMED audio, proving the same-origin proxy keeps the
|
||||
434→ analyser untainted); canvas luminance/bright-pixel counts change frame-to-frame (live
|
||||
435→ animation); screenshot ava-link-playing.png
|
||||
436→- Failed YouTube attempt while SoundHelix playing: music KEEPS playing (failed resolve
|
||||
437→ never touches the engine) — verified footer still LINK @60fps after closing dialog
|
||||
438→- RECENT LINKS: chip "DIRECT · SoundHelix-Song-1.mp3" appears on re-open; one-click reload
|
||||
439→- YouTube jNQXAC9IVRw from this datacenter IP: clean 422 bot-wall message + actionable hint
|
||||
440→ (verified twice — before/after the BOT_WALL_RE improvement)
|
||||
441→- Beat predictor regression check: tap-tempo path (6×500 ms taps on the real Set Beat
|
||||
442→ button) → "120.0 BPM, 98% confidence" → Stop → beat count 54→63 over 4.5 s, PRED badge,
|
||||
443→ confidence decays 98→80% — "keeps the beat when the bass drops out" confirmed. (Note:
|
||||
444→ auto-detect confidence on the 124 BPM demo stays low with the leftover test beatConfig
|
||||
445→ ×1.35/210 ms — interval spread from the demo bassline; the designed learning path
|
||||
446→ (Set Beat) locks instantly.)
|
||||
447→- Console: zero errors (only HMR messages)
|
||||
448→- Mobile 390×844: no horizontal scroll; footer pushed naturally by content and STICKS to
|
||||
449→ viewport bottom when scrolled to end; VLM inspection of desktop + mobile screenshots
|
||||
450→ confirms clean layout, no breakage
|
||||
451→
|
||||
452→Stage Summary:
|
||||
453→- Link source fully verified in-browser: resolve → stream → analyze → visualize at 60 fps,
|
||||
454→ beat detection on streamed audio, graceful YouTube bot-wall, recent-links, predictor
|
||||
455→ regression pass. The only environmental limitation: YouTube blocks this datacenter IP
|
||||
456→ (honest 422; works from residential IPs or with server-side cookies). SoundCloud/Bandcamp/
|
||||
457→ archive.org/direct files all resolve; SoundCloud serves HLS-only for some tracks → clean
|
||||
458→ adaptive-stream error.
|
||||
459→
|
||||
460→---
|
||||
461→Task ID: 9
|
||||
462→Agent: main (Z.ai Code)
|
||||
463→Task: Preset library folder-tree data layer (backend + store) for the requested
|
||||
464→"folder/file tree" Presets page redesign
|
||||
465→
|
||||
466→Work Log:
|
||||
467→- Prisma schema: Preset += folderPath (default "root/user created") + originalFile,
|
||||
468→ @@unique([folderPath, name]); new Folder model (path @unique). db:push OK,
|
||||
469→ client regenerated, dev server restarted once to pick up the new client.
|
||||
470→- src/lib/server/preset-tree.ts — shared helpers: normalizeFolderPath (root-prefixed,
|
||||
471→ max depth 8, 80-char segments, control chars rejected), isValidName, parentOf,
|
||||
472→ subtreeFilter (presets) + subtreeFolderFilter (folders), ensureFolderRows
|
||||
473→ (creates missing ancestors), PROTECTED_FOLDERS = root | root/embedded | root/user created.
|
||||
474→- GET /api/presets — one-time seed: folder backbone (root, root/embedded,
|
||||
475→ root/user created, root/embedded/{Synth-C,Amp Picks,Dynamics,Envision}) +
|
||||
476→ 95 presets (5 hand-ported classics → Synth-C; 90 auto-converted Google-Drive
|
||||
477→ presets → Amp Picks 66 / Dynamics 9 / Envision 15, doubling prefix normalized).
|
||||
478→ Legacy pre-folder built-ins migrated automatically. Response now
|
||||
479→ { presets, folders } with folderPath + originalFile on each preset.
|
||||
480→- POST /api/presets — create in folder (409 on name clash within folder).
|
||||
481→- PUT /api/presets/[id] — update content/author/description or RENAME (unique
|
||||
482→ per folder enforced); built-in restriction REMOVED (tree is fully manageable).
|
||||
483→- DELETE /api/presets/[id] — any preset deletable.
|
||||
484→- /api/folders — POST create subfolder (parent must exist), PATCH rename folder
|
||||
485→ + all descendants (transaction, prefix rewrite), DELETE ?path= removes subtree
|
||||
486→ (folders + presets). Protected folders reject rename/delete with 400.
|
||||
487→- /api/presets/export?path=… — jszip download preserving the directory structure
|
||||
488→ from root ("root/user created/Club Set/Test Ring.ava" style entries + dir entries).
|
||||
489→- /api/presets/import (multipart file + targetPath) — imports .ava/.txt entries;
|
||||
490→ "root/…"-prefixed paths land at their exact path, others under the target
|
||||
491→ folder; creates missing folder rows; skips name-clash duplicates; reports
|
||||
492→ { imported, skipped, skippedOther, foldersCreated }.
|
||||
493→- src/lib/store.ts — PresetItem += folderPath/originalFile; EditTarget; new state:
|
||||
494→ folders, selectedFolder, selectedPresetId, expandedFolders (depth<=2 default
|
||||
495→ open), editTarget, activePresetId, randomize/randomIntervalSec/randomScope;
|
||||
496→ actions selectFolder (also sets randomize scope live), selectPreset, setEditTarget,
|
||||
497→ setActivePresetId, setRandomize, setRandomIntervalSec (3–60 s validation);
|
||||
498→ module-level club-mode timer (syncRandomTimer/stopRandomTimer, loadRandomPreset
|
||||
499→ applies to the ENGINE ONLY — never clobbers unsaved editor text; up to 4 random
|
||||
500→ retries for compilable scripts; toast per switch); settings persisted to
|
||||
501→ localStorage "ava.randomize" and restored via restoreRandomSettings(folders);
|
||||
502→ refreshPresets now fetches presets+folders and prunes stale selections.
|
||||
503→- src/app/page.tsx — initial load sets folders, startup fallback prefers the
|
||||
504→ first root/embedded/Synth-C preset, sets activePresetId, restores randomize.
|
||||
505→- VERIFIED via curl: seed 95/7 folders correct; create folder/preset; 409 dup;
|
||||
506→ rename folder moves preset paths; export zip listing shows full paths; delete
|
||||
507→ subtree; import restores the exact tree; protected-folder rename/delete → 400;
|
||||
508→ export root → 200. tsc clean on all touched files.
|
||||
509→
|
||||
510→Stage Summary:
|
||||
511→- API contract for the new UI:
|
||||
512→ GET /api/presets → { presets: PresetItem[], folders: string[] }
|
||||
513→ POST /api/presets { name, content, author?, description?, folderPath? } → { preset } | 409 | 400
|
||||
514→ PUT /api/presets/[id] { name?, content?, author?, description? } → { preset }
|
||||
515→ DELETE /api/presets/[id] → { deleted }
|
||||
516→ POST /api/folders { path } → { folder } | 409 | 400
|
||||
517→ PATCH /api/folders { path, newName } → { renamed, to, folders, presets }
|
||||
518→ DELETE /api/folders?path=… → { deleted, folders, presets } (protected → 400)
|
||||
519→ GET /api/presets/export?path=… → zip download (structure preserved from root)
|
||||
520→ POST /api/presets/import (file, targetPath) → { imported, skipped, skippedOther, foldersCreated }
|
||||
521→- Store API for the UI (src/lib/store.ts): useAvaStore fields folders,
|
||||
522→ selectedFolder, selectedPresetId, expandedFolders, editTarget, activePresetId,
|
||||
523→ randomize, randomIntervalSec, randomScope + actions listed above; helpers
|
||||
524→ refreshPresets(), stopRandomTimer(), syncRandomTimer(), restoreRandomSettings().
|
||||
525→- Random presets are applied engine-only (editor untouched) by design.
|
||||
526→
|
||||
527→---
|
||||
528→Task ID: 10
|
||||
529→Agent: full-stack-developer (Presets UI)
|
||||
530→Task: Presets tab redesign — folder/file tree manager + Script tab Save-to-target / Save As
|
||||
531→
|
||||
532→Work Log:
|
||||
533→- Read worklog (Task 9 contract), store.ts, all 5 API routes, old presets-tab,
|
||||
534→ script-tab, shared.ts, control-panel.tsx, page.tsx, audio-source.ts,
|
||||
535→ presets.ts, detect/beat tabs for style matching.
|
||||
536→- src/components/ava/presets-tab.tsx — COMPLETE REWRITE (~1000 lines):
|
||||
537→ - buildTree(folders, presets): synthesizes missing Folder rows + ancestors
|
||||
538→ from preset folderPaths (defensive); segment-wise alphabetical sort
|
||||
539→ (parents before children), presets sorted within folders; subtree preset
|
||||
540→ counts per node.
|
||||
541→ - TreeFolder/TreePreset recursive rows: chevron-only expand/collapse
|
||||
542→ (store.toggleFolder; default open = path depth <= 2 segments, same rule
|
||||
543→ as the store), row click selects (selectFolder/selectPreset), preset
|
||||
544→ double-click loads, running preset = emerald Play icon + emerald-300 text
|
||||
545→ + bg-emerald-400/10, selected rows = emerald left border + bg-zinc-800/70,
|
||||
546→ subtree count badge (font-mono, tooltip "presets in this folder and
|
||||
547→ below"), originalFile rows get a hover-visible (always on mobile) .avs
|
||||
548→ download anchor, empty folders show "(empty)" italic hint. role=tree/
|
||||
549→ treeitem/group, aria-expanded/selected/level, 36px (size-9/h-9) targets.
|
||||
550→ Tree scrolls in max-h-[420px] lg:max-h-[480px] with thinScrollbar.
|
||||
551→ - Two-column layout: tree 58% / actions 42% on sm+, stacked on mobile.
|
||||
552→ - Context-sensitive actions panel (header shows selection + counts):
|
||||
553→ preset -> LOAD PRESET (emerald; applyScriptText -> toast -> setActivePresetId
|
||||
554→ + setEditTarget, compile error -> destructive toast + script tab), RENAME,
|
||||
555→ DELETE; folder -> CREATE PRESET (POST starter template -> refresh ->
|
||||
556→ editTarget + editor + engine + select + tab switch + toast), NEW SUBFOLDER
|
||||
557→ (POST -> expand parent + select new), RENAME (PATCH folder / PUT preset,
|
||||
558→ selection + editTarget follow the rename), DELETE (AlertDialog with
|
||||
559→ client-side blast radius "removes N presets in M folders", protected
|
||||
560→ folders root | root/embedded | root/user created have Rename+Delete
|
||||
561→ disabled with "Protected folder" tooltip), EXPORT .ZIP (asChild anchor ->
|
||||
562→ /api/presets/export?path=..., download attr), IMPORT .ZIP (hidden .zip
|
||||
563→ input -> FormData POST -> summary toast with duplicates/foldersCreated).
|
||||
564→ All server errors (409/400/404/500) surface as destructive toasts with
|
||||
565→ the server message; empty names get inline dialog validation.
|
||||
566→ - Randomize section (border-t): Checkbox + 3-60s delay input; invalid input
|
||||
567→ shows red hint and reverts to the committed store value on blur (valid
|
||||
568→ values commit live via setRandomIntervalSec); scope line reflects
|
||||
569→ randomScope (folder name or "all presets"), amber "no presets in this
|
||||
570→ folder yet" hint when scope empty; pulsing emerald "ON · every Ns" badge
|
||||
571→ while running. Timer itself runs in the store — UI only wires controls.
|
||||
572→ - NameDialog made fully controlled (parent owns value/error) to satisfy
|
||||
573→ react-hooks/set-state-in-effect lint rule.
|
||||
574→- src/components/ava/script-tab.tsx — EDITS:
|
||||
575→ - "Saving to: root/…/Name" chip (emerald, middle-truncated path, full path
|
||||
576→ in tooltip) with X button clearing the edit target; shown only while
|
||||
577→ editTarget is set.
|
||||
578→ - Save button (emerald, only with editTarget): PUT /api/presets/[id]
|
||||
579→ {content, author from validateScript meta} -> refreshPresets + toast
|
||||
580→ 'Saved to "Name"'; disabled while saving.
|
||||
581→ - Save As… button (only with editTarget): Dialog prefilled with the current
|
||||
582→ name -> POST /api/presets same folder -> setEditTarget(new) + refresh +
|
||||
583→ toast; 409 handled; empty name inline error.
|
||||
584→ - New button now also clears the edit target.
|
||||
585→- VERIFIED in headless browser (agent-browser): tree render/expansion/
|
||||
586→ selection, Load (toast + tab wiring), Save (PUT 200), Save As (copy
|
||||
587→ created server-side + chip retargeted), delete preset + folder dialogs
|
||||
588→ (blast-radius + empty-folder wording), create preset (full flow incl.
|
||||
589→ editor + tab switch), new subfolder, rename folder (selection follows,
|
||||
590→ editTarget path rewritten), import .zip end-to-end (DataTransfer dispatch
|
||||
591→ into the hidden input -> 96 presets/8 folders -> toast -> cleanup),
|
||||
592→ randomize persistence + scope line + invalid-delay revert + ON badge.
|
||||
593→ Library restored to pristine 95 presets / 7 folders after testing.
|
||||
594→- bun run lint: clean. tsc: clean on both touched files (pre-existing errors
|
||||
595→ elsewhere in examples/, skills/, audio.ts, script.ts untouched).
|
||||
596→ dev.log: no runtime errors.
|
||||
597→
|
||||
598→Stage Summary:
|
||||
599→- Files modified: src/components/ava/presets-tab.tsx (full rewrite),
|
||||
600→ src/components/ava/script-tab.tsx (chip + Save/Save As/New edits).
|
||||
601→ No changes to page.tsx, store.ts, API routes, or other tabs.
|
||||
602→- Key decisions: subtree (not direct) preset counts on folder badges
|
||||
603→ (labeled via tooltip); NameDialog fully controlled to pass the new
|
||||
604→ react-hooks lint rules; rename keeps editTarget/randomScope/selection
|
||||
605→ pointing at the renamed paths client-side (refreshPresets prunes stale
|
||||
606→ ones server-side); delay input commits valid values live but only reverts
|
||||
607→ invalid text on blur so typing isn't fighted; export/import allowed on
|
||||
608→ protected folders (rename/delete disabled) exactly per spec.
|
||||
609→- Store/API notes for Task 11: none needed — the Task 9 contract covered
|
||||
610→ everything. One cosmetic gap: setRandomIntervalSec is typed (sec: number)
|
||||
611→ though the store handles numeric strings at runtime; UI converts with
|
||||
612→ Number() before calling.
|
||||
613→- Left for Task 11 verification: visual polish pass in a real browser
|
||||
614→ (spacing at 420 vs 440px panel widths, mobile stacking), touch-device
|
||||
615→ check of the hover-only .avs download button, and double-click-to-run
|
||||
616→ ergonomics (agent-browser cannot dblclick, so that path was verified via
|
||||
617→ the identical Load Preset code path).
|
||||
618→
|
||||
619→---
|
||||
620→Task ID: 11
|
||||
621→Agent: main (Z.ai Code)
|
||||
622→Task: E2E verification of the new folder-tree Presets page + final cleanup
|
||||
623→
|
||||
624→Work Log:
|
||||
625→- bun run lint: zero errors. tsc clean on all files touched by Tasks 9–10.
|
||||
626→ dev.log: no runtime errors, no hydration warnings.
|
||||
627→- Agent-browser E2E golden path, ALL VERIFIED:
|
||||
628→ * Tree renders exactly as requested: root/ → embedded/ (Synth-C 5, Amp Picks 66,
|
||||
629→ Dynamics 9, Envision 15) + user created/ 0; subtree count badges; depth≤2
|
||||
630→ folders default-expanded; chevron-only expand/collapse; role=tree/treeitem.
|
||||
631→ * Preset select → action panel switches to preset context (Load/Rename/Delete);
|
||||
632→ folder select → full management set; protected folders (root/embedded/user
|
||||
633→ created) have Rename+Delete disabled.
|
||||
634→ * Load Preset: applied, toast, emerald Play-icon running indicator in tree,
|
||||
635→ footer SCRIPT updated, engine running.
|
||||
636→ * Create Preset: dialog → created in selected folder → auto-switch to Script
|
||||
637→ tab with starter template → "Saving to: root/user created/…/Name" chip →
|
||||
638→ edit → Save (PUT) persisted server-side (verified content round-trip) →
|
||||
639→ Save As… created "Club Ring v2" in the same folder and retargeted the chip.
|
||||
640→ * New Subfolder → "My Fav Presets" created + selected + toast.
|
||||
641→ * Rename folder "My Fav Presets" → "Club Night": both presets' paths updated.
|
||||
642→ * Delete folder: AlertDialog with blast radius ("removes 2 presets"), confirmed
|
||||
643→ → folder + presets gone, library back to 95.
|
||||
644→ * Export .zip: link href /api/presets/export?path=… correct; zip contains
|
||||
645→ "root/user created/Round Trip/RT Preset.ava" (structure preserved from root).
|
||||
646→ * Import .zip via the UI file input (DataTransfer dispatch): toast
|
||||
647→ "Imported 1 presets — 1 folders created", tree restored at the exact path.
|
||||
648→ * Randomize: invalid delay 2 and 61 both rejected with the "3 and 60 seconds"
|
||||
649→ error + revert; 4 accepted; checkbox on → "ON · every 4s" badge; with Demo
|
||||
650→ audio running, the timer switched to a random preset from the selected
|
||||
651→ scope after ~4s (toast + footer PTS 333 matching the loaded script);
|
||||
652→ scope line follows folder selection; settings persist to localStorage and
|
||||
653→ do NOT auto-restart after reload unless enabled.
|
||||
654→ * Mobile 390×844: no horizontal scroll, tree/actions stack, footer pushed
|
||||
655→ naturally and sticks to viewport bottom when scrolled to the end.
|
||||
656→ * Fresh reload: startup falls back to the first root/embedded/Synth-C preset
|
||||
657→ (Gap The Mind), tree repopulates, zero console errors.
|
||||
658→- Cleanup: deleted the Round Trip / Club Ring test data (library back to the
|
||||
659→ pristine 95 presets / 7 folders); test artifacts in tool-results/.
|
||||
660→
|
||||
661→Stage Summary:
|
||||
662→- The requested Presets page redesign is COMPLETE and browser-verified:
|
||||
663→ left folder/file tree (root → embedded {Synth-C, Amp Picks, Dynamics,
|
||||
664→ Envision} + user created) with counts, expansion, selection, running
|
||||
665→ indicator and .avs original downloads; right management panel (Load Preset,
|
||||
666→ Create Preset → Script tab → Save/Save As, New Subfolder, Rename, Delete with
|
||||
667→ confirmation + blast radius, Export .zip preserving the tree, Import .zip
|
||||
668→ restoring the tree) and Randomize (3–60 s validated) scoped to the selected
|
||||
669→ folder. All flows verified end-to-end with zero console errors.
|
||||
670→
|
||||
671→---
|
||||
672→Task ID: 12
|
||||
673→Agent: main (Z.ai Code)
|
||||
674→Task: LOST-WORK RECOVERY — restore the folder-tree Presets page (Tasks 9–11)
|
||||
675→from the user's Google Drive project download after the sandbox was rebuilt
|
||||
676→from an earlier snapshot
|
||||
677→
|
||||
678→Context:
|
||||
679→- The sandbox running Tasks 9–11 was replaced by a rebuilt environment whose
|
||||
680→ git history ended at the "link source" commit (51e07079 → local 4caaa3e).
|
||||
681→ The two commits containing the Google-Drive preset import (b33474e) and the
|
||||
682→ folder-tree implementation (2a23db4) existed only in the user's downloaded
|
||||
683→ copy of the project — which the user kindly re-hosted on Google Drive.
|
||||
684→
|
||||
685→Work Log:
|
||||
686→- Downloaded the user's archive (drive file 1yUmimOpLyz7l8BQO6keUHPCvLaV6TW5k,
|
||||
687→ 86 MB tar.gz, includes .git with the two lost commits) to /tmp/ava-restore.
|
||||
688→- Verified the archive's base commit (fb12d97, subject 51e07079) is
|
||||
689→ content-identical to the workspace HEAD 4caaa3e (git diff empty except
|
||||
690→ .zscripts/tool-results) → archive HEAD is a strict superset, safe to restore.
|
||||
691→- Stopped the dev server, then surgically checked out all 200 files changed
|
||||
692→ between fb12d97 → 2a23db4 (excluding .zscripts/dev.pid, tool-results,
|
||||
693→ dev.log): prisma/schema.prisma (Folder model + Preset.folderPath/originalFile),
|
||||
694→ db/custom.db (pre-seeded 95 presets / 7 folders), src/lib/server/preset-tree.ts,
|
||||
695→ src/lib/ava/embedded-presets.ts, src/lib/ava/{engine,expression,script}.ts
|
||||
696→ (functions needed by the converted presets), src/app/api/{folders,presets/**
|
||||
697→ export,import}/route.ts, presets-tab.tsx (full tree rewrite), script-tab.tsx
|
||||
698→ (Save/Save As chip), store.ts (tree + randomize state), page.tsx, plus the
|
||||
699→ 95 .avs originals under public/avs-originals/… and tools/convert-avs.ts.
|
||||
700→- Ran `bun run db:generate` to rebuild the Prisma client for the new schema
|
||||
701→ (the restored db already matched it — no db:push needed, no data loss).
|
||||
702→- Restarted the dev server (port 3000, single instance).
|
||||
703→- VERIFIED via API: GET /api/presets → 95 presets / 7 folders
|
||||
704→ (Synth-C 5, Amp Picks 66, Dynamics 9, Envision 15); export
|
||||
705→ root/embedded/Synth-C → zip with full "root/embedded/Synth-C/*.ava" paths;
|
||||
706→ import of the same zip → 0 imported / 5 skipped (duplicate paths) — round
|
||||
707→ trip intact.
|
||||
708→- VERIFIED in agent-browser (desktop 1440×900 + mobile 390×844): tree renders
|
||||
709→ (root 95 → embedded {Amp Picks 66, Dynamics 9, Envision 15, Synth-C 5} +
|
||||
710→ user created), preset select → Load Preset → engine running (footer
|
||||
711→ "SRC: DEMO, FPS: 60, PTS: 480, BPM: 126.8, SCRIPT: Gap The Mind, engine ok"),
|
||||
712→ New Subfolder ("My Fav Presets" created + selected), Rename (→ "Club Night",
|
||||
713→ toast shows new path), Delete (alertdialog + server 200, tree cleaned),
|
||||
714→ Randomize delay validation ("2" → red "Delay must be between 3 and 60
|
||||
715→ seconds." hint; "4" commits), .avs original HEAD → 200, reload → startup
|
||||
716→ fallback loads Gap The Mind, zero console/page errors, mobile: no horizontal
|
||||
717→ scroll, footer pushed naturally and flush with the viewport bottom when
|
||||
718→ scrolled to end. bun run lint: clean.
|
||||
719→- Test data cleaned up (Club Night folder deleted; library back to 95/7).
|
||||
720→- Committed the restoration to git and left the recovery remote/objects
|
||||
721→ out of the repo (temp files remain in /tmp/ava-restore only).
|
||||
722→
|
||||
723→Stage Summary:
|
||||
724→- The folder-tree Presets page (Tasks 9–11) is fully restored and re-verified
|
||||
725→ end-to-end. Root cause of the "revert": the environment was rebuilt from a
|
||||
726→ snapshot predating the morning's commits; the work itself was never lost in
|
||||
727→ the user's download. All state (schema, data, API, UI, presets, tooling)
|
||||
728→ now matches the archive's HEAD (2a23db4) on top of the current workspace.
|
||||
729→- NOTE for the future: commit snapshots are the source of truth when an
|
||||
730→ environment rebuild is suspected — compare git log before re-implementing.
|
||||
731→
|
||||
732→---
|
||||
733→Task ID: 13
|
||||
734→Agent: main (Z.ai Code)
|
||||
735→Task: v6.0 — rename to "Open Visualization Engine" (AVA → OVE) + footer
|
||||
736→version indicator (establishes the versioning convention)
|
||||
737→
|
||||
738→Work Log:
|
||||
739→- New src/lib/version.ts — single source of truth: APP_NAME
|
||||
740→ "Open Visualization Engine", APP_SHORT "OVE", APP_VERSION "6.0" + a
|
||||
741→ short version-history comment (6.0 rename+stamp, 5.0 folder-tree, 4.0
|
||||
742→ link source) so future bumps are a one-line change.
|
||||
743→- src/app/layout.tsx — document title now "OVE — Open Visualization Engine
|
||||
744→ (v6.0)" (built from the constants); description + keywords updated
|
||||
745→ (AVA→OVE, "agent"→"engine").
|
||||
746→- src/components/ava/header.tsx — logo block h1 "AVA" → "OVE" (APP_SHORT),
|
||||
747→ subtitle "ADVANCED VISUALIZATION AGENT" → "OPEN VISUALIZATION ENGINE"
|
||||
748→ (APP_NAME.toUpperCase()), both imported from version.ts.
|
||||
749→- src/components/ava/footer-bar.tsx — right cluster is now a ml-auto wrapper
|
||||
750→ holding: "version 6.0" stamp (text-zinc-600, tooltip "Open Visualization
|
||||
751→ Engine — version 6.0") immediately LEFT of the engine-ok dot + label,
|
||||
752→ exactly as requested. Stamp is visible on mobile too (fits: no horizontal
|
||||
753→ scroll at 390px).
|
||||
754→- Internal code identifiers (ava store/lib/component paths, ava-agent rust
|
||||
755→ crate) intentionally left unchanged — the rename is user-facing only.
|
||||
756→- VERIFIED in agent-browser: title "OVE — Open Visualization Engine (v6.0)";
|
||||
757→ header logo/subtitle; footer text "...version 6.0\nengine ok" with the
|
||||
758→ stamp on the same row, right of the stats, left of the dot; mobile 390×844
|
||||
759→ no horizontal scroll + stamp visible; zero console/page errors; lint clean.
|
||||
760→- Committed as "v6.0 — rename to Open Visualization Engine + version stamp".
|
||||
761→
|
||||
762→Stage Summary:
|
||||
763→- Versioning convention established: bump APP_VERSION in src/lib/version.ts
|
||||
764→ on every large change; the stamp renders bottom-right of the footer and the
|
||||
765→ document title carries it too. Users can name local backups by the stamp
|
||||
766→ (e.g. "OVE v6.0") and cross-check against z.ai Version History.
|
||||
767→
|
||||
768→---
|
||||
769→Task ID: 14
|
||||
770→Agent: main (Z.ai Code)
|
||||
771→Task: v6.1 — preset library quality pass: every embedded stand-in is now a
|
||||
772→UNIQUE visual (user reported "Containment" and "Dragonscales" look identical)
|
||||
773→
|
||||
774→Root cause:
|
||||
775→- The old converter had only 6 fixed fallback templates chosen by
|
||||
776→ seed % 6, so 76 of 90 embedded presets collapsed into 6 identical visuals
|
||||
777→ (19×swirl, 16×pulse-rings, 14×starfield, 12×lattice, 10×kaleido, 5×tunnel);
|
||||
778→ library-wide only 25 of 95 distinct script bodies.
|
||||
779→
|
||||
780→Work Log:
|
||||
781→- tools/convert-avs.ts — REPLACED the fallback generator:
|
||||
782→ * 16 geometric families (pulse rings, tunnel, starfield, lattice, swirl,
|
||||
783→ kaleido, lissajous, spectrum bars, moire, fountain, rose, orbits, ribbon,
|
||||
784→ web, rain, helix), each parameterized.
|
||||
785→ * 6 palettes (rainbow / fire / ice / neon-band / mono / duotone).
|
||||
786→ * Every numeric parameter drawn from mulberry32 seeded by hashStr(preset
|
||||
787→ name) — same name regenerates the same visual forever; different names
|
||||
788→ always differ (counts, speeds, twists, couplings, widths, persistence).
|
||||
789→ * Family chosen from the original's unportable components (BlitterFeedback
|
||||
790→ → tunnel/kaleido/moire, Movement → swirl/ribbon/web, Mirror → kaleido,
|
||||
791→ DotFountain → fountain, …) + guaranteed full 16-family rotation per run
|
||||
792→ (unused-families first) + 25% wild card.
|
||||
793→ * Every generated fallback is smoke-tested; a known-good safeFallback is
|
||||
794→ the last resort (never emits a broken script).
|
||||
795→ * Fixed during regen: getspec() needs 3 args (spectrum family); tiny-n
|
||||
796→ guard (ported scopes rendering <16 points now also become stand-ins —
|
||||
797→ 5 more presets rescued); folder path no longer double-prefixed
|
||||
798→ ("root/embedded/root/embedded/…" → clean at the source); same-path
|
||||
799→ copyFileSync guard so the converter can re-run in place.
|
||||
800→- Regenerated src/lib/ava/embedded-presets.ts from public/avs-originals/
|
||||
801→ embedded/ (90 presets: 9 ported original-code + 81 unique stand-ins; all
|
||||
802→ 16 families used, max 9 presets per family, 90/90 distinct bodies).
|
||||
803→- prisma/schema.prisma — Preset.approx Boolean (marks stand-ins), Settings.
|
||||
804→ embeddedSeed String? (seed-version marker). db push + client regenerated;
|
||||
805→ dev server restarted once to pick up the new client.
|
||||
806→- GET /api/presets — ensureSeeded is now VERSION-GATED: when Settings.
|
||||
807→ embeddedSeed !== EMBEDDED_SEED_VERSION ('6.1', exported from
|
||||
808→ embedded-presets.ts), the root/embedded subtree + stray built-ins are
|
||||
809→ deleted and re-seeded from BUILTIN + EMBEDDED (user presets untouched).
|
||||
810→ Future library regenerations propagate automatically on next request.
|
||||
811→ Response now includes approx per preset.
|
||||
812→- src/lib/store.ts — PresetItem.approx?: boolean.
|
||||
813→- src/components/ava/presets-tab.tsx — amber "≈" badge on tree rows for
|
||||
814→ approx presets (tooltip explains: original relies on effects the script
|
||||
815→ engine can't run; uniquely generated stand-in).
|
||||
816→- src/lib/version.ts — APP_VERSION '6.1'.
|
||||
817→- VERIFIED: API re-seed → 95 presets / 7 folders, 81 approx + 14 original
|
||||
818→ code, 95/95 distinct bodies, Containment ≠ Dragonscales (different
|
||||
819→ families + parameters); VLM image comparison of live screenshots confirms
|
||||
820→ clearly different visuals (organic single loop vs woven orbital knot);
|
||||
821→ Kingish (594 pts) renders; ≈ badges (60 of 66 Amp Picks rows); Randomize
|
||||
822→ over root scope switches presets at 3 s (Kingish → Plankton); export zip
|
||||
823→ 66 entries with clean paths; zero console/page errors; lint clean.
|
||||
824→
|
||||
825→Stage Summary:
|
||||
826→- Every embedded preset is now visually unique (95/95 distinct bodies).
|
||||
827→- Honesty layer: approx stand-ins are badged "≈" in the tree; descriptions
|
||||
828→ name the generated family; the original .avs download remains one click.
|
||||
829→- Regeneration pipeline: bun tools/convert-avs.ts public/avs-originals/
|
||||
830→ embedded → bump SEED_VERSION in the converter → DB refreshes itself via
|
||||
831→ the embeddedSeed marker on the next GET /api/presets.
|
||||
832→- 9 error files (parser "out of bytes") remain excluded — parser hardening
|
||||
833→ is future work if those originals matter.
|
||||
834→
|
||||
1028
tool-results/read_1793082296314_a82167d4aa24.txt
Normal file
1028
tool-results/read_1793082296314_a82167d4aa24.txt
Normal file
File diff suppressed because it is too large
Load diff
1028
tool-results/read_1793082298965_cc5347613a20.txt
Normal file
1028
tool-results/read_1793082298965_cc5347613a20.txt
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue