feat(06-03): add AI Sync panel UI and frontend wiring

- 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
This commit is contained in:
Claude
2026-04-07 11:47:14 +00:00
parent b6aacad9ce
commit 70b86f78ce
3 changed files with 228 additions and 0 deletions

View File

@@ -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';
}
});