summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/index.html
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-05 21:21:59 +0100
committerskal <pascal.massimino@gmail.com>2026-02-05 21:21:59 +0100
commitf49e7131c59ff3dd4dea02c34e713227011f7683 (patch)
tree36cd396d1fc4a5ee0865230a5e4b04e6a9cc4569 /tools/timeline_editor/index.html
parent6bd01cd5d4c731871b433dc209147817f7e71be6 (diff)
fix(timeline-editor): Fix effect stacking and add beats mode with snap-to-beat
Bugs fixed: - Effects within sequences now stack vertically instead of overlapping - Sequence height now dynamically adjusts based on effect count - Effects positioned with proper 40px vertical spacing Features added: - Seconds/Beats toggle checkbox with BPM display - Time markers show beats (0b, 1b, etc.) when in beat mode - Snap-to-beat when dragging in beat mode - Effect/sequence time labels show beats when enabled - BPM tracking from loaded demo.seq file The effect stacking bug was caused by all effects using the same vertical position formula (seqIndex * 80 + 25). Fixed by adding effectIndex * 40 to stack effects properly. Sequences now grow in height to accommodate multiple stacked effects.
Diffstat (limited to 'tools/timeline_editor/index.html')
-rw-r--r--tools/timeline_editor/index.html105
1 files changed, 91 insertions, 14 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index 96c2b17..b84908a 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -39,9 +39,23 @@
display: flex;
gap: 10px;
flex-wrap: wrap;
+ align-items: center;
margin-bottom: 20px;
}
+ .checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #d4d4d4;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .checkbox-label input[type="checkbox"] {
+ cursor: pointer;
+ }
+
button {
background: #0e639c;
color: white;
@@ -240,6 +254,10 @@
<label>Zoom: <input type="range" id="zoomSlider" min="10" max="200" value="100" step="10"></label>
<span id="zoomLevel">100%</span>
<label style="margin-left: 20px">Pixels per second: <span id="pixelsPerSec">100</span></label>
+ <label class="checkbox-label" style="margin-left: 20px">
+ <input type="checkbox" id="showBeatsCheckbox">
+ Show Beats (BPM: <span id="currentBPM">120</span>)
+ </label>
</div>
<div id="messageArea"></div>
@@ -263,6 +281,8 @@
let currentFile = null;
let selectedItem = null;
let pixelsPerSecond = 100;
+ let showBeats = false;
+ let bpm = 120;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
@@ -371,14 +391,14 @@
}
}
- return sequences;
+ return { sequences, bpm };
}
// Serializer: JavaScript objects → demo.seq
function serializeSeqFile(sequences) {
let output = '# Demo Timeline\n';
output += '# Generated by Timeline Editor\n';
- output += '# BPM 120\n\n';
+ output += `# BPM ${bpm}\n\n`;
for (const seq of sequences) {
const seqLine = `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}`;
@@ -419,12 +439,27 @@
const timelineWidth = maxTime * pixelsPerSecond;
timeline.style.width = `${timelineWidth}px`;
- for (let t = 0; t <= maxTime; t += 1) {
- const marker = document.createElement('div');
- marker.className = 'time-marker';
- marker.style.left = `${t * pixelsPerSecond}px`;
- marker.textContent = `${t}s`;
- timeMarkers.appendChild(marker);
+ if (showBeats) {
+ // Show beats
+ const beatDuration = 60.0 / bpm; // seconds per beat
+ const maxBeats = Math.ceil(maxTime / beatDuration);
+ for (let beat = 0; beat <= maxBeats; beat++) {
+ const timeSec = beat * beatDuration;
+ const marker = document.createElement('div');
+ marker.className = 'time-marker';
+ marker.style.left = `${timeSec * pixelsPerSecond}px`;
+ marker.textContent = `${beat}b`;
+ timeMarkers.appendChild(marker);
+ }
+ } else {
+ // Show seconds
+ for (let t = 0; t <= maxTime; t += 1) {
+ const marker = document.createElement('div');
+ marker.className = 'time-marker';
+ marker.style.left = `${t * pixelsPerSecond}px`;
+ marker.textContent = `${t}s`;
+ timeMarkers.appendChild(marker);
+ }
}
// Render sequences
@@ -439,14 +474,28 @@
seqDuration = Math.max(...seq.effects.map(e => e.endTime));
}
+ // Calculate sequence height based on number of effects (stacked vertically)
+ const numEffects = seq.effects.length;
+ const seqHeight = Math.max(70, 25 + numEffects * 40 + 5);
+
seqDiv.style.left = `${seq.startTime * pixelsPerSecond}px`;
seqDiv.style.top = `${seqIndex * 80}px`;
seqDiv.style.width = `${seqDuration * pixelsPerSecond}px`;
- seqDiv.style.height = '70px';
+ seqDiv.style.height = `${seqHeight}px`;
+
+ // Format time display based on mode
+ let seqTimeDisplay;
+ if (showBeats) {
+ const beatDuration = 60.0 / bpm;
+ const startBeat = (seq.startTime / beatDuration).toFixed(1);
+ seqTimeDisplay = `Start: ${startBeat}b`;
+ } else {
+ seqTimeDisplay = `Start: ${seq.startTime.toFixed(2)}s`;
+ }
seqDiv.innerHTML = `
<strong>Sequence ${seqIndex + 1}</strong><br>
- <small>Start: ${seq.startTime.toFixed(2)}s | Priority: ${seq.priority}</small>
+ <small>${seqTimeDisplay} | Priority: ${seq.priority}</small>
`;
if (selectedItem && selectedItem.type === 'sequence' && selectedItem.index === seqIndex) {
@@ -472,13 +521,24 @@
const effectWidth = (effect.endTime - effect.startTime) * pixelsPerSecond;
effectDiv.style.left = `${effectStart}px`;
- effectDiv.style.top = `${seqIndex * 80 + 25}px`;
+ effectDiv.style.top = `${seqIndex * 80 + 25 + effectIndex * 40}px`;
effectDiv.style.width = `${effectWidth}px`;
effectDiv.style.height = '35px';
+ // Format time display based on mode
+ let timeDisplay;
+ if (showBeats) {
+ const beatDuration = 60.0 / bpm;
+ const startBeat = (effect.startTime / beatDuration).toFixed(1);
+ const endBeat = (effect.endTime / beatDuration).toFixed(1);
+ timeDisplay = `${startBeat}-${endBeat}b`;
+ } else {
+ timeDisplay = `${effect.startTime.toFixed(1)}-${effect.endTime.toFixed(1)}s`;
+ }
+
effectDiv.innerHTML = `
${effect.className}<br>
- <small>${effect.startTime.toFixed(1)}-${effect.endTime.toFixed(1)}s</small>
+ <small>${timeDisplay}</small>
`;
if (selectedItem && selectedItem.type === 'effect' &&
@@ -524,7 +584,14 @@
const timelineRect = timeline.getBoundingClientRect();
const newX = e.clientX - timelineRect.left - dragOffset.x;
- const newTime = Math.max(0, newX / pixelsPerSecond);
+ let newTime = Math.max(0, newX / pixelsPerSecond);
+
+ // Snap to beat when in beat mode
+ if (showBeats) {
+ const beatDuration = 60.0 / bpm;
+ const nearestBeat = Math.round(newTime / beatDuration);
+ newTime = nearestBeat * beatDuration;
+ }
if (selectedItem.type === 'sequence') {
sequences[selectedItem.index].startTime = Math.round(newTime * 100) / 100;
@@ -633,7 +700,10 @@
reader.onload = (e) => {
try {
- sequences = parseSeqFile(e.target.result);
+ const parsed = parseSeqFile(e.target.result);
+ sequences = parsed.sequences;
+ bpm = parsed.bpm;
+ document.getElementById('currentBPM').textContent = bpm;
renderTimeline();
saveBtn.disabled = false;
addSequenceBtn.disabled = false;
@@ -694,6 +764,13 @@
renderTimeline();
});
+ // Beats toggle
+ const showBeatsCheckbox = document.getElementById('showBeatsCheckbox');
+ showBeatsCheckbox.addEventListener('change', (e) => {
+ showBeats = e.target.checked;
+ renderTimeline();
+ });
+
// Click outside to deselect
timeline.addEventListener('click', () => {
selectedItem = null;