From 70b86f78ced23a9f1d218489898a254837164493 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 11:47:14 +0000 Subject: [PATCH] feat(06-03): add AI Sync panel UI and frontend wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AI SYNC button to timeline toolbar, panel hidden by default (D-08) - Panel: feature toggles (BEATS, ONSETS, CHROMA, RMS), GENERATE button, USE LLM toggle - GENERATE disabled until audio loaded; re-enables on loadedmetadata event - LLM toggle enabled/disabled based on /api/sync/llm-available at page load - D-07: confirmation dialog before replacing existing timeline blocks - D-11: section_range passed when a segment is selected in the overlay - Segment selection watcher (200ms interval) updates info text dynamically - Add AI sync panel styles — flat terminal aesthetic, no border-radius --- lightsync/frontend/app.js | 141 ++++++++++++++++++++++++++++++++++ lightsync/frontend/index.html | 21 +++++ lightsync/frontend/style.css | 66 ++++++++++++++++ 3 files changed, 228 insertions(+) diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js index f05cebd..037e82a 100644 --- a/lightsync/frontend/app.js +++ b/lightsync/frontend/app.js @@ -494,6 +494,14 @@ function initTimeline() { timeline.loadWaveform(path); // Load structural segments for overlay timeline.loadSegments(path); + // Enable AI Sync generate button + const genBtn = document.getElementById('btn-ai-generate'); + if (genBtn) genBtn.disabled = false; + const infoEl = document.getElementById('ai-sync-info'); + if (infoEl) { + const seg = timeline.segmentOverlay?.getSelected(); + infoEl.textContent = seg ? `SECTION: ${seg.label}` : 'FULL SHOW'; + } // Load beats and update BPM display when analysis completes timeline.loadBeats(path).then(() => { if (timeline.beatGrid.tempoBpm) { @@ -574,6 +582,21 @@ function initTimeline() { }); timeline.startRenderLoop(); + + // Update AI Sync info when segment selection changes + let _lastSegIdx = -1; + setInterval(() => { + if (!timeline?.segmentOverlay) return; + const idx = timeline.segmentOverlay.selectedIndex; + if (idx !== _lastSegIdx) { + _lastSegIdx = idx; + const infoEl = document.getElementById('ai-sync-info'); + if (infoEl) { + const seg = timeline.segmentOverlay.getSelected(); + infoEl.textContent = seg ? `SECTION: ${seg.label}` : 'FULL SHOW'; + } + } + }, 200); } initTimeline(); @@ -652,3 +675,121 @@ document.getElementById('btn-load-show')?.addEventListener('click', async () => btn.textContent = 'LOAD'; } }); + +// ── AI Sync panel ─────────────────────────────────────────────────────────── + +document.getElementById('btn-ai-sync-open')?.addEventListener('click', () => { + const panel = document.getElementById('ai-sync-panel'); + if (!panel) return; + const visible = panel.style.display !== 'none'; + panel.style.display = visible ? 'none' : 'block'; +}); + +fetch('/api/sync/llm-available') + .then(r => r.json()) + .then(data => { + const checkbox = document.getElementById('ai-use-llm'); + const label = document.getElementById('ai-llm-label'); + const container = checkbox?.closest('.ai-sync-llm-toggle'); + if (data.available) { + checkbox.disabled = false; + if (container) container.classList.remove('disabled'); + } else { + checkbox.disabled = true; + if (label) label.title = 'ANTHROPIC_API_KEY not configured'; + if (container) container.classList.add('disabled'); + } + }) + .catch(() => {}); + +document.getElementById('btn-ai-generate')?.addEventListener('click', async () => { + if (!timeline) return; + + const audio = document.getElementById('player'); + if (!audio?.src) return; + + // D-07: Confirm replacement if existing blocks + const hasBlocks = timeline.tracks.some(t => t.cues.length > 0); + if (hasBlocks) { + if (!confirm('Replace all existing blocks?')) return; + } + + const btn = document.getElementById('btn-ai-generate'); + const infoEl = document.getElementById('ai-sync-info'); + btn.disabled = true; + btn.textContent = 'GENERATING...'; + if (infoEl) infoEl.textContent = 'ANALYZING...'; + + try { + const filename = audio.src.split('/').pop(); + const path = `/app/shows/${filename}`; + + // D-11: If a section is selected, only fill that range + const selectedSeg = timeline.segmentOverlay?.getSelected(); + const sectionRange = selectedSeg ? [selectedSeg.start, selectedSeg.end] : null; + + // Collect device IDs from timeline tracks + const deviceIds = timeline.tracks.map(t => t.device_id); + + const useLlm = document.getElementById('ai-use-llm')?.checked || false; + + const res = await fetch('/api/sync/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path: path, + device_ids: deviceIds, + use_llm: useLlm, + section_range: sectionRange, + }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || `HTTP ${res.status}`); + } + + const data = await res.json(); + + // Clear existing blocks (or section range only) + if (sectionRange) { + // Clear only blocks in the section range + for (const track of timeline.tracks) { + track.cues = track.cues.filter(c => + c.timestamp + (c.duration || 0) < sectionRange[0] || + c.timestamp >= sectionRange[1] + ); + } + } else { + // Clear all blocks + for (const track of timeline.tracks) { + track.cues = []; + } + } + + // Place generated blocks onto tracks + for (const block of data.blocks) { + const track = timeline.tracks.find(t => String(t.device_id) === String(block.device_id)); + if (track) { + track.cues.push({ + id: block.id, + timestamp: block.timestamp, + duration: block.duration, + animation: block.animation, + params: block.params, + mode: 'animation', + }); + } + } + + if (infoEl) infoEl.textContent = `GENERATED ${data.count} BLOCKS (${data.mode.toUpperCase()})`; + console.log(`[ai-sync] Generated ${data.count} blocks via ${data.mode}`); + + } catch (e) { + console.error('[ai-sync]', e); + if (infoEl) infoEl.textContent = `ERROR: ${e.message}`; + } finally { + btn.disabled = false; + btn.textContent = 'GENERATE'; + } +}); diff --git a/lightsync/frontend/index.html b/lightsync/frontend/index.html index 0ba4fe5..dcdf79f 100644 --- a/lightsync/frontend/index.html +++ b/lightsync/frontend/index.html @@ -63,6 +63,27 @@
+
+ + + diff --git a/lightsync/frontend/style.css b/lightsync/frontend/style.css index acdd088..f73c484 100644 --- a/lightsync/frontend/style.css +++ b/lightsync/frontend/style.css @@ -579,3 +579,69 @@ button:hover { white-space: nowrap; min-width: 200px; } + +/* AI Sync panel (Phase 6) */ +.ai-sync-panel { + background: var(--bg-panel-dark); + border: 1px solid var(--border-dim); + margin: 0 4px; + padding: 0; + font-family: var(--font-mono); +} +.ai-sync-header { + background: var(--bg-panel); + padding: 4px 8px; + font-size: 11px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid var(--border-dim); +} +.ai-sync-body { + padding: 6px 8px; + display: flex; + align-items: center; + gap: 12px; +} +.ai-sync-info { + font-size: 11px; + color: var(--text-dim); + min-width: 140px; +} +.ai-sync-toggles { + display: flex; + gap: 8px; +} +.ai-sync-toggle { + font-size: 11px; + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + gap: 3px; +} +.ai-sync-toggle input[type="checkbox"] { + accent-color: var(--accent); +} +.ai-sync-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} +.ai-sync-generate { + background: var(--accent); + color: var(--bg-primary); + font-weight: bold; + padding: 3px 12px; +} +.ai-sync-generate:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.ai-sync-llm-toggle { + opacity: 0.4; +} +.ai-sync-llm-toggle:not(.disabled) { + opacity: 1; +}