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
This commit is contained in:
@@ -492,6 +492,8 @@ function initTimeline() {
|
|||||||
const path = `/app/shows/${filename}`;
|
const path = `/app/shows/${filename}`;
|
||||||
timeline.audioDuration = audio.duration;
|
timeline.audioDuration = audio.duration;
|
||||||
timeline.loadWaveform(path);
|
timeline.loadWaveform(path);
|
||||||
|
// Load structural segments for overlay
|
||||||
|
timeline.loadSegments(path);
|
||||||
// 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) {
|
||||||
|
|||||||
122
lightsync/frontend/timeline/segments.js
Normal file
122
lightsync/frontend/timeline/segments.js
Normal file
@@ -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 };
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { CommandHistory } from './history.js';
|
import { CommandHistory } from './history.js';
|
||||||
import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js';
|
import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js';
|
||||||
import { BeatGrid } from './beats.js';
|
import { BeatGrid } from './beats.js';
|
||||||
|
import { SegmentOverlay } from './segments.js';
|
||||||
|
|
||||||
const PIXELS_PER_SECOND = 100; // default, adjustable via zoom
|
const PIXELS_PER_SECOND = 100; // default, adjustable via zoom
|
||||||
const TRACK_HEIGHT = 48;
|
const TRACK_HEIGHT = 48;
|
||||||
@@ -26,6 +27,7 @@ class TimelineCanvas {
|
|||||||
this.waveformPeaks = [];
|
this.waveformPeaks = [];
|
||||||
this.audioDuration = 0;
|
this.audioDuration = 0;
|
||||||
this.beatGrid = new BeatGrid();
|
this.beatGrid = new BeatGrid();
|
||||||
|
this.segmentOverlay = new SegmentOverlay();
|
||||||
this.selectedBlock = null;
|
this.selectedBlock = null;
|
||||||
this._dragState = null;
|
this._dragState = null;
|
||||||
this._onSelectBlock = null; // callback(cue) for inspector
|
this._onSelectBlock = null; // callback(cue) for inspector
|
||||||
@@ -120,6 +122,11 @@ class TimelineCanvas {
|
|||||||
ctx.fillRect(HEADER_WIDTH, y, W - HEADER_WIDTH, TRACK_HEIGHT);
|
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
|
// 3. Waveform background
|
||||||
if (this.waveformPeaks.length > 0 && this.audioDuration > 0) {
|
if (this.waveformPeaks.length > 0 && this.audioDuration > 0) {
|
||||||
const totalH = this.tracks.length * TRACK_HEIGHT;
|
const totalH = this.tracks.length * TRACK_HEIGHT;
|
||||||
@@ -406,9 +413,11 @@ class TimelineCanvas {
|
|||||||
const hit = this.hitTest(mx, my);
|
const hit = this.hitTest(mx, my);
|
||||||
|
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
// Clicked on empty canvas — deselect
|
// Clicked on empty canvas — deselect block, update segment selection
|
||||||
this.selectedBlock = null;
|
this.selectedBlock = null;
|
||||||
if (this._onSelectBlock) this._onSelectBlock(null);
|
if (this._onSelectBlock) this._onSelectBlock(null);
|
||||||
|
const clickTime = this.xToTime(mx);
|
||||||
|
this.segmentOverlay.handleClick(clickTime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +631,10 @@ class TimelineCanvas {
|
|||||||
async loadBeats(audioPath) {
|
async loadBeats(audioPath) {
|
||||||
await this.beatGrid.load(audioPath);
|
await this.beatGrid.load(audioPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadSegments(audioPath) {
|
||||||
|
await this.segmentOverlay.load(audioPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TimelineCanvas };
|
export { TimelineCanvas };
|
||||||
|
|||||||
Reference in New Issue
Block a user