summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/cnn_test.cc53
-rw-r--r--tools/seq_compiler.cc28
-rw-r--r--tools/timeline_editor/README.md180
-rw-r--r--tools/timeline_editor/ROADMAP.md31
-rw-r--r--tools/timeline_editor/index.html543
5 files changed, 595 insertions, 240 deletions
diff --git a/tools/cnn_test.cc b/tools/cnn_test.cc
index 39ed436..c2983a9 100644
--- a/tools/cnn_test.cc
+++ b/tools/cnn_test.cc
@@ -42,6 +42,8 @@ struct Args {
float blend = 1.0f;
bool output_png = true; // Default to PNG
const char* save_intermediates = nullptr;
+ int num_layers = 3; // Default to 3 layers
+ bool debug_hex = false; // Print first 8 pixels as hex
};
// Parse command-line arguments
@@ -73,6 +75,14 @@ static bool parse_args(int argc, char** argv, Args* args) {
}
} else if (strcmp(argv[i], "--save-intermediates") == 0 && i + 1 < argc) {
args->save_intermediates = argv[++i];
+ } else if (strcmp(argv[i], "--layers") == 0 && i + 1 < argc) {
+ args->num_layers = atoi(argv[++i]);
+ if (args->num_layers < 1 || args->num_layers > 10) {
+ fprintf(stderr, "Error: layers must be in range [1, 10]\n");
+ return false;
+ }
+ } else if (strcmp(argv[i], "--debug-hex") == 0) {
+ args->debug_hex = true;
} else if (strcmp(argv[i], "--help") == 0) {
return false;
} else {
@@ -90,7 +100,9 @@ static void print_usage(const char* prog) {
fprintf(stderr, "\nOPTIONS:\n");
fprintf(stderr, " --blend F Final blend amount (0.0-1.0, default: 1.0)\n");
fprintf(stderr, " --format ppm|png Output format (default: png)\n");
+ fprintf(stderr, " --layers N Number of CNN layers (1-10, default: 3)\n");
fprintf(stderr, " --save-intermediates DIR Save intermediate layers to directory\n");
+ fprintf(stderr, " --debug-hex Print first 8 pixels as hex (debug)\n");
fprintf(stderr, " --help Show this help\n");
}
@@ -273,6 +285,7 @@ int main(int argc, char** argv) {
WGPUTexture input_texture =
load_texture(device, queue, args.input_path, &width, &height);
if (!input_texture) {
+ SamplerCache::Get().clear();
fixture.shutdown();
return 1;
}
@@ -303,6 +316,7 @@ int main(int argc, char** argv) {
if (pipeline_final) wgpuRenderPipelineRelease(pipeline_final);
wgpuTextureViewRelease(input_view);
wgpuTextureRelease(input_texture);
+ SamplerCache::Get().clear();
fixture.shutdown();
return 1;
}
@@ -360,8 +374,8 @@ int main(int argc, char** argv) {
WGPUSampler sampler =
SamplerCache::Get().get_or_create(device, SamplerCache::clamp());
- // Multi-layer processing (fixed 3 layers)
- const int NUM_LAYERS = 3;
+ // Multi-layer processing
+ const int NUM_LAYERS = args.num_layers;
int dst_idx = 0; // Index of texture to render to
// First layer reads from input, subsequent layers read from previous output
@@ -373,11 +387,12 @@ int main(int argc, char** argv) {
// Update uniforms
CommonPostProcessUniforms common_u = {
.resolution = {static_cast<float>(width), static_cast<float>(height)},
- ._pad = {0.0f, 0.0f},
.aspect_ratio = static_cast<float>(width) / static_cast<float>(height),
.time = 0.0f,
- .beat = 0.0f,
+ .beat_time = 0.0f,
+ .beat_phase = 0.0f,
.audio_intensity = 0.0f,
+ ._pad = 0.0f,
};
wgpuQueueWriteBuffer(queue, common_uniform_buffer, 0, &common_u,
sizeof(common_u));
@@ -427,6 +442,18 @@ int main(int argc, char** argv) {
printf("Reading pixels from GPU...\n");
std::vector<uint8_t> pixels = rt.read_pixels();
+ // Debug: print first 8 pixels as hex
+ if (args.debug_hex && !pixels.empty()) {
+ printf("First 8 pixels (BGRA hex):\n");
+ for (int i = 0; i < 8 && i < width * height; ++i) {
+ const uint8_t b = pixels[i * 4 + 0];
+ const uint8_t g = pixels[i * 4 + 1];
+ const uint8_t r = pixels[i * 4 + 2];
+ const uint8_t a = pixels[i * 4 + 3];
+ printf(" [%d] 0x%02X%02X%02X%02X (RGBA)\n", i, r, g, b, a);
+ }
+ }
+
if (pixels.empty()) {
fprintf(stderr, "Error: GPU readback failed\n");
wgpuTextureViewRelease(intermediate_views[0]);
@@ -440,6 +467,7 @@ int main(int argc, char** argv) {
wgpuBindGroupLayoutRelease(bgl);
wgpuRenderPipelineRelease(pipeline_final);
wgpuRenderPipelineRelease(pipeline_intermediate);
+ SamplerCache::Get().clear();
fixture.shutdown();
return 1;
}
@@ -466,6 +494,7 @@ int main(int argc, char** argv) {
wgpuBindGroupLayoutRelease(bgl);
wgpuRenderPipelineRelease(pipeline_final);
wgpuRenderPipelineRelease(pipeline_intermediate);
+ SamplerCache::Get().clear();
fixture.shutdown();
return 1;
}
@@ -501,6 +530,18 @@ int main(int argc, char** argv) {
std::vector<uint8_t> pixels = texture_readback_fp16_to_u8(
device, queue, intermediate_textures[dst_idx], width, height);
+ // Debug: print first 8 pixels as hex
+ if (args.debug_hex && !pixels.empty()) {
+ printf("Layer %d first 8 pixels (BGRA hex):\n", layer);
+ for (int i = 0; i < 8 && i < width * height; ++i) {
+ const uint8_t b = pixels[i * 4 + 0];
+ const uint8_t g = pixels[i * 4 + 1];
+ const uint8_t r = pixels[i * 4 + 2];
+ const uint8_t a = pixels[i * 4 + 3];
+ printf(" [%d] 0x%02X%02X%02X%02X (RGBA)\n", i, r, g, b, a);
+ }
+ }
+
if (!pixels.empty()) {
save_png(layer_path, pixels, width, height);
} else {
@@ -517,6 +558,9 @@ int main(int argc, char** argv) {
}
}
+ // Wait for all GPU work to complete before cleanup
+ wgpuDevicePoll(device, true, nullptr);
+
// Cleanup
wgpuTextureViewRelease(intermediate_views[0]);
wgpuTextureViewRelease(intermediate_views[1]);
@@ -529,6 +573,7 @@ int main(int argc, char** argv) {
wgpuRenderPipelineRelease(pipeline_final);
wgpuTextureViewRelease(input_view);
wgpuTextureRelease(input_texture);
+ SamplerCache::Get().clear();
fixture.shutdown();
return 0;
diff --git a/tools/seq_compiler.cc b/tools/seq_compiler.cc
index ecb9908..069122a 100644
--- a/tools/seq_compiler.cc
+++ b/tools/seq_compiler.cc
@@ -633,30 +633,22 @@ void generate_gantt_html(const std::string& output_file,
// (seconds)
std::string convert_to_time(const std::string& value, float bpm) {
std::string val = value;
- bool is_beat = false;
- // Check for explicit 'b' suffix (beat)
- if (!val.empty() && val.back() == 'b') {
- is_beat = true;
- val.pop_back();
- }
- // Check for explicit 's' suffix (seconds)
- else if (!val.empty() && val.back() == 's') {
+ // Check for explicit 's' suffix (seconds) - return as-is
+ if (!val.empty() && val.back() == 's') {
val.pop_back();
- return val; // Already in seconds
- }
- // If no suffix and no decimal point, assume beats
- else if (val.find('.') == std::string::npos) {
- is_beat = true;
+ return val;
}
- if (is_beat) {
- float beat = std::stof(val);
- float time = beat * 60.0f / bpm;
- return std::to_string(time);
+ // Check for explicit 'b' suffix (beats) - strip and convert
+ if (!val.empty() && val.back() == 'b') {
+ val.pop_back();
}
- return val; // Return as-is (seconds)
+ // DEFAULT: All numbers (with or without 'b' suffix) are beats
+ float beat = std::stof(val);
+ float time = beat * 60.0f / bpm;
+ return std::to_string(time);
}
int main(int argc, char* argv[]) {
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md
index adf9d4e..6e368cf 100644
--- a/tools/timeline_editor/README.md
+++ b/tools/timeline_editor/README.md
@@ -1,73 +1,43 @@
# Timeline Editor
-Interactive web-based editor for `demo.seq` timeline files.
+Interactive web-based editor for `timeline.seq` files.
## Features
-✅ **Implemented:**
-- 📂 Load/save `demo.seq` files
-- 📊 Visual Gantt-style timeline
-- 🎯 Drag & drop sequences along timeline
-- 🎯 Drag & drop effects along timeline
-- 🎯 Resize effects with left/right handles
-- ⏱️ Edit timing (start/end times)
+- 📂 Load/save `timeline.seq` files
+- 📊 Visual Gantt-style timeline with sticky time markers (beat-based)
+- 🎯 Drag & drop sequences and effects
+- 🎯 Resize effects with handles
+- 📦 Collapsible sequences (double-click to collapse)
+- 📏 Vertical grid lines synchronized with time ticks
+- ⏱️ Edit timing and properties (in beats)
- ⚙️ Stack-order based priority system
-- ⚙️ Edit effect class names and constructor arguments
-- 🔍 Zoom in/out (10% - 200%)
-- 🎵 Audio waveform visualization (WAV files)
-- 📋 Real-time statistics
+- 🔍 Zoom (10%-200%) with mouse wheel + Ctrl/Cmd
+- 🎵 Audio waveform visualization (aligned to beats)
+- 🎼 Snap-to-beat mode (enabled by default)
+- 🎛️ BPM slider (60-200 BPM)
+- 🔄 Re-order sequences by time
- 🗑️ Delete sequences/effects
-- ➕ Add new sequences
-- 🎼 Snap-to-beat mode with beat markers
+- ▶️ **Audio playback with auto-expand/collapse** (NEW)
+- 🎚️ **Sticky audio track and timeline ticks** (NEW)
## Usage
-1. **Open the editor:**
- ```bash
- open tools/timeline_editor/index.html
- ```
- Or double-click `index.html` in Finder.
-
-2. **Load a timeline:**
- - Click "📂 Load demo.seq"
- - Select your `assets/demo.seq` file
-
-3. **Edit the timeline:**
- - **Drag sequences/effects** to move them along the timeline
- - **Click an item** to select it and view properties
- - **Edit properties** in the panel below
- - **Click "Apply"** to save property changes
-
-4. **Save your changes:**
- - Click "💾 Save demo.seq"
- - Choose where to save the modified file
-
-5. **Load audio waveform (optional):**
- - Click "🎵 Load Audio (WAV)" to visualize your music track
- - The waveform appears above the timeline for visual reference
- - Use it to align sequences with beats, drops, and musical phrases
- - Click "✖ Clear Audio" to remove the waveform
-
- **Tip:** Generate a WAV file from your demo using:
- ```bash
- ./build/demo64k --dump_wav output.wav
- ```
- Then load `output.wav` in the timeline editor to align sequences with the actual audio output.
-
-6. **Zoom controls:**
- - Use the zoom slider to adjust timeline scale
- - Higher zoom = more pixels per second
- - Waveform scales automatically with zoom
-
-7. **Snap-to-beat mode:**
- - Enable "Show Beats" checkbox to display beat markers
- - Sequences and effects snap to beat boundaries when dragged
- - Helps maintain musical timing
-
-## Keyboard Shortcuts
-
-- **Delete key**: Delete selected item (when implemented)
-- **Escape**: Deselect current item
+1. **Open:** `open tools/timeline_editor/index.html` or double-click in browser
+2. **Load timeline:** Click "📂 Load timeline.seq" → select `workspaces/main/timeline.seq`
+3. **Load audio:** Click "🎵 Load Audio (WAV)" → select audio file
+4. **Playback:**
+ - Click "▶ Play" or press **Spacebar** to play/pause
+ - Click waveform to seek
+ - Watch sequences auto-expand/collapse during playback
+ - Red playback indicator shows current position
+5. **Edit:**
+ - Drag sequences/effects to reposition
+ - Double-click sequence header to collapse/expand
+ - Click item to edit properties in side panel
+ - Drag effect handles to resize
+6. **Zoom:** Ctrl/Cmd + mouse wheel (zooms at cursor position)
+7. **Save:** Click "💾 Save timeline.seq"
## File Format
@@ -87,71 +57,43 @@ SEQUENCE <start_time> <priority> ["optional_name"] [optional_end]
- `=` = Keep same priority as previous
- `-` = Decrement priority (background layers)
-**Time Notation:**
-- `0b`, `4b`, `64b` = Beats (converted using BPM)
-- `0.0`, `2.0`, `32.0` = Seconds
-- Integer without 'b': treated as beats
-- Decimal point: treated as seconds
+**Time Notation (Beat-Based):**
+- **Default:** All numbers are beats (e.g., `4`, `16.5` = beats)
+- `4b`, `16b` = Explicit beats (optional 'b' suffix for clarity)
+- `2.0s`, `8.25s` = Explicit seconds (rare, for physical timing)
-Example:
+Example (Beat-Based):
```
# BPM 120
-SEQUENCE 0b 0 "Opening Scene"
- EFFECT - FlashCubeEffect .2 3 # Background (priority -1)
- EFFECT + FlashEffect 0.0 1.0 # Foreground (priority 0)
- EFFECT + FadeEffect 0.5 2.0 # Overlay (priority 1)
-
-SEQUENCE 4b 1 "Beat Drop"
- EFFECT + HeptagonEffect 0.0 0.5 # Priority 0
- EFFECT = ParticlesEffect 0.0 2.0 # Priority 0 (same layer)
-```
-
-## Color Coding
-
-- **Blue boxes**: Sequences (container for effects)
-- **Gray boxes**: Effects (visual elements)
-- **Green highlight**: Selected sequence
-- **Orange highlight**: Selected effect
-
-## Tips
-
-- **Sequences** have absolute start times
-- **Effects** have start/end times **relative to their sequence**
-- Priority determines rendering order (higher = rendered later = on top)
-- Effect constructor arguments are passed as-is to the C++ code
-
-## Limitations
+SEQUENCE 0 0 "Opening Scene" # Start at beat 0
+ EFFECT - FlashCubeEffect 0 4 # Beats 0-4 (0-2s @ 120 BPM)
+ EFFECT + FlashEffect 0 2 # Beats 0-2 (0-1s)
+ EFFECT + FadeEffect 1 4 # Beats 1-4 (0.5-2s)
-- No preview rendering (this is intentional - it's just a timeline editor)
-- No automatic overlap detection yet
-- No undo/redo (coming soon)
-- Cannot add effects to sequences (manually edit properties for now)
+SEQUENCE 8 1 "Beat Drop" # Start at beat 8 (bar 3)
+ EFFECT + HeptagonEffect 0 1 # First beat of sequence
+ EFFECT = ParticlesEffect 0 4 # Full bar (4 beats)
-## Future Enhancements
-
-- [ ] Undo/redo functionality
-- [ ] Add effect button (create new effects within sequences)
-- [ ] Overlap detection warnings
-- [ ] Timeline playback indicator
-- [ ] Multiple file comparison
-- [ ] Export to different formats
-- [ ] **Music.track visualization**: Parse `music.track` file and overlay tracker patterns/samples on timeline for alignment assistance
-
-## Technical Details
-
-- Pure HTML/CSS/JavaScript (no dependencies)
-- No backend required
-- Works offline
-- All processing happens in the browser
-- Files are saved via browser download API
+SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds
+ EFFECT + Fade 0 4 # Still uses beats for duration
+```
-## Integration
+## Keyboard Shortcuts
-After editing in the timeline editor:
+- **Spacebar**: Play/pause audio playback
+- **Ctrl/Cmd + Wheel**: Zoom in/out at cursor position
-1. Save the modified `demo.seq`
-2. Copy it to `assets/demo.seq`
-3. Rebuild the project: `cmake --build build`
-4. The new timeline will be compiled into the demo
+## Technical Notes
-No code changes needed - the `seq_compiler` automatically processes the updated file.
+- Pure HTML/CSS/JavaScript (no dependencies, works offline)
+- **Internal representation uses beats** (not seconds)
+- Sequences have absolute times (beats), effects are relative to parent sequence
+- BPM used for seconds conversion (tooltips, audio waveform alignment)
+- Priority determines render order (higher = on top)
+- Collapsed sequences show 35px title bar, expanded show full effect stack
+- Time markers show beats by default (4-beat/bar increments)
+- **Waveform and time markers are sticky** at top during scroll/zoom
+- Vertical grid lines aid alignment
+- Snap-to-beat enabled by default for musical alignment
+- **Auto-expand/collapse**: Active sequence expands during playback, previous collapses
+- **Auto-scroll**: Timeline follows playback indicator (keeps it in middle third of viewport)
diff --git a/tools/timeline_editor/ROADMAP.md b/tools/timeline_editor/ROADMAP.md
index 4bfc35c..216adbf 100644
--- a/tools/timeline_editor/ROADMAP.md
+++ b/tools/timeline_editor/ROADMAP.md
@@ -4,6 +4,37 @@ This document outlines planned enhancements for the interactive timeline editor.
---
+## Known Bugs (High Priority)
+
+### Audio Playback Integration Issues
+
+1. **Audio waveform doesn't scale with zoom nor follow timeline**
+ - Waveform should horizontally sync with timeline ticks/sequences
+ - Should scale to match `pixelsPerSecond` zoom level
+ - Currently remains static regardless of zoom
+
+2. **Playback indicator doesn't follow zoom and height issues**
+ - Vertical red bar position calculation doesn't account for `pixelsPerSecond`
+ - Doesn't reach bottom when sequences have scrolled
+ - Needs to span full `timeline-content` height dynamically
+
+3. **Sequences overlap timeline at scroll origin**
+ - Some sequences still go behind timeline ticks
+ - Notably when wheel pans back to beginning (scrollLeft = 0)
+ - Need proper clipping or z-index management
+
+4. **Timeline and waveform should be fixed, not floating**
+ - Currently using sticky positioning
+ - Should use true fixed positioning at top
+ - Should remain stationary regardless of scroll
+
+5. **Status indicator causes reflow**
+ - Green status text appears/disappears causing layout shift
+ - Should be relocated to top or bottom as fixed/always-visible
+ - Prevents jarring reflow when messages appear
+
+---
+
## Phase 1: Core Editing Features (High Priority)
### 1.1 Snap-to-Beat ⭐ Priority: HIGH
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index db71beb..c9385ad 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Timeline Editor - demo.seq</title>
+ <title>Timeline Editor - timeline.seq</title>
<style>
* {
margin: 0;
@@ -33,11 +33,17 @@
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ flex-wrap: wrap;
}
h1 {
- margin-bottom: 10px;
+ margin: 0;
color: #4ec9b0;
+ white-space: nowrap;
}
.controls {
@@ -45,7 +51,6 @@
gap: 10px;
flex-wrap: wrap;
align-items: center;
- margin-bottom: 20px;
}
.checkbox-label {
@@ -101,17 +106,24 @@
background: #252526;
border-radius: 8px;
padding: 20px;
- overflow-x: auto;
- overflow-y: auto;
position: relative;
height: calc(100vh - 280px);
min-height: 500px;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .timeline-content {
+ flex: 1;
+ overflow-x: auto;
+ overflow-y: auto;
+ position: relative;
/* Hide scrollbars while keeping scroll functionality */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
- .timeline-container::-webkit-scrollbar {
+ .timeline-content::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
@@ -121,20 +133,56 @@
border-left: 2px solid #3c3c3c;
}
+ .sticky-header {
+ position: relative;
+ background: #252526;
+ z-index: 100;
+ padding-bottom: 10px;
+ border-bottom: 2px solid #3c3c3c;
+ flex-shrink: 0;
+ }
+
+ .playback-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 0;
+ }
+
+ #playPauseBtn {
+ width: 60px;
+ padding: 8px 12px;
+ }
+
#waveformCanvas {
position: relative;
height: 80px;
width: 100%;
- margin-bottom: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
cursor: crosshair;
}
+ .playback-indicator {
+ position: absolute;
+ top: 0;
+ width: 2px;
+ height: 100%;
+ background: #f48771;
+ box-shadow: 0 0 4px rgba(244, 135, 113, 0.8);
+ pointer-events: none;
+ z-index: 90;
+ display: none;
+ }
+
+ .playback-indicator.playing {
+ display: block;
+ }
+
.time-markers {
position: relative;
height: 30px;
- margin-bottom: 10px;
+ margin-top: 10px;
border-bottom: 1px solid #3c3c3c;
}
@@ -155,6 +203,17 @@
background: #3c3c3c;
}
+ .time-marker::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 30px;
+ width: 1px;
+ height: 10000px;
+ background: rgba(60, 60, 60, 0.2);
+ pointer-events: none;
+ }
+
.sequence {
position: absolute;
background: #264f78;
@@ -190,6 +249,36 @@
}
}
+ .sequence-header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ padding: 8px;
+ z-index: 5;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .sequence-header-name {
+ font-size: 14px;
+ font-weight: bold;
+ color: #ffffff;
+ }
+
+ .sequence:not(.collapsed) .sequence-header-name {
+ display: none;
+ }
+
+ .sequence.collapsed {
+ overflow: hidden !important;
+ background: #1a3a4a !important;
+ }
+
+ .sequence.collapsed .sequence-name {
+ display: none !important;
+ }
+
.sequence-name {
position: absolute;
top: 50%;
@@ -283,8 +372,8 @@
.properties-panel {
position: fixed;
- top: 80px;
- right: 20px;
+ bottom: 20px;
+ left: 20px;
width: 350px;
max-height: 80vh;
background: #252526;
@@ -297,7 +386,7 @@
}
.properties-panel.collapsed {
- transform: translateX(370px);
+ transform: translateY(calc(100% + 40px));
}
.panel-header {
@@ -331,8 +420,8 @@
.panel-collapse-btn {
position: fixed;
- top: 80px;
- right: 20px;
+ bottom: 20px;
+ left: 20px;
background: #252526;
border: 1px solid #858585;
color: #d4d4d4;
@@ -408,49 +497,57 @@
<div class="container">
<header>
<h1>📊 Timeline Editor</h1>
- <p>Interactive editor for demo.seq files</p>
+ <div class="controls">
+ <label class="file-label">
+ 📂 Load timeline.seq
+ <input type="file" id="fileInput" accept=".seq">
+ </label>
+ <button id="saveBtn" disabled>💾 Save timeline.seq</button>
+ <label class="file-label">
+ 🎵 Load Audio (WAV)
+ <input type="file" id="audioInput" accept=".wav">
+ </label>
+ <button id="clearAudioBtn" disabled>✖ Clear Audio</button>
+ <button id="addSequenceBtn" disabled>➕ Add Sequence</button>
+ <button id="deleteBtn" disabled>🗑️ Delete Selected</button>
+ <button id="reorderBtn" disabled>🔄 Re-order by Time</button>
+ </div>
</header>
- <div class="controls">
- <label class="file-label">
- 📂 Load demo.seq
- <input type="file" id="fileInput" accept=".seq">
- </label>
- <button id="saveBtn" disabled>💾 Save demo.seq</button>
- <label class="file-label">
- 🎵 Load Audio (WAV)
- <input type="file" id="audioInput" accept=".wav">
- </label>
- <button id="clearAudioBtn" disabled>✖ Clear Audio</button>
- <button id="addSequenceBtn" disabled>➕ Add Sequence</button>
- <button id="deleteBtn" disabled>🗑️ Delete Selected</button>
- <button id="reorderBtn" disabled>🔄 Re-order by Time</button>
- </div>
-
<div class="zoom-controls">
<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 style="margin-left: 20px">BPM: <input type="range" id="bpmSlider" min="60" max="200" value="120" step="1"></label>
+ <span id="currentBPM">120</span>
<label class="checkbox-label" style="margin-left: 20px">
- <input type="checkbox" id="showBeatsCheckbox">
- Show Beats (BPM: <span id="currentBPM">120</span>)
+ <input type="checkbox" id="showBeatsCheckbox" checked>
+ Show Beats
</label>
</div>
<div id="messageArea"></div>
<div class="timeline-container">
- <canvas id="waveformCanvas" style="display: none;"></canvas>
- <div class="time-markers" id="timeMarkers"></div>
- <div class="timeline" id="timeline"></div>
+ <div class="sticky-header">
+ <div class="playback-controls" id="playbackControls" style="display: none;">
+ <button id="playPauseBtn">▶ Play</button>
+ <span id="playbackTime">0.00s</span>
+ </div>
+ <canvas id="waveformCanvas" style="display: none;"></canvas>
+ <div class="time-markers" id="timeMarkers"></div>
+ </div>
+ <div class="timeline-content" id="timelineContent">
+ <div class="playback-indicator" id="playbackIndicator"></div>
+ <div class="timeline" id="timeline"></div>
+ </div>
</div>
- <button class="panel-collapse-btn" id="panelCollapseBtn">◀ Properties</button>
+ <button class="panel-collapse-btn" id="panelCollapseBtn">▲ Properties</button>
<div class="properties-panel" id="propertiesPanel" style="display: none;">
<div class="panel-header">
<h2>Properties</h2>
- <button class="panel-toggle" id="panelToggle">▶ Collapse</button>
+ <button class="panel-toggle" id="panelToggle">▼ Collapse</button>
</div>
<div id="propertiesContent"></div>
</div>
@@ -464,7 +561,7 @@
let currentFile = null;
let selectedItem = null;
let pixelsPerSecond = 100;
- let showBeats = false;
+ let showBeats = true;
let bpm = 120;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
@@ -473,10 +570,18 @@
let handleType = null; // 'left' or 'right'
let audioBuffer = null; // Decoded audio data
let audioDuration = 0; // Duration in seconds
+ let audioSource = null; // Current playback source
+ let audioContext = null; // Audio context for playback
+ let isPlaying = false;
+ let playbackStartTime = 0; // When playback started (audioContext.currentTime)
+ let playbackOffset = 0; // Offset into audio (seconds)
+ let animationFrameId = null;
+ let lastExpandedSeqIndex = -1;
// DOM elements
const timeline = document.getElementById('timeline');
const timelineContainer = document.querySelector('.timeline-container');
+ const timelineContent = document.getElementById('timelineContent');
const fileInput = document.getElementById('fileInput');
const saveBtn = document.getElementById('saveBtn');
const audioInput = document.getElementById('audioInput');
@@ -490,10 +595,13 @@
const messageArea = document.getElementById('messageArea');
const zoomSlider = document.getElementById('zoomSlider');
const zoomLevel = document.getElementById('zoomLevel');
- const pixelsPerSecLabel = document.getElementById('pixelsPerSec');
const stats = document.getElementById('stats');
+ const playPauseBtn = document.getElementById('playPauseBtn');
+ const playbackControls = document.getElementById('playbackControls');
+ const playbackTime = document.getElementById('playbackTime');
+ const playbackIndicator = document.getElementById('playbackIndicator');
- // Parser: demo.seq → JavaScript objects
+ // Parser: timeline.seq → JavaScript objects
// Format specification: doc/SEQUENCE.md
function parseSeqFile(content) {
const sequences = [];
@@ -502,13 +610,18 @@
let bpm = 120; // Default BPM
let currentPriority = 0; // Track priority for + = - modifiers
- // Helper: Convert time notation to seconds
+ // Helper: Parse time notation (returns beats)
function parseTime(timeStr) {
+ if (timeStr.endsWith('s')) {
+ // Explicit seconds: "2.5s" = convert to beats
+ const seconds = parseFloat(timeStr.slice(0, -1));
+ return seconds * bpm / 60.0;
+ }
if (timeStr.endsWith('b')) {
- // Beat notation: "4b" = 4 beats
- const beats = parseFloat(timeStr.slice(0, -1));
- return beats * (60.0 / bpm);
+ // Explicit beats: "4b" = 4 beats
+ return parseFloat(timeStr.slice(0, -1));
}
+ // Default: beats
return parseFloat(timeStr);
}
@@ -551,7 +664,8 @@
startTime: parseTime(seqMatch[1]),
priority: parseInt(seqMatch[2]),
effects: [],
- name: seqMatch[3] || ''
+ name: seqMatch[3] || '',
+ _collapsed: true
};
sequences.push(currentSequence);
currentPriority = -1; // Reset effect priority for new sequence
@@ -587,7 +701,7 @@
return { sequences, bpm };
}
- // Serializer: JavaScript objects → demo.seq
+ // Serializer: JavaScript objects → timeline.seq (outputs beats)
function serializeSeqFile(sequences) {
let output = '# Demo Timeline\n';
output += '# Generated by Timeline Editor\n';
@@ -619,12 +733,15 @@
async function loadAudioFile(file) {
try {
const arrayBuffer = await file.arrayBuffer();
- const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
audioDuration = audioBuffer.duration;
renderWaveform();
waveformCanvas.style.display = 'block';
+ playbackControls.style.display = 'flex';
clearAudioBtn.disabled = false;
showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success');
@@ -641,8 +758,9 @@
const canvas = waveformCanvas;
const ctx = canvas.getContext('2d');
- // Set canvas size based on audio duration and zoom
- const canvasWidth = audioDuration * pixelsPerSecond;
+ // Set canvas size based on audio duration (convert to beats) and zoom
+ const audioDurationBeats = audioDuration * bpm / 60.0;
+ const canvasWidth = audioDurationBeats * pixelsPerSecond;
const canvasHeight = 80;
// Set actual canvas resolution (for sharp rendering)
@@ -707,24 +825,157 @@
}
function clearAudio() {
+ stopPlayback();
audioBuffer = null;
audioDuration = 0;
waveformCanvas.style.display = 'none';
+ playbackControls.style.display = 'none';
clearAudioBtn.disabled = true;
renderTimeline();
showMessage('Audio cleared', 'success');
}
+ // Playback functions
+ function startPlayback() {
+ if (!audioBuffer || !audioContext) return;
+
+ // Resume audio context if suspended
+ if (audioContext.state === 'suspended') {
+ audioContext.resume();
+ }
+
+ // Create and start audio source
+ audioSource = audioContext.createBufferSource();
+ audioSource.buffer = audioBuffer;
+ audioSource.connect(audioContext.destination);
+ audioSource.start(0, playbackOffset);
+
+ playbackStartTime = audioContext.currentTime;
+ isPlaying = true;
+ playPauseBtn.textContent = '⏸ Pause';
+ playbackIndicator.classList.add('playing');
+
+ // Start animation loop
+ updatePlaybackPosition();
+
+ audioSource.onended = () => {
+ if (isPlaying) {
+ stopPlayback();
+ }
+ };
+ }
+
+ function stopPlayback() {
+ if (audioSource) {
+ try {
+ audioSource.stop();
+ } catch (e) {
+ // Already stopped
+ }
+ audioSource = null;
+ }
+
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ }
+
+ if (isPlaying) {
+ // Save current position for resume
+ const elapsed = audioContext.currentTime - playbackStartTime;
+ playbackOffset = Math.min(playbackOffset + elapsed, audioDuration);
+ }
+
+ isPlaying = false;
+ playPauseBtn.textContent = '▶ Play';
+ playbackIndicator.classList.remove('playing');
+ }
+
+ function updatePlaybackPosition() {
+ if (!isPlaying) return;
+
+ const elapsed = audioContext.currentTime - playbackStartTime;
+ const currentTime = playbackOffset + elapsed;
+
+ // Update time display
+ playbackTime.textContent = `${currentTime.toFixed(2)}s`;
+
+ // Convert to beats for position calculation
+ const currentBeats = currentTime * bpm / 60.0;
+
+ // Update playback indicator position
+ const indicatorX = currentBeats * pixelsPerSecond;
+ playbackIndicator.style.left = `${indicatorX}px`;
+
+ // Auto-scroll timeline to follow playback
+ const viewportWidth = timelineContent.clientWidth;
+ const scrollX = timelineContent.scrollLeft;
+ const relativeX = indicatorX - scrollX;
+
+ // Keep indicator in middle third of viewport
+ if (relativeX < viewportWidth * 0.33 || relativeX > viewportWidth * 0.67) {
+ timelineContent.scrollLeft = indicatorX - viewportWidth * 0.5;
+ }
+
+ // Auto-expand/collapse sequences
+ expandSequenceAtTime(currentBeats);
+
+ // Continue animation
+ animationFrameId = requestAnimationFrame(updatePlaybackPosition);
+ }
+
+ function expandSequenceAtTime(currentBeats) {
+ // Find which sequence is active at current time
+ let activeSeqIndex = -1;
+ for (let i = 0; i < sequences.length; i++) {
+ const seq = sequences[i];
+ const seqEndBeats = seq.startTime + (seq.effects.length > 0
+ ? Math.max(...seq.effects.map(e => e.endTime))
+ : 0);
+
+ if (currentBeats >= seq.startTime && currentBeats <= seqEndBeats) {
+ activeSeqIndex = i;
+ break;
+ }
+ }
+
+ // Changed sequence - collapse old, expand new
+ if (activeSeqIndex !== lastExpandedSeqIndex) {
+ // Collapse previous sequence
+ if (lastExpandedSeqIndex >= 0 && lastExpandedSeqIndex < sequences.length) {
+ sequences[lastExpandedSeqIndex]._collapsed = true;
+ }
+
+ // Expand new sequence
+ if (activeSeqIndex >= 0) {
+ sequences[activeSeqIndex]._collapsed = false;
+ lastExpandedSeqIndex = activeSeqIndex;
+
+ // Flash animation
+ const seqDivs = timeline.querySelectorAll('.sequence');
+ if (seqDivs[activeSeqIndex]) {
+ seqDivs[activeSeqIndex].classList.add('active-flash');
+ setTimeout(() => {
+ seqDivs[activeSeqIndex]?.classList.remove('active-flash');
+ }, 600);
+ }
+ }
+
+ // Re-render to show collapse/expand changes
+ renderTimeline();
+ }
+ }
+
// Render timeline
function renderTimeline() {
timeline.innerHTML = '';
const timeMarkers = document.getElementById('timeMarkers');
timeMarkers.innerHTML = '';
- // Calculate max time
- let maxTime = 30; // Default 30 seconds
+ // Calculate max time (in beats)
+ let maxTime = 60; // Default 60 beats (15 bars)
for (const seq of sequences) {
- const seqEnd = seq.startTime + 10; // Default sequence duration
+ const seqEnd = seq.startTime + 16; // Default 4 bars
maxTime = Math.max(maxTime, seqEnd);
for (const effect of seq.effects) {
@@ -734,7 +985,8 @@
// Extend timeline to fit audio if loaded
if (audioDuration > 0) {
- maxTime = Math.max(maxTime, audioDuration);
+ const audioBeats = audioDuration * bpm / 60.0;
+ maxTime = Math.max(maxTime, audioBeats);
}
// Render time markers
@@ -742,23 +994,22 @@
timeline.style.width = `${timelineWidth}px`;
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;
+ // Show beats (default)
+ for (let beat = 0; beat <= maxTime; beat += 4) {
const marker = document.createElement('div');
marker.className = 'time-marker';
- marker.style.left = `${timeSec * pixelsPerSecond}px`;
+ marker.style.left = `${beat * pixelsPerSecond}px`;
marker.textContent = `${beat}b`;
timeMarkers.appendChild(marker);
}
} else {
// Show seconds
- for (let t = 0; t <= maxTime; t += 1) {
+ const maxSeconds = maxTime * 60.0 / bpm;
+ for (let t = 0; t <= maxSeconds; t += 1) {
+ const beatPos = t * bpm / 60.0;
const marker = document.createElement('div');
marker.className = 'time-marker';
- marker.style.left = `${t * pixelsPerSecond}px`;
+ marker.style.left = `${beatPos * pixelsPerSecond}px`;
marker.textContent = `${t}s`;
timeMarkers.appendChild(marker);
}
@@ -786,20 +1037,53 @@
const seqVisualWidth = seqVisualEnd - seqVisualStart;
+ // Initialize collapsed state if undefined
+ if (seq._collapsed === undefined) {
+ seq._collapsed = false;
+ }
+
// Calculate sequence height based on number of effects (stacked vertically)
const numEffects = seq.effects.length;
const effectSpacing = 30;
- const seqHeight = Math.max(70, 20 + numEffects * effectSpacing + 5);
+ const fullHeight = Math.max(70, 20 + numEffects * effectSpacing + 5);
+ const seqHeight = seq._collapsed ? 35 : fullHeight;
seqDiv.style.left = `${seqVisualStart * pixelsPerSecond}px`;
seqDiv.style.top = `${cumulativeY}px`;
seqDiv.style.width = `${seqVisualWidth * pixelsPerSecond}px`;
seqDiv.style.height = `${seqHeight}px`;
+ seqDiv.style.minHeight = `${seqHeight}px`;
+ seqDiv.style.maxHeight = `${seqHeight}px`;
// Store Y position for this sequence (used by effects and scroll)
seq._yPosition = cumulativeY;
cumulativeY += seqHeight + sequenceGap;
+ // Create sequence header (double-click to collapse)
+ const seqHeaderDiv = document.createElement('div');
+ seqHeaderDiv.className = 'sequence-header';
+
+ const headerName = document.createElement('span');
+ headerName.className = 'sequence-header-name';
+ headerName.textContent = seq.name || `Sequence ${seqIndex + 1}`;
+
+ seqHeaderDiv.appendChild(headerName);
+
+ // Prevent drag on header
+ seqHeaderDiv.addEventListener('mousedown', (e) => {
+ e.stopPropagation();
+ });
+
+ // Double-click to toggle collapse
+ seqHeaderDiv.addEventListener('dblclick', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ seq._collapsed = !seq._collapsed;
+ renderTimeline();
+ });
+
+ seqDiv.appendChild(seqHeaderDiv);
+
// Create sequence name overlay (large, centered, fades on hover)
const seqNameDiv = document.createElement('div');
seqNameDiv.className = 'sequence-name';
@@ -807,6 +1091,11 @@
seqDiv.appendChild(seqNameDiv);
+ // Apply collapsed state
+ if (seq._collapsed) {
+ seqDiv.classList.add('collapsed');
+ }
+
if (selectedItem && selectedItem.type === 'sequence' && selectedItem.index === seqIndex) {
seqDiv.classList.add('selected');
}
@@ -827,7 +1116,8 @@
timeline.appendChild(seqDiv);
- // Render effects within sequence
+ // Render effects within sequence (skip if collapsed)
+ if (!seq._collapsed) {
seq.effects.forEach((effect, effectIndex) => {
const effectDiv = document.createElement('div');
effectDiv.className = 'effect';
@@ -842,16 +1132,14 @@
effectDiv.style.width = `${effectWidth}px`;
effectDiv.style.height = '26px';
- // Format time display based on mode (for tooltip)
- 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`;
- }
+ // Format time display (beats primary, seconds in tooltip)
+ const startBeat = effect.startTime.toFixed(1);
+ const endBeat = effect.endTime.toFixed(1);
+ const startSec = (effect.startTime * 60.0 / bpm).toFixed(1);
+ const endSec = (effect.endTime * 60.0 / bpm).toFixed(1);
+ const timeDisplay = showBeats
+ ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)`
+ : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`;
// Show only class name, full info on hover
effectDiv.innerHTML = `
@@ -894,6 +1182,7 @@
timeline.appendChild(effectDiv);
});
+ }
});
updateStats();
@@ -926,11 +1215,9 @@
const newX = e.clientX - timelineRect.left - dragOffset.x;
let newTime = Math.max(0, newX / pixelsPerSecond);
- // Snap to beat when in beat mode
+ // Snap to beat when enabled
if (showBeats) {
- const beatDuration = 60.0 / bpm;
- const nearestBeat = Math.round(newTime / beatDuration);
- newTime = nearestBeat * beatDuration;
+ newTime = Math.round(newTime);
}
if (selectedItem.type === 'sequence') {
@@ -977,11 +1264,9 @@
const newX = e.clientX - timelineRect.left;
let newTime = Math.max(0, newX / pixelsPerSecond);
- // Snap to beat when in beat mode
+ // Snap to beat when enabled
if (showBeats) {
- const beatDuration = 60.0 / bpm;
- const nearestBeat = Math.round(newTime / beatDuration);
- newTime = nearestBeat * beatDuration;
+ newTime = Math.round(newTime);
}
const seq = sequences[selectedItem.seqIndex];
@@ -1153,6 +1438,7 @@
sequences = parsed.sequences;
bpm = parsed.bpm;
document.getElementById('currentBPM').textContent = bpm;
+ document.getElementById('bpmSlider').value = bpm;
renderTimeline();
saveBtn.disabled = false;
addSequenceBtn.disabled = false;
@@ -1172,7 +1458,7 @@
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
- a.download = currentFile || 'demo.seq';
+ a.download = currentFile || 'timeline.seq';
a.click();
URL.revokeObjectURL(url);
showMessage('File saved', 'success');
@@ -1189,12 +1475,52 @@
audioInput.value = ''; // Reset file input
});
+ playPauseBtn.addEventListener('click', () => {
+ if (isPlaying) {
+ stopPlayback();
+ } else {
+ // Reset to beginning if at end
+ if (playbackOffset >= audioDuration) {
+ playbackOffset = 0;
+ }
+ startPlayback();
+ }
+ });
+
+ // Waveform click to seek
+ waveformCanvas.addEventListener('click', (e) => {
+ if (!audioBuffer) return;
+
+ const rect = waveformCanvas.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+ const audioDurationBeats = audioDuration * bpm / 60.0;
+ const clickBeats = (clickX / waveformCanvas.width) * audioDurationBeats;
+ const clickTime = clickBeats * 60.0 / bpm;
+
+ const wasPlaying = isPlaying;
+ if (wasPlaying) {
+ stopPlayback();
+ }
+
+ playbackOffset = Math.max(0, Math.min(clickTime, audioDuration));
+
+ if (wasPlaying) {
+ startPlayback();
+ } else {
+ // Update display even when paused
+ playbackTime.textContent = `${playbackOffset.toFixed(2)}s`;
+ const indicatorX = (playbackOffset * bpm / 60.0) * pixelsPerSecond;
+ playbackIndicator.style.left = `${indicatorX}px`;
+ }
+ });
+
addSequenceBtn.addEventListener('click', () => {
sequences.push({
type: 'sequence',
startTime: 0,
priority: 0,
- effects: []
+ effects: [],
+ _collapsed: true
});
renderTimeline();
showMessage('New sequence added', 'success');
@@ -1232,7 +1558,7 @@
const newIndex = sequences.indexOf(currentActiveSeq);
if (newIndex >= 0 && sequences[newIndex]._yPosition !== undefined) {
// Scroll to keep it in view
- timelineContainer.scrollTop = sequences[newIndex]._yPosition;
+ timelineContent.scrollTop = sequences[newIndex]._yPosition;
lastActiveSeqIndex = newIndex;
}
}
@@ -1245,13 +1571,24 @@
const zoom = parseInt(e.target.value);
pixelsPerSecond = zoom;
zoomLevel.textContent = `${zoom}%`;
- pixelsPerSecLabel.textContent = zoom;
if (audioBuffer) {
renderWaveform(); // Re-render waveform at new zoom
}
renderTimeline();
});
+ // BPM slider
+ const bpmSlider = document.getElementById('bpmSlider');
+ const currentBPMDisplay = document.getElementById('currentBPM');
+ bpmSlider.addEventListener('input', (e) => {
+ bpm = parseInt(e.target.value);
+ currentBPMDisplay.textContent = bpm;
+ if (audioBuffer) {
+ renderWaveform();
+ }
+ renderTimeline();
+ });
+
// Beats toggle
const showBeatsCheckbox = document.getElementById('showBeatsCheckbox');
showBeatsCheckbox.addEventListener('change', (e) => {
@@ -1266,13 +1603,13 @@
panelToggle.addEventListener('click', () => {
propertiesPanel.classList.add('collapsed');
panelCollapseBtn.classList.add('visible');
- panelToggle.textContent = '◀ Expand';
+ panelToggle.textContent = '▲ Expand';
});
panelCollapseBtn.addEventListener('click', () => {
propertiesPanel.classList.remove('collapsed');
panelCollapseBtn.classList.remove('visible');
- panelToggle.textContent = '▶ Collapse';
+ panelToggle.textContent = '▼ Collapse';
});
// Click outside to deselect
@@ -1283,18 +1620,27 @@
updateProperties();
});
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ // Spacebar: play/pause (if audio loaded)
+ if (e.code === 'Space' && audioBuffer) {
+ e.preventDefault();
+ playPauseBtn.click();
+ }
+ });
+
// Mouse wheel: zoom (with Ctrl/Cmd) or diagonal scroll
- timelineContainer.addEventListener('wheel', (e) => {
+ timelineContent.addEventListener('wheel', (e) => {
e.preventDefault();
// Zoom mode: Ctrl/Cmd + wheel
if (e.ctrlKey || e.metaKey) {
- // Get mouse position relative to timeline container
- const rect = timelineContainer.getBoundingClientRect();
+ // Get mouse position relative to timeline content
+ const rect = timelineContent.getBoundingClientRect();
const mouseX = e.clientX - rect.left; // Mouse X in viewport coordinates
// Calculate time position under cursor BEFORE zoom
- const scrollLeft = timelineContainer.scrollLeft;
+ const scrollLeft = timelineContent.scrollLeft;
const timeUnderCursor = (scrollLeft + mouseX) / pixelsPerSecond;
// Calculate new zoom level
@@ -1308,7 +1654,6 @@
// Update zoom slider and labels
zoomSlider.value = pixelsPerSecond;
zoomLevel.textContent = `${pixelsPerSecond}%`;
- pixelsPerSecLabel.textContent = pixelsPerSecond;
// Re-render waveform and timeline at new zoom
if (audioBuffer) {
@@ -1319,17 +1664,17 @@
// Adjust scroll position so time under cursor stays in same place
// After zoom: new_scrollLeft = time_under_cursor * newPixelsPerSecond - mouseX
const newScrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX;
- timelineContainer.scrollLeft = newScrollLeft;
+ timelineContent.scrollLeft = newScrollLeft;
}
return;
}
// Normal mode: diagonal scroll
- timelineContainer.scrollLeft += e.deltaY;
+ timelineContent.scrollLeft += e.deltaY;
// Calculate current time position with 10% headroom for visual comfort
- const currentScrollLeft = timelineContainer.scrollLeft;
- const viewportWidth = timelineContainer.clientWidth;
+ const currentScrollLeft = timelineContent.scrollLeft;
+ const viewportWidth = timelineContent.clientWidth;
const slack = (viewportWidth / pixelsPerSecond) * 0.1; // 10% of viewport width in seconds
const currentTime = (currentScrollLeft / pixelsPerSecond) + slack;
@@ -1361,12 +1706,12 @@
// Smooth vertical scroll to bring target sequence to top of viewport
const targetScrollTop = sequences[targetSeqIndex]?._yPosition || 0;
- const currentScrollTop = timelineContainer.scrollTop;
+ const currentScrollTop = timelineContent.scrollTop;
const scrollDiff = targetScrollTop - currentScrollTop;
// Smooth transition (don't jump instantly)
if (Math.abs(scrollDiff) > 5) {
- timelineContainer.scrollTop += scrollDiff * 0.3;
+ timelineContent.scrollTop += scrollDiff * 0.3;
}
}, { passive: false });