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:
@@ -494,6 +494,14 @@ function initTimeline() {
|
|||||||
timeline.loadWaveform(path);
|
timeline.loadWaveform(path);
|
||||||
// Load structural segments for overlay
|
// Load structural segments for overlay
|
||||||
timeline.loadSegments(path);
|
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
|
// Load beats and update BPM display when analysis completes
|
||||||
timeline.loadBeats(path).then(() => {
|
timeline.loadBeats(path).then(() => {
|
||||||
if (timeline.beatGrid.tempoBpm) {
|
if (timeline.beatGrid.tempoBpm) {
|
||||||
@@ -574,6 +582,21 @@ function initTimeline() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
timeline.startRenderLoop();
|
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();
|
initTimeline();
|
||||||
@@ -652,3 +675,121 @@ document.getElementById('btn-load-show')?.addEventListener('click', async () =>
|
|||||||
btn.textContent = 'LOAD';
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,6 +63,27 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="toolbar-sep"></div>
|
<div class="toolbar-sep"></div>
|
||||||
<button id="btn-snap" class="transport-btn snap-btn active" title="Snap to beat">SNAP</button>
|
<button id="btn-snap" class="transport-btn snap-btn active" title="Snap to beat">SNAP</button>
|
||||||
|
<div class="toolbar-sep"></div>
|
||||||
|
<button id="btn-ai-sync-open" class="transport-btn" title="AI-assisted show generation">AI SYNC</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-sync-panel" class="ai-sync-panel" style="display:none;">
|
||||||
|
<div class="ai-sync-header">AI SYNC</div>
|
||||||
|
<div class="ai-sync-body">
|
||||||
|
<div class="ai-sync-info" id="ai-sync-info">SELECT AUDIO FILE FIRST</div>
|
||||||
|
<div class="ai-sync-toggles">
|
||||||
|
<label class="ai-sync-toggle"><input type="checkbox" id="ai-use-beats" checked> BEATS</label>
|
||||||
|
<label class="ai-sync-toggle"><input type="checkbox" id="ai-use-onsets" checked> ONSETS</label>
|
||||||
|
<label class="ai-sync-toggle"><input type="checkbox" id="ai-use-chroma" checked> CHROMA</label>
|
||||||
|
<label class="ai-sync-toggle"><input type="checkbox" id="ai-use-rms" checked> RMS</label>
|
||||||
|
</div>
|
||||||
|
<div class="ai-sync-actions">
|
||||||
|
<button id="btn-ai-generate" class="transport-btn ai-sync-generate" disabled>GENERATE</button>
|
||||||
|
<label class="ai-sync-toggle ai-sync-llm-toggle">
|
||||||
|
<input type="checkbox" id="ai-use-llm" disabled>
|
||||||
|
<span id="ai-llm-label">USE LLM</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="timeline-canvas"></canvas>
|
<canvas id="timeline-canvas"></canvas>
|
||||||
<div id="live-preview" class="live-preview" style="display:none;"></div>
|
<div id="live-preview" class="live-preview" style="display:none;"></div>
|
||||||
|
|||||||
@@ -579,3 +579,69 @@ button:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 200px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user