From 48e15ee013fdf9149a1488d9b24efcca47cbb8a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 11:41:41 +0000 Subject: [PATCH] feat(06-02): add segment overlay canvas rendering and click interaction - Create lightsync/frontend/timeline/segments.js with SegmentOverlay class - Integrate SegmentOverlay into timeline.js render pipeline (between row banding and waveform) - Add loadSegments method to TimelineCanvas - Wire loadSegments call in app.js loadedmetadata handler --- lightsync/frontend/app.js | 2 + lightsync/frontend/timeline/segments.js | 122 ++++++++++++++++++++++++ lightsync/frontend/timeline/timeline.js | 15 ++- 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 lightsync/frontend/timeline/segments.js diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js index f6c0b79..f05cebd 100644 --- a/lightsync/frontend/app.js +++ b/lightsync/frontend/app.js @@ -492,6 +492,8 @@ function initTimeline() { const path = `/app/shows/${filename}`; timeline.audioDuration = audio.duration; timeline.loadWaveform(path); + // Load structural segments for overlay + timeline.loadSegments(path); // Load beats and update BPM display when analysis completes timeline.loadBeats(path).then(() => { if (timeline.beatGrid.tempoBpm) { diff --git a/lightsync/frontend/timeline/segments.js b/lightsync/frontend/timeline/segments.js new file mode 100644 index 0000000..7bc54b5 --- /dev/null +++ b/lightsync/frontend/timeline/segments.js @@ -0,0 +1,122 @@ +// SegmentOverlay — draws structural section bands on the timeline canvas (Phase 6) +// Bands are translucent, drawn below waveform. Clicking selects a section. + +const BAND_COLORS = [ + 'rgba(0,255,255,0.15)', // --accent cyan variant + 'rgba(180,0,255,0.15)', // purple complement + 'rgba(0,200,200,0.12)', // darker cyan variant +]; + +class SegmentOverlay { + constructor() { + this.segments = []; // [{start, end, label}, ...] + this.selectedIndex = -1; // -1 = no selection + this.loading = false; + this.error = null; + } + + /** + * Load segments from the API. + * @param {string} audioPath — absolute path to audio file (e.g. /app/shows/file.mp3) + */ + async load(audioPath) { + this.loading = true; + this.error = null; + this.segments = []; + this.selectedIndex = -1; + try { + const res = await fetch(`/api/audio/segments?path=${encodeURIComponent(audioPath)}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + this.segments = data.segments || []; + } catch (e) { + this.error = `SEGMENTATION FAILED: ${e.message}`; + console.warn('[segments]', e); + } finally { + this.loading = false; + } + } + + /** + * Get the currently selected segment, or null. + * @returns {{start: number, end: number, label: string} | null} + */ + getSelected() { + if (this.selectedIndex >= 0 && this.selectedIndex < this.segments.length) { + return this.segments[this.selectedIndex]; + } + return null; + } + + /** + * Handle a click at a given time position. Selects the segment containing that time, + * or deselects if clicking outside any segment. + * @param {number} time — time in seconds + * @returns {boolean} — true if selection changed + */ + handleClick(time) { + const prev = this.selectedIndex; + this.selectedIndex = -1; + for (let i = 0; i < this.segments.length; i++) { + if (time >= this.segments[i].start && time < this.segments[i].end) { + this.selectedIndex = i; + break; + } + } + return this.selectedIndex !== prev; + } + + /** + * Draw segment bands on the canvas context. + * Must be called BETWEEN track row banding and waveform in the render pipeline. + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} W — canvas logical width + * @param {number} H — canvas logical height + * @param {number} trackCount — number of tracks + * @param {Function} timeToX — converts time (seconds) to canvas X coordinate + */ + draw(ctx, W, H, trackCount, timeToX) { + if (this.segments.length === 0) return; + + const totalH = trackCount * 48; // TRACK_HEIGHT + const topY = 24; // TIME_AXIS_HEIGHT + const headerW = 140; // HEADER_WIDTH + + for (let i = 0; i < this.segments.length; i++) { + const seg = this.segments[i]; + const x1 = timeToX(seg.start); + const x2 = timeToX(seg.end); + + // Skip segments entirely off-screen + if (x2 < headerW || x1 > W) continue; + + // Clamp to visible area + const drawX1 = Math.max(x1, headerW); + const drawX2 = Math.min(x2, W); + const drawW = drawX2 - drawX1; + + const isSelected = this.selectedIndex === i; + + // D-09: Translucent colored band, full waveform height, alternating colors + ctx.fillStyle = BAND_COLORS[i % BAND_COLORS.length]; + ctx.fillRect(drawX1, topY, drawW, totalH); + + // D-11: Selected section gets accent-color border highlight + if (isSelected) { + ctx.strokeStyle = 'rgba(0,255,255,1.0)'; + ctx.lineWidth = 1; + ctx.strokeRect(drawX1, topY, drawW, totalH); + } + + // D-10: Label — small monospace uppercase at top-left of band + if (drawW > 30) { + ctx.fillStyle = 'rgba(0,255,255,0.6)'; + ctx.font = '9px monospace'; + ctx.fillText(seg.label, Math.max(x1 + 3, headerW + 2), topY + 10); + } + } + } +} + +export { SegmentOverlay }; diff --git a/lightsync/frontend/timeline/timeline.js b/lightsync/frontend/timeline/timeline.js index 28db9ad..49d1cc0 100644 --- a/lightsync/frontend/timeline/timeline.js +++ b/lightsync/frontend/timeline/timeline.js @@ -5,6 +5,7 @@ import { CommandHistory } from './history.js'; import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js'; import { BeatGrid } from './beats.js'; +import { SegmentOverlay } from './segments.js'; const PIXELS_PER_SECOND = 100; // default, adjustable via zoom const TRACK_HEIGHT = 48; @@ -26,6 +27,7 @@ class TimelineCanvas { this.waveformPeaks = []; this.audioDuration = 0; this.beatGrid = new BeatGrid(); + this.segmentOverlay = new SegmentOverlay(); this.selectedBlock = null; this._dragState = null; this._onSelectBlock = null; // callback(cue) for inspector @@ -120,6 +122,11 @@ class TimelineCanvas { ctx.fillRect(HEADER_WIDTH, y, W - HEADER_WIDTH, TRACK_HEIGHT); }); + // 2.5 Segment overlay bands (drawn below waveform) + if (this.segmentOverlay.segments.length > 0) { + this.segmentOverlay.draw(ctx, W, H, this.tracks.length, (t) => this.timeToX(t)); + } + // 3. Waveform background if (this.waveformPeaks.length > 0 && this.audioDuration > 0) { const totalH = this.tracks.length * TRACK_HEIGHT; @@ -406,9 +413,11 @@ class TimelineCanvas { const hit = this.hitTest(mx, my); if (!hit) { - // Clicked on empty canvas — deselect + // Clicked on empty canvas — deselect block, update segment selection this.selectedBlock = null; if (this._onSelectBlock) this._onSelectBlock(null); + const clickTime = this.xToTime(mx); + this.segmentOverlay.handleClick(clickTime); return; } @@ -622,6 +631,10 @@ class TimelineCanvas { async loadBeats(audioPath) { await this.beatGrid.load(audioPath); } + + async loadSegments(audioPath) { + await this.segmentOverlay.load(audioPath); + } } export { TimelineCanvas };