diff --git a/.gitignore b/.gitignore index ada2bc0..47cb259 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ packaging/AppDir/ packaging/squashfs-root/ # Wrapper script (generated, not needed with static FFmpeg) -lightningbeam-ui/lightningbeam-editor/debian/ \ No newline at end of file +lightningbeam-ui/lightningbeam-editor/debian/ + +# Personal working notes (not version-controlled) +TODO.md \ No newline at end of file diff --git a/Changelog.md b/Changelog.md index ef65494..f00dfdd 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,25 @@ +# 1.0.10-alpha: +Changes: +- Cycle recording: highlight a range on the timeline ruler (the cycle strip, shown when looping is armed) and the transport loops it. Recording round the loop turns each pass into a separate take you can choose between afterwards, and recording again over the same region adds more takes rather than stacking a second clip on top. Starting playback from outside the region jumps to its start +- Takes: a "Take N/M" badge on the clip picks which take plays. Splitting a clip lets each half play a different take, so you can comp a part together from the best bits of several passes. Right-click a clip to delete the take that's playing or to delete all the unused ones; double-click a take in the list to rename it +- MIDI cycle recording can either merge every pass into one clip (the default — earlier passes play back as you record, so you can layer a hi-hat over a kick) or keep each pass as a separate take, like audio does. Set it in Preferences → Audio +- Painting: every colour-using tool (brush, paint bucket, eyedropper) now shares one Foreground/Background swatch row, and the eyedropper has an explicit toggle for which of the two it fills +- Painting: the effect brushes (dodge/burn, sponge, blur, smudge, clone stamp, healing brush, pattern stamp) were stuck on a single fixed brush shape. They now each have their own size, strength, hardness and spacing, and can use any brush from the library +- Tablet: stylus barrel buttons now work, with actions you can bind in Preferences (Pan, Eyedropper, or Eraser). Middle-mouse panning added too +- Timeline: raster and text layers get an opacity slider; each layer row now shows only the controls that mean anything for it (volume for audio, opacity for raster/text, both for vector and video), and layers gain a visibility (eye) toggle +- The stage gains undo/redo buttons, sized for touch and tablet use + +Bugfixes: +- Splitting an audio clip made it play back at the wrong length (a clip split at 1 second cut off after half a second at 120 BPM) +- Trimmed MIDI clips were the wrong length on the timeline at any tempo other than 60 BPM +- The paint bucket always filled with the background colour, whatever the brush was set to +- Where an eyedropper sample landed (foreground or background) depended on which colour picker you had opened last +- Stylus barrel buttons were silently ignored on Wayland +- The opacity slider on a raster layer actually changed its volume, which means nothing on a raster layer +- Tool cursors lagged behind the pointer while drawing; they're now drawn by the system rather than into the canvas +- Colour picker popups wouldn't close when you dragged on the stage, so drawing left one hanging open, and clicking inside a picker's own hue slider closed it +- The toolbar could not be scrolled when the pane was too short to show every tool + # 1.0.9-alpha: Bugfixes: - Fix audio recording placement: a second recording landed at the wrong spot (and clicking/dragging clips was similarly off) at any tempo other than 60 BPM — recordings now start exactly at the playhead at any tempo diff --git a/README.md b/README.md index 1f6d5be..c4de207 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,21 @@ Lightningbeam is developed on the `main` branch. The project has been rewritten - ✅ Audio engine with node graph processing - ✅ GPU waveform rendering with mipmaps - ✅ Video decoding integration -- 🚧 Export system (in progress) -- 🚧 Node editor UI (planned) -- 🚧 Piano roll editor (planned) +- ✅ Export system (video, image, audio, animated GIF, SVG) +- ✅ Node editor UI +- 🚧 Piano roll editor (in progress) ## Getting Started -### Prerequisites +### Download a prebuilt release + +If you don't want to build from source, prebuilt binaries for each release are on the +[GitHub releases page](https://github.com/skykooler/lightningbeam/releases). Download the build for +your platform and run it directly. + +### Building from source + +#### Prerequisites - Rust (stable toolchain via [rustup](https://rustup.rs/)) - System dependencies: @@ -74,7 +82,7 @@ Lightningbeam is developed on the `main` branch. The project has been rewritten See [docs/BUILDING.md](docs/BUILDING.md) for detailed setup instructions. -### Building and Running +#### Building and Running ```bash # Clone the repository diff --git a/TODO.md b/TODO.md deleted file mode 100644 index a718f1e..0000000 --- a/TODO.md +++ /dev/null @@ -1,25 +0,0 @@ -# Lightningbeam TODO - -## Known Issues (Rust) - -### Animation: Tweens are broken — LOW PRIORITY -- Shape/vector interpolation between keyframes, and the `tween_after` behavior on - keyframes, don't work correctly in the current app. Needs investigation + fix. - Not urgent — revisit later. - -## Backlog / Feature ideas - -### Animation curve enhancements -- [ ] Extrapolation modes, separate for start vs end: hold (default), extend, repeat, decay -- [ ] Position / scale / rotation animation curves for shapes -- [ ] Shape morphing / tweening between keyframes - -### Keyframing behavior -- [ ] User preference for keyframing when editing objects: - - Auto-keyframe (current default): create/update keyframe at current time - - Edit previous (Flash-style): update most recent keyframe before current time - - Ephemeral (Blender-style): changes don't persist without manual keyframe - - Optional modifier key (e.g. Shift) to toggle modes - -### Shape ordering -- [ ] Bring Forward / Send Backward / Bring to Front / Send to Back menu options diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index bc3c224..b129a5f 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -33,6 +33,18 @@ pub struct Engine { playing: bool, channels: u32, + /// Transport cycle (loop) region, authored in BEATS so it survives tempo changes. + /// Sample bounds are derived from the tempo map at the wrap check. + loop_region: Option<(Beats, Beats)>, + /// Whether the transport wraps at the end of `loop_region`. + loop_enabled: bool, + /// Cycle-region sample bounds frozen for the duration of an **audio** recording. + /// See `loop_bounds_samples` for why. `None` = derive live from the tempo map. + loop_bounds_frozen: Option<(i64, i64)>, + /// How a cycle MIDI recording treats its passes: merge into one clip (default), or one clip per + /// pass for the editor to fold into a take folder. + cycle_midi_separate_takes: bool, + // Lock-free communication command_rx: rtrb::Consumer, midi_command_rx: Option>, @@ -155,6 +167,10 @@ impl Engine { sample_rate, playing: false, channels, + loop_region: None, + loop_enabled: false, + loop_bounds_frozen: None, + cycle_midi_separate_takes: false, command_rx, midi_command_rx: None, event_tx, @@ -482,6 +498,11 @@ impl Engine { self.project.reset_read_ahead_targets(); // Render the entire project hierarchy into the mix buffer + // Silence everything else on the track being recorded into (see RenderContext). + let recording_midi = self + .midi_recording_state + .as_ref() + .map(|rec| (rec.track_id, rec.clip_id)); self.project.render( &mut self.mix_buffer, &self.audio_pool, @@ -491,6 +512,7 @@ impl Engine { self.sample_rate, self.channels, false, + recording_midi, ); // Copy mix to output @@ -508,6 +530,95 @@ impl Engine { // Update playhead (convert total samples to frames) self.playhead += (output.len() / self.channels as usize) as i64; + // Cycle/loop wrap. Gated on playhead >= 0 so a count-in pre-roll never wraps. + // Sounding voices are deliberately left alone — no `stop_all_notes()` / + // `reset_all_graphs()` like Command::Seek does, since that would chop sustain and + // reverb tails at every wrap. + if self.loop_enabled && self.playhead >= 0 { + if let Some((ls_beats, le_beats)) = self.loop_region { + // Sample bounds are frozen while audio is recording (see loop_bounds_samples). + let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats); + if le > ls && self.playhead >= le { + // Phase-preserving wrap. Modulo (not a single subtraction) so an overshoot + // larger than the loop — e.g. after a tempo change shrank it — can't strand + // the playhead outside the region. + self.playhead = ls + (self.playhead - ls) % (le - ls); + + // A MIDI recording in progress stores note times as offsets from its + // start, and the playhead just jumped backwards — so any note still held + // across the boundary needs its note-off written at the region end (and + // re-opened at the region start if the key is still down). Otherwise it + // would get a negative duration, or never close at all. + if let Some(ref mut rec) = self.midi_recording_state { + rec.wrap_at_cycle(le_beats, ls_beats); + } + + // Overdub monitoring. In MERGE mode every pass layers onto the same clip, so + // the next pass has to PLAY BACK what was just laid down — otherwise you're + // overdubbing against silence, which defeats the point of merging (you can't + // put a hi-hat on a kick you can't hear). + // + // The notes only reach the pool clip at stop otherwise, so fold what's been + // captured so far into it now, at the boundary. Disjoint field borrows: + // `midi_recording_state` read, `project` written. + // + // Deliberately NOT done in separate-takes mode: there, each pass is an + // alternative rather than a layer, so hearing the previous take play back + // under you would just be confusing — you'd be playing along with the take + // you're trying to replace. + if let Some(rec) = self.midi_recording_state.as_ref().filter(|_| !self.cycle_midi_separate_takes) { + if let Some(clip) = + self.project.midi_clip_pool.get_clip_mut(rec.clip_id) + { + // Note offsets are relative to `start_time`, which for a cycle + // recording IS the region start — so they're already region-relative + // and drop straight in. + clip.duration = le_beats - ls_beats; + // Reusing the existing Vec keeps this allocation-free after the + // first wrap, which matters on the audio thread. (Mutating the pool + // here is the same thing `Command::UpdateMidiClipNotes` already does + // from this thread.) + clip.events.clear(); + for (start, note, velocity, duration) in rec.get_notes() { + clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity)); + clip.events.push(MidiEvent::note_off( + *start + *duration, + 0, + *note, + 64, + )); + } + clip.events.sort_by(|a, b| { + a.timestamp.partial_cmp(&b.timestamp).unwrap() + }); + } + } + // The clip INSTANCE is sized by the recording-progress block above, which + // now pins it to the whole region once `cycle_loop_len` is set (this wrap + // sets it). That's what lets the sequencer actually schedule the events we + // just wrote — an instance sized to the elapsed-since-start playhead would + // have collapsed back to nothing here. + + // An audio recording in progress just completed a pass. This only decides + // *whether* the recording becomes multi-take — the takes themselves are cut + // geometrically at stop, since the playhead advances before the capture + // block below, so the wrap instant isn't sample-exact against the buffer + // that was just captured. The geometry is. + if let Some(ref mut rec) = self.recording_state { + if let Some(ref mut cycle) = rec.cycle { + cycle.wrap_count += 1; + } + } + + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { + frame: self.playhead.max(0) as u64, + }); + } + } + } + } + // Update atomic playhead for UI reads (clamped to 0; negative = count-in pre-roll) self.playhead_atomic .store(self.playhead.max(0) as u64, Ordering::Relaxed); @@ -517,7 +628,7 @@ impl Engine { if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize { // Clamp to 0 during count-in pre-roll (negative playhead = before project start) - let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64; + let position_seconds = Seconds(self.playhead.max(0) as f64 / self.sample_rate as f64); let _ = self .event_tx .push(AudioEvent::PlaybackPosition(position_seconds)); @@ -527,8 +638,31 @@ impl Engine { if let Some(recording) = &self.midi_recording_state { let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64); let current_time = self.tempo_map.seconds_to_beats(current_time_secs); - let duration = current_time - recording.start_time; - let notes = recording.get_notes_with_active(current_time); + // Once the transport has wrapped, the recording covers the WHOLE cycle region and + // stays there — every further pass merges into the same clip rather than + // extending it. Measuring from the playhead instead would reset to zero at each + // wrap (the playhead jumps back), which both made the clip bar restart from zero + // every pass and — because this block also resizes the backend clip instance + // below — shrank the instance back to nothing, so the notes just merged into it + // were never scheduled and you overdubbed against silence. + // Pinned only once a pass has actually completed — until then the bar still grows. + let duration = recording + .cycle_loop_len + .filter(|_| recording.wrapped) + .unwrap_or(current_time - recording.start_time); + // In separate-takes mode the preview shows only the pass being played now — the + // earlier passes are alternative takes, not layers, so drawing them all on top of + // each other would misrepresent what's being recorded. + let notes = if self.cycle_midi_separate_takes && recording.cycle_loop_len.is_some() { + let mut current = recording + .notes_by_pass(recording.pass_count()) + .pop() + .unwrap_or_default(); + current.extend(recording.active_notes_with_provisional_end(current_time)); + current + } else { + recording.get_notes_with_active(current_time) + }; let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( recording.track_id, recording.clip_id, @@ -566,6 +700,7 @@ impl Engine { self.sample_rate, self.channels, true, // live_only + None, // no clips are scheduled at all in live_only, so nothing to mute ); output.copy_from_slice(&self.mix_buffer); } @@ -679,6 +814,8 @@ impl Engine { format!("Recording write error: {}", e) )); self.recording_state = None; + // Audio recording is over — let the cycle region track tempo again. + self.loop_bounds_frozen = None; } } } @@ -783,12 +920,74 @@ impl Engine { None } + /// Convert a beats position to a sample position using the current tempo map. + /// + /// The cycle region is authored in beats (so it survives tempo changes); its sample bounds are + /// derived here at the wrap check rather than cached, which keeps it correct across tempo edits + /// with no invalidation bookkeeping. + fn beats_to_samples(&self, beats: Beats) -> i64 { + (self.tempo_map.beats_to_seconds(beats).seconds_to_f64() * self.sample_rate as f64) as i64 + } + + /// Sample bounds of the cycle region. + /// + /// These are FROZEN while an audio recording is in flight (see `loop_bounds_frozen`, captured + /// in `handle_start_recording`), so a tempo change mid-take cannot resize the loop. + /// + /// Why freeze only for audio: take segmentation for audio is geometric in samples — every pass + /// is exactly `loop_len` frames, which is what lets us pad all takes to a uniform length and + /// comp between them. A tempo change would resize `loop_len` mid-session and break that. And + /// since we have no time-stretching, audio takes captured at two different tempos could never + /// be comped together anyway, so honoring the change would buy nothing and cost the invariant. + /// + /// MIDI is deliberately unaffected: it is segmented in *beats*, which are tempo-invariant, so + /// changing tempo during a MIDI-only recording is fully supported (slow down a hard passage and + /// keep stacking takes). The freeze is keyed on *audio* being recorded, not on "not MIDI", so + /// when multi-track recording lands it will correctly freeze if ANY recorded track is audio. + fn loop_bounds_samples(&self, ls_beats: Beats, le_beats: Beats) -> (i64, i64) { + if let Some(frozen) = self.loop_bounds_frozen { + return frozen; + } + (self.beats_to_samples(ls_beats), self.beats_to_samples(le_beats)) + } + /// Handle a command from the UI thread fn handle_command(&mut self, cmd: Command) { match cmd { Command::Play => { + // Starting playback from outside the cycle region jumps to its start — otherwise + // you'd play forward from wherever the playhead happened to be and only fall into + // the loop if you happened to cross its end. Inside the region we start where we + // are, so you can still audition from the middle of a loop. + // + // A negative playhead is a count-in pre-roll that was deliberately placed *before* + // the region, so leave it alone. + if self.loop_enabled && self.playhead >= 0 { + if let Some((ls_beats, le_beats)) = self.loop_region { + let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats); + if le > ls && (self.playhead < ls || self.playhead >= le) { + self.playhead = ls; + self.playhead_atomic.store(ls.max(0) as u64, Ordering::Relaxed); + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { + frame: ls.max(0) as u64, + }); + } + } + } + } self.playing = true; } + Command::SetLoopRegion(region) => { + // Stored in beats; sample bounds are derived at the wrap check. + self.loop_region = region; + } + Command::SetLoopEnabled(enabled) => { + self.loop_enabled = enabled; + } + Command::SetCycleMidiSeparateTakes(separate) => { + self.cycle_midi_separate_takes = separate; + } Command::Stop => { self.playing = false; self.playhead = 0; @@ -806,7 +1005,7 @@ impl Engine { self.project.stop_all_notes(); } Command::Seek(seconds) => { - self.playhead = (seconds * self.sample_rate as f64) as i64; + self.playhead = (seconds.seconds_to_f64() * self.sample_rate as f64) as i64; // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) let clamped = self.playhead.max(0) as u64; self.playhead_atomic.store(clamped, Ordering::Relaxed); @@ -839,36 +1038,56 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_start = Beats(new_start_time); + clip.external_start = new_start_time; } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { - instance.external_start = Beats(new_start_time); + instance.external_start = new_start_time; } } _ => {} } self.refresh_clip_snapshot(); } - Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { - // Trim changes which portion of the source content is used - // Also updates external_duration to match internal duration (no looping after trim) - match self.project.get_track_mut(track_id) { - Some(crate::audio::track::TrackNode::Audio(track)) => { + Command::TrimClip(track_id, clip_id, range) => { + // Trim changes which portion of the source content is used. + // Also collapses external_duration to the trimmed content length (no looping after + // a trim). + let tempo_map = self.tempo_map.clone(); + match (self.project.get_track_mut(track_id), range) { + ( + Some(crate::audio::track::TrackNode::Audio(track)), + crate::command::TrimRange::Seconds { start, end }, + ) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.internal_start = Seconds(new_internal_start); - clip.internal_end = Seconds(new_internal_end); - clip.external_duration = Beats(new_internal_end - new_internal_start); + clip.internal_start = start; + clip.internal_end = end; + // external_duration is BEATS while the trims are SECONDS, so the span + // has to be converted at the clip's position on the timeline — NOT + // reinterpreted as `Beats(end - start)`, which played a 1-second trim + // back as half a second at 120 BPM. + clip.external_duration = tempo_map.seconds_to_beats( + tempo_map.beats_to_seconds(clip.external_start) + (end - start), + ) - clip.external_start; } } - Some(crate::audio::track::TrackNode::Midi(track)) => { + ( + Some(crate::audio::track::TrackNode::Midi(track)), + crate::command::TrimRange::Beats { start, end }, + ) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { - instance.internal_start = Beats(new_internal_start); - instance.internal_end = Beats(new_internal_end); - instance.external_duration = Beats(new_internal_end - new_internal_start); + instance.internal_start = start; + instance.internal_end = end; + // MIDI content time IS beats, so the span carries over directly. + instance.external_duration = end - start; } } + // A domain that doesn't match the track kind (seconds at a MIDI track, or vice + // versa) is a caller bug, not something to guess at. + (Some(_), _) => { + debug_assert!(false, "TrimClip domain does not match the track kind"); + } _ => {} } self.refresh_clip_snapshot(); @@ -878,12 +1097,12 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_duration = Beats(new_external_duration); + clip.external_duration = new_external_duration; } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { - instance.external_duration = Beats(new_external_duration); + instance.external_duration = new_external_duration; } } _ => {} @@ -910,7 +1129,7 @@ impl Engine { } Command::SetOffset(track_id, offset) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.offset = Seconds(offset); + metatrack.offset = offset; } } Command::SetPitchShift(track_id, semitones) => { @@ -920,12 +1139,12 @@ impl Engine { } Command::SetTrimStart(track_id, trim_start) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_start = Seconds(trim_start.max(0.0)); + metatrack.trim_start = Seconds(trim_start.seconds_to_f64().max(0.0)); } } Command::SetTrimEnd(track_id, trim_end) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0))); + metatrack.trim_end = trim_end.map(|t| Seconds(t.seconds_to_f64().max(0.0))); } } Command::CreateAudioTrack(name, parent_id) => { @@ -979,9 +1198,13 @@ impl Engine { // Send chunks via MPSC channel (will be forwarded by audio thread) if !chunks.is_empty() { println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .into_iter() - .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .map(|chunk| { + // A chunk's time_range is a wall-clock span into the audio file. + let (start, end) = chunk.time_range; + (chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks) + }) .collect(); match chunk_tx.send(AudioEvent::WaveformChunksReady { @@ -1045,12 +1268,12 @@ impl Engine { let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); // Create clip content in the pool - let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id)); + let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id)); self.project.midi_clip_pool.add_existing_clip(clip); // Create an instance for this clip on the track let instance_id = self.project.next_midi_clip_instance_id(); - let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time)); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { track.clip_instances.push(instance); @@ -1065,11 +1288,11 @@ impl Engine { // Note: clip_id here refers to the clip in the pool, not the instance if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { // Timestamp is in beats (canonical) - let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); clip.add_event(note_on); // Add note off event - let note_off_time = Beats(time_offset + duration); + let note_off_time = time_offset + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } else { @@ -1078,9 +1301,9 @@ impl Engine { if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { let actual_clip_id = instance.clip_id; if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { - let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); clip.add_event(note_on); - let note_off_time = Beats(time_offset + duration); + let note_off_time = time_offset + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } @@ -1090,7 +1313,7 @@ impl Engine { } Command::AddLoadedMidiClip(track_id, clip, start_time) => { // Add a pre-loaded MIDI clip to the track with the given start time - if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) { // instance positions are already in beats; nothing to sync } self.refresh_clip_snapshot(); @@ -1104,11 +1327,11 @@ impl Engine { // Add new events from the notes array // Timestamps are in beats (canonical) for (start_time, note, velocity, duration) in notes { - let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity); + let note_on = MidiEvent::note_on(start_time, 0, note, velocity); clip.events.push(note_on); // Add note off event - let note_off_time = Beats(start_time + duration); + let note_off_time = start_time + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.events.push(note_off); } @@ -1170,7 +1393,7 @@ impl Engine { } Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { // Add an automation point to the specified lane - let point = crate::audio::AutomationPoint::new(Beats(time), value, curve); + let point = crate::audio::AutomationPoint::new(time, value, curve); match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { @@ -1196,17 +1419,17 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } Some(crate::audio::track::TrackNode::Group(group)) => { if let Some(lane) = group.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } None => {} @@ -1269,9 +1492,9 @@ impl Engine { None => {} } } - Command::StartRecording(track_id, start_time) => { + Command::StartRecording(track_id, start_time, force_takes) => { // Start recording on the specified track - self.handle_start_recording(track_id, start_time); + self.handle_start_recording(track_id, start_time, force_takes); } Command::StopRecording => { // Stop the current recording @@ -1289,9 +1512,9 @@ impl Engine { recording.resume(); } } - Command::StartMidiRecording(track_id, clip_id, start_time) => { + Command::StartMidiRecording(track_id, clip_id, start_time, force_takes) => { // Start MIDI recording on the specified track - self.handle_start_midi_recording(track_id, clip_id, start_time); + self.handle_start_midi_recording(track_id, clip_id, start_time, force_takes); } Command::StopMidiRecording => { eprintln!("[ENGINE] Received StopMidiRecording command"); @@ -1309,6 +1532,12 @@ impl Engine { // Stop any active recording self.recording_state = None; + // Clear the cycle region — it's a document property, and the new document will + // push its own (or none) once loaded. + self.loop_region = None; + self.loop_enabled = false; + self.loop_bounds_frozen = None; + // Clear all project data self.project = Project::new(self.sample_rate); @@ -2289,7 +2518,7 @@ impl Engine { // Downcast to AutomationInputNode using as_any_mut if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { let keyframe = AutomationKeyframe { - time: Beats(time), + time, value, interpolation, ease_out, @@ -2317,7 +2546,7 @@ impl Engine { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { - auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance + auto_node.remove_keyframe_at_time(time, Beats(0.001)); // 1ms tolerance } else { eprintln!("Node {} is not an AutomationInputNode", node_id); } @@ -2386,9 +2615,12 @@ impl Engine { // Send chunks via MPSC channel (will be forwarded by audio thread) if !chunks.is_empty() { - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .into_iter() - .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .map(|chunk| { + let (start, end) = chunk.time_range; + (chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks) + }) .collect(); let _ = chunk_tx.send(AudioEvent::WaveformChunksReady { @@ -2552,7 +2784,7 @@ impl Engine { path: path_str, channels: metadata.channels, sample_rate: metadata.sample_rate, - duration: metadata.duration, + duration: Seconds(metadata.duration), format: metadata.format, }); @@ -2660,7 +2892,7 @@ impl Engine { if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { use crate::command::MidiClipData; QueryResponse::MidiClipData(Ok(MidiClipData { - duration: clip.duration.0, + duration: clip.duration, events: clip.events.clone(), })) } else { @@ -2692,7 +2924,7 @@ impl Engine { InterpolationType::Hold => "hold", }.to_string(); AutomationKeyframeData { - time: kf.time.0, + time: kf.time, value: kf.value, interpolation: interpolation_str, ease_out: kf.ease_out, @@ -2861,7 +3093,11 @@ impl Engine { } Query::GetPoolFileInfo(pool_index) => { match self.audio_pool.get_file_info(pool_index) { - Some(info) => QueryResponse::PoolFileInfo(Ok(info)), + // The pool measures a file's length in wall-clock seconds; name it as such at + // the boundary rather than handing a bare f64 to the UI. + Some((duration, sample_rate, channels)) => { + QueryResponse::PoolFileInfo(Ok((Seconds(duration), sample_rate, channels))) + } None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), } } @@ -2908,7 +3144,7 @@ impl Engine { } Query::AddMidiClipSync(track_id, clip, start_time) => { // Add MIDI clip to track and return the instance ID (positions already in beats) - let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + let result = match self.project.add_midi_clip_at(track_id, clip, start_time) { Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), }; @@ -3030,10 +3266,23 @@ impl Engine { } /// Handle starting a recording - fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) { + fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) { use crate::io::WavWriter; use std::env; + // Freeze the cycle region's sample bounds for the duration of this AUDIO recording, so a + // tempo change mid-take can't resize the loop and break the uniform-take invariant that + // take segmentation and comping depend on. See `loop_bounds_samples`. (MIDI-only + // recordings never take this path, so they stay free to change tempo.) + if self.loop_enabled { + if let Some((ls_beats, le_beats)) = self.loop_region { + self.loop_bounds_frozen = Some(( + self.beats_to_samples(ls_beats), + self.beats_to_samples(le_beats), + )); + } + } + // Check if track exists and is an audio track if let Some(crate::audio::track::TrackNode::Audio(_)) = self.project.get_track_mut(track_id) { // Generate a unique temp file path @@ -3088,8 +3337,29 @@ impl Engine { self.recording_state = Some(recording_state); self.recording_progress_counter = 0; // Reset progress counter + // Arm cycle recording. `start_time` is the region start (the editor anchors a + // cycle recording there, for punch-in too), while capture actually begins at + // the current playhead — the gap between them is the lead pad that take 1 gets + // prepended as silence so it still spans the whole region. + let cycle_info = if self.loop_enabled { + self.loop_region.map(|(ls_beats, le_beats)| { + let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats); + crate::audio::recording::CycleRecordInfo { + loop_start: ls_beats, + loop_len_beats: le_beats - ls_beats, + loop_len_frames: (le - ls).max(0) as usize, + lead_pad_frames: (self.playhead - ls).max(0) as usize, + wrap_count: 0, + force_takes, + } + }) + } else { + None + }; + // Set samples to skip (drained incrementally across callbacks) if let Some(recording) = &mut self.recording_state { + recording.cycle = cycle_info; recording.samples_to_skip = samples_in_buffer; if self.debug_audio && samples_in_buffer > 0 { eprintln!("[AUDIO DEBUG] Will skip {} stale samples from input buffer", samples_in_buffer); @@ -3114,10 +3384,47 @@ impl Engine { } } + /// Write one cycle take to a temp WAV, add it to the audio pool, and return its pool index. + /// + /// Mirrors what the single-recording path does with its own buffer: the pool file is backed by + /// the in-memory samples, and the temp WAV is written and then removed (the pool only reads the + /// path opportunistically, e.g. to keep original bytes on save). + fn write_take_to_pool( + &mut self, + samples: Vec, + sample_rate: u32, + channels: u32, + clip_id: ClipId, + take_index: usize, + ) -> Result { + use crate::io::WavWriter; + + let path = std::env::temp_dir() + .join(format!("daw_take_{}_{}.wav", clip_id, take_index)); + + let mut writer = WavWriter::create(&path, sample_rate, channels)?; + writer.write_samples(&samples)?; + writer.finalize()?; + + let pool_file = crate::audio::pool::AudioFile::with_format( + path.clone(), + samples, + channels, + sample_rate, + Some("wav".to_string()), + ); + let pool_index = self.audio_pool.add_file(pool_file); + let _ = std::fs::remove_file(&path); + Ok(pool_index) + } + /// Handle stopping a recording fn handle_stop_recording(&mut self) { eprintln!("[STOP_RECORDING] handle_stop_recording called"); + // Audio is no longer recording, so the cycle region can track the tempo map again. + self.loop_bounds_frozen = None; + // Check if we have an active MIDI recording first if self.midi_recording_state.is_some() { eprintln!("[STOP_RECORDING] Detected active MIDI recording, delegating to handle_stop_midi_recording"); @@ -3134,11 +3441,74 @@ impl Engine { eprintln!("[STOP_RECORDING] Stopping recording for clip_id={}, track_id={}", clip_id, track_id); + // Slice cycle takes BEFORE finalize consumes the recording. `None` here means the + // transport never wrapped, which stays an ordinary single recording on the path below. + let cycle = recording.cycle; + let frames_per_peak = recording.frames_per_peak; + let cycle_takes = recording.slice_takes(); + // Finalize the recording (flush buffers, close file, get waveform and audio data) let frames_recorded = recording.frames_written; eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded); match recording.finalize() { Ok((temp_file_path, waveform, audio_data)) => { + // ---- Cycle recording: one take per pass, each spanning the whole region ---- + if let (Some(takes), Some(cycle)) = (cycle_takes, cycle) { + eprintln!( + "[STOP_RECORDING] Cycle recording: {} wraps -> {} takes of {} frames", + cycle.wrap_count, takes.len(), cycle.loop_len_frames + ); + let _ = std::fs::remove_file(&temp_file_path); + + let mut pool_takes: Vec<(usize, Vec)> = Vec::new(); + for (i, take) in takes.into_iter().enumerate() { + let peaks = crate::audio::recording::compute_peaks( + &take, + channels, + frames_per_peak, + ); + match self.write_take_to_pool(take, sample_rate, channels, clip_id, i) { + Ok(pool_index) => pool_takes.push((pool_index, peaks)), + Err(e) => { + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Failed to store take {}: {}", i + 1, e), + )); + return; + } + } + } + + // Point the engine's clip at the take the editor will make active (the last + // one, GarageBand-style) and stretch it to cover the whole cycle region — + // the clip was created at the punch-in point with zero length. + let loop_len_secs = + Seconds(cycle.loop_len_frames as f64 / sample_rate as f64); + if let Some(&(last_pool_index, _)) = pool_takes.last() { + if let Some(crate::audio::track::TrackNode::Audio(track)) = + self.project.get_track_mut(track_id) + { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) + { + clip.audio_pool_index = last_pool_index; + clip.internal_start = Seconds(0.0); + clip.internal_end = loop_len_secs; + clip.external_start = cycle.loop_start; + clip.external_duration = cycle.loop_len_beats; + } + } + self.refresh_clip_snapshot(); + } + + let _ = self.event_tx.push(AudioEvent::CycleRecordingStopped { + clip_id, + takes: pool_takes, + loop_start: cycle.loop_start, + loop_len_beats: cycle.loop_len_beats, + loop_len_seconds: loop_len_secs, + }); + return; + } + eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory", frames_recorded, temp_file_path, waveform.len(), audio_data.len()); @@ -3185,12 +3555,23 @@ impl Engine { } /// Handle starting MIDI recording - fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { + fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) { // Check if track exists and is a MIDI track if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { // Create MIDI recording state let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time); + // Note the cycle region up front. `wrapped` stays false until a pass actually completes, + // so the clip bar still grows with the playhead through the first pass. + if self.loop_enabled { + if let Some((ls, le)) = self.loop_region { + if le > ls { + recording_state.cycle_loop_len = Some(le - ls); + recording_state.force_takes = force_takes; + } + } + } + // Inject any notes currently held on this track (pressed during count-in pre-roll) // so they start at t=0 of the recording rather than being lost if let Some(held) = self.midi_held_notes.get(&track_id) { @@ -3228,9 +3609,87 @@ impl Engine { let clip_id = recording.clip_id; let track_id = recording.track_id; + + // ---- Separate takes: one pool clip per cycle pass ---- + // + // Normally only when the transport actually wrapped: a recording that stopped inside the + // first pass is an ordinary single recording and falls through to the merge path below, + // just as it does for audio. `force_takes` overrides that, because the region already + // holds takes and this is another one however short it ran. + let takes_mode = self.cycle_midi_separate_takes + && (recording.wrapped || recording.force_takes); + if let (true, Some(loop_len)) = (takes_mode, recording.cycle_loop_len) { + let loop_start = recording.start_time; // a cycle recording is anchored at the region + let passes = recording.pass_count(); + let buckets = recording.notes_by_pass(passes); + eprintln!( + "[MIDI_RECORDING] Cycle recording (separate takes): {} passes", + passes + ); + + let mut clip_ids: Vec = Vec::with_capacity(buckets.len()); + for (i, bucket) in buckets.iter().enumerate() { + // Pass 0 reuses the clip the recording started on; later passes get fresh ones. + let take_clip_id = if i == 0 { + clip_id + } else { + let id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); + let clip = MidiClip::empty(id, loop_len, format!("Take {}", i + 1)); + self.project.midi_clip_pool.add_existing_clip(clip); + id + }; + + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(take_clip_id) { + clip.events.clear(); + clip.duration = loop_len; + for (start, note, velocity, duration) in bucket { + clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity)); + clip.events.push(MidiEvent::note_off(*start + *duration, 0, *note, 64)); + } + clip.events + .sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + } + clip_ids.push(take_clip_id); + } + + // Point the track's instance at the take the editor will make active (the last one, + // GarageBand-style) and stretch it over the region. + if let Some(&last) = clip_ids.last() { + if let Some(crate::audio::track::TrackNode::Midi(track)) = + self.project.get_track_mut(track_id) + { + if let Some(instance) = + track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) + { + instance.clip_id = last; + instance.internal_start = Beats::ZERO; + instance.internal_end = loop_len; + instance.external_start = loop_start; + instance.external_duration = loop_len; + } + } + } + self.refresh_clip_snapshot(); + + let _ = self.event_tx.push(AudioEvent::MidiCycleRecordingStopped { + track_id, + clip_ids, + loop_start, + loop_len_beats: loop_len, + }); + return; + } + let notes = recording.get_notes().to_vec(); let note_count = notes.len(); - let recording_duration = end_time - recording.start_time; + // A cycle MIDI recording that came round is anchored at the region start and every pass + // overdubs into the same clip (MERGE), so the clip is exactly one region long — not + // however long the user held the record button, which would run past the loop end. One + // that stopped inside the first pass is just an ordinary recording. + let recording_duration = match recording.cycle_loop_len.filter(|_| recording.wrapped) { + Some(loop_len) => loop_len, + None => end_time - recording.start_time, + }; eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats", clip_id, track_id, note_count, recording_duration.0); @@ -3342,6 +3801,26 @@ impl EngineController { let _ = self.command_tx.push(Command::Pause); } + /// Set the cycle region the transport loops over (None clears it). + /// + /// Authored in beats so it survives tempo changes. Note the region's sample bounds are frozen + /// while an audio recording is in flight, so a call made mid-take won't resize the loop until + /// recording stops (see `Engine::loop_bounds_samples`). + pub fn set_loop_region(&mut self, region: Option<(Beats, Beats)>) { + let _ = self.command_tx.push(Command::SetLoopRegion(region)); + } + + /// Enable/disable wrapping at the end of the cycle region. + pub fn set_loop_enabled(&mut self, enabled: bool) { + let _ = self.command_tx.push(Command::SetLoopEnabled(enabled)); + } + + /// How a cycle MIDI recording treats its passes: merge into one clip (false, the default), or + /// one clip per pass (true) for the editor to fold into a take folder. + pub fn set_cycle_midi_separate_takes(&mut self, separate: bool) { + let _ = self.command_tx.push(Command::SetCycleMidiSeparateTakes(separate)); + } + /// Stop playback and reset to beginning pub fn stop(&mut self) { let _ = self.command_tx.push(Command::Stop); @@ -3349,7 +3828,7 @@ impl EngineController { /// Seek to a specific position in seconds pub fn seek(&mut self, seconds: Seconds) { - let _ = self.command_tx.push(Command::Seek(seconds.seconds_to_f64())); + let _ = self.command_tx.push(Command::Seek(seconds)); } /// Set track volume (0.0 = silence, 1.0 = unity gain) @@ -3381,22 +3860,22 @@ impl EngineController { /// Move a clip to a new timeline position (changes external_start) pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) { - let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time.beats_to_f64())); + let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time)); } - /// Trim a clip's internal boundaries (changes which portion of source content is used) - /// This also resets external_duration to match internal duration (disables looping) - /// Trim a clip's internal content bounds. The units are content-domain and depend on the - /// track type: SECONDS for a sampled-audio clip, BEATS for a MIDI clip (see the TrimClip - /// handler). Left as raw f64 because a single newtype can't express both; callers pass the - /// clip's own `trim_start`/`trim_end`, which already match its content domain. - pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_internal_start: f64, new_internal_end: f64) { - let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end)); + /// Trim a clip's internal content bounds — which portion of the source content plays. + /// + /// Collapses external_duration to the trimmed length (disables looping). The bounds are + /// content-domain, which differs by clip kind, so they're passed as a [`TrimRange`] that names + /// the domain: `Seconds` for sampled audio, `Beats` for MIDI. The engine rejects a range whose + /// domain doesn't match the track. + pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, range: crate::command::TrimRange) { + let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, range)); } /// Extend or shrink a clip's external duration (enables looping if > internal duration) pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) { - let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64())); + let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration)); } /// Send a generic command to the audio thread @@ -3410,9 +3889,9 @@ impl EngineController { } /// Get current playhead position in seconds - pub fn get_playhead_seconds(&self) -> f64 { + pub fn get_playhead_seconds(&self) -> Seconds { let frames = self.playhead.load(Ordering::Relaxed); - frames as f64 / self.sample_rate as f64 + Seconds(frames as f64 / self.sample_rate as f64) } /// Get the shared clip snapshot. The UI can read this each frame to display @@ -3445,7 +3924,7 @@ impl EngineController { /// Set metatrack time offset in seconds /// Positive = shift content later, negative = shift earlier pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) { - let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64())); + let _ = self.command_tx.push(Command::SetOffset(track_id, offset)); } /// Set metatrack pitch shift in semitones (for future use) @@ -3455,12 +3934,12 @@ impl EngineController { /// Set metatrack trim start in seconds pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) { - let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start.seconds_to_f64())); + let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start)); } /// Set metatrack trim end in seconds (None = no end trim) pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option) { - let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end.map(|s| s.seconds_to_f64()))); + let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end)); } /// Create a new audio track @@ -3614,23 +4093,22 @@ impl EngineController { pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId { // Peek at the next clip ID that will be used let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed); - let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time.beats_to_f64(), duration.beats_to_f64())); + let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time, duration)); clip_id } /// Add a MIDI note to a clip pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) { - let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset.beats_to_f64(), note, velocity, duration.beats_to_f64())); + let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration)); } /// Add a pre-loaded MIDI clip to a track at the given timeline position (beats) pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) { - let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time.beats_to_f64())); + let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time)); } /// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]). pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) { - let notes = notes.into_iter().map(|(t, n, v, d)| (t.beats_to_f64(), n, v, d.beats_to_f64())).collect(); let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes)); } @@ -3671,7 +4149,7 @@ impl EngineController { curve: crate::audio::CurveType, ) { let _ = self.command_tx.push(Command::AddAutomationPoint( - track_id, lane_id, time.beats_to_f64(), value, curve, + track_id, lane_id, time, value, curve, )); } @@ -3684,7 +4162,7 @@ impl EngineController { tolerance: Beats, ) { let _ = self.command_tx.push(Command::RemoveAutomationPoint( - track_id, lane_id, time.beats_to_f64(), tolerance.beats_to_f64(), + track_id, lane_id, time, tolerance, )); } @@ -3723,13 +4201,13 @@ impl EngineController { time: Beats, value: f32, interpolation: String, ease_out: (f32, f32), ease_in: (f32, f32)) { let _ = self.command_tx.push(Command::AutomationAddKeyframe( - track_id, node_id, time.beats_to_f64(), value, interpolation, ease_out, ease_in)); + track_id, node_id, time, value, interpolation, ease_out, ease_in)); } /// Remove a keyframe from an AutomationInput node pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) { let _ = self.command_tx.push(Command::AutomationRemoveKeyframe( - track_id, node_id, time.beats_to_f64())); + track_id, node_id, time)); } /// Set the display name of an AutomationInput node @@ -3744,8 +4222,11 @@ impl EngineController { } /// Start recording on a track - pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats) { - let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); + /// `force_takes`: cut takes even if the transport never wraps, because the cycle region already + /// holds takes and this recording is another one. Whether that's so is document state, so only + /// the editor can answer it. + pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) { + let _ = self.command_tx.push(Command::StartRecording(track_id, start_time, force_takes)); } /// Stop the current recording @@ -3764,8 +4245,9 @@ impl EngineController { } /// Start MIDI recording on a track - pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { - let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time)); + /// `force_takes`: see [`EngineController::start_recording`]. + pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) { + let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time, force_takes)); } /// Stop the current MIDI recording @@ -4265,7 +4747,7 @@ impl EngineController { } /// Get file info from pool (duration, sample_rate, channels) - pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(f64, u32, u32), String> { + pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(Seconds, u32, u32), String> { // Send query if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) { return Err("Failed to send query - queue full".to_string()); diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 789b9d8..3110024 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -211,6 +211,7 @@ pub fn render_to_memory( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we actually need from this chunk @@ -557,6 +558,7 @@ fn export_mp3>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk @@ -727,6 +729,7 @@ fn export_aac>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 4cce4a1..c44eafc 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -383,6 +383,7 @@ impl Project { sample_rate: u32, channels: u32, live_only: bool, + recording_midi: Option<(TrackId, MidiClipId)>, ) { output.fill(0.0); @@ -391,6 +392,7 @@ impl Project { // Create initial render context let ctx = RenderContext { live_only, + recording_midi, ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) }; diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 8203081..652d603 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -5,6 +5,61 @@ use crate::time::{Beats, Seconds}; use std::collections::HashMap; use std::path::PathBuf; +/// Cycle-recording bookkeeping attached to a recording that started with a cycle region armed. +/// +/// Takes are sliced **geometrically** at stop, in exact `loop_len_frames` multiples — not at the +/// instant the wrap was detected. The playhead advances before the capture block in `process()`, so +/// the wrap instant isn't sample-exact against the buffer that was just captured, but the geometry +/// is. `wrap_count` therefore only decides *whether* this is a multi-take recording, never where the +/// cuts land. +#[derive(Debug, Clone, Copy)] +pub struct CycleRecordInfo { + /// Where the cycle region starts, in beats. Takes are laid down here, not at the punch-in point. + pub loop_start: Beats, + /// The cycle region's length in beats — what the take folder records as `recorded_loop_beats`. + pub loop_len_beats: Beats, + /// One cycle pass, in frames. The take size. + pub loop_len_frames: usize, + /// Frames between the region start and where capture actually began. Non-zero only for a + /// punch-in (record while already rolling); take 1 gets this much silence prepended so it still + /// spans the whole region. + pub lead_pad_frames: usize, + /// How many times the transport wrapped during this recording. Zero normally means the user + /// stopped before completing a pass, which stays an ordinary single recording — unless + /// `force_takes` says otherwise. + pub wrap_count: usize, + /// Cut takes even if the transport never wrapped. + /// + /// Set when the region already holds takes: a further recording there is another take, however + /// short, and it gets padded out to the region like any partial pass. Without this a run that + /// stopped before the loop came round would land as a separate overlapping clip instead of + /// joining the take list. + /// + /// The editor decides this at record start, because whether takes already exist is document + /// state the engine can't see. + pub force_takes: bool, +} + +/// Min/max waveform peaks for a finished buffer of interleaved samples. +/// +/// The live recording path builds its peaks incrementally as samples arrive; cycle takes don't +/// exist until the recording is sliced at stop, so they get theirs in one pass here. +pub fn compute_peaks(samples: &[f32], channels: u32, frames_per_peak: usize) -> Vec { + let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1); + samples + .chunks(samples_per_peak) + .map(|chunk| { + let mut min = 0.0f32; + let mut max = 0.0f32; + for s in chunk { + min = min.min(*s); + max = max.max(*s); + } + WaveformPeak { min, max } + }) + .collect() +} + /// State of an active recording session pub struct RecordingState { /// Track being recorded to @@ -35,6 +90,8 @@ pub struct RecordingState { pub frames_per_peak: usize, /// All recorded audio data accumulated in memory (written to disk at finalization) pub audio_data: Vec, + /// Cycle-recording bookkeeping, when a cycle region was armed at record start. + pub cycle: Option, } impl RecordingState { @@ -69,9 +126,69 @@ impl RecordingState { waveform_buffer: Vec::new(), frames_per_peak, audio_data: Vec::new(), + cycle: None, } } + /// Slice the recording into cycle takes: one per pass, each spanning the FULL cycle region. + /// + /// Partial passes are padded with silence — the head of take 1 for a punch-in, the tail of the + /// last take when the user stops mid-pass — so every take is the same length and aligned to the + /// region. That uniformity is what makes comping-via-split work: take 1 on the left half and + /// take 3 on the right always line up. + /// + /// Returns `None` if this wasn't a cycle recording or the transport never wrapped (an ordinary + /// single recording, which keeps the existing path untouched). + pub fn slice_takes(&self) -> Option>> { + let cycle = self.cycle?; + if (cycle.wrap_count == 0 && !cycle.force_takes) || cycle.loop_len_frames == 0 { + return None; + } + + let ch = self.channels.max(1) as usize; + let take_len = cycle.loop_len_frames * ch; + let lead = cycle.lead_pad_frames * ch; + + // The recording as positioned *within the region*: silence for the gap between the region + // start and the punch-in, then the captured audio. Slicing this at whole-take boundaries is + // the whole trick — take 1 comes out short-by-`lead` at the front, already padded. + let virtual_len = lead + self.audio_data.len(); + let take_count = virtual_len.div_ceil(take_len); + + let mut takes: Vec> = Vec::with_capacity(take_count); + for i in 0..take_count { + let mut take = vec![0.0f32; take_len]; + let take_begin = i * take_len; + for slot in 0..take_len { + // Position in the virtual (lead-padded) buffer. + let v = take_begin + slot; + if v < lead { + continue; // still in the punch-in silence + } + match self.audio_data.get(v - lead) { + Some(s) => take[slot] = *s, + None => break, // past the end of capture; the rest stays silent + } + } + takes.push(take); + } + + // A final take holding only a sliver of real audio is a stop artifact (the user hit stop a + // moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL + // pass, and never the only take. A pass that filled the region is a real take no matter how + // short the region is. + const MIN_TAKE_SECONDS: f64 = 0.05; + if takes.len() > 1 { + let last_real_samples = virtual_len - (takes.len() - 1) * take_len; + let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64; + if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS { + takes.pop(); + } + } + + Some(takes) + } + /// Add samples to the accumulation buffer /// Returns true if a flush occurred pub fn add_samples(&mut self, samples: &[f32]) -> Result { @@ -189,6 +306,26 @@ pub struct MidiRecordingState { active_notes: HashMap, /// Completed notes: (time_offset, note, velocity, duration) — all times in beats pub completed_notes: Vec<(Beats, u8, u8, Beats)>, + /// The cycle region's length in beats, if one was armed at record start. + /// + /// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is + /// what makes MERGE fall out for free: the transport always wraps back into the region, so every + /// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each + /// other with no folding needed. + pub cycle_loop_len: Option, + /// Whether the transport actually came round. Distinct from `cycle_loop_len`, which only says a + /// region was armed: the clip only pins to the full region once a pass has completed, so until + /// then the bar still grows with the playhead. + pub wrapped: bool, + /// Cut takes even if the transport never wrapped — see [`CycleRecordInfo::force_takes`]. + pub force_takes: bool, + /// Which cycle pass is currently being recorded (0-based). Bumped at each wrap. + current_pass: usize, + /// The pass each completed note belongs to, parallel to `completed_notes`. + /// + /// Only meaningful in "separate takes" mode, where each pass becomes its own MIDI clip. Merge + /// mode ignores it — all passes fold into one clip, which is the whole point. + note_pass: Vec, } impl MidiRecordingState { @@ -199,9 +336,28 @@ impl MidiRecordingState { start_time, active_notes: HashMap::new(), completed_notes: Vec::new(), + cycle_loop_len: None, + wrapped: false, + force_takes: false, + current_pass: 0, + note_pass: Vec::new(), } } + /// Record a finished note, tagging it with the pass it was played in. + /// + /// Every completion goes through here so `completed_notes` and `note_pass` can't drift apart. + fn push_completed(&mut self, note: &ActiveMidiNote, end_time: Beats) { + let note_start = note.start_time.max(self.start_time); + self.completed_notes.push(( + note_start - self.start_time, + note.note, + note.velocity, + end_time - note_start, + )); + self.note_pass.push(self.current_pass); + } + pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) { self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time }); } @@ -211,16 +367,38 @@ impl MidiRecordingState { if absolute_time <= self.start_time { return; } - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - absolute_time - note_start, - )); + self.push_completed(&active_note, absolute_time); } } + /// Completed notes grouped by cycle pass — one bucket per pass, in recording order. + /// + /// Used by "separate takes" mode, where each pass becomes its own MIDI clip. + /// + /// An *interior* pass in which nothing was played still yields an empty take, so take N in the + /// folder is always pass N on the transport — otherwise the numbering would silently shift and + /// "take 3" would stop meaning "the third time round". A *trailing* empty pass is dropped + /// though: that's what you get by hitting stop shortly after a wrap, and it's a stop artifact + /// rather than a take you played. (Same reasoning as the audio path's short-final-take rule.) + pub fn notes_by_pass(&self, passes: usize) -> Vec> { + let mut buckets = vec![Vec::new(); passes.max(1)]; + for (note, &pass) in self.completed_notes.iter().zip(self.note_pass.iter()) { + if let Some(bucket) = buckets.get_mut(pass) { + bucket.push(*note); + } + } + // Never drop the only take. + while buckets.len() > 1 && buckets.last().is_some_and(|b| b.is_empty()) { + buckets.pop(); + } + buckets + } + + /// How many cycle passes this recording covered (1 if the transport never wrapped). + pub fn pass_count(&self) -> usize { + self.current_pass + 1 + } + pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] { &self.completed_notes } @@ -229,18 +407,28 @@ impl MidiRecordingState { self.completed_notes.len() } + /// The still-held notes, given a provisional duration running to `current_time`. + /// + /// These belong to whatever pass is in progress, so a per-pass view can append them as-is. + pub fn active_notes_with_provisional_end(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { + self.active_notes + .values() + .map(|active| { + let note_start = active.start_time.max(self.start_time); + ( + note_start - self.start_time, + active.note, + active.velocity, + (current_time - note_start).max(Beats::ZERO), + ) + }) + .collect() + } + /// Get all completed notes plus currently-held notes with a provisional duration. pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { let mut notes = self.completed_notes.clone(); - for active in self.active_notes.values() { - let note_start = active.start_time.max(self.start_time); - notes.push(( - note_start - self.start_time, - active.note, - active.velocity, - (current_time - note_start).max(Beats::ZERO), - )); - } + notes.extend(self.active_notes_with_provisional_end(current_time)); notes } @@ -252,13 +440,259 @@ impl MidiRecordingState { let active_notes: Vec<_> = self.active_notes.drain().collect(); for (_note_num, active_note) in active_notes { - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - end_time - note_start, - )); + self.push_completed(&active_note, end_time); } } + + /// Handle a transport cycle wrap during MIDI recording. + /// + /// Note times are stored as offsets from `start_time`, and the playhead jumps *backwards* at a + /// wrap — so a note still held across the boundary would otherwise get a nonsensical (negative) + /// duration, or never be closed at all. Write its note-off at `region_end` (exactly as + /// `close_active_notes` does when recording stops), then re-open it at `region_start` so a key + /// the player is still physically holding keeps being captured in the next pass. Mirrors the + /// way `handle_start_midi_recording` re-injects already-held notes at the recording start. + pub fn wrap_at_cycle(&mut self, region_end: Beats, region_start: Beats) { + // Snapshot the held notes (close_active_notes drains them and loses the velocities). + let held: Vec<(u8, u8)> = self + .active_notes + .values() + .map(|n| (n.note, n.velocity)) + .collect(); + + // Close first, so a note held across the boundary has its tail attributed to the pass that's + // ending; then advance, so the re-opened half belongs to the pass that's beginning. + self.close_active_notes(region_end); + self.current_pass += 1; + + for (note, velocity) in held { + self.note_on(note, velocity, region_start); + } + + // A pass has completed, so from here the clip spans the whole region rather than however long + // the user happens to hold the record button. + self.cycle_loop_len = Some(region_end - region_start); + self.wrapped = true; + } +} + +#[cfg(test)] +mod cycle_tests { + use super::*; + + /// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame + /// is a sample and 5 frames is 50 ms (exactly the min-take threshold). + fn rec_forced(audio: Vec, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState { + let mut r = rec(audio, loop_len_frames, lead_pad_frames, wraps); + if let Some(c) = r.cycle.as_mut() { + c.force_takes = true; + } + r + } + + fn rec(audio: Vec, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState { + let mut r = RecordingState::new( + 0, + 0, + PathBuf::from("/dev/null"), + WavWriter::create(&PathBuf::from("/dev/null"), 100, 1).expect("wav writer"), + 100, + 1, + Beats(0.0), + 1.0, + ); + r.audio_data = audio; + r.cycle = Some(CycleRecordInfo { + loop_start: Beats(0.0), + loop_len_beats: Beats(4.0), + loop_len_frames, + lead_pad_frames, + wrap_count: wraps, + force_takes: false, + }); + r + } + + #[test] + fn no_wrap_is_not_a_cycle_recording() { + // Stopping before the transport ever wraps stays an ordinary single recording — the whole + // point of triggering on the wrap rather than on the cycle region merely existing. + let r = rec(vec![1.0; 10], 4, 0, 0); + assert!(r.slice_takes().is_none()); + } + + #[test] + fn force_takes_makes_a_partial_pass_a_take() { + // Recording over a region that already holds takes: this run is another take however short + // it ran, so it's cut and padded like any partial pass rather than landing as a separate + // overlapping clip. Two real frames of an 8-frame region -> one take, silence for the rest. + let takes = rec_forced(vec![1.0, 2.0], 8, 0, 0) + .slice_takes() + .expect("forced takes"); + assert_eq!(takes.len(), 1); + assert_eq!(takes[0], vec![1.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + } + + #[test] + fn takes_are_cut_at_exact_loop_multiples() { + // 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes. + let audio: Vec = (1..=12).map(|i| i as f32).collect(); + let takes = rec(audio, 4, 0, 2).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 3); + assert_eq!(takes[0], vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]); + assert_eq!(takes[2], vec![9.0, 10.0, 11.0, 12.0]); + } + + #[test] + fn punch_in_pads_the_head_of_take_one() { + // Punched in 2 frames into the region: take 1 gets 2 frames of silence at the FRONT so it + // still spans the whole region and lines up with every other take. + let audio: Vec = (1..=10).map(|i| i as f32).collect(); + let takes = rec(audio, 4, 2, 2).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 3); + assert_eq!(takes[0], vec![0.0, 0.0, 1.0, 2.0]); + assert_eq!(takes[1], vec![3.0, 4.0, 5.0, 6.0]); + assert_eq!(takes[2], vec![7.0, 8.0, 9.0, 10.0]); + } + + #[test] + fn stopping_mid_pass_pads_the_tail_of_the_last_take() { + // 13 frames, 8-frame loop => the second take holds 5 real frames (50 ms at 100 Hz, right at + // the keep threshold) and 3 of silence. + let audio: Vec = (1..=13).map(|i| i as f32).collect(); + let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 2); + assert_eq!(takes[1], vec![9.0, 10.0, 11.0, 12.0, 13.0, 0.0, 0.0, 0.0]); + } + + #[test] + fn every_take_is_the_same_length() { + // Uniform length is the invariant comping-via-split depends on. + let audio: Vec = (1..=23).map(|i| i as f32).collect(); + let takes = rec(audio, 8, 3, 3).slice_takes().expect("cycle takes"); + assert!(takes.iter().all(|t| t.len() == 8), "takes must be uniform"); + } + + #[test] + fn a_sliver_of_a_final_take_is_dropped() { + // Stopped 1 frame (10 ms at 100 Hz) after the wrap — below the 50 ms floor, so that stub of + // a take is a stop artifact and goes. + let audio: Vec = (1..=9).map(|i| i as f32).collect(); + let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 1, "a 10ms tail take should be dropped"); + assert_eq!(takes[0].len(), 8); + } + + #[test] + fn a_full_final_take_is_never_dropped() { + // Regression: the sliver rule must only fire on a PARTIAL pass. A pass that filled the + // region is a real take however short the region is — an earlier version compared a full + // take's duration to the floor and silently ate it. + let audio: Vec = (1..=8).map(|i| i as f32).collect(); + let takes = rec(audio, 4, 0, 1).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 2, "both passes filled the region"); + assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]); + } +} + +#[cfg(test)] +mod midi_cycle_tests { + use super::*; + + /// A MIDI recording anchored at the region start (beat 0), region 4 beats long. + fn rec() -> MidiRecordingState { + MidiRecordingState::new(0, 0, Beats(0.0)) + } + + /// One pass of the transport around a 4-beat region. + fn wrap(r: &mut MidiRecordingState) { + r.wrap_at_cycle(Beats(4.0), Beats(0.0)); + } + + #[test] + fn notes_are_bucketed_by_the_pass_they_were_played_in() { + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + r.note_on(62, 100, Beats(1.0)); + r.note_off(62, Beats(2.0)); // pass 1 + wrap(&mut r); + r.note_on(64, 100, Beats(1.0)); + r.note_off(64, Beats(2.0)); // pass 2 + + assert_eq!(r.pass_count(), 3); + let by_pass = r.notes_by_pass(r.pass_count()); + let pitches: Vec> = by_pass + .iter() + .map(|p| p.iter().map(|n| n.1).collect()) + .collect(); + assert_eq!(pitches, vec![vec![60], vec![62], vec![64]]); + } + + #[test] + fn a_note_held_across_a_wrap_splits_between_the_two_passes() { + // The key is still down at the boundary: the sounding half belongs to the pass that's + // ending, and the re-opened half to the pass that's beginning. Getting the pass bump on the + // wrong side of close_active_notes would file the whole note under one pass. + let mut r = rec(); + r.note_on(60, 100, Beats(3.0)); + wrap(&mut r); // still held + r.note_off(60, Beats(1.0)); // released 1 beat into the next pass + + assert_eq!(r.pass_count(), 2); + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass[0].len(), 1, "the held half lands in the pass that ended"); + assert_eq!(by_pass[1].len(), 1, "the re-opened half lands in the next pass"); + // Pass 0's half runs from beat 3 to the region end at 4. + assert_eq!(by_pass[0][0].0, Beats(3.0)); + assert_eq!(by_pass[0][0].3, Beats(1.0)); + // Pass 1's half starts at the region start and runs to the release. + assert_eq!(by_pass[1][0].0, Beats(0.0)); + assert_eq!(by_pass[1][0].3, Beats(1.0)); + } + + #[test] + fn a_silent_interior_pass_still_yields_an_empty_take() { + // Take N in the folder must be pass N on the transport, even if nothing was played — else + // the take numbering silently shifts under the user. + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + wrap(&mut r); // pass 1: played nothing + r.note_on(64, 100, Beats(1.0)); + r.note_off(64, Beats(2.0)); // pass 2 + + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass.len(), 3); + assert_eq!(by_pass[1].len(), 0, "the silent pass is still take 2"); + assert_eq!(by_pass[2][0].1, 64); + } + + #[test] + fn a_trailing_empty_pass_is_dropped() { + // Hitting stop shortly after a wrap leaves a pass you never played into. That's a stop + // artifact, not a take — unlike a silent pass in the middle, which was a deliberate rest. + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + r.note_on(62, 100, Beats(1.0)); + r.note_off(62, Beats(2.0)); // pass 1 + wrap(&mut r); // pass 2 begins... and the user hits stop + + assert_eq!(r.pass_count(), 3); + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass.len(), 2, "the empty trailing pass is not a take"); + } + + #[test] + fn an_empty_recording_still_yields_one_take() { + let mut r = rec(); + wrap(&mut r); + wrap(&mut r); + assert_eq!(r.notes_by_pass(r.pass_count()).len(), 1); + } } diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index f7da3d2..b79cb1a 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -1,6 +1,6 @@ use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; use super::clip::{AudioClipInstance, AudioClipInstanceId}; -use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent}; +use super::midi::{MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; use super::midi_pool::MidiClipPool; use super::node_graph::AudioGraph; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; @@ -43,6 +43,13 @@ pub struct RenderContext<'a> { /// Used after pause/stop to route note-off tails through the normal group hierarchy /// without re-triggering notes from clips at the paused position. pub live_only: bool, + /// The MIDI recording in progress, if any: (track being recorded to, clip being recorded into). + /// + /// On that track, every OTHER clip is silenced for the duration of the recording. You're playing + /// a part into this region — hearing what's already there (a previous take, say) fighting with + /// what you're playing now is just noise. The clip being recorded into is exempt, because in + /// merge mode that's exactly what you DO want to hear: the overdub you've been building up. + pub recording_midi: Option<(TrackId, MidiClipId)>, } impl<'a> RenderContext<'a> { @@ -61,6 +68,7 @@ impl<'a> RenderContext<'a> { buffer_size, time_stretch: 1.0, live_only: false, + recording_midi: None, } } @@ -864,9 +872,21 @@ impl MidiTrack { let playhead_beats = ctx.playhead_beats(); let buffer_end_beats = ctx.buffer_end_beats(); + // While recording into this track, every clip EXCEPT the one being recorded into is + // silenced. Otherwise a take folder already sitting in the cycle region would play its + // active take underneath you on every pass, fighting the part you're trying to record. + // The recording clip itself is exempt: in merge mode that's the overdub monitoring. + let muted_clip = match ctx.recording_midi { + Some((track_id, clip_id)) if track_id == self.id => Some(clip_id), + _ => None, + }; + // Collect MIDI events from all clip instances that overlap with current beat range let mut currently_active = HashSet::new(); for instance in &self.clip_instances { + if muted_clip.is_some_and(|recording| instance.clip_id != recording) { + continue; + } if instance.overlaps_range(playhead_beats, buffer_end_beats) { currently_active.insert(instance.id); } diff --git a/daw-backend/src/command/mod.rs b/daw-backend/src/command/mod.rs index 5baaac2..917ec0c 100644 --- a/daw-backend/src/command/mod.rs +++ b/daw-backend/src/command/mod.rs @@ -1,3 +1,3 @@ pub mod types; -pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse}; +pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse, TrimRange}; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 98cce70..a87d58a 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -8,6 +8,21 @@ use crate::audio::node_graph::nodes::LoopMode; use crate::io::WaveformPeak; use crate::time::{Beats, Seconds}; +/// A clip's internal (content) boundaries, tagged with the domain they're measured in. +/// +/// A clip's content time is SECONDS for sampled audio but BEATS for MIDI — the same polymorphism +/// `ClipInstance::trim_start`/`trim_end` carry. Passing these as bare `f64`s meant the caller and +/// the engine could disagree about the unit with nothing to catch it: an audio trim of "1.0" was +/// once stored as `Beats(1.0)` for the clip's external duration, so a 1-second split played back as +/// half a second at 120 BPM. Tagging the domain makes that a type error instead of a bug report. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TrimRange { + /// Sampled-audio content time. + Seconds { start: Seconds, end: Seconds }, + /// MIDI content time. + Beats { start: Beats, end: Beats }, +} + /// Commands sent from UI/control thread to audio thread #[derive(Debug, Clone)] pub enum Command { @@ -19,7 +34,7 @@ pub enum Command { /// Pause playback (maintains position) Pause, /// Seek to a specific position in seconds - Seek(f64), + Seek(Seconds), // Track management commands /// Set track volume (0.0 = silence, 1.0 = unity gain) @@ -31,13 +46,12 @@ pub enum Command { // Clip management commands /// Move a clip to a new timeline position (track_id, clip_id, new_external_start) - MoveClip(TrackId, ClipId, f64), - /// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) - /// This changes which portion of the source content is used - TrimClip(TrackId, ClipId, f64, f64), + MoveClip(TrackId, ClipId, Beats), + /// Trim a clip's internal boundaries — which portion of the source content is used. + TrimClip(TrackId, ClipId, TrimRange), /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// If duration > internal duration, the clip will loop - ExtendClip(TrackId, ClipId, f64), + ExtendClip(TrackId, ClipId, Beats), // Metatrack management commands /// Create a new metatrack with a name and optional parent group @@ -53,15 +67,15 @@ pub enum Command { SetTimeStretch(TrackId, f32), /// Set metatrack time offset in seconds (track_id, offset) /// Positive = shift content later, negative = shift earlier - SetOffset(TrackId, f64), + SetOffset(TrackId, Seconds), /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use SetPitchShift(TrackId, f32), /// Set metatrack trim start in seconds (track_id, trim_start) /// Children won't hear content before this point - SetTrimStart(TrackId, f64), + SetTrimStart(TrackId, Seconds), /// Set metatrack trim end in seconds (track_id, trim_end) /// None means no end trim - SetTrimEnd(TrackId, Option), + SetTrimEnd(TrackId, Option), // Audio track commands /// Create a new audio track with a name and optional parent group @@ -80,14 +94,14 @@ pub enum Command { /// Add a MIDI clip to the pool without placing it on a track AddMidiClipToPool(MidiClip), /// Create a new MIDI clip on a track (track_id, start_time, duration) - CreateMidiClip(TrackId, f64, f64), + CreateMidiClip(TrackId, Beats, Beats), /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) - AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), + AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats), /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) - AddLoadedMidiClip(TrackId, MidiClip, f64), + AddLoadedMidiClip(TrackId, MidiClip, Beats), /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes - UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), + UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>), /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. UpdateMidiClipEvents(TrackId, MidiClipId, Vec), /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support @@ -103,9 +117,9 @@ pub enum Command { /// Create a new automation lane on a track (track_id, parameter_id) CreateAutomationLane(TrackId, ParameterId), /// Add an automation point to a lane (track_id, lane_id, time, value, curve) - AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType), + AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType), /// Remove an automation point at a specific time (track_id, lane_id, time, tolerance) - RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), + RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats), /// Clear all automation points from a lane (track_id, lane_id) ClearAutomationLane(TrackId, AutomationLaneId), /// Remove an automation lane (track_id, lane_id) @@ -113,9 +127,24 @@ pub enum Command { /// Enable/disable an automation lane (track_id, lane_id, enabled) SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool), + // Transport cycle (loop) region + /// Set the cycle region the transport loops over, in beats (None clears it). + /// Authored in beats so it survives tempo changes. + SetLoopRegion(Option<(Beats, Beats)>), + /// Enable/disable wrapping at the cycle region's end. + SetLoopEnabled(bool), + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = MERGE: every pass overdubs into one clip. `true` = SEPARATE TAKES: each + /// pass becomes its own MIDI clip, and the editor folds them into a take folder — the same shape + /// audio always gets. + SetCycleMidiSeparateTakes(bool), + // Recording commands /// Start recording on a track (track_id, start_time) - StartRecording(TrackId, Beats), + /// (track, start_time, force_takes — cut takes even if the transport never wraps, because the + /// region already holds takes and this is another one) + StartRecording(TrackId, Beats, bool), /// Stop the current recording StopRecording, /// Pause the current recording @@ -125,7 +154,8 @@ pub enum Command { // MIDI Recording commands /// Start MIDI recording on a track (track_id, clip_id, start_time) - StartMidiRecording(TrackId, MidiClipId, Beats), + /// (track, clip, start_time, force_takes — see [`Command::StartRecording`]) + StartMidiRecording(TrackId, MidiClipId, Beats, bool), /// Stop the current MIDI recording StopMidiRecording, @@ -237,9 +267,9 @@ pub enum Command { // Automation Input Node commands /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) - AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)), + AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)), /// Remove a keyframe from an AutomationInput node (track_id, node_id, time) - AutomationRemoveKeyframe(TrackId, u32, f64), + AutomationRemoveKeyframe(TrackId, u32, Beats), /// Set the display name of an AutomationInput node (track_id, node_id, name) AutomationSetName(TrackId, u32, String), @@ -271,7 +301,7 @@ pub enum Command { #[derive(Debug, Clone)] pub enum AudioEvent { /// Current playback position in seconds - PlaybackPosition(f64), + PlaybackPosition(Seconds), /// Playback has stopped (reached end of audio) PlaybackStopped, /// Audio buffer underrun detected @@ -292,6 +322,35 @@ pub enum AudioEvent { RecordingProgress(ClipId, Seconds), /// Recording stopped (clip_id, pool_index, waveform) RecordingStopped(ClipId, usize, Vec), + /// A MIDI recording that wrapped the cycle region at least once, in SEPARATE TAKES mode. + /// + /// One MIDI clip per pass, in recording order. (Merge mode emits the ordinary + /// `MidiRecordingStopped` instead — all passes are already folded into the one clip.) + MidiCycleRecordingStopped { + track_id: TrackId, + /// One pool MIDI clip per pass. The first is the clip the recording started on. + clip_ids: Vec, + /// Where the takes sit on the timeline — the cycle region's start. + loop_start: Beats, + /// The region's length in beats: every take spans exactly this. + loop_len_beats: Beats, + }, + /// A recording that wrapped the cycle region at least once, and so became multi-take. + /// + /// Each take spans the full region and they're all the same length (partial passes are padded + /// with silence), so the editor can promote the recording clip straight to a take folder. + CycleRecordingStopped { + clip_id: ClipId, + /// One entry per pass: (audio pool index, waveform peaks), in recording order. + takes: Vec<(usize, Vec)>, + /// Where the takes sit on the timeline — the cycle region's start, not the punch-in point. + loop_start: Beats, + /// The region's length in beats (what the take folder stores as `recorded_loop_beats`). + loop_len_beats: Beats, + /// The same length in seconds — the take folder's content duration, which is seconds-domain + /// for audio. + loop_len_seconds: Seconds, + }, /// Recording error (error_message) RecordingError(String), /// MIDI recording stopped (track_id, clip_id, note_count) @@ -342,7 +401,7 @@ pub enum AudioEvent { WaveformChunksReady { pool_index: usize, detail_level: u8, - chunks: Vec<(u32, (f64, f64), Vec)>, + chunks: Vec<(u32, (Seconds, Seconds), Vec)>, }, /// An audio file has been imported and is ready for playback. @@ -353,7 +412,7 @@ pub enum AudioEvent { path: String, channels: u32, sample_rate: u32, - duration: f64, + duration: Seconds, format: crate::io::audio_file::AudioFormat, }, @@ -427,7 +486,7 @@ pub enum Query { /// Export audio to file (settings, output_path) ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID - AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), + AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats), /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// The clip must already exist in the MidiClipPool AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), @@ -472,16 +531,21 @@ pub struct OscilloscopeData { } /// MIDI clip data for serialization +/// +/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk — +/// the `.beam` still holds a plain number. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClipData { - pub duration: f64, + /// MIDI content length is musical, so beats. + pub duration: Beats, pub events: Vec, } /// Automation keyframe data for serialization #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AutomationKeyframeData { - pub time: f64, + /// Automation x-axes are all beats. + pub time: Beats, pub value: f32, pub interpolation: String, pub ease_out: (f32, f32), @@ -518,7 +582,7 @@ pub enum QueryResponse { /// Pool waveform data PoolWaveform(Result, String>), /// Pool file info (duration, sample_rate, channels) - PoolFileInfo(Result<(f64, u32, u32), String>), + PoolFileInfo(Result<(Seconds, u32, u32), String>), /// Audio exported AudioExported(Result<(), String>), /// MIDI clip instance added (returns instance ID) diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs index a8153e6..c258ca9 100644 --- a/daw-backend/src/lib.rs +++ b/daw-backend/src/lib.rs @@ -20,7 +20,7 @@ pub use audio::{ TrackNode, }; pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode}; -pub use time::{Beats, Seconds}; +pub use time::{Beats, ContentTime, Seconds}; pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack}; pub use command::{AudioEvent, Command, OscilloscopeData}; pub use command::types::AutomationKeyframeData; diff --git a/daw-backend/src/time.rs b/daw-backend/src/time.rs index b2e2913..fdea4f4 100644 --- a/daw-backend/src/time.rs +++ b/daw-backend/src/time.rs @@ -16,6 +16,50 @@ pub struct Beats(pub f64); #[serde(transparent)] pub struct Seconds(pub f64); +/// A time *inside a clip's own content*, in whatever unit that clip measures content in. +/// +/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS +/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are +/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI +/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM. +/// +/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no +/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other +/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a +/// real timeline duration out is to resolve it against the clip that knows the domain, via +/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs +/// nothing, and mixing domains won't compile. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ContentTime(pub f64); + +impl ContentTime { + pub const ZERO: Self = Self(0.0); + + pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) } + pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) } + + /// The raw magnitude, with the domain discarded. + /// + /// Only for code that is *already* working in this clip's content domain (trim arithmetic, + /// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about + /// to combine this with a timeline position, resolve it against the clip instead. + pub fn raw(self) -> f64 { self.0 } +} + +impl Add for ContentTime { + type Output = Self; + fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) } +} +impl Sub for ContentTime { + type Output = Self; + fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) } +} +impl Rem for ContentTime { + type Output = Self; + fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) } +} + impl Beats { pub const ZERO: Self = Self(0.0); diff --git a/daw-backend/src/tui/mod.rs b/daw-backend/src/tui/mod.rs index 0d44a6b..645c699 100644 --- a/daw-backend/src/tui/mod.rs +++ b/daw-backend/src/tui/mod.rs @@ -556,7 +556,7 @@ pub fn run_tui( while let Ok(event) = rx.pop() { match event { AudioEvent::PlaybackPosition(pos) => { - app.update_playback_position(pos); + app.update_playback_position(pos.seconds_to_f64()); } AudioEvent::PlaybackStopped => { app.set_playing(false); diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 97aed88..185ba15 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -3628,7 +3628,7 @@ dependencies = [ [[package]] name = "lightningbeam-editor" -version = "1.0.8-alpha" +version = "1.0.9-alpha" dependencies = [ "beamdsp", "bytemuck", diff --git a/lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md b/lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md deleted file mode 100644 index 17e2d19..0000000 --- a/lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md +++ /dev/null @@ -1,142 +0,0 @@ -# GPU-resident video decode + dynamic decode resolution - -## Context - -Profiling the zero-copy H.264 export (single Group[Video, Audio] clip, `LB_RENDER_PROFILE=1`) -broke the per-frame CPU "render" bucket down as: - -| Cost (ms/frame) | 1080p | 4K | What it is | -|-------------------------|-------|-------|-----------------------------------------------------| -| decode | 3.1 | 19.0 | software ffmpeg decode (`video.rs::get_frame`) | -| background re-render | 3.6 | 7.5 | static background pushed through Vello *every frame* | -| video upload + blit | 4.1 | 4.2 | per-frame transient texture alloc + `write_texture` | -| srgb | 0.4 | 0.4 | linear→sRGB pass | - -The video correctly takes the GPU Video-instance path (not Vello-baked) — `LB_LAYER_DEBUG=1` -shows `Video (1 instance)`. So the cost is **the video frame itself**: software decode, then an -8 MB `write_texture` upload of the decoded RGBA every frame. At 4K, software decode (19 ms) -dominates everything. - -### Two correctness problems found alongside the perf issue - -1. **Decode resolution is frozen to document size at import.** `load_video(clip, src, doc_w, doc_h)` - (`main.rs:4302`) sizes the decoder's swscale output to the document, capped to never upscale - (`video.rs:149`). Export *reuses that decoder*, so exporting **above** document resolution yields - video that was decoded to ≤document res and then GPU-**up**scaled — real source detail thrown away. -2. **It can't follow the consumer or a document resize.** Preview wants small/fast frames; export - wants full res; changing the document size should re-target the decode. None of that works with a - size frozen at import. - -## Goal - -Decouple **decode resolution** from import/document size: the renderer requests a frame *at a target -resolution*, and the decode path produces it. Hardware-decode H.264 (and later HEVC/AV1) into a GPU -surface and keep it GPU-resident through composite into the encoder — no CPU frame copy in either -direction. Software decode stays a **first-class** path (codecs/platforms without HW support), decoding -at the requested target res. This fixes the 4K decode wall, the 8 MB upload, *and* the resolution bugs. - -## Design principles - -- **Decode native, scale to the consumer's target.** - - *Hardware path:* decode into a native VAAPI surface → import as a wgpu texture (reuse the - `gpu-video-encoder` `dmabuf.rs` / `vk_device.rs` plumbing, read direction) → the GPU blit that - already composites the Video instance scales native→target for free. Handles any target res and - document resizes inherently; the cached frame is a native GPU texture. - - *Software path:* decode native → `swscale` to the requested target (the reusable scaler is keyed - on input format/size **and** output size — rebuilt when the target changes). Preview requests - preview res (cheap); export requests export res (full quality). -- **`VideoManager::get_frame` takes a target `(w, h)`** instead of relying on a frozen output size. - The frame cache is keyed to handle multiple live targets (preview + export) — either cache native - frames and scale on demand, or key by `(clip, ts, target)`; decide in Stage 2 by measuring cache - hit/scale tradeoff. -- **Software is not optional.** Hardware decode is an acceleration of the same `get_frame` contract, - selected per source when the codec/driver supports it; everything falls back to software cleanly. - -## Approach (staged; each stage compiles + is independently useful) - -### Stage 0 — independent quick wins (not blocked on decode) -- **Cache the static background** (`composite_document_to_hdr`): render once, reuse via a persistent - HDR texture (copy-in each frame) instead of a full Vello render + 2 passes/submits every frame. - Recovers ~3.6 ms (1080p) / ~7.5 ms (4K) per frame on *every* export. (In flight.) - -### Stage 1 — software: decode at the requested target res (testable; fixes the quality bug now) -- Change `VideoManager::get_frame(clip, ts)` → `get_frame(clip, ts, target_w, target_h)`; thread the - target from the renderer (preview = current doc/preview res, export = export res). Cap at native. -- Rework `VideoDecoder` so output size is per-request, not frozen at construction; cache the swscale - context per output size (already cached per stream — extend the key). Adjust the frame cache key. -- Result: software exports are full-quality at any export res, and document resizes re-target decode. - No hardware needed; this is the correctness fix for the codecs HW can't handle anyway. - -### Stage 2 — hardware decode primitive (DONE, commit 255e164) -`decoder::VaapiDecoder` in `gpu-video-encoder`: decode → VAAPI surface → DRM-PRIME DMA-BUF → -`dmabuf::import_raw` → wgpu textures. Round-trip test (encode gray → decode → readback Y≈128) passes. - -### The device-affinity problem (drives the whole rest of the design) -wgpu textures can't cross devices, and a decoded frame is a wgpu texture imported from a DMA-BUF — -which **requires a device with the DMA-BUF-import extensions** (`VK_EXT_image_drm_format_modifier` -+ external-memory), built via wgpu-hal `device_from_raw` (the safe `DeviceDescriptor` can't add -them). So a hardware-decoded frame is only usable by a compositor running on **such** a device. -- **Export** composites on the encoder's custom device → already fine. -- **Preview** composites on eframe's *normal* device → can't import DMA-BUFs → can't use HW frames. - -Since **preview must HW-decode 4K** (software 4K decode ≈19 ms/frame), the resolution is a **single -shared custom device** used by eframe + preview compositor + decoder + encoder. eframe 0.33 (local -`egui-fork`) accepts it via `WgpuSetup::Existing { instance, adapter, device, queue }` — confirmed. -The earlier "separate export device" becomes redundant once this lands. - -### Stage 3a — windowed shared `DrmDevice`, injected into eframe (highest-risk; blind) -Today `vk_device::create()` is **headless**. Make a windowed variant (or extend it) that is a -**superset** device: DMA-BUF import ext **+** `VK_KHR_swapchain` (device) and the WSI surface -instance extensions, **+** everything eframe/egui/vello need — `adapter.limits()` (already; Vello -needs `max_storage_buffers_per_shader_stage` ≥ 5), `max_texture_dimension_2d` 8192, and the optional -features main.rs requests (`SHADER_F16`, `TIMESTAMP_QUERY[_INSIDE_ENCODERS]`). Pick the adapter that -is the **VAAPI GPU** (the render node must match libva's, or DMA-BUF sharing fails on multi-GPU). -- main.rs: try to build the shared device; on success pass `WgpuSetup::Existing`, else fall back to - the current `WgpuSetupCreateNew` (software decode only). Gate on Linux + VAAPI + a config/env - override; **must be bulletproof** — this device now renders *every* frame of *every* session for - Linux/VAAPI users, video or not. Milestone: editor runs normally on it with no video involved. - -### Stage 3b — VideoManager hardware decode on the shared device (blind) -- `VideoManager` holds a `VaapiDecoder` per HW-decodable clip (built on the shared device), plus the - software `VideoDecoder` fallback. `get_frame` gains a GPU-returning variant: yields an imported NV12 - texture pair (native res) instead of `Arc>`. Probe HW support per source; non-VAAPI / - unsupported codecs / non-Linux → software path (Stage 1, target-res). -- Cache native GPU textures keyed by (clip, ts); revisit the byte budget (4K NV12 ≈ 12 MB each). - -### Stage 3c — compositor consumes the GPU frame (blind; user-verifies) -- The video-instance composite path takes an NV12 texture (or a small NV12→RGB GPU pass) and blits it - to the target with the existing bilinear blit — **no `write_texture` upload**. GPU scales native→ - target (preview res or export res). Both preview and the zero-copy export become - decode→composite(→encode) with no CPU frame. Software frames still upload as today. - -## Critical files -- `lightningbeam-core/src/video.rs` — `VideoDecoder` (per-request output size, scaler cache), - `VideoManager::get_frame` (target param, cache key). -- `lightningbeam-core/src/renderer.rs` — pass the render target res into the video-instance build. -- `lightningbeam-editor/src/export/video_exporter.rs` — background cache (Stage 0); consume a GPU - texture instead of uploading RGBA (Stage 3). -- `gpu-video-encoder/` (→ `gpu-video-codec`) — `dmabuf.rs`/`vk_device.rs` reused for the decode import. - -## Risks -- **Shared custom device is the editor's main device (BIGGEST risk)** — Stage 3a makes a hand-built - wgpu-hal Vulkan device render every frame for Linux/VAAPI users. It must satisfy eframe + egui + - vello + winit presentation across varied Intel/AMD/Mesa stacks, or the editor won't start. Mitigate - with a strict try-and-fall-back-to-normal-device path + an env/config kill switch. Test broadly. -- **Multi-GPU** — the shared render device must be the *same* GPU as libva's VAAPI device, or DMA-BUF - import fails. Adapter selection must match the render node to the VAAPI node (laptops with iGPU + - dGPU, PRIME). -- **Codec coverage** — only some codecs are HW-decodable per GPU/driver; software must stay correct - and well-tested. Probe support per source, don't assume. -- **Cache memory** — native-res GPU textures (esp. 4K NV12 ≈12 MB) are large; revisit the frame cache - budget, and the two live targets (preview res + export res) shouldn't thrash. -- **Colorspace/format** — VAAPI decode surfaces are NV12/tiled; import handles NV12, but 10-bit/HDR - (P010) needs format handling. Decoded NV12 also needs the right BT.601/709 + range on the NV12→RGB - read (mirror the encoder's color tags, [[gpu-video-decode]] color-range work). -- **Non-Linux / no-VAAPI** — must cleanly run on the normal eframe device with software decode. - -## Verification -- Stage 0/1: visual — export above document res is now full-quality (not upscaled); profile shows - background ≈ 0 and (Stage 1) software export correct at the chosen res. -- Stage 2: headless hardware test in `gpu-video-codec` (decode → wgpu texture, ffprobe/byte checks). -- Stage 3 (user): 1080p + 4K H.264 export — decode/upload buckets collapse; software fallback for a - non-HW codec (e.g. ProRes) still produces correct full-res output. diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 226ddfb..2c96842 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -47,6 +47,113 @@ pub struct BackendContext<'a> { // Future: pub video_controller: Option<&'a mut VideoController>, } +impl BackendContext<'_> { + /// Hand a clip instance to the audio engine and record it in the instance→backend map. + /// + /// Take folders are resolved through the instance's `active_take`, so the backend gets whichever + /// take is selected. Returns the backend track and instance IDs, or `None` when there's nothing + /// to sync yet (a recording in progress, or an empty take folder). + /// + /// Lives here rather than in any one action because more than one action needs it: adding an + /// instance, and switching a take folder's active take (which is a remove + re-add, there being + /// no in-place pool-swap command). Keeping one implementation keeps the trim/duration + /// conversions — the easy thing to get subtly wrong, since `trim_*` is SECONDS while + /// `timeline_*` is BEATS — from drifting between copies. + pub fn add_clip_instance( + &mut self, + document: &Document, + layer_id: &Uuid, + instance: &crate::clip::ClipInstance, + ) -> Result, String> { + use crate::clip::ResolvedContent; + + let clip = document + .get_audio_clip(&instance.clip_id) + .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; + + let track_id = *self + .layer_to_track_map + .get(layer_id) + .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; + + let resolved = instance.resolve(clip); + let content = clip.content_duration(); + let internal_start = instance.trim_start; + let internal_end = instance + .trim_end + .unwrap_or(daw_backend::ContentTime(content.native())); + let start_time = instance.timeline_start; + + // How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the + // content window in the clip's own domain — beats content carries over directly, wall-clock + // content converts at the clip's position — so neither kind can be read as the other here. + let effective_duration = instance.effective_duration_beats(content, document.tempo_map()); + + let controller = self + .audio_controller + .as_mut() + .ok_or_else(|| "Audio controller not available".to_string())?; + + let backend_id = match resolved { + ResolvedContent::Midi { midi_clip_id } => { + use daw_backend::command::{Query, QueryResponse}; + + // MIDI content time IS beats, so the trims carry straight over. + let midi_instance = daw_backend::MidiClipInstance::new( + 0, // assigned by the backend + midi_clip_id, + daw_backend::Beats(internal_start.raw()), + daw_backend::Beats(internal_end.raw()), + start_time, + effective_duration, + ); + + match controller + .send_query(Query::AddMidiClipInstanceSync(track_id, midi_instance))? + { + QueryResponse::MidiClipInstanceAdded(Ok(id)) => BackendClipInstanceId::Midi(id), + QueryResponse::MidiClipInstanceAdded(Err(e)) => return Err(e), + _ => return Err("Unexpected query response".to_string()), + } + } + ResolvedContent::Audio { audio_pool_index } => { + // Sampled-audio content time is SECONDS; the backend's start/duration are BEATS. + let id = controller.add_audio_clip( + track_id, + audio_pool_index, + start_time, + effective_duration, + daw_backend::Seconds(internal_start.raw()), + ); + BackendClipInstanceId::Audio(id) + } + // Nothing to sync until it has content. + ResolvedContent::Recording => return Ok(None), + }; + + self.clip_instance_to_backend_map + .insert(instance.id, backend_id); + + Ok(Some((track_id, backend_id))) + } + + /// Remove a clip instance's backend clip and drop it from the instance→backend map. + pub fn remove_clip_instance( + &mut self, + track_id: daw_backend::TrackId, + backend_id: BackendClipInstanceId, + instance_id: Uuid, + ) { + if let Some(controller) = self.audio_controller.as_mut() { + match backend_id { + BackendClipInstanceId::Midi(id) => controller.remove_midi_clip(track_id, id), + BackendClipInstanceId::Audio(id) => controller.remove_audio_clip(track_id, id), + } + } + self.clip_instance_to_backend_map.remove(&instance_id); + } +} + /// Action trait for undo/redo operations /// /// Each action must be able to execute (apply changes) and rollback (undo changes). diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index f39d8ad..e7aa768 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction { // `get_clip_duration` is the content length in seconds; the placement span // must be beats (the timeline is beats-domain), so convert via the clip's // typed helper rather than treating the seconds span as beats. - let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id) + // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI. + let clip_content = document.clip_trim_duration(&self.clip_instance.clip_id) .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?; let effective_duration = self.clip_instance - .effective_duration_beats(clip_duration, document.tempo_map()); + .effective_duration_beats(clip_content, document.tempo_map()); // Auto-adjust position for audio/video layers to avoid overlaps let adjusted_start = document.find_nearest_valid_position( @@ -196,113 +197,23 @@ impl Action for AddClipInstanceAction { return Ok(()); } - // Look up the clip from the document - let clip = document - .get_audio_clip(&self.clip_instance.clip_id) - .ok_or_else(|| "Audio clip not found".to_string())?; - - // Look up backend track ID from layer mapping - let backend_track_id = backend - .layer_to_track_map - .get(&self.layer_id) - .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; - - // Get audio controller - let controller = backend - .audio_controller - .as_mut() - .ok_or_else(|| "Audio controller not available".to_string())?; - - // Handle different clip types - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - // Create a MIDI clip instance referencing the existing clip in the backend pool - // No need to add to pool again - it was added during MIDI import - use daw_backend::command::{Query, QueryResponse}; - - // Calculate internal start/end from trim parameters - let internal_start = self.clip_instance.trim_start; - let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = self.clip_instance.timeline_start; - - // Calculate external duration (for looping if timeline_duration is set). - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = self.clip_instance.timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - // Create MidiClipInstance - let instance = daw_backend::MidiClipInstance::new( - 0, // Instance ID will be assigned by backend - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - // Send query to add instance and get instance ID - let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); - - match controller.send_query(query)? { - QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => { - self.backend_track_id = Some(*backend_track_id); - self.backend_midi_instance_id = Some(instance_id); - - // Add to global clip instance mapping - backend.clip_instance_to_backend_map.insert( - self.clip_instance.id, - crate::action::BackendClipInstanceId::Midi(instance_id) - ); - - Ok(()) - } - QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e), - _ => Err("Unexpected query response".to_string()), + // Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so + // the trim/duration conversions (and take-folder resolution) live in exactly one place. + if let Some((track_id, backend_id)) = + backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)? + { + self.backend_track_id = Some(track_id); + match backend_id { + crate::action::BackendClipInstanceId::Midi(id) => { + self.backend_midi_instance_id = Some(id) + } + crate::action::BackendClipInstanceId::Audio(id) => { + self.backend_audio_instance_id = Some(id) } } - AudioClipType::Sampled { audio_pool_index } => { - // `trim_*` / `clip.duration` are in SECONDS (audio content time), - // while `timeline_*` and the backend's `duration` are in BEATS. - let internal_start = self.clip_instance.trim_start; - let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = self.clip_instance.timeline_start; - // `effective_duration` is in BEATS. When `timeline_duration` is set - // it already is; otherwise the clip occupies its natural content - // length, so convert that seconds-span to beats at the clip's start - // (NOT `internal_end - internal_start`, which is seconds — that was - // the seconds-as-beats bug that made clips stop early off 60 BPM). - let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let instance_id = controller.add_audio_clip( - *backend_track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - - self.backend_track_id = Some(*backend_track_id); - self.backend_audio_instance_id = Some(instance_id); - - // Add to global clip instance mapping - backend.clip_instance_to_backend_map.insert( - self.clip_instance.id, - crate::action::BackendClipInstanceId::Audio(instance_id) - ); - - Ok(()) - } - AudioClipType::Recording => { - // Recording clips are not synced to backend until finalized - Ok(()) - } } + + Ok(()) } fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs new file mode 100644 index 0000000..24f9bf4 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs @@ -0,0 +1,141 @@ +//! Append freshly-recorded takes to an existing take folder. +//! +//! Cycle-recording over a region that already holds a take folder should *add* to that folder, not +//! drop a second clip on top of it. Otherwise the takes from your second attempt are stranded in a +//! separate, overlapping clip and you can't audition them against the first. +//! +//! The recorded content already exists in the backend pools by the time this runs (the engine put it +//! there at stop), so this action only touches the document — plus the one backend clip the instance +//! plays, which has to be repointed at the newly-active take. + +use crate::action::{Action, BackendClipInstanceId, BackendContext}; +use crate::clip::AudioTake; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Action that appends takes to an instance's take list and selects the last of them. +pub struct AppendTakesAction { + layer_id: Uuid, + /// The instance whose take list is being extended (and whose active take changes). + instance_id: Uuid, + /// The takes to add, in recording order. + new_takes: Vec, + + // Stored during execute for rollback. + old_take_count: usize, + old_active_take: Option, + executed: bool, +} + +impl AppendTakesAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, new_takes: Vec) -> Self { + Self { + layer_id, + instance_id, + new_takes, + old_take_count: 0, + old_active_take: None, + executed: false, + } + } + + /// Swap the instance's backend clip to whatever take the document now says is active. + /// + /// Same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command. + fn resync(&self, backend: &mut BackendContext, document: &Document) -> Result<(), String> { + let instance = document + .get_layer(&self.layer_id) + .and_then(|l| match l { + AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id), + _ => None, + }) + .cloned() + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + + let existing: Option = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); + let track_id = backend.layer_to_track_map.get(&self.layer_id).copied(); + if let (Some(backend_id), Some(track_id)) = (existing, track_id) { + backend.remove_clip_instance(track_id, backend_id, self.instance_id); + } + + backend.add_clip_instance(document, &self.layer_id, &instance)?; + Ok(()) + } +} + +impl Action for AppendTakesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Audio(audio_layer) = layer else { + return Err("Takes only exist on audio layers".to_string()); + }; + let instance = audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == self.instance_id) + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + + // Only record the pre-state on the first execute; a redo must not overwrite it with the + // post-state left behind by the previous run. + if !self.executed { + self.old_take_count = instance.takes.len(); + self.old_active_take = instance.active_take; + } + + // Number the new takes on from what's already there. Existing names are left alone — the + // user may well have renamed them, and renumbering would clobber that. + let base = instance.takes.len(); + for (i, take) in self.new_takes.iter().enumerate() { + let mut take = take.clone(); + if take.name.is_empty() { + take.name = format!("Take {}", base + i + 1); + } + instance.takes.push(take); + } + + // Land on the take just recorded, GarageBand-style. + instance.active_take = Some(instance.takes.len() - 1); + self.executed = true; + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer_mut(&self.layer_id) { + if let Some(instance) = audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == self.instance_id) + { + instance.takes.truncate(self.old_take_count); + instance.active_take = self.old_active_take; + } + } + Ok(()) + } + + fn description(&self) -> String { + format!("Record {} take(s)", self.new_takes.len()) + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 4e05db1..4def017 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -91,7 +91,7 @@ impl Action for LoopClipInstancesAction { impl LoopClipInstancesAction { fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> { - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; let controller = match backend.audio_controller.as_mut() { Some(c) => c, @@ -128,26 +128,22 @@ impl LoopClipInstancesAction { (new_dur, new_lb) }; - let content_window = { - let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - (trim_end - instance.trim_start).max(0.0) // seconds - }; - // Natural content length as a beats span at the clip's start (the - // fallback when no explicit timeline_duration is set). - let tempo_map = document.tempo_map(); - let content_window_beats = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) - + daw_backend::Seconds(content_window), - ) - instance.timeline_start; + // Natural content length as a beats span (the fallback when no explicit + // timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats + // content carries over directly rather than being read as seconds. + let content_window_beats = instance.effective_duration_beats( + clip.content_duration(), + document.tempo_map(), + ); let right_duration = target_duration.unwrap_or(content_window_beats); let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); let external_duration = left_duration + right_duration; let external_start = instance.timeline_start - left_duration; let get_backend_clip_id = |inst_id: &Uuid| -> Result { - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id), - AudioClipType::Sampled { .. } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id), + ResolvedContent::Audio { .. } => { let backend_id = backend.clip_instance_to_backend_map.get(inst_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?; match backend_id { @@ -155,7 +151,7 @@ impl LoopClipInstancesAction { _ => Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => Err("Cannot sync recording clip".to_string()), + ResolvedContent::Recording => Err("Cannot sync recording clip".to_string()), } }; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs new file mode 100644 index 0000000..4790a2b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs @@ -0,0 +1,419 @@ +//! Take management: delete and rename the takes on a clip instance. +//! +//! Takes live on the INSTANCE, so both of these are naturally scoped to the one the user clicked — +//! deleting a take from one half of a comped split leaves the other half's list alone. + +use crate::action::{Action, BackendClipInstanceId, BackendContext}; +use crate::clip::{AudioTake, ClipInstance}; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// The instance a take action targets, looked up mutably. +fn instance_mut<'a>( + document: &'a mut Document, + layer_id: &Uuid, + instance_id: &Uuid, +) -> Result<&'a mut ClipInstance, String> { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + let AnyLayer::Audio(audio_layer) = layer else { + return Err("Takes only exist on audio layers".to_string()); + }; + audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == *instance_id) + .ok_or_else(|| format!("Clip instance {} not found", instance_id)) +} + +/// Swap an instance's backend clip to whatever take the document now says is active. +/// +/// The same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command. +fn resync( + backend: &mut BackendContext, + document: &Document, + layer_id: &Uuid, + instance_id: &Uuid, +) -> Result<(), String> { + let instance = document + .get_layer(layer_id) + .and_then(|l| match l { + AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == *instance_id), + _ => None, + }) + .cloned() + .ok_or_else(|| format!("Clip instance {} not found", instance_id))?; + + let existing: Option = backend + .clip_instance_to_backend_map + .get(instance_id) + .copied(); + let track_id = backend.layer_to_track_map.get(layer_id).copied(); + if let (Some(backend_id), Some(track_id)) = (existing, track_id) { + backend.remove_clip_instance(track_id, backend_id, *instance_id); + } + + backend.add_clip_instance(document, layer_id, &instance)?; + Ok(()) +} + +/// Remove a take from an instance's take list. +/// +/// The take's recorded audio/MIDI stays in the backend pool — undo has to be able to put it back, +/// and other instances (the other half of a split, say) may still be playing it. +pub struct DeleteTakeAction { + layer_id: Uuid, + instance_id: Uuid, + take_index: usize, + + // Stored during execute for rollback. + removed: Option, + old_active_take: Option, +} + +impl DeleteTakeAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize) -> Self { + Self { + layer_id, + instance_id, + take_index, + removed: None, + old_active_take: None, + } + } +} + +impl Action for DeleteTakeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + + if self.take_index >= instance.takes.len() { + return Err(format!("Take {} does not exist", self.take_index + 1)); + } + // An instance with no takes at all would fall back to the clip's own content, which for a + // cycle recording is a take we may just have deleted. Refuse rather than strand it. + if instance.takes.len() == 1 { + return Err("Can't delete the only take".to_string()); + } + + self.old_active_take = instance.active_take; + self.removed = Some(instance.takes.remove(self.take_index)); + + // Everything above the removed take shifts down one, so the selection has to move with it. + // Deleting the *active* take lands on the one that took its place (or the new last take, if + // it was at the end) — that keeps the clip sounding rather than silently picking take 1. + let active = instance.active_take.unwrap_or(0); + instance.active_take = Some(if active > self.take_index { + active - 1 + } else if active == self.take_index { + self.take_index.min(instance.takes.len() - 1) + } else { + active + }); + + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(take) = self.removed.take() else { + return Ok(()); + }; + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + let at = self.take_index.min(instance.takes.len()); + instance.takes.insert(at, take); + instance.active_take = self.old_active_take; + Ok(()) + } + + fn description(&self) -> String { + format!("Delete take {}", self.take_index + 1) + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + resync(backend, document, &self.layer_id, &self.instance_id) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + resync(backend, document, &self.layer_id, &self.instance_id) + } +} + +/// Throw away every take except the one that's playing. +/// +/// The tidy-up once you've picked your keeper. Scoped to this instance, so on a comped split it +/// prunes the half you clicked and leaves the other half's alternatives intact. +pub struct DeleteUnusedTakesAction { + layer_id: Uuid, + instance_id: Uuid, + + // Stored during execute for rollback. + old_takes: Vec, + old_active_take: Option, +} + +impl DeleteUnusedTakesAction { + pub fn new(layer_id: Uuid, instance_id: Uuid) -> Self { + Self { + layer_id, + instance_id, + old_takes: Vec::new(), + old_active_take: None, + } + } +} + +impl Action for DeleteUnusedTakesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + if instance.takes.len() < 2 { + return Err("Nothing to delete".to_string()); + } + + let keep = instance.active_take_index(); + self.old_takes = instance.takes.clone(); + self.old_active_take = instance.active_take; + + let kept = instance.takes.remove(keep); + instance.takes.clear(); + instance.takes.push(kept); + instance.active_take = Some(0); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + instance.takes = std::mem::take(&mut self.old_takes); + instance.active_take = self.old_active_take; + Ok(()) + } + + fn description(&self) -> String { + "Delete unused takes".to_string() + } + + // The take that plays doesn't change, so the backend clip is already correct. +} + +/// Rename a take. Document-only — which take *plays* doesn't change, so the backend is untouched. +pub struct RenameTakeAction { + layer_id: Uuid, + instance_id: Uuid, + take_index: usize, + new_name: String, + old_name: String, +} + +impl RenameTakeAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize, new_name: String) -> Self { + Self { + layer_id, + instance_id, + take_index, + new_name, + old_name: String::new(), + } + } +} + +impl Action for RenameTakeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + let take = instance + .takes + .get_mut(self.take_index) + .ok_or_else(|| format!("Take {} does not exist", self.take_index + 1))?; + self.old_name = std::mem::replace(&mut take.name, self.new_name.clone()); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + if let Some(take) = instance.takes.get_mut(self.take_index) { + take.name = self.old_name.clone(); + } + Ok(()) + } + + fn description(&self) -> String { + format!("Rename take to \"{}\"", self.new_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clip::TakeContent; + use crate::layer::AudioLayer; + + /// A document with one audio layer holding one instance with 4 takes (pools 10..13). + fn doc_with_takes() -> (Document, Uuid, Uuid) { + let mut document = Document::new("Test"); + let clip = crate::clip::AudioClip::new_sampled("Cycle rec", 10, 2.0); + let clip_id = document.add_audio_clip(clip); + + let mut instance = ClipInstance::new(clip_id); + instance.takes = (10..14) + .map(|pool| AudioTake { + name: format!("Take {}", pool - 9), + content: TakeContent::Audio { audio_pool_index: pool }, + }) + .collect(); + let instance_id = instance.id; + + let mut layer = AudioLayer::new("Layer"); + layer.clip_instances.push(instance); + let layer_id = document.root.add_child(AnyLayer::Audio(layer)); + (document, layer_id, instance_id) + } + + fn takes_of(document: &Document, layer_id: &Uuid, instance_id: &Uuid) -> ClipInstance { + let AnyLayer::Audio(al) = document.get_layer(layer_id).unwrap() else { panic!() }; + al.clip_instances.iter().find(|ci| ci.id == *instance_id).unwrap().clone() + } + + #[test] + fn deleting_a_take_below_the_active_one_shifts_the_selection_down() { + // Everything above the removed take shifts down one, so a selection above it has to move + // with it — otherwise the instance silently starts playing a different take. + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(3); // playing pool 13 + } + + DeleteTakeAction::new(layer_id, instance_id, 1) + .execute(&mut document) + .expect("delete"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 3); + assert_eq!(inst.active_take, Some(2), "index shifted down with the take"); + assert_eq!( + inst.takes[inst.active_take_index()].content, + TakeContent::Audio { audio_pool_index: 13 }, + "still playing the same take it was", + ); + } + + #[test] + fn deleting_the_active_take_lands_on_its_replacement() { + // Deleting what you're listening to should hand you the take that took its place, not + // silently jump you back to take 1. + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(1); // playing pool 11 + } + + DeleteTakeAction::new(layer_id, instance_id, 1) + .execute(&mut document) + .expect("delete"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.active_take, Some(1)); + assert_eq!( + inst.takes[1].content, + TakeContent::Audio { audio_pool_index: 12 }, + "the take that slid into the deleted one's place", + ); + } + + #[test] + fn deleting_the_last_take_in_the_list_steps_back() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(3); + } + + DeleteTakeAction::new(layer_id, instance_id, 3) + .execute(&mut document) + .expect("delete"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.active_take, Some(2), "there is no take 4 to land on"); + } + + #[test] + fn the_only_take_cannot_be_deleted() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].takes.truncate(1); + } + assert!(DeleteTakeAction::new(layer_id, instance_id, 0) + .execute(&mut document) + .is_err()); + } + + #[test] + fn undoing_a_delete_puts_the_take_back_where_it_was() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(2); + } + + let mut action = DeleteTakeAction::new(layer_id, instance_id, 1); + action.execute(&mut document).expect("delete"); + action.rollback(&mut document).expect("undo"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 4); + assert_eq!( + inst.takes[1].content, + TakeContent::Audio { audio_pool_index: 11 }, + "restored at its original index", + ); + assert_eq!(inst.active_take, Some(2), "and the selection with it"); + } + + #[test] + fn deleting_unused_takes_keeps_the_one_thats_playing() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(2); // pool 12 — the keeper + } + + let mut action = DeleteUnusedTakesAction::new(layer_id, instance_id); + action.execute(&mut document).expect("prune"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 1); + assert_eq!(inst.active_take, Some(0)); + assert_eq!( + inst.takes[0].content, + TakeContent::Audio { audio_pool_index: 12 }, + "the take that was playing survives, and nothing else", + ); + + action.rollback(&mut document).expect("undo"); + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 4); + assert_eq!(inst.active_take, Some(2), "back to what was playing before"); + } + + #[test] + fn renaming_a_take_round_trips() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + let mut action = + RenameTakeAction::new(layer_id, instance_id, 2, "The good one".to_string()); + + action.execute(&mut document).expect("rename"); + assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "The good one"); + + action.rollback(&mut document).expect("undo"); + assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "Take 3"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 7bed42f..a848cab 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -14,6 +14,10 @@ pub mod move_clip_instances; pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; +pub mod set_cycle_region; +pub mod append_takes; +pub mod manage_takes; +pub mod set_active_take; pub mod set_document_properties; pub mod set_instance_properties; pub mod set_layer_properties; @@ -50,6 +54,10 @@ pub mod set_text_content; pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; +pub use set_cycle_region::SetCycleRegionAction; +pub use append_takes::AppendTakesAction; +pub use manage_takes::{DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction}; +pub use set_active_take::SetActiveTakeAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index 73f49b2..20aa7ef 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction { let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| { let inst = clip_instances.iter().find(|ci| &ci.id == id)?; - let dur = document.get_clip_duration(&inst.clip_id)?; + let dur = document.clip_trim_duration(&inst.clip_id)?; let eff = inst.effective_duration_beats(dur, document.tempo_map()); Some((*id, *old_start, eff)) }).collect(); @@ -190,7 +190,7 @@ impl Action for MoveClipInstancesAction { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction { // Check if this clip has a metatrack if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -246,12 +247,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *new_start); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: move_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; @@ -263,7 +264,7 @@ impl Action for MoveClipInstancesAction { _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be moved - skip } } @@ -275,7 +276,7 @@ impl Action for MoveClipInstancesAction { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -295,8 +296,9 @@ impl Action for MoveClipInstancesAction { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -330,12 +332,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type (restore old position) - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *old_start); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: move_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; @@ -347,7 +349,7 @@ impl Action for MoveClipInstancesAction { _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be moved - skip } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index 947e6e0..319e397 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction { backend: &mut BackendContext, document: &Document, ) -> Result<(), String> { - use crate::clip::AudioClipType; + if backend.audio_controller.is_none() { + return Ok(()); + } - let controller = match backend.audio_controller.as_mut() { - Some(c) => c, - None => return Ok(()), - }; - - // Re-add clips that were removed from backend - for (layer_id, instance) in &self.saved { - let layer = match document.get_layer(layer_id) { - Some(l) => l, - None => continue, - }; - if !matches!(layer, AnyLayer::Audio(_)) { + // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same + // helper the add and split actions use, so the trim/duration conversions (and take-folder + // resolution) stay in exactly one place instead of being copied into every action that has + // to put a clip back. + let saved = std::mem::take(&mut self.saved); + for (layer_id, instance) in &saved { + if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) { continue; } - - let track_id = match backend.layer_to_track_map.get(layer_id) { - Some(id) => *id, - None => continue, - }; - - let clip = match document.get_audio_clip(&instance.clip_id) { - Some(c) => c, - None => continue, - }; - - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - use daw_backend::command::{Query, QueryResponse}; - - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = instance.timeline_start; - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - let midi_instance = daw_backend::MidiClipInstance::new( - 0, - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); - if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) = - controller.send_query(query) - { - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Midi(new_id), - ); - } - } - AudioClipType::Sampled { audio_pool_index } => { - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = instance.timeline_start; - // Fallback span is the content seconds converted to beats at the - // clip's start (not the seconds span treated as beats). - let effective_duration = instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let new_id = controller.add_audio_clip( - track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Audio(new_id), - ); - } - AudioClipType::Recording => {} - } + // A missing track/clip just means there's nothing to restore on the backend. + let _ = backend.add_clip_instance(document, layer_id, instance); } + self.saved = saved; // Clear saved backend IDs self.saved_backend_ids.clear(); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs new file mode 100644 index 0000000..142d485 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs @@ -0,0 +1,117 @@ +//! Choose which take of a take-folder clip an instance plays. +//! +//! The take list lives on the *clip* but the selection lives on the *instance*, so two instances of +//! the same folder can play different takes. Splitting a take-folder instance clones it, which is +//! what makes comping work: set take 1 on the left half and take 3 on the right, and you've comped. +//! +//! There's no in-place pool-swap command in the backend, so switching a take means removing the +//! instance's backend clip and re-adding it against the new take's audio/MIDI resource. Both halves +//! of that go through `BackendContext`, which is also what `AddClipInstanceAction` uses. + +use crate::action::{Action, BackendClipInstanceId, BackendContext}; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Action that points a clip instance at a different take of its take folder. +#[derive(Clone)] +pub struct SetActiveTakeAction { + layer_id: Uuid, + instance_id: Uuid, + new_take: Option, + old_take: Option, + /// The backend track/clip the instance was on before we swapped, so rollback can undo it. + backend_track_id: Option, +} + +impl SetActiveTakeAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, new_take: usize, old_take: Option) -> Self { + Self { + layer_id, + instance_id, + new_take: Some(new_take), + old_take, + backend_track_id: None, + } + } + + /// Point the instance at `take`, mutating the document. Shared by execute and rollback. + fn apply(&self, document: &mut Document, take: Option) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Audio(audio_layer) = layer else { + return Err("Take folders only exist on audio layers".to_string()); + }; + let instance = audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == self.instance_id) + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + instance.active_take = take; + Ok(()) + } + + /// Swap the instance's backend clip to whatever take the document now says is active. + /// + /// Called after the document has already been mutated, so re-adding just re-resolves the + /// instance — `BackendContext::add_clip_instance` reads `active_take` itself. + fn resync(&mut self, backend: &mut BackendContext, document: &Document) -> Result<(), String> { + let instance = document + .get_layer(&self.layer_id) + .and_then(|l| match l { + AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id), + _ => None, + }) + .cloned() + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + + // Drop the old backend clip first. Its track comes from the map we're about to overwrite, + // so read it before add_clip_instance replaces the entry. + let existing: Option = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); + let track_id = backend.layer_to_track_map.get(&self.layer_id).copied(); + if let (Some(backend_id), Some(track_id)) = (existing, track_id) { + backend.remove_clip_instance(track_id, backend_id, self.instance_id); + } + + let added = backend.add_clip_instance(document, &self.layer_id, &instance)?; + self.backend_track_id = added.map(|(track_id, _)| track_id); + Ok(()) + } +} + +impl Action for SetActiveTakeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + self.apply(document, self.new_take) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + self.apply(document, self.old_take) + } + + fn description(&self) -> String { + match self.new_take { + Some(i) => format!("Select take {}", i + 1), + None => "Select take".to_string(), + } + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs new file mode 100644 index 0000000..28d1597 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs @@ -0,0 +1,93 @@ +//! Set the transport cycle (loop) region. +//! +//! The cycle region is document state (it's saved in the `.beam`), so changing it goes through the +//! action system like any other edit: it's undoable and it marks the document modified. +//! +//! The region is stored in **beats** so it stays put musically across tempo changes. Callers commit +//! one action per gesture (e.g. on drag release, or a toggle click) rather than one per frame — +//! the timeline previews the drag from its own local state, exactly like a clip drag does. + +use crate::action::{Action, BackendContext}; +use crate::document::Document; +use daw_backend::Beats; + +/// Action that sets the cycle region and/or whether the transport loops over it. +#[derive(Clone)] +pub struct SetCycleRegionAction { + old_region: Option<(Beats, Beats)>, + old_enabled: bool, + new_region: Option<(Beats, Beats)>, + new_enabled: bool, +} + +impl SetCycleRegionAction { + /// Build from the document's current state and the desired new region/enabled flag. + pub fn new( + document: &Document, + new_region: Option<(Beats, Beats)>, + new_enabled: bool, + ) -> Self { + Self { + old_region: document.cycle_region, + old_enabled: document.cycle_enabled, + new_region, + new_enabled, + } + } + + /// Toggle looping on/off, leaving the region itself alone. + pub fn toggle_enabled(document: &Document) -> Self { + Self::new(document, document.cycle_region, !document.cycle_enabled) + } + + /// True if this action would not actually change anything (lets callers skip a no-op undo entry). + pub fn is_noop(&self) -> bool { + self.old_region == self.new_region && self.old_enabled == self.new_enabled + } +} + +impl Action for SetCycleRegionAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + document.cycle_region = self.new_region; + document.cycle_enabled = self.new_enabled; + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + document.cycle_region = self.old_region; + document.cycle_enabled = self.old_enabled; + Ok(()) + } + + fn description(&self) -> String { + "Set cycle region".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + controller.set_loop_region(self.new_region); + controller.set_loop_enabled(self.new_enabled); + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = match backend.audio_controller.as_mut() { + Some(c) => c, + None => return Ok(()), + }; + controller.set_loop_region(self.old_region); + controller.set_loop_enabled(self.old_enabled); + Ok(()) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index 1e2c6d4..2f19b85 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext}; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; +use daw_backend::ContentTime; use uuid::Uuid; /// Action that splits a clip instance at a specific timeline position @@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction { // Stored during execute for rollback /// Original trim_end value of the left (original) instance - original_trim_end: Option, + original_trim_end: Option, /// Original timeline_duration value of the left (original) instance (beats) original_timeline_duration: Option, /// ID of the new (right) instance created by the split @@ -122,13 +123,15 @@ impl Action for SplitClipInstanceAction { .find(|ci| ci.id == self.instance_id) .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; - // Get the clip's duration - let clip_duration = document - .get_clip_duration(&instance.clip_id) + // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for + // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain + // this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim. + let trim_duration = document + .clip_trim_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; // Calculate the effective duration and timeline end (both in beats) - let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); + let effective_duration = instance.effective_duration(trim_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; // Validate: split_time must be strictly within the clip's timeline span @@ -146,27 +149,30 @@ impl Action for SplitClipInstanceAction { self.original_trim_end = instance.trim_end; self.original_timeline_duration = instance.timeline_duration; - // Check if this is a looping clip. `content_duration` is a trim-domain - // span (seconds), so `clip_duration` must be unwrapped as seconds. let is_looping = instance.timeline_duration.is_some(); - let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start; + let content_duration = ContentTime(instance.content_window(trim_duration).native()); // Timeline split point (beats). let time_into_clip = self.split_time - instance.timeline_start; let left_duration = time_into_clip; let right_duration = effective_duration - left_duration; - // How far the split lands into the clip's *content* (seconds, trim domain). + // How far the split lands into the clip's *content*, expressed in the content domain: beats + // content takes the beats delta directly, wall-clock content takes the seconds delta. let tempo_map = document.tempo_map(); - let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time) - - tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64(); + let time_into_content = ContentTime(match trim_duration { + crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(), + crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time) + - tempo_map.beats_to_seconds(instance.timeline_start)) + .seconds_to_f64(), + }); - // Calculate content split time (seconds) - let content_split_time = if is_looping { + // Calculate the content split point (content domain). + let content_split_time = if is_looping && content_duration > ContentTime::ZERO { // For looping clips, wrap around content - instance.trim_start + (time_into_clip_secs % content_duration) + instance.trim_start + (time_into_content % content_duration) } else { - instance.trim_start + time_into_clip_secs + instance.trim_start + time_into_content }; // Clone the instance for the right side @@ -357,119 +363,63 @@ impl Action for SplitClipInstanceAction { .get_audio_clip(&new_instance.clip_id) .ok_or_else(|| "Audio clip not found".to_string())?; - // Look up backend track ID from layer mapping - let backend_track_id = backend + use crate::clip::ResolvedContent; + if matches!(original_instance.resolve(clip), ResolvedContent::Recording) { + return Err("Cannot split a clip that is currently recording".to_string()); + } + + // A split is: shorten the left half's backend clip, then add the right half as a new one. + // + // 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own + // content domain, so a MIDI clip's beats trims can't be sent as seconds. + let left_trim = clip.trim_range( + original_instance.trim_start, + original_instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())), + ); + let new_instance = new_instance.clone(); + + let backend_track_id = *backend .layer_to_track_map .get(&self.layer_id) .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; + let left_backend_id = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); - // Get audio controller - let controller = backend - .audio_controller - .as_mut() - .ok_or_else(|| "Audio controller not available".to_string())?; - - // Handle different clip types - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - use daw_backend::command::{Query, QueryResponse}; - - // 1. Trim the original (left) instance - let orig_internal_start = original_instance.trim_start; - let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native()); - - // Look up the original backend instance ID - if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = - backend.clip_instance_to_backend_map.get(&self.instance_id) - { - controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end); + { + let controller = backend + .audio_controller + .as_mut() + .ok_or_else(|| "Audio controller not available".to_string())?; + match left_backend_id { + Some(crate::action::BackendClipInstanceId::Midi(id)) + | Some(crate::action::BackendClipInstanceId::Audio(id)) => { + controller.trim_clip(backend_track_id, id, left_trim); } - - // 2. Add the new (right) instance - let internal_start = new_instance.trim_start; - let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = new_instance.timeline_start; - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = new_instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - let instance = daw_backend::MidiClipInstance::new( - 0, - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); - - match controller.send_query(query)? { - QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => { - self.backend_track_id = Some(*backend_track_id); - self.backend_midi_instance_id = Some(instance_id); - - backend.clip_instance_to_backend_map.insert( - new_instance_id, - crate::action::BackendClipInstanceId::Midi(instance_id), - ); - - Ok(()) - } - QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e), - _ => Err("Unexpected query response".to_string()), - } - } - AudioClipType::Sampled { audio_pool_index } => { - // 1. Trim the original (left) instance - let orig_internal_start = original_instance.trim_start; - let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native()); - - // Look up the original backend instance ID - if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = - backend.clip_instance_to_backend_map.get(&self.instance_id) - { - controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end); - } - - // 2. Add the new (right) instance - let internal_start = new_instance.trim_start; - let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = new_instance.timeline_start; - // Fallback span is the content seconds converted to beats at the - // clip's start (not the seconds span treated as beats). - let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let instance_id = controller.add_audio_clip( - *backend_track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - - self.backend_track_id = Some(*backend_track_id); - self.backend_audio_instance_id = Some(instance_id); - - backend.clip_instance_to_backend_map.insert( - new_instance_id, - crate::action::BackendClipInstanceId::Audio(instance_id), - ); - - Ok(()) - } - AudioClipType::Recording => { - // Recording clips cannot be split - Err("Cannot split a clip that is currently recording".to_string()) + None => {} } } + + // 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction + // uses, so the trim/duration conversions live in exactly one place. + if let Some((track_id, backend_id)) = + backend.add_clip_instance(document, &self.layer_id, &new_instance)? + { + self.backend_track_id = Some(track_id); + match backend_id { + crate::action::BackendClipInstanceId::Midi(id) => { + self.backend_midi_instance_id = Some(id) + } + crate::action::BackendClipInstanceId::Audio(id) => { + self.backend_audio_instance_id = Some(id) + } + } + } + + Ok(()) } fn rollback_backend( @@ -499,26 +449,28 @@ impl Action for SplitClipInstanceAction { if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) { if let Some(clip) = document.get_audio_clip(&instance.clip_id) { let orig_internal_start = instance.trim_start; - let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); + let orig_internal_end = self + .original_trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Restore based on clip type - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { .. } => { + use crate::clip::ResolvedContent; + match &instance.resolve(clip) { + ResolvedContent::Midi { .. } => { if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) { - controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); + controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) { - controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); + controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips - nothing to rollback } } @@ -554,8 +506,8 @@ mod tests { // Create a clip instance at timeline 0, with trim 0-10 (10 seconds) let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = daw_backend::Beats::ZERO; - clip_instance.trim_start = 0.0; - clip_instance.trim_end = Some(10.0); + clip_instance.trim_start = ContentTime::ZERO; + clip_instance.trim_end = Some(ContentTime(10.0)); let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -575,4 +527,42 @@ mod tests { let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0)); assert_eq!(action.description(), "Split clip instance"); } + + #[test] + fn splitting_a_midi_clip_stays_in_the_beats_domain() { + // Regression: `trim_start`/`trim_end` are domain-polymorphic — SECONDS for audio/video/ + // vector, but BEATS for MIDI (the backend takes MIDI trims as `Beats`). Split used to map + // the split point into the clip's content in seconds unconditionally, so on a MIDI clip it + // added a seconds delta to a beats offset. At anything but 60 BPM the right half started at + // the wrong place in the content. + // + // At 120 BPM, beat 4 is 2 SECONDS in. The right half must trim to beat 4, not "4 seconds" + // (= beat 8) and not 2 (the seconds value). + let mut document = Document::new("Test"); + document.set_bpm(120.0); + + // 8-beat MIDI clip at the timeline origin. + let clip = crate::clip::AudioClip::new_midi("Midi", 1, daw_backend::Beats(8.0)); + let clip_id = document.add_audio_clip(clip); + + let mut audio_layer = crate::layer::AudioLayer::new("Layer 1"); + let mut instance = ClipInstance::new(clip_id); + instance.timeline_start = daw_backend::Beats::ZERO; + instance.trim_start = ContentTime::ZERO; + instance.trim_end = Some(ContentTime(8.0)); // beats + let instance_id = instance.id; + audio_layer.clip_instances.push(instance); + let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer)); + + let mut action = SplitClipInstanceAction::new(layer_id, instance_id, daw_backend::Beats(4.0)); + action.execute(&mut document).expect("split"); + let new_id = action.new_instance_id().expect("right instance"); + + let AnyLayer::Audio(al) = document.get_layer(&layer_id).unwrap() else { panic!() }; + let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap(); + let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap(); + + assert_eq!(right.trim_start, ContentTime(4.0), "right half must start 4 BEATS into the content"); + assert_eq!(left.trim_end, Some(ContentTime(4.0)), "left half must end 4 BEATS into the content"); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index d4d48bc..7e99d94 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -6,7 +6,7 @@ use crate::action::Action; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use std::collections::HashMap; use uuid::Uuid; @@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction { pub struct TrimData { /// For TrimLeft: trim_start value /// For TrimRight: trim_end value (Option because it can be None) - pub trim_value: Option, + /// + /// A content time — measured in the clip's own domain (seconds for audio/video/vector, beats + /// for MIDI), so it must be resolved against the clip before meeting a timeline position. + pub trim_value: Option, /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimRight: unused (None) pub timeline_start: Option, } +/// A wall-clock gap on the timeline, expressed in a clip's content domain. +/// +/// Trim validation clamps how far a clip may be dragged against the empty space next to it, and that +/// space is measured on the timeline (seconds) while the trim lives in the clip's content domain. For +/// wall-clock content they're the same number; for MIDI (beats content) the gap has to be converted +/// at the clip's position, or a seconds gap silently clamps a beats trim. +fn gap_to_content( + gap: Seconds, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> ContentTime { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => ContentTime(gap.seconds_to_f64()), + crate::clip::ClipDuration::Beats(_) => { + let beats = tempo_map + .seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + gap) + - timeline_start; + ContentTime(beats.beats_to_f64()) + } + } +} + +/// The inverse: a content-domain span as wall-clock seconds at the clip's position. +fn content_to_secs( + span: ContentTime, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> Seconds { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => Seconds(span.raw()), + crate::clip::ClipDuration::Beats(_) => { + tempo_map.beats_to_seconds(timeline_start + Beats(span.raw())) + - tempo_map.beats_to_seconds(timeline_start) + } + } +} + impl TrimData { /// Create TrimData for left trim - pub fn left(trim_start: f64, timeline_start: Beats) -> Self { + pub fn left(trim_start: ContentTime, timeline_start: Beats) -> Self { Self { trim_value: Some(trim_start), timeline_start: Some(timeline_start), @@ -48,7 +90,7 @@ impl TrimData { } /// Create TrimData for right trim - pub fn right(trim_end: Option) -> Self { + pub fn right(trim_end: Option) -> Self { Self { trim_value: trim_end, timeline_start: None, @@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction { .find(|ci| &ci.id == instance_id) .ok_or_else(|| format!("Instance {} not found", instance_id))?; - let clip_duration = document.get_clip_duration(&instance.clip_id) + // The clip's content duration in ITS OWN domain, so trims resolve correctly for MIDI. + let clip_content = document.clip_trim_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; let mut clamped_new = new.clone(); @@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction { { // If extending to the left (new_trim < old_trim) if should_validate && new_trim < old_trim { - // Max leftward extension as content seconds (the gap's wall-clock span). - let max_extend_secs = document.find_max_trim_extend_left( - layer_id, - instance_id, - instance.timeline_start, - ).seconds_to_f64(); - - // Calculate how much we want to extend (content seconds) - let desired_extend = old_trim - new_trim; - - // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend_secs); - let clamped_trim_start = old_trim - actual_extend; - // Move the timeline left by the same wall-clock seconds. let tempo_map = document.tempo_map(); + + // Max leftward extension: the gap's wall-clock span, converted into + // the clip's content domain so it can clamp a content-domain trim. + let max_extend = gap_to_content( + document.find_max_trim_extend_left( + layer_id, + instance_id, + instance.timeline_start, + ), + clip_content, + instance.timeline_start, + tempo_map, + ); + + let desired_extend = old_trim - new_trim; + let actual_extend = desired_extend.min(max_extend); + let clamped_trim_start = old_trim - actual_extend; + + // Move the timeline left by the same span, as wall-clock seconds. + let shift = content_to_secs( + actual_extend, + clip_content, + instance.timeline_start, + tempo_map, + ); let clamped_timeline_start = tempo_map - .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend)) + .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - shift) .max(Beats::ZERO); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); @@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction { } } TrimType::TrimRight => { - let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); - let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); + let content_end = ContentTime(clip_content.native()); + let old_trim_end = old.trim_value.unwrap_or(content_end); + let new_trim_end = new.trim_value.unwrap_or(content_end); // If extending to the right (new_trim_end > old_trim_end) if should_validate && new_trim_end > old_trim_end { let tempo_map = document.tempo_map(); - // Current effective duration in beats (content seconds - // converted to beats at the clip's start). - let content_secs = Seconds(old_trim_end - instance.trim_start); - let current_effective_duration = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) + content_secs, - ) - instance.timeline_start; - // Max rightward extension as content seconds (the gap's wall-clock span). - let max_extend_secs = document.find_max_trim_extend_right( - layer_id, - instance_id, + // How long the clip currently occupies the timeline, in beats. Resolved + // in the clip's own domain, so a MIDI clip's beats content isn't run + // through the seconds→beats conversion a second time. + let current_effective_duration = instance + .effective_duration_beats(clip_content, tempo_map); + + // Max rightward extension: the gap's wall-clock span, in content domain. + let max_extend = gap_to_content( + document.find_max_trim_extend_right( + layer_id, + instance_id, + instance.timeline_start, + current_effective_duration, + ), + clip_content, instance.timeline_start, - current_effective_duration, - ).seconds_to_f64(); + tempo_map, + ); - // Calculate how much we want to extend (content seconds) let desired_extend = new_trim_end - old_trim_end; - - // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend_secs); + let actual_extend = desired_extend.min(max_extend); let clamped_trim_end = old_trim_end + actual_extend; - // Don't exceed clip duration - let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); + // Don't exceed the clip's content. + let final_trim_end = clamped_trim_end.min(content_end); clamped_new = TrimData::right(Some(final_trim_end)); } @@ -366,7 +423,7 @@ impl Action for TrimClipInstancesAction { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -387,8 +444,9 @@ impl Action for TrimClipInstancesAction { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has new values after execute() controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -424,27 +482,29 @@ impl Action for TrimClipInstancesAction { // Calculate new internal_start and internal_end for backend // Note: instance already has the new trim values after execute() let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); + let internal_end = instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: trim_clip expects the pool clip ID - controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end); + controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end)); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: trim_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; match backend_instance_id { crate::action::BackendClipInstanceId::Audio(audio_id) => { - controller.trim_clip(*track_id, *audio_id, internal_start, internal_end); + controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end)); } _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be trimmed - skip } } @@ -456,7 +516,7 @@ impl Action for TrimClipInstancesAction { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -477,8 +537,9 @@ impl Action for TrimClipInstancesAction { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has old values after rollback() controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -512,34 +573,35 @@ impl Action for TrimClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Calculate old internal_start and internal_end for backend + let content_end = ContentTime(clip.content_duration().native()); let internal_start = match trim_type { - TrimType::TrimLeft => old.trim_value.unwrap_or(0.0), + TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO), TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed }; let internal_end = match trim_type { - TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed - TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()), + TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed + TrimType::TrimRight => old.trim_value.unwrap_or(content_end), }; // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &instance.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: trim_clip expects the pool clip ID - controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end); + controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end)); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: trim_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; match backend_instance_id { crate::action::BackendClipInstanceId::Audio(audio_id) => { - controller.trim_clip(*track_id, *audio_id, internal_start, internal_end); + controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end)); } _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be trimmed - skip } } @@ -569,7 +631,7 @@ mod tests { let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = Beats::ZERO; - clip_instance.trim_start = 0.0; + clip_instance.trim_start = ContentTime::ZERO; let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -582,8 +644,8 @@ mod tests { vec![( instance_id, TrimType::TrimLeft, - TrimData::left(0.0, Beats::ZERO), - TrimData::left(2.0, Beats(2.0)), + TrimData::left(ContentTime::ZERO, Beats::ZERO), + TrimData::left(ContentTime(2.0), Beats(2.0)), )], ); @@ -599,7 +661,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_start, ContentTime(2.0)); assert_eq!(instance.timeline_start, Beats(2.0)); } @@ -613,7 +675,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO); } } @@ -644,7 +706,7 @@ mod tests { instance_id, TrimType::TrimRight, TrimData::right(None), - TrimData::right(Some(8.0)), + TrimData::right(Some(ContentTime(8.0))), )], ); @@ -660,7 +722,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); } // Rollback diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index dd294c4..f39da7c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -14,7 +14,7 @@ use crate::layer::AnyLayer; use crate::layer_tree::LayerTree; use crate::object::Transform; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use uuid::Uuid; @@ -130,10 +130,14 @@ impl VectorClip { let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { ci.timeline_start + td_beats } else if let Some(te) = ci.trim_end { - let secs = (te - ci.trim_start).max(0.0); + // `clip_duration_fn` hands back seconds, so this whole path treats nested + // content as wall-clock. That's right for the vector/video/audio clips a vector + // clip actually nests; a nested MIDI clip (beats content) would need resolving + // against its clip, which this callback can't do. Pre-existing limitation. + let secs = (te - ci.trim_start).raw().max(0.0); tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) { - let secs = (clip_dur_secs - ci.trim_start).max(0.0); + let secs = (clip_dur_secs - ci.trim_start.raw()).max(0.0); tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else { continue; @@ -201,7 +205,9 @@ impl VectorClip { // Convert parent clip time (seconds) to nested clip local time (seconds). // timeline_start is in beats; convert to seconds using document BPM. let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + // Nested clips here are vector clips, whose content is wall-clock seconds. + let nested_clip_time = + ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Look up the nested clip definition let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { @@ -468,6 +474,25 @@ pub enum AudioClipType { Recording, } +/// One take of a cycle recording — see [`ClipInstance::takes`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AudioTake { + /// Display name, e.g. "Take 1". User-editable. + pub name: String, + /// The recorded content this take points at. + pub content: TakeContent, +} + +/// What a take actually holds. An instance's takes are all the same kind — one cycle-record session +/// captures either audio or MIDI, never a mix. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum TakeContent { + /// Sampled audio: index into the audio pool. + Audio { audio_pool_index: usize }, + /// MIDI: backend MIDI clip ID. + Midi { midi_clip_id: u32 }, +} + /// A clip's content duration, tagged by its native unit. /// /// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures @@ -497,6 +522,17 @@ impl ClipDuration { ClipDuration::Beats(b) => b.beats_to_f64(), } } + + /// Tag a [`ContentTime`] with *this* duration's domain. + /// + /// Handy when you already hold a clip's content duration (so you know the domain) and need to + /// resolve one of its trim bounds, without going back to the clip. + pub fn same_domain(self, t: ContentTime) -> ClipDuration { + match self { + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())), + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())), + } + } } /// Audio clip @@ -532,11 +568,10 @@ impl AudioClip { /// The clip's content duration, tagged with its native domain (seconds for sampled/recording, /// beats for MIDI). This is the only sanctioned way to read the raw `duration` field. pub fn content_duration(&self) -> ClipDuration { - match self.clip_type { - AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)), - AudioClipType::Sampled { .. } | AudioClipType::Recording => { - ClipDuration::Seconds(Seconds(self.duration)) - } + if self.is_midi_domain() { + ClipDuration::Beats(Beats(self.duration)) + } else { + ClipDuration::Seconds(Seconds(self.duration)) } } @@ -545,15 +580,19 @@ impl AudioClip { pub fn set_content_duration(&mut self, duration: ClipDuration) { debug_assert!( matches!( - (&self.clip_type, duration), - (AudioClipType::Midi { .. }, ClipDuration::Beats(_)) - | (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_)) + (self.is_midi_domain(), duration), + (true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_)) ), "clip duration domain must match clip type", ); self.duration = duration.native(); } + /// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds. + fn is_midi_domain(&self) -> bool { + matches!(self.clip_type, AudioClipType::Midi { .. }) + } + /// Create a new sampled audio clip /// /// # Arguments @@ -637,6 +676,73 @@ impl AudioClip { _ => None, } } + + /// The clip's own content, ignoring takes. + /// + /// Callers that are handing content to the backend want [`ClipInstance::resolve`] instead — an + /// instance with takes overrides the clip's content with whichever take is active. + pub fn resolve(&self) -> ResolvedContent { + match &self.clip_type { + AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio { + audio_pool_index: *audio_pool_index, + }, + AudioClipType::Midi { midi_clip_id } => ResolvedContent::Midi { + midi_clip_id: *midi_clip_id, + }, + AudioClipType::Recording => ResolvedContent::Recording, + } + } + + /// Resolve a content time against this clip's domain. + /// + /// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has + /// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows + /// whether its content is measured in seconds or beats, has to be consulted. + pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration { + if self.is_midi_domain() { + ClipDuration::Beats(Beats(t.raw())) + } else { + ClipDuration::Seconds(Seconds(t.raw())) + } + } + + /// Tag a pair of trim bounds with this clip's content domain, ready for the backend. + /// + /// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant: + /// the clip is the one thing that knows the domain. + pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange { + if self.is_midi_domain() { + daw_backend::command::TrimRange::Beats { + start: Beats(start.raw()), + end: Beats(end.raw()), + } + } else { + daw_backend::command::TrimRange::Seconds { + start: Seconds(start.raw()), + end: Seconds(end.raw()), + } + } + } + + /// Whether this clip's own content is the given audio pool index. + pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool { + self.audio_pool_index() == Some(pool_index) + } + + /// Whether this clip's own content is the given backend MIDI clip ID. + pub fn owns_midi_clip_id(&self, id: u32) -> bool { + self.midi_clip_id() == Some(id) + } +} + +/// What a clip instance actually plays, once takes are resolved to the active one. +/// Produced by [`ClipInstance::resolve`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ResolvedContent { + Audio { audio_pool_index: usize }, + Midi { midi_clip_id: u32 }, + /// A recording in progress (or an empty take folder) — no backend content yet. + Recording, } /// Unified clip enum for polymorphic handling @@ -715,16 +821,17 @@ pub struct ClipInstance { /// Default: None (use trimmed clip duration, no looping) pub timeline_duration: Option, - /// Trim start: offset into the clip's internal content, in **seconds**. - /// - For audio: byte-offset into the audio file - /// - For video: seek position in the video file - /// - For vector: time offset into the animation + /// Trim start: offset into the clip's internal content. + /// + /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled + /// audio/video/vector but BEATS for MIDI. Resolve it against the clip + /// ([`Document::resolve_content_time`]) before combining it with anything on the timeline. /// Default: 0.0 - pub trim_start: f64, + pub trim_start: ContentTime, - /// Trim end: offset into the clip's internal content, in **seconds**. + /// Trim end: offset into the clip's internal content. See [`Self::trim_start`]. /// Default: None (use full clip duration) - pub trim_end: Option, + pub trim_end: Option, /// Playback speed multiplier /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed @@ -741,6 +848,35 @@ pub struct ClipInstance { /// Default: None (no pre-loop) #[serde(default, skip_serializing_if = "Option::is_none")] pub loop_before: Option, + + /// Alternate takes from cycle recording. Empty = an ordinary instance with no takes. + /// + /// The takes live on the INSTANCE, not the clip, so managing them is per-instance: deleting or + /// renaming a take on one instance leaves every other instance alone. Splitting clones the list + /// along with the rest of the instance, so the two halves get independent take lists — and, + /// since they can each select a different take, comping still falls out for free. + /// + /// Every take spans the full cycle region (partial passes are padded with silence at capture + /// time), so they're all the same length as the clip's own content. That uniformity is what lets + /// a take switch leave the instance's geometry untouched. + /// + /// When non-empty, these OVERRIDE the clip's own content — see [`Self::resolve`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub takes: Vec, + + /// Which of [`Self::takes`] plays. `None` (or a stale index) means take 0. + /// Default: None + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_take: Option, + + /// The cycle region's length in beats when these takes were recorded. + /// + /// Audio takes are cut geometrically (by sample count), so they're only meaningful against the + /// tempo they were recorded at. Keeping the recorded length lets a future time-stretch/conform + /// feature reconcile them if the tempo moves underneath, and lets a new recording tell whether + /// it belongs in this take list or a fresh one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recorded_loop_beats: Option, } /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. @@ -794,11 +930,14 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, loop_before: None, + takes: Vec::new(), + active_take: None, + recorded_loop_beats: None, } } @@ -812,11 +951,14 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, loop_before: None, + takes: Vec::new(), + active_take: None, + recorded_loop_beats: None, } } @@ -852,7 +994,7 @@ impl ClipInstance { } /// Set trimming (start and end time within the clip's internal content) - pub fn with_trimming(mut self, trim_start: f64, trim_end: Option) -> Self { + pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option) -> Self { self.trim_start = trim_start; self.trim_end = trim_end; self @@ -876,24 +1018,89 @@ impl ClipInstance { self } - /// Content window size in seconds: `trim_end - trim_start`. + /// The take this instance plays, if it has any. + /// + /// A `None`/stale `active_take` falls back to take 0 — an index can go stale (an undo that + /// shrank the list, an old `.beam`), and silently playing the first take beats playing nothing. + pub fn active_take(&self) -> Option<&AudioTake> { + self.takes + .get(self.active_take.unwrap_or(0)) + .or_else(|| self.takes.first()) + } + + /// The index [`Self::active_take`] actually resolves to, clamped into range. + pub fn active_take_index(&self) -> usize { + let i = self.active_take.unwrap_or(0); + if i < self.takes.len() { i } else { 0 } + } + + /// What this instance plays: its active take if it has takes, otherwise the clip's own content. + /// + /// This is the sanctioned way to ask "what content do I hand the backend for this instance?". + /// Takes are never a distinct *case* at the call site — an instance with takes is just an audio + /// or MIDI instance whose identity depends on which take is live. + pub fn resolve(&self, clip: &AudioClip) -> ResolvedContent { + match self.active_take().map(|t| &t.content) { + Some(TakeContent::Audio { audio_pool_index }) => ResolvedContent::Audio { + audio_pool_index: *audio_pool_index, + }, + Some(TakeContent::Midi { midi_clip_id }) => ResolvedContent::Midi { + midi_clip_id: *midi_clip_id, + }, + None => clip.resolve(), + } + } + + /// The audio pool index this instance plays. See [`Self::resolve`]. + pub fn resolved_audio_pool_index(&self, clip: &AudioClip) -> Option { + match self.resolve(clip) { + ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index), + _ => None, + } + } + + /// The backend MIDI clip ID this instance plays. See [`Self::resolve`]. + pub fn resolved_midi_clip_id(&self, clip: &AudioClip) -> Option { + match self.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id), + _ => None, + } + } + + /// Content window (`trim_end - trim_start`) in the clip's own content domain. /// Used for internal looping calculations. - pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { - let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); - Seconds((end - self.trim_start).max(0.0)) + pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration { + let end = self.trim_end.map_or(clip_content.native(), |t| t.raw()); + let window = (end - self.trim_start.raw()).max(0.0); + match clip_content { + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)), + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)), + } } /// How long this instance appears on the timeline, in **beats**. /// - /// If `timeline_duration` is set, returns that (enabling content looping). - /// Otherwise converts the content window from seconds to beats using the tempo map. - pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + /// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip + /// occupies its content window — converted to beats *in the clip's own domain*: + /// + /// - MIDI content is already beats and is tempo-invariant, so it carries over directly. + /// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's + /// position on the timeline. + /// + /// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to + /// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a + /// beats offset from a seconds duration and got the clip's length wrong. + pub fn effective_duration_beats(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { if let Some(td) = self.timeline_duration { return td; } - let window = self.content_window_secs(clip_duration_secs); - let start_secs = tempo_map.beats_to_seconds(self.timeline_start); - tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start + match self.content_window(clip_content) { + ClipDuration::Beats(b) => b, + ClipDuration::Seconds(s) => { + let start_secs = tempo_map.beats_to_seconds(self.timeline_start); + tempo_map.seconds_to_beats(start_secs + s) - self.timeline_start + } + } } /// Left edge of the clip's visual extent on the timeline, in **beats**. @@ -902,27 +1109,32 @@ impl ClipInstance { } /// Total visual duration (loop_before + effective_duration), in **beats**. - pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map) } /// Map a playback time (in **seconds**) to clip-local content time (in **seconds**). /// - /// Returns `None` if the clip instance is not active at `time_secs`. - pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { + /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats + /// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video + /// seek, vector/raster rendering), which want seconds regardless of how the clip stores content. + /// + /// Returns `None` if the clip instance is not active at `time`. + pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { let start_secs = tempo_map.beats_to_seconds(self.timeline_start); - let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); + let dur_beats = self.effective_duration_beats(clip_content, tempo_map); let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats); if time < start_secs || time >= end_secs { return None; } + let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map); let content_time = (time - start_secs) * self.playback_speed; - let content_window = self.content_window_secs(clip_duration_secs); + let content_window = self.content_window(clip_content).to_seconds(tempo_map); if content_window == Seconds::ZERO { - return Some(Seconds(self.trim_start)); + return Some(trim_start_secs); } let looped = if content_time > content_window { @@ -931,19 +1143,19 @@ impl ClipInstance { content_time }; - Some(Seconds(self.trim_start) + looped) + Some(trim_start_secs + looped) } /// Alias for `remap_time_secs`. #[inline] - pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { - self.remap_time_secs(time, clip_duration_secs, tempo_map) + pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { + self.remap_time_secs(time, clip_content, tempo_map) } /// Alias for `effective_duration_beats`. #[inline] - pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.effective_duration_beats(clip_content, tempo_map) } /// Convert to affine transform @@ -1020,7 +1232,7 @@ mod tests { assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.opacity, 1.0); assert_eq!(instance.timeline_start, Beats::ZERO); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.trim_end, None); assert_eq!(instance.playback_speed, 1.0); assert_eq!(instance.gain, 1.0); @@ -1030,27 +1242,52 @@ mod tests { fn test_clip_instance_trimming() { let clip_id = Uuid::new_v4(); let instance = ClipInstance::new(clip_id) - .with_trimming(2.0, Some(8.0)); + .with_trimming(ContentTime(2.0), Some(ContentTime(8.0))); - assert_eq!(instance.trim_start, 2.0); - assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.trim_start, ContentTime(2.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); // At 60 BPM the tempo map is identity (1 beat == 1 second), so the // beats-domain effective duration equals the seconds content window. let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0)); + let content = ClipDuration::Seconds(Seconds(10.0)); + assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0)); } #[test] fn test_clip_instance_no_end_trim() { let clip_id = Uuid::new_v4(); let instance = ClipInstance::new(clip_id) - .with_trimming(2.0, None); + .with_trimming(ContentTime(2.0), None); - assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_start, ContentTime(2.0)); assert_eq!(instance.trim_end, None); // At 60 BPM the tempo map is identity (1 beat == 1 second). let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0)); + let content = ClipDuration::Seconds(Seconds(10.0)); + assert_eq!(instance.effective_duration(content, &tempo_map), Beats(8.0)); + } + + #[test] + fn trimmed_midi_clip_keeps_its_beats_length_across_tempo() { + // Regression: `effective_duration_beats` used to take a SECONDS clip duration and subtract + // `trim_start` from it. For a TRIMMED MIDI clip that subtracted a beats offset from a + // seconds duration, so the clip's timeline length came out wrong at any tempo but 60 BPM. + // + // MIDI content is beats and tempo-invariant: a clip trimmed to beats 2..6 is 4 beats long + // whatever the tempo says. + let clip_id = Uuid::new_v4(); + let instance = ClipInstance::new(clip_id) + .with_trimming(ContentTime(2.0), Some(ContentTime(6.0))); + let content = ClipDuration::Beats(Beats(8.0)); + + for bpm in [60.0, 120.0, 90.0] { + let tempo_map = crate::tempo_map::TempoMap::constant(bpm); + assert_eq!( + instance.effective_duration(content, &tempo_map), + Beats(4.0), + "a MIDI clip trimmed to beats 2..6 is 4 beats long at {bpm} BPM", + ); + } } #[test] @@ -1070,4 +1307,109 @@ mod tests { assert_eq!(instance.playback_speed, 2.0); assert_eq!(instance.gain, 0.8); } + + /// A clip whose own content is pool 10, plus an instance carrying takes over the given pools. + fn with_takes(pool_indices: &[usize]) -> (AudioClip, ClipInstance) { + let clip = AudioClip::new_sampled("Cycle rec", pool_indices[0], 2.0); + let mut instance = ClipInstance::new(clip.id); + instance.takes = pool_indices + .iter() + .enumerate() + .map(|(i, &audio_pool_index)| AudioTake { + name: format!("Take {}", i + 1), + content: TakeContent::Audio { audio_pool_index }, + }) + .collect(); + instance.recorded_loop_beats = Some(Beats(8.0)); + (clip, instance) + } + + #[test] + fn active_take_selects_the_pool_file() { + let (clip, mut instance) = with_takes(&[10, 11, 12]); + instance.active_take = Some(0); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + instance.active_take = Some(2); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(12)); + // None means take 0. + instance.active_take = None; + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + } + + #[test] + fn out_of_range_take_falls_back_to_the_first() { + // An index can go stale (an undo that shrank the list, an old .beam). Playing the first take + // beats playing nothing. + let (clip, mut instance) = with_takes(&[10, 11]); + instance.active_take = Some(99); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + assert_eq!(instance.active_take_index(), 0); + } + + #[test] + fn an_instance_without_takes_plays_the_clips_own_content() { + let clip = AudioClip::new_sampled("Plain", 42, 2.0); + let instance = ClipInstance::new(clip.id); + assert!(instance.takes.is_empty()); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(42)); + } + + #[test] + fn midi_takes_resolve_to_midi_content() { + let clip = AudioClip::new_midi("Cycle rec", 7, Beats(4.0)); + let mut instance = ClipInstance::new(clip.id); + instance.takes = vec![AudioTake { + name: "Take 1".into(), + content: TakeContent::Midi { midi_clip_id: 7 }, + }]; + assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0))); + assert_eq!(instance.resolved_midi_clip_id(&clip), Some(7)); + assert_eq!(instance.resolved_audio_pool_index(&clip), None); + } + + #[test] + fn splitting_gives_each_half_an_independent_take_list() { + // Takes live on the INSTANCE, so a split (which clones the instance) hands each half its own + // list. Two consequences, both wanted: the halves can select different takes (comping), and + // deleting a take from one leaves the other alone. + let (clip, left_src) = with_takes(&[10, 11, 12]); + let mut left = left_src.clone(); + let mut right = left_src.clone(); + right.id = Uuid::new_v4(); + + left.active_take = Some(0); + right.active_take = Some(2); + assert_eq!(left.resolved_audio_pool_index(&clip), Some(10)); + assert_eq!(right.resolved_audio_pool_index(&clip), Some(12)); + + // Delete take 2 (pool 11) from the left half only. + left.takes.remove(1); + assert_eq!(left.takes.len(), 2); + assert_eq!(right.takes.len(), 3, "the other half keeps its own takes"); + assert_eq!( + right.resolved_audio_pool_index(&clip), + Some(12), + "and its selection still points where it did", + ); + } + + #[test] + fn clip_instance_without_active_take_deserializes() { + // Back-compat: .beam files written before take folders have no `active_take` field. + let json = r#"{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "clip_id": "550e8400-e29b-41d4-a716-446655440001", + "transform": {"x": 0.0, "y": 0.0, "rotation": 0.0, "scale_x": 1.0, "scale_y": 1.0, "skew_x": 0.0, "skew_y": 0.0}, + "opacity": 1.0, + "name": null, + "timeline_start": 0.0, + "timeline_duration": null, + "trim_start": 0.0, + "trim_end": null, + "playback_speed": 1.0, + "gain": 1.0 + }"#; + let instance: ClipInstance = serde_json::from_str(json).expect("old instances must load"); + assert_eq!(instance.active_take, None); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index a26b86b..72bde66 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -201,6 +201,17 @@ pub struct Document { #[serde(default)] pub time_signature: TimeSignature, + /// Transport cycle (loop) region, as `(start, end)` in **beats**. + /// + /// Authored in beats so it stays put musically when the tempo changes. `None` = no region set. + /// Saved with the project; `#[serde(default)]` keeps older `.beam` files loading. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cycle_region: Option<(Beats, Beats)>, + + /// Whether the transport loops over `cycle_region`. + #[serde(default)] + pub cycle_enabled: bool, + /// Master track (master bus + tempo automation lane). /// Stored separately from the root layer tree; shown in timeline when /// `show_master_track` is enabled in the editor state. @@ -297,6 +308,8 @@ impl Default for Document { height: 1080.0, framerate: 60.0, time_signature: TimeSignature::default(), + cycle_region: None, + cycle_enabled: false, master_layer: { let mut ml = GroupLayer::new_master(120.0); ml.layer.id = uuid::Uuid::new_v4(); @@ -451,8 +464,11 @@ impl Document { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { instance.timeline_start + timeline_duration } else { - let trim_end = instance.trim_end.unwrap_or(clip_duration); - let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); + // `clip_duration` arrives as seconds (the recursive helper's signature), so this + // path is the wall-clock one; MIDI content would need resolving against its clip. + let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw()); + let trimmed_secs = + ((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start); tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) }; @@ -767,16 +783,18 @@ impl Document { } /// Find the document audio clip (UUID + ref) that owns the given backend pool index. + /// A take folder owns one pool file per take, so any of them maps back to the folder. pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> { self.audio_clips.iter() - .find(|(_, c)| c.audio_pool_index() == Some(pool_index)) + .find(|(_, c)| c.owns_audio_pool_index(pool_index)) .map(|(&id, c)| (id, c)) } /// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID. + /// As above, a take folder owns one MIDI clip per take. pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> { self.audio_clips.iter() - .find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id)) + .find(|(_, c)| c.owns_midi_clip_id(midi_clip_id)) .map(|(&id, c)| (id, c)) } @@ -898,6 +916,81 @@ impl Document { /// Searches through all clip libraries to find the clip and return its duration. /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// have infinite internal duration. + /// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**. + /// + /// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled + /// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value — + /// mapping a timeline position into the clip's content, say — has to work in that same domain, + /// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds. + /// + /// Returns `None` for unknown clips. + pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option { + if let Some(clip) = self.audio_clips.get(clip_id) { + return Some(clip.content_duration()); + } + // Everything else measures its content in wall-clock seconds. + self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds) + } + + /// Find an existing take folder on `layer_id` that a new cycle recording should be appended to. + /// + /// Cycle-recording over a region that already holds a take folder should ADD to that folder + /// rather than drop a second clip on top of it — otherwise the new takes are stranded in a + /// separate, overlapping clip and can't be auditioned against the ones already there. + /// + /// "The same region" means the instance starts at `loop_start` and its folder was recorded + /// against the same loop length. Matching the length too means resizing the cycle region starts + /// a fresh folder rather than appending takes of a different length to an existing one, which + /// would break the uniform-take invariant comping depends on. + /// + /// `exclude` is the in-progress recording's own instance, which is on the layer but isn't a + /// candidate. Returns the instance id. + pub fn take_folder_at( + &self, + layer_id: &Uuid, + loop_start: Beats, + loop_len: Beats, + exclude: &Uuid, + ) -> Option { + let Some(AnyLayer::Audio(audio_layer)) = self.get_layer(layer_id) else { + return None; + }; + const EPS: f64 = 1e-6; + audio_layer.clip_instances.iter().find_map(|ci| { + if ci.id == *exclude + || ci.takes.is_empty() + || (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS + { + return None; + } + let recorded = ci.recorded_loop_beats?; + ((recorded - loop_len).beats_to_f64().abs() < EPS).then_some(ci.id) + }) + } + + /// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to. + /// + /// The clip is the only thing that knows whether its content is measured in seconds or beats, so + /// this is the sanctioned exit from `ContentTime`. Works for every clip kind, not just audio. + /// Returns `None` for unknown clips. + pub fn resolve_content_time( + &self, + clip_id: &Uuid, + t: daw_backend::ContentTime, + ) -> Option { + if let Some(clip) = self.audio_clips.get(clip_id) { + return Some(clip.resolve_content_time(t)); + } + if self.vector_clips.contains_key(clip_id) + || self.video_clips.contains_key(clip_id) + || self.effect_definitions.contains_key(clip_id) + { + // Wall-clock content. + return Some(crate::clip::ClipDuration::Seconds(Seconds(t.raw()))); + } + None + } + pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { if clip.is_group { @@ -951,9 +1044,9 @@ impl Document { }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; - let clip_duration = self.get_clip_duration(&instance.clip_id)?; - // End position on the timeline, in beats (convert the seconds content window via tempo map). - Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) + // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI. + let clip_content = self.clip_trim_duration(&instance.clip_id)?; + Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map())) } /// Check if a time range overlaps with any existing clip on the layer @@ -993,13 +1086,14 @@ impl Document { continue; } - // Calculate instance extent (accounting for loop_before) - let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { + // Calculate instance extent (accounting for loop_before). Content duration in the clip's + // own domain, so the trims resolve correctly for MIDI. + let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else { continue; }; let instance_start = instance.effective_start(); - let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map()); + let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map()); // Check overlap: start_a < end_b AND start_b < end_a if start_time < instance_end && instance_start < end_time { @@ -1056,7 +1150,7 @@ impl Document { continue; } - if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { + if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) { let inst_start = instance.effective_start(); let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); occupied_ranges.push((inst_start, inst_end, instance.id)); @@ -1152,7 +1246,7 @@ impl Document { if group_ids.contains(&inst.id) { continue; } - if let Some(dur) = self.get_clip_duration(&inst.clip_id) { + if let Some(dur) = self.clip_trim_duration(&inst.clip_id) { let start = inst.effective_start(); let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); non_group.push((start, end)); @@ -1226,9 +1320,8 @@ impl Document { } // Calculate other clip's extent (accounting for loop_before) - if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { + if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) { let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); - // (clip_duration is Seconds via get_clip_duration; effective_duration converts.) // If this clip is to the left and closer than current nearest if other_end <= current_timeline_start && other_end > nearest_end { @@ -1326,7 +1419,7 @@ impl Document { continue; } - if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { + if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) { let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); if other_end <= current_effective_start && other_end > nearest_end { diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs index 3d52675..a6f762a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -152,7 +152,12 @@ impl EffectLayer { self.clip_instances .iter() .filter(|e| { - let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map); + // Effects have an "infinite" wall-clock content length. + let end = e.timeline_start + + e.effective_duration( + crate::clip::ClipDuration::Seconds(daw_backend::Seconds(EFFECT_DURATION)), + tempo_map, + ); time_beats >= e.timeline_start && time_beats < end }) .collect() diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 9cc7a92..e9d9f39 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -3,7 +3,7 @@ //! Provides functions for testing if points or rectangles intersect with //! vector graph elements and clip instances, taking into account transform hierarchies. -use crate::clip::ClipInstance; +use crate::clip::{ClipDuration, ClipInstance}; use crate::vector_graph::{VertexId, EdgeId, FillId}; use crate::layer::VectorLayer; use crate::shape::Shape; @@ -260,7 +260,10 @@ pub fn hit_test_clip_instances( for clip_instance in clip_instances.iter().rev() { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); + // Hit-testing runs on vector/raster content, which is wall-clock seconds. + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO), + ); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { @@ -269,7 +272,8 @@ pub fn hit_test_clip_instances( // clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds) let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = + ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { vector_clip.calculate_content_bounds(document, clip_time) @@ -304,7 +308,10 @@ pub fn hit_test_clip_instances_in_rect( for clip_instance in clip_instances { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); + // Hit-testing runs on vector/raster content, which is wall-clock seconds. + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO), + ); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { @@ -312,7 +319,8 @@ pub fn hit_test_clip_instances_in_rect( } let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = + ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { vector_clip.calculate_content_bounds(document, clip_time) diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 9c2cbd8..72df6cd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -9,7 +9,7 @@ //! The compositing mode enables proper per-layer opacity, blend modes, and effects. use crate::animation::TransformProperty; -use crate::clip::{ClipInstance, ImageAsset}; +use crate::clip::{ClipDuration, ClipInstance, ImageAsset}; use crate::document::Document; use daw_backend::Seconds; use crate::gpu::BlendMode; @@ -568,7 +568,7 @@ pub fn render_layer_isolated( let tempo_map = document.tempo_map(); for clip_instance in &video_layer.clip_instances { let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; - let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue }; + let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue }; let clip_time = clip_time.seconds_to_f64(); let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue }; @@ -1019,7 +1019,10 @@ fn render_clip_instance( } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + // A vector clip's content is wall-clock seconds. + let clip_dur = ClipDuration::Seconds( + document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)), + ); let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return; // Clip instance not active at this time }; @@ -1174,7 +1177,7 @@ fn render_video_layer( // Remap timeline time to clip's internal time let tempo_map = document.tempo_map(); - let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { + let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue; // Clip instance not active at this time }; let clip_time = clip_time.seconds_to_f64(); @@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu( if time < start_secs || time >= end { return; } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + // A vector clip's content is wall-clock seconds. + let clip_dur = ClipDuration::Seconds( + document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)), + ); let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return }; t.seconds_to_f64() }; diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index a24b1c6..f43b278 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightningbeam-editor" -version = "1.0.9-alpha" +version = "1.0.10-alpha" edition = "2021" description = "Multimedia editor for audio, video and 2D animation" license = "GPL-3.0-or-later" diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index a724288..98ba307 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -35,6 +35,18 @@ pub struct AppConfig { #[serde(default = "defaults::audio_buffer_size")] pub audio_buffer_size: u32, + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = **merge**: every pass overdubs into one clip, and earlier passes play back + /// as you record so you can layer against them. `true` = **separate takes**: each pass becomes + /// its own take in a take folder, exactly as audio always does, and earlier passes stay silent + /// (they're alternatives, not layers). + /// + /// Only applies when the transport actually wraps; a recording that stops inside the first pass + /// is an ordinary single recording either way. + #[serde(default = "defaults::cycle_midi_separate_takes")] + pub cycle_midi_separate_takes: bool, + /// Reopen last session on startup #[serde(default = "defaults::reopen_last_session")] pub reopen_last_session: bool, @@ -78,6 +90,47 @@ pub struct AppConfig { /// Last-used audio-export "Album" tag, remembered so it prefills next time. #[serde(default)] pub last_audio_album: String, + + /// What the stylus's lower barrel button does while held. + #[serde(default = "defaults::tablet_button_lower")] + pub tablet_button_lower: TabletButtonAction, + + /// What the stylus's upper barrel button does while held. + #[serde(default = "defaults::tablet_button_upper")] + pub tablet_button_upper: TabletButtonAction, +} + +/// What a stylus barrel button does while held down. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TabletButtonAction { + /// Do nothing. + None, + /// Drag to pan the stage. + #[default] + Pan, + /// Temporarily switch to the eyedropper. + Eyedropper, + /// Temporarily switch to the eraser. + Erase, +} + +impl TabletButtonAction { + pub const ALL: [TabletButtonAction; 4] = [ + TabletButtonAction::None, + TabletButtonAction::Pan, + TabletButtonAction::Eyedropper, + TabletButtonAction::Erase, + ]; + + pub fn label(self) -> &'static str { + match self { + TabletButtonAction::None => "None", + TabletButtonAction::Pan => "Pan", + TabletButtonAction::Eyedropper => "Eyedropper", + TabletButtonAction::Erase => "Eraser", + } + } } impl Default for AppConfig { @@ -90,6 +143,7 @@ impl Default for AppConfig { file_height: defaults::file_height(), scroll_speed: defaults::scroll_speed(), audio_buffer_size: defaults::audio_buffer_size(), + cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(), reopen_last_session: defaults::reopen_last_session(), restore_layout_from_file: defaults::restore_layout_from_file(), debug: defaults::debug(), @@ -100,6 +154,8 @@ impl Default for AppConfig { waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), last_audio_artist: String::new(), last_audio_album: String::new(), + tablet_button_lower: defaults::tablet_button_lower(), + tablet_button_upper: defaults::tablet_button_upper(), } } } @@ -290,12 +346,16 @@ impl AppConfig { /// Default values for preferences (matches JS implementation) mod defaults { + use super::TabletButtonAction; + pub fn tablet_button_lower() -> TabletButtonAction { TabletButtonAction::Pan } + pub fn tablet_button_upper() -> TabletButtonAction { TabletButtonAction::Eyedropper } pub fn bpm() -> u32 { 120 } pub fn framerate() -> u32 { 24 } pub fn file_width() -> u32 { 800 } pub fn file_height() -> u32 { 600 } pub fn scroll_speed() -> f64 { 1.0 } pub fn audio_buffer_size() -> u32 { 256 } + pub fn cycle_midi_separate_takes() -> bool { false } pub fn reopen_last_session() -> bool { false } pub fn restore_layout_from_file() -> bool { true } pub fn debug() -> bool { false } diff --git a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs index c4ae419..1acc9de 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs @@ -1,205 +1,380 @@ //! Custom cursor system //! -//! Provides SVG-based custom cursors beyond egui's built-in system cursors. -//! When a custom cursor is active, the system cursor is hidden and the SVG -//! cursor image is drawn at the pointer position. +//! Tool cursors are drawn from the bundled Lucide icon font. A tool falls into one of three +//! kinds, because "draw the tool's icon at the pointer" only works for icons that actually have +//! a point: +//! +//! * [`CursorKind::System`] — the OS cursor already says it better than we can (the arrow for +//! Select, the move cross for Transform). We use the real OS cursor. +//! * [`CursorKind::Precise`] — the glyph has an unambiguous tip (pencil, brush, pipette), so it +//! *is* the cursor: the tip lands exactly on the click point. +//! * [`CursorKind::Badge`] — the glyph has no focus point (stamp, bandage, spray can). Using it +//! alone would leave you guessing where you're clicking, so we pair it with a crosshair: the +//! crosshair marks the click point and the glyph hangs off its bottom-right. +//! +//! Rather than *painting* the cursor into the egui scene, we rasterize it and hand it to the +//! windowing system as a real OS cursor (`egui::CursorImage` → winit `CustomCursor`). A painted +//! cursor is composited with our frame and therefore always trails the pointer by a frame or +//! more; an OS cursor is composited by the window system and doesn't lag at all. +//! +//! The glyphs are rasterized out of egui's own font atlas using the same placement maths as +//! `epaint`'s text tessellator, so a glyph lands exactly where `painter.text` would have put it. use eframe::egui; -use egui::TextureHandle; use lightningbeam_core::tool::Tool; use std::collections::HashMap; +use std::sync::Arc; -/// Custom cursor identifiers +use crate::mobile::icons; + +/// What kind of cursor a tool gets. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CursorKind { + /// Use a native OS cursor; build nothing ourselves. + System(egui::CursorIcon), + /// The glyph is the cursor. `hotspot` is the click point in Lucide's 24×24 icon grid. + Precise { + glyph: &'static str, + hotspot: egui::Vec2, + }, + /// A crosshair marks the click point; the glyph sits to its bottom-right. + Badge { glyph: &'static str }, +} + +/// Hotspot for a glyph whose tip is at the bottom-left (pencil, brush, pen, pipette). +const TIP_BOTTOM_LEFT: egui::Vec2 = egui::vec2(3.0, 21.0); +/// Hotspot for a glyph centred on the click point. +const TIP_CENTER: egui::Vec2 = egui::vec2(12.0, 12.0); + +/// Which cursor a stage tool uses. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CustomCursor { - // Stage tool cursors - Select, - Draw, - Transform, - Rectangle, - Ellipse, - PaintBucket, - Eyedropper, - Line, - Polygon, - BezierEdit, - Text, - // Timeline cursors + Tool(Tool), + /// Timeline: dragging the end of the loop region. LoopExtend, } impl CustomCursor { - /// Convert a Tool enum to the corresponding custom cursor pub fn from_tool(tool: Tool) -> Self { + CustomCursor::Tool(tool) + } + + pub fn kind(&self) -> CursorKind { + use egui::CursorIcon as Sys; + + let tool = match self { + CustomCursor::LoopExtend => { + return CursorKind::Precise { + glyph: icons::REPEAT, + hotspot: TIP_CENTER, + } + } + CustomCursor::Tool(t) => *t, + }; + + let precise = |glyph, hotspot| CursorKind::Precise { glyph, hotspot }; + let badge = |glyph| CursorKind::Badge { glyph }; + match tool { - Tool::Select => CustomCursor::Select, - Tool::Draw => CustomCursor::Draw, - Tool::Transform => CustomCursor::Transform, - Tool::Rectangle => CustomCursor::Rectangle, - Tool::Ellipse => CustomCursor::Ellipse, - Tool::PaintBucket => CustomCursor::PaintBucket, - Tool::Eyedropper => CustomCursor::Eyedropper, - Tool::Line => CustomCursor::Line, - Tool::Polygon => CustomCursor::Polygon, - Tool::BezierEdit => CustomCursor::BezierEdit, - Tool::Text => CustomCursor::Text, - Tool::RegionSelect => CustomCursor::Select, - Tool::Split => CustomCursor::Select, - Tool::Erase => CustomCursor::Draw, - Tool::Smudge => CustomCursor::Draw, - Tool::SelectLasso => CustomCursor::Select, - // Raster brush tools — use draw cursor until implemented - Tool::Pencil - | Tool::Pen - | Tool::Airbrush - | Tool::CloneStamp - | Tool::HealingBrush - | Tool::PatternStamp - | Tool::DodgeBurn - | Tool::Sponge - | Tool::BlurSharpen => CustomCursor::Draw, - // Selection tools — use select cursor until implemented - Tool::SelectEllipse - | Tool::MagicWand - | Tool::QuickSelect => CustomCursor::Select, - // Other tools — use select cursor until implemented - Tool::Gradient - | Tool::CustomShape - | Tool::Warp - | Tool::Liquify => CustomCursor::Select, - } - } + // The OS knows these better than we do. + Tool::Select => CursorKind::System(Sys::Default), + Tool::Transform => CursorKind::System(Sys::Move), - /// Hotspot offset — the "click point" relative to the image top-left - pub fn hotspot(&self) -> egui::Vec2 { - match self { - // Select cursor: pointer tip at top-left - CustomCursor::Select => egui::vec2(3.0, 1.0), - // Drawing tools: tip at bottom-left - CustomCursor::Draw => egui::vec2(1.0, 23.0), - // Transform: center - CustomCursor::Transform => egui::vec2(12.0, 12.0), - // Shape tools: crosshair at center - CustomCursor::Rectangle - | CustomCursor::Ellipse - | CustomCursor::Line - | CustomCursor::Polygon => egui::vec2(12.0, 12.0), - // Paint bucket: tip at bottom-left - CustomCursor::PaintBucket => egui::vec2(2.0, 21.0), - // Eyedropper: tip at bottom - CustomCursor::Eyedropper => egui::vec2(4.0, 22.0), - // Bezier edit: tip at top-left - CustomCursor::BezierEdit => egui::vec2(3.0, 1.0), - // Text: I-beam center - CustomCursor::Text => egui::vec2(12.0, 12.0), - // Loop extend: center of circular arrow - CustomCursor::LoopExtend => egui::vec2(12.0, 12.0), - } - } + // Glyphs with a real tip: the icon is the cursor. + Tool::Draw => precise(icons::BRUSH, TIP_BOTTOM_LEFT), + Tool::Pencil => precise(icons::PENCIL, TIP_BOTTOM_LEFT), + Tool::Pen => precise(icons::PEN_TOOL, TIP_BOTTOM_LEFT), + Tool::Eyedropper => precise(icons::PIPETTE, TIP_BOTTOM_LEFT), + Tool::PaintBucket => precise(icons::PAINT_BUCKET, TIP_BOTTOM_LEFT), + Tool::Text => precise(icons::TEXT_CURSOR, TIP_CENTER), - /// Get the embedded SVG data for this cursor - fn svg_data(&self) -> &'static [u8] { - match self { - CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"), - CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"), - CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"), - CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"), - CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"), - CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"), - CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"), - CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"), - CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"), - CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"), - CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"), - CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"), + // Shape tools: crosshair for precision, glyph to say which shape. + Tool::Rectangle => badge(icons::SQUARE), + Tool::Ellipse => badge(icons::CIRCLE), + Tool::Line => badge(icons::MINUS), + Tool::Polygon => badge(icons::HEXAGON), + Tool::CustomShape => badge(icons::SHAPES), + + // Selection tools. + Tool::RegionSelect => badge(icons::SQUARE_DASHED), + Tool::SelectEllipse => badge(icons::CIRCLE_DASHED), + Tool::SelectLasso => badge(icons::LASSO_SELECT), + Tool::MagicWand => badge(icons::WAND_SPARKLES), + Tool::QuickSelect => badge(icons::BRUSH), + Tool::Split => badge(icons::SCISSORS), + + // Raster tools whose icons have no focus point. These also draw a brush-size ring on + // the stage, which is the real precision cue. + Tool::Erase => badge(icons::ERASER), + Tool::Airbrush => badge(icons::SPRAY_CAN), + Tool::Smudge => badge(icons::POINTER), + Tool::CloneStamp => badge(icons::STAMP), + Tool::PatternStamp => badge(icons::SHAPES), + Tool::HealingBrush => badge(icons::BANDAGE), + Tool::DodgeBurn => badge(icons::SUN_MOON), + Tool::Sponge => badge(icons::DROPLETS), + Tool::BlurSharpen => badge(icons::CONTRAST), + Tool::Gradient => badge(icons::BLEND), + Tool::Warp => badge(icons::SCALING), + Tool::Liquify => badge(icons::DROPLET), + + // Vertex editing: crosshair to place the point, glyph to say we're in bezier mode. + Tool::BezierEdit => badge(icons::SPLINE), } } } -/// Cache of rasterized cursor textures (black fill + white outline version) +// --------------------------------------------------------------------------- +// Geometry (logical points; scaled by pixels_per_point when rasterized) +// --------------------------------------------------------------------------- + +/// Rendered size of a cursor glyph. +const GLYPH_SIZE: f32 = 18.0; +/// Lucide icons are authored on a 24×24 grid; hotspots are given in those units. +const ICON_GRID: f32 = 24.0; +/// Where a badge glyph's top-left sits relative to the crosshair centre. +const BADGE_OFFSET: egui::Vec2 = egui::vec2(6.0, 6.0); +/// Half-length of a crosshair arm. +const CROSSHAIR_ARM: f32 = 7.0; +/// Gap between the crosshair centre and the start of each arm, so the click point stays visible. +const CROSSHAIR_GAP: f32 = 2.0; +/// Margin around the artwork, leaving room for the outline. +const MARGIN: f32 = 1.5; + +// --------------------------------------------------------------------------- +// Rasterization +// --------------------------------------------------------------------------- + +/// Cache of rasterized cursor images, keyed by cursor and DPI scale. +#[derive(Default)] pub struct CursorCache { - /// Black cursor for the main image - textures: HashMap, - /// White cursor for the outline - outline_textures: HashMap, + images: HashMap<(CustomCursor, u32), Option>, + next_id: u64, } impl CursorCache { pub fn new() -> Self { - Self { - textures: HashMap::new(), - outline_textures: HashMap::new(), + Self::default() + } + + fn get_or_build( + &mut self, + ctx: &egui::Context, + cursor: CustomCursor, + ppp: f32, + ) -> Option { + let key = (cursor, ppp.to_bits()); + if let Some(cached) = self.images.get(&key) { + return cached.clone(); } - } - /// Get or lazily load the black (fill) cursor texture - pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { - self.textures.entry(cursor).or_insert_with(|| { - let svg_data = cursor.svg_data(); - let svg_string = String::from_utf8_lossy(svg_data); - let svg_with_color = svg_string.replace("currentColor", "#000000"); - rasterize_cursor_svg(svg_with_color.as_bytes(), &format!("cursor_{:?}", cursor), CURSOR_SIZE, ctx) - .expect("Failed to rasterize cursor SVG") - }) - } + let id = self.next_id; + self.next_id += 1; - /// Get or lazily load the white (outline) cursor texture - pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { - self.outline_textures.entry(cursor).or_insert_with(|| { - let svg_data = cursor.svg_data(); - let svg_string = String::from_utf8_lossy(svg_data); - // Replace all colors with white for the outline - let svg_white = svg_string - .replace("currentColor", "#ffffff") - .replace("#000000", "#ffffff") - .replace("#000", "#ffffff"); - rasterize_cursor_svg(svg_white.as_bytes(), &format!("cursor_{:?}_outline", cursor), CURSOR_SIZE, ctx) - .expect("Failed to rasterize cursor SVG outline") - }) + let built = build_cursor_image(ctx, cursor.kind(), ppp, id); + self.images.insert(key, built.clone()); + built } } -const CURSOR_SIZE: u32 = 24; -const OUTLINE_OFFSET: f32 = 1.0; +/// An alpha coverage mask being composed, in physical pixels. +struct Mask { + w: usize, + h: usize, + a: Vec, +} -/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg) -fn rasterize_cursor_svg( - svg_data: &[u8], - name: &str, - render_size: u32, +impl Mask { + fn new(w: usize, h: usize) -> Self { + Self { w, h, a: vec![0.0; w * h] } + } + + fn add(&mut self, x: usize, y: usize, coverage: f32) { + if x < self.w && y < self.h { + let p = &mut self.a[y * self.w + x]; + *p = (*p + coverage).min(1.0); + } + } + + fn get(&self, x: isize, y: isize) -> f32 { + if x < 0 || y < 0 || x as usize >= self.w || y as usize >= self.h { + 0.0 + } else { + self.a[y as usize * self.w + x as usize] + } + } + + /// Fill an axis-aligned rect (physical px, may be fractional) with full coverage. + fn fill_rect(&mut self, rect: egui::Rect) { + let x0 = rect.min.x.floor().max(0.0) as usize; + let y0 = rect.min.y.floor().max(0.0) as usize; + let x1 = (rect.max.x.ceil() as usize).min(self.w); + let y1 = (rect.max.y.ceil() as usize).min(self.h); + for y in y0..y1 { + for x in x0..x1 { + self.add(x, y, 1.0); + } + } + } +} + +/// Rasterize a cursor into a premultiplied-RGBA image: black artwork with a white outline, so it +/// reads against both light and dark backgrounds. +fn build_cursor_image( ctx: &egui::Context, -) -> Option { - let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?; - let pixmap_size = tree.size().to_int_size(); - let scale_x = render_size as f32 / pixmap_size.width() as f32; - let scale_y = render_size as f32 / pixmap_size.height() as f32; - let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?; - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale_x, scale_y), - &mut pixmap.as_mut(), - ); - let rgba_data = pixmap.data().to_vec(); - let color_image = egui::ColorImage::from_rgba_unmultiplied( - [render_size as usize, render_size as usize], - &rgba_data, - ); - Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR)) + kind: CursorKind, + ppp: f32, + id: u64, +) -> Option { + let (glyph, glyph_origin, hotspot, crosshair) = match kind { + CursorKind::System(_) => return None, + CursorKind::Precise { glyph, hotspot } => { + let scale = GLYPH_SIZE / ICON_GRID; + ( + glyph, + egui::vec2(MARGIN, MARGIN), + egui::pos2(MARGIN, MARGIN) + hotspot * scale, + false, + ) + } + CursorKind::Badge { glyph } => { + // The crosshair centre is the click point; place it a margin + arm in from the corner. + let centre = egui::pos2(MARGIN + CROSSHAIR_ARM, MARGIN + CROSSHAIR_ARM); + (glyph, centre.to_vec2() + BADGE_OFFSET, centre, true) + } + }; + + // Lay the glyph out exactly as `painter.text(.., Align2::LEFT_TOP, ..)` would, so we inherit + // epaint's placement rather than re-deriving it from font metrics. + let galley = ctx.fonts_mut(|f| { + f.layout_no_wrap( + glyph.to_owned(), + icons::font(GLYPH_SIZE), + egui::Color32::WHITE, + ) + }); + + // Canvas must cover the glyph, and the crosshair too when there is one. + let mut content = egui::Rect::from_min_size(glyph_origin.to_pos2(), galley.size()); + if crosshair { + content = content.union(egui::Rect::from_center_size( + hotspot, + egui::Vec2::splat(2.0 * CROSSHAIR_ARM), + )); + } + let canvas = content.expand(MARGIN); + + let w = (canvas.width() * ppp).ceil() as usize; + let h = (canvas.height() * ppp).ceil() as usize; + if w == 0 || h == 0 || w > 2048 || h > 2048 { + return None; // winit caps cursors at 2048px. + } + + let mut mask = Mask::new(w, h); + // Everything is positioned relative to the canvas origin. + let to_px = |p: egui::Pos2| ((p - canvas.min.to_vec2()).to_vec2() * ppp).to_pos2(); + + // --- Crosshair --- + if crosshair { + let c = to_px(hotspot); + let thickness = ppp.max(1.0); // one physical pixel, at least + let arm = CROSSHAIR_ARM * ppp; + let gap = CROSSHAIR_GAP * ppp; + // Four arms, leaving a gap at the centre so the exact click point stays visible. + for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] { + let from = egui::pos2(c.x + dx * gap, c.y + dy * gap); + let to = egui::pos2(c.x + dx * arm, c.y + dy * arm); + let rect = egui::Rect::from_two_pos(from, to).expand2(if dx == 0.0 { + egui::vec2(thickness / 2.0, 0.0) + } else { + egui::vec2(0.0, thickness / 2.0) + }); + mask.fill_rect(rect); + } + } + + // --- Glyph, copied out of egui's font atlas --- + let atlas = ctx.fonts_mut(|f| f.image()); + let atlas_w = atlas.size[0]; + for row in &galley.rows { + for g in &row.glyphs { + let uv = g.uv_rect; + if uv.is_nothing() { + continue; + } + // Same maths as epaint's text tessellator: the glyph's top-left in points is + // `glyph.pos + uv_rect.offset`, relative to the galley's top-left. + let left_top = to_px((glyph_origin + (g.pos + uv.offset).to_vec2()).to_pos2()); + let src_w = (uv.max[0] - uv.min[0]) as usize; + let src_h = (uv.max[1] - uv.min[1]) as usize; + + for sy in 0..src_h { + for sx in 0..src_w { + let src_i = (uv.min[1] as usize + sy) * atlas_w + (uv.min[0] as usize + sx); + let Some(px) = atlas.pixels.get(src_i) else { continue }; + let coverage = px.a() as f32 / 255.0; + if coverage <= 0.0 { + continue; + } + let dx = (left_top.x.round() as isize) + sx as isize; + let dy = (left_top.y.round() as isize) + sy as isize; + if dx >= 0 && dy >= 0 { + mask.add(dx as usize, dy as usize, coverage); + } + } + } + } + } + + // --- Compose: black fill over a white outline (dilate the mask by one pixel) --- + let mut rgba = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let fill = mask.get(x as isize, y as isize); + + let mut outline: f32 = 0.0; + for oy in -1..=1_isize { + for ox in -1..=1_isize { + outline = outline.max(mask.get(x as isize + ox, y as isize + oy)); + } + } + + // White outline underneath, black fill on top; premultiplied. + let white = outline * (1.0 - fill); + let alpha = fill + outline * (1.0 - fill); + let c = (white * 255.0).round().clamp(0.0, 255.0) as u8; + + let i = (y * w + x) * 4; + rgba[i] = c; + rgba[i + 1] = c; + rgba[i + 2] = c; + rgba[i + 3] = (alpha * 255.0).round().clamp(0.0, 255.0) as u8; + } + } + + let hot = to_px(hotspot); + Some(egui::CursorImage { + id, + rgba: Arc::new(rgba), + size: (w as u16, h as u16), + hotspot: ( + hot.x.round().clamp(0.0, w as f32 - 1.0) as u16, + hot.y.round().clamp(0.0, h as f32 - 1.0) as u16, + ), + }) } // --- Per-frame cursor slot using egui context data --- -/// Key for storing the active custom cursor in egui's per-frame data #[derive(Clone, Copy)] struct ActiveCustomCursor(CustomCursor); /// Set the custom cursor for this frame. Call from any pane during rendering. -/// This hides the system cursor and draws the SVG cursor at pointer position. pub fn set(ctx: &egui::Context, cursor: CustomCursor) { ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor))); } -/// Render the custom cursor overlay. Call at the end of the main update loop. +/// Hand the active cursor to the windowing system. Call at the end of the main update loop. pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) { // Take and remove the cursor so it doesn't persist to the next frame let id = egui::Id::new("active_custom_cursor"); @@ -209,43 +384,24 @@ pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) { val }); - if let Some(ActiveCustomCursor(cursor)) = cursor { - // If a system cursor was explicitly set (resize handles, text inputs, etc.), - // let it take priority over the custom cursor - let system_cursor = ctx.output(|o| o.cursor_icon); - if system_cursor != egui::CursorIcon::Default { - return; - } + let Some(ActiveCustomCursor(cursor)) = cursor else { return }; - // Hide the system cursor - ctx.set_cursor_icon(egui::CursorIcon::None); + // If a widget explicitly asked for a system cursor (resize handles, text inputs, ...), let it + // win — it knows something about the hover target that we don't. + if ctx.output(|o| o.cursor_icon) != egui::CursorIcon::Default { + return; + } - if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) { - let hotspot = cursor.hotspot(); - let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32); - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - let painter = ctx.debug_painter(); - - // Draw white outline: render white version offset in 8 directions - let outline_tex = cache.get_or_load_outline(cursor, ctx); - let outline_id = outline_tex.id(); - for &(dx, dy) in &[ - (-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0), - (0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET), - (-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET), - (-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET), - ] { - let offset_rect = egui::Rect::from_min_size( - pos - hotspot + egui::vec2(dx, dy), - size, - ); - painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE); + match cursor.kind() { + CursorKind::System(icon) => ctx.set_cursor_icon(icon), + _ => { + let ppp = ctx.pixels_per_point(); + if let Some(image) = cache.get_or_build(ctx, cursor, ppp) { + ctx.set_cursor_image(Some(image)); + } else { + // Rasterization failed — better a crosshair than an invisible cursor. + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); } - - // Draw black fill on top - let fill_tex = cache.get_or_load(cursor, ctx); - let cursor_rect = egui::Rect::from_min_size(pos - hotspot, size); - painter.image(fill_tex.id(), cursor_rect, uv, egui::Color32::WHITE); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index bea0759..cde1545 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -1064,7 +1064,12 @@ fn composite_document_to_hdr( } let tempo_map = document.tempo_map(); let effect_end_beats = effect_instance.timeline_start - + effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + + effect_instance.effective_duration( + lightningbeam_core::clip::ClipDuration::Seconds(daw_backend::Seconds( + lightningbeam_core::effect::EFFECT_DURATION, + )), + tempo_map, + ); let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index e97c10e..9e0e2e5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1105,6 +1105,23 @@ impl EditingContext { } } +/// A finished cycle recording whose takes belong to a take folder that already exists at that +/// region, queued for [`EditorApp::append_cycle_takes`]. +struct PendingTakeAppend { + layer_id: Uuid, + /// The throwaway clip + instance the recording itself was captured into. + recording_instance_id: Uuid, + recording_clip_id: Uuid, + /// The recording's backend clip, which has to be torn down or it keeps playing alongside the + /// take folder we're appending to. Carried explicitly rather than looked up in + /// `clip_instance_to_backend_map`, because on the audio path the recording instance isn't in + /// that map yet — it's only added after promotion, which the append path skips. + recording_backend_id: Option, + loop_start: Beats, + loop_len: Beats, + takes: Vec, +} + struct EditorApp { layouts: Vec, current_layout_index: usize, @@ -1136,7 +1153,6 @@ struct EditorApp { selected_tool: Tool, // Currently selected drawing tool fill_color: egui::Color32, // Fill color for drawing stroke_color: egui::Color32, // Stroke color for drawing - active_color_mode: panes::ColorMode, // Which color (fill/stroke) was last interacted with pane_instances: HashMap, // Pane instances per path menu_system: Option, // Native menu system for event checking pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane @@ -1213,6 +1229,10 @@ struct EditorApp { metronome_enabled: bool, // Whether metronome clicks during recording count_in_enabled: bool, // Whether count-in fires before recording recording_clips: HashMap, // layer_id -> backend clip_id during recording + /// Cycle takes waiting to be folded into an existing take folder. Queued from the audio-event + /// loop (which holds a borrow on the event queue, so it can't call a `&mut self` method) and + /// drained just after it. + pending_take_appends: Vec, recording_start_time: f64, // Playback time when recording started recording_layer_ids: Vec, // Layers being recorded to (for creating clips) // Asset drag-and-drop state @@ -1351,9 +1371,9 @@ struct EditorApp { effect_thumbnail_generator: Option, /// Custom cursor cache for SVG cursors - cursor_cache: custom_cursor::CursorCache, /// Cross-platform graphics tablet (pen/stylus) input state tablet: tablet::TabletInput, + cursor_cache: custom_cursor::CursorCache, /// Debug test mode (F5) — input recording, panic capture & visual replay #[cfg(debug_assertions)] test_mode: test_mode::TestModeState, @@ -1410,6 +1430,10 @@ impl EditorApp { // Load application config let mut config = AppConfig::load(); + + // Mirror the configured stylus barrel-button actions into the tablet layer. + tablet::set_button_actions(config.tablet_button_lower, config.tablet_button_upper); + // One-time cleanup: earlier builds added a Recovered file to Recent on restore. Drop any // recovery-dir paths that leaked in (and re-save if we removed any) so they don't show in // Recent or get auto-reopened below. @@ -1532,7 +1556,6 @@ impl EditorApp { selected_tool: Tool::Select, // Default tool fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke - active_color_mode: panes::ColorMode::default(), // Default to fill color pane_instances: HashMap::new(), // Initialize empty, panes created on-demand menu_system, pending_view_action: None, @@ -1581,6 +1604,7 @@ impl EditorApp { metronome_enabled: false, // Metronome off by default count_in_enabled: false, // Count-in off by default recording_clips: HashMap::new(), // No active recording clips + pending_take_appends: Vec::new(), recording_start_time: 0.0, // Will be set when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts dragging_asset: None, // No asset being dragged initially @@ -1645,8 +1669,8 @@ impl EditorApp { test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying, pending_geometry), // Debug overlay (F3) - cursor_cache: custom_cursor::CursorCache::new(), tablet: tablet::TabletInput::new(cc), + cursor_cache: custom_cursor::CursorCache::new(), debug_overlay_visible: false, debug_stats_collector: debug_overlay::DebugStatsCollector::new(), gpu_info, @@ -2036,9 +2060,113 @@ impl EditorApp { /// 2. For MIDI: Loads the default instrument /// 3. Stores the bidirectional mapping /// 4. Syncs any existing clips on the layer + /// Fold freshly-recorded cycle takes into an existing take folder covering the same region, if + /// there is one. Returns true if it did. + /// + /// Recording more takes over a region you've already recorded should extend that folder, not + /// stack a second clip on top of it — otherwise the new takes are stranded in an overlapping + /// clip and you can't audition them against the ones already there. + /// + /// The in-progress recording's own clip and instance are throwaway scaffolding in this case (the + /// takes themselves already live in the backend pools), so they're discarded. They were never + /// committed as an action, so there's nothing on the undo stack to unwind — the single + /// `AppendTakesAction` is the whole undoable step. + fn append_cycle_takes( + &mut self, + layer_id: uuid::Uuid, + recording_instance_id: uuid::Uuid, + recording_clip_id: uuid::Uuid, + recording_backend_id: Option, + loop_start: Beats, + loop_len: Beats, + takes: Vec, + ) -> bool { + let Some(target_instance_id) = self.action_executor.document().take_folder_at( + &layer_id, + loop_start, + loop_len, + &recording_instance_id, + ) else { + return false; + }; + + // Tear down the recording's own backend clip. Without this it keeps playing on top of the + // take folder we're appending to — two takes sounding at once. The target instance's clip is + // separately repointed at the new active take by the action's backend sync below. + let backend_id = recording_backend_id + .or_else(|| self.clip_instance_to_backend_map.remove(&recording_instance_id)); + self.clip_instance_to_backend_map.remove(&recording_instance_id); + let track_id = self.layer_to_track_map.get(&layer_id).copied(); + if let (Some(backend_id), Some(track_id), Some(controller_arc)) = + (backend_id, track_id, self.audio_controller.as_ref()) + { + let mut controller = controller_arc.lock().unwrap(); + match backend_id { + lightningbeam_core::action::BackendClipInstanceId::Audio(id) => { + controller.remove_audio_clip(track_id, id) + } + lightningbeam_core::action::BackendClipInstanceId::Midi(id) => { + controller.remove_midi_clip(track_id, id) + } + } + } + + // Discard the scaffolding clip + instance. + { + let doc = self.action_executor.document_mut(); + if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + al.clip_instances.retain(|ci| ci.id != recording_instance_id); + } + doc.audio_clips.remove(&recording_clip_id); + } + + let action = lightningbeam_core::actions::AppendTakesAction::new( + layer_id, + target_instance_id, + takes, + ); + + if let Some(controller_arc) = self.audio_controller.clone() { + let mut controller = controller_arc.lock().unwrap(); + let mut backend_context = lightningbeam_core::action::BackendContext { + audio_controller: Some(&mut *controller), + layer_to_track_map: &self.layer_to_track_map, + clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + }; + if let Err(e) = self + .action_executor + .execute_with_backend(Box::new(action), &mut backend_context) + { + eprintln!("Failed to append cycle takes: {}", e); + } + } + + self.autosave.pending_event = true; + true + } + fn sync_audio_layers_to_backend(&mut self) { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; + // Push the document's cycle region to the engine. Needed on load/new: the region is + // document state, but the engine starts blank (and is cleared on Reset), so without this a + // loaded project would show its cycle strip while the transport never actually looped. + // Changes made later go through SetCycleRegionAction's execute_backend. + { + let (region, enabled) = { + let doc = self.action_executor.document(); + (doc.cycle_region, doc.cycle_enabled) + }; + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_loop_region(region); + controller.set_loop_enabled(enabled); + // Cycle MIDI mode is a *preference*, not document state, so it rides along here + // rather than through an action. + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); + } + } + // Ensure the master layer has a backend group track. let master_layer_id = self.action_executor.document().master_layer.layer.id; if !self.layer_to_track_map.contains_key(&master_layer_id) { @@ -2371,7 +2499,7 @@ impl EditorApp { ) -> Vec { let mut result = Vec::new(); for instance in clip_instances { - if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { + if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) { let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; @@ -3260,7 +3388,9 @@ impl EditorApp { let duplicates: Vec = clips_to_duplicate.iter().map(|original| { let mut duplicate = original.clone(); duplicate.id = uuid::Uuid::new_v4(); - let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0)); + let clip_duration = document + .clip_trim_duration(&original.clip_id) + .unwrap_or(ClipDuration::Seconds(Seconds(1.0))); let effective_duration = original.effective_duration(clip_duration, document.tempo_map()); duplicate.timeline_start = original.timeline_start + effective_duration; if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { @@ -5461,7 +5591,7 @@ impl EditorApp { // matches the video clip exactly). let (_dur, sample_rate, channels) = controller .get_pool_file_info(pool_index) - .unwrap_or((video_duration, 0, 0)); + .unwrap_or((Seconds(video_duration), 0, 0)); drop(controller); let audio_clip_name = format!("{} (Audio)", video_name); @@ -6368,7 +6498,9 @@ impl eframe::App for EditorApp { use daw_backend::AudioEvent; match event { AudioEvent::PlaybackPosition(time) => { - self.playback_time = time; + // `playback_time` is the UI's seconds playhead (see the timeline's + // seconds/beats model); unwrap at this boundary, not before it. + self.playback_time = time.seconds_to_f64(); } AudioEvent::PlaybackStopped => { self.is_playing = false; @@ -6474,6 +6606,25 @@ impl eframe::App for EditorApp { }) }; + // While cycling, the recording keeps running but the clip only ever + // occupies ONE region — each further pass is a new take layered on + // the same span, not more length. Cap the preview there so the bar + // doesn't grow off past the loop end while the playhead wraps. + let cycle_cap = { + let doc = self.action_executor.document(); + match (doc.cycle_enabled, doc.cycle_region) { + (true, Some((ls, le))) if le > ls => { + let tm = doc.tempo_map(); + Some(tm.beats_to_seconds(le) - tm.beats_to_seconds(ls)) + } + _ => None, + } + }; + let duration = match cycle_cap { + Some(cap) if duration > cap => cap, + _ => duration, + }; + // Then update the clip duration (mutable borrow) if let Some(doc_clip_id) = doc_clip_id { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { @@ -6485,6 +6636,158 @@ impl eframe::App for EditorApp { } ctx.request_repaint(); } + AudioEvent::CycleRecordingStopped { clip_id: backend_clip_id, takes, loop_start, loop_len_beats, loop_len_seconds } => { + eprintln!("[STOP] CycleRecordingStopped: {} takes", takes.len()); + + // Clean up the live-recording waveform cache (keyed usize::MAX). + self.raw_audio_cache.remove(&usize::MAX); + self.waveform_gpu_dirty.remove(&usize::MAX); + + // Pull every take's samples in for waveform rendering — the user can + // switch to any of them, not just the active one. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &(pool_index, _) in &takes { + match controller.get_pool_audio_samples(pool_index) { + Ok((samples, sr, ch)) => { + self.raw_audio_cache.insert(pool_index, (Arc::new(samples), sr, ch)); + self.waveform_gpu_dirty.insert(pool_index); + self.audio_pools_with_new_waveforms.insert(pool_index); + } + Err(e) => eprintln!("Failed to fetch take audio: {}", e), + } + self.audio_duration_cache.insert(pool_index, loop_len_seconds.seconds_to_f64()); + } + } + + let recording_layer = self.recording_clips.iter() + .find(|(_, &cid)| cid == backend_clip_id) + .map(|(&lid, _)| lid); + + if let (Some(layer_id), false) = (recording_layer, takes.is_empty()) { + let (clip_id, instance_id) = { + let document = self.action_executor.document(); + document.get_layer(&layer_id) + .and_then(|layer| { + if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { + audio_layer.clip_instances.last().map(|i| (i.clip_id, i.id)) + } else { + None + } + }) + .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil())) + }; + + if !clip_id.is_nil() { + self.autosave.pending_event = true; + let last_take = takes.len() - 1; + + let new_takes: Vec = takes + .iter() + .map(|&(pool_index, _)| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + if self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, &instance_id) + .is_some() + { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: instance_id, + recording_clip_id: clip_id, + // The engine's recording clip. It isn't in + // clip_instance_to_backend_map yet (that only happens on + // promotion, which we're skipping), so hand it over + // directly or it'll keep sounding alongside the folder. + recording_backend_id: Some( + lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id), + ), + loop_start, + loop_len: loop_len_beats, + takes: new_takes, + }); + self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id); + ctx.request_repaint(); + continue; + } + + // Finalize the recording clip, and hang the takes off the INSTANCE. + { + let doc = self.action_executor.document_mut(); + if let Some(clip) = doc.audio_clips.get_mut(&clip_id) { + // The clip's own content is take 1; the instance's take + // list overrides it with whichever take is active. Every + // take is exactly one cycle region long, so the clip's + // duration is the region. + clip.finalize_recording( + takes[0].0, + loop_len_seconds.seconds_to_f64(), + ); + clip.name = format!("Cycle recording ({} takes)", takes.len()); + } + + // Anchor the instance to the region and select the most + // recent take, GarageBand-style. + // + // timeline_duration stays None on purpose: pinning it would + // make a later tempo change loop/repeat the take's content + // to fill the span instead of letting it drift naturally. + if let Some(lightningbeam_core::layer::AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) { + inst.timeline_start = loop_start; + inst.timeline_duration = None; + // Audio take content is seconds, and each take spans + // exactly one cycle region. + inst.trim_start = daw_backend::ContentTime::ZERO; + inst.trim_end = + Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64())); + inst.takes = takes.iter().enumerate().map(|(i, &(pool_index, _))| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + } + }).collect(); + inst.active_take = Some(last_take); + inst.recorded_loop_beats = Some(loop_len_beats); + } + } + } + + // The backend already has a clip for the active take (the engine + // pointed it at the last take on stop), so map to it rather than + // adding a duplicate. + let backend_id = lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id); + self.clip_instance_to_backend_map.insert(instance_id, backend_id); + + // Commit the whole cycle-record session as ONE undoable action. + let clip_instance = self.layer_to_track_map.get(&layer_id).copied().and_then(|track_id| { + self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.id == instance_id).cloned() + } else { None }) + .map(|ci| (track_id, ci)) + }); + if let Some((track_id, clip_instance)) = clip_instance { + let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied( + layer_id, clip_instance, track_id, backend_id, + ); + self.action_executor.push_applied(Box::new(action)); + } else { + self.media_modified = true; + } + } + } + + self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id); + } AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => { eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index); @@ -6510,6 +6813,7 @@ impl eframe::App for EditorApp { let mut controller = controller_arc.lock().unwrap(); match controller.get_pool_file_info(pool_index) { Ok((dur, _, _)) => { + let dur = dur.seconds_to_f64(); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); self.audio_duration_cache.insert(pool_index, dur); dur @@ -6543,7 +6847,7 @@ impl eframe::App for EditorApp { None } }) - .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0)) + .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO)) }; if !clip_id.is_nil() { @@ -6667,7 +6971,11 @@ impl eframe::App for EditorApp { ); } } - // Update the clip's duration so the timeline bar grows + // Update the clip's duration so the timeline bar grows. Once the + // transport has wrapped, the backend reports the whole cycle + // region here and keeps reporting it, so the bar grows through + // the first pass and then holds — every further pass merges into + // the same clip rather than extending it. if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { clip.set_content_duration(ClipDuration::Beats(duration)); } @@ -6687,6 +6995,144 @@ impl eframe::App for EditorApp { } ctx.request_repaint(); } + AudioEvent::MidiCycleRecordingStopped { track_id, clip_ids, loop_start, loop_len_beats } => { + println!("🎹 MIDI cycle recording stopped: {} takes", clip_ids.len()); + + // Pull every take's events into the cache — the user can switch to any of + // them, not just the active one. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &take_clip_id in &clip_ids { + if let Ok(data) = controller.query_midi_clip(track_id, take_clip_id) { + self.midi_event_cache.insert(take_clip_id, data.events); + } + } + } + + let layer_id = self.track_to_layer_map.get(&track_id).copied(); + if let (Some(layer_id), Some(&first_clip_id)) = (layer_id, clip_ids.first()) { + // The doc clip is the one the recording started on — which the backend + // reused as take 1. + let doc_clip_id = self.action_executor.document() + .audio_clip_by_midi_clip_id(first_clip_id) + .map(|(id, _)| id); + + if let Some(doc_clip_id) = doc_clip_id { + self.autosave.pending_event = true; + let last_take = clip_ids.len() - 1; + + let recording_instance_id = self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).map(|ci| ci.id) + } else { None }); + + let new_takes: Vec = clip_ids + .iter() + .map(|&mid| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + let existing_folder = recording_instance_id.filter(|rec_inst| { + self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, rec_inst) + .is_some() + }); + if let Some(rec_inst) = existing_folder { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: rec_inst, + recording_clip_id: doc_clip_id, + // The MIDI path maps its recording instance during + // MidiRecordingProgress, so the map has it. + recording_backend_id: None, + loop_start, + loop_len: loop_len_beats, + takes: new_takes, + }); + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } + ctx.request_repaint(); + continue; + } + + { + let doc = self.action_executor.document_mut(); + if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) { + // The clip's own content stays take 1 (the clip the + // recording started on); the instance's take list + // overrides it with whichever take is active. MIDI takes + // are beats-domain and each spans one cycle region. + clip.set_content_duration(ClipDuration::Beats(loop_len_beats)); + clip.name = format!("Cycle recording ({} takes)", clip_ids.len()); + } + + // Anchor to the region and select the most recent take. + // timeline_duration stays None on purpose — pinning it would + // make a later tempo change loop the take's content to fill + // the span instead of letting it drift naturally. + if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.clip_id == doc_clip_id) { + inst.timeline_start = loop_start; + inst.timeline_duration = None; + inst.trim_start = daw_backend::ContentTime::ZERO; + inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64())); + inst.takes = clip_ids.iter().enumerate().map(|(i, &mid)| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + } + }).collect(); + inst.active_take = Some(last_take); + inst.recorded_loop_beats = Some(loop_len_beats); + } + } + } + + // Commit the whole cycle-record session as ONE undoable action. + // The backend instance was mapped during MidiRecordingProgress and + // the engine already repointed it at the active take. + let instance = self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).cloned() + } else { None }); + if let Some(instance) = instance { + match (self.layer_to_track_map.get(&layer_id).copied(), + self.clip_instance_to_backend_map.get(&instance.id).copied()) { + (Some(tid), Some(backend_id)) => { + let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied( + layer_id, instance, tid, backend_id, + ); + self.action_executor.push_applied(Box::new(action)); + } + _ => self.media_modified = true, + } + } + } + } + + // Clear recording state, same as the single-clip MIDI path. + if let Some(&layer_id) = self.track_to_layer_map.get(&track_id) { + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + } + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } + ctx.request_repaint(); + } AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => { println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes", track_id, clip_id, note_count); @@ -6705,7 +7151,7 @@ impl eframe::App for EditorApp { .map(|(id, _)| id); if let Some(doc_clip_id) = doc_clip_id { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { - clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration))); + clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration)); clip.name = format!("MIDI Recording {}", clip_id); } } @@ -6833,6 +7279,20 @@ impl eframe::App for EditorApp { } + // Cycle takes that landed on an existing take folder. Deferred out of the event loop above, + // which holds a borrow on the event queue and so can't call a `&mut self` method. + for req in std::mem::take(&mut self.pending_take_appends) { + self.append_cycle_takes( + req.layer_id, + req.recording_instance_id, + req.recording_clip_id, + req.recording_backend_id, + req.loop_start, + req.loop_len, + req.takes, + ); + } + // Update input monitoring based on active layer (only send command when changed) { let should_monitor = self.audio_controller.is_some() && self.active_layer_id.map_or(false, |layer_id| { @@ -7027,6 +7487,11 @@ impl eframe::App for EditorApp { if result.buffer_size_changed { println!("⚠️ Audio buffer size will be applied on next app restart"); } + // Cycle MIDI mode takes effect immediately — no restart needed, unlike the buffer size. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); + } // Apply new keybindings if changed if let Some(new_keymap) = result.new_keymap { self.keymap = new_keymap; @@ -7949,7 +8414,6 @@ impl EditorApp { selected_tool: &mut self.selected_tool, fill_color: &mut self.fill_color, stroke_color: &mut self.stroke_color, - active_color_mode: &mut self.active_color_mode, pending_view_action: &mut self.pending_view_action, fallback_pane_priority: &mut scratch.fallback_pane_priority, pending_handlers: &mut scratch.pending_handlers, diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index 979abd5..afafe0c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -42,6 +42,8 @@ pub const GRIP_HORIZONTAL: &str = "\u{e0ea}"; pub const CHEVRONS_UP: &str = "\u{e074}"; pub const PLAY: &str = "\u{e13c}"; pub const PAUSE: &str = "\u{e12e}"; +pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle +pub const TRASH: &str = "\u{e18d}"; // delete a take pub const SETTINGS: &str = "\u{e154}"; pub const SEARCH: &str = "\u{e151}"; pub const PLUS: &str = "\u{e13d}"; @@ -81,3 +83,60 @@ pub const FILE_PLUS: &str = "\u{e0c9}"; pub const COPY: &str = "\u{e09e}"; pub const LAYERS: &str = "\u{e529}"; pub const ELLIPSIS: &str = "\u{e0b6}"; +// Undo / redo (stage header). +pub const UNDO_2: &str = "\u{e2a1}"; +pub const REDO_2: &str = "\u{e2a0}"; +// Tool cursors (see custom_cursor.rs). +pub const STAMP: &str = "\u{e3bb}"; +pub const SPRAY_CAN: &str = "\u{e495}"; +pub const BANDAGE: &str = "\u{e61d}"; +pub const DROPLET: &str = "\u{e0b4}"; +pub const TEXT_CURSOR: &str = "\u{e264}"; +pub const CROSSHAIR: &str = "\u{e0ac}"; +pub const POINTER: &str = "\u{e1e8}"; +pub const CONTRAST: &str = "\u{e09d}"; +pub const WIND: &str = "\u{e1b0}"; +pub const SUN_MOON: &str = "\u{e2b2}"; +pub const SPLINE: &str = "\u{e38b}"; +pub const DROPLETS: &str = "\u{e0b5}"; +pub const CIRCLE_DASHED: &str = "\u{e4b0}"; +pub const PALETTE: &str = "\u{e1dd}"; +pub const SHAPES: &str = "\u{e4b3}"; +pub const SCALING: &str = "\u{e2ec}"; +pub const FRAME: &str = "\u{e291}"; +// Timeline layer-row toggles. +pub const VOLUME_2: &str = "\u{e1ab}"; // unmuted +pub const VOLUME_X: &str = "\u{e1ac}"; // muted +pub const HEADPHONES: &str = "\u{e0f1}"; // solo +pub const LOCK: &str = "\u{e10b}"; +pub const LOCK_OPEN: &str = "\u{e10c}"; +pub const EYE: &str = "\u{e0ba}"; +pub const EYE_OFF: &str = "\u{e0bb}"; +pub const VIDEO: &str = "\u{e1a5}"; // camera enabled +pub const VIDEO_OFF: &str = "\u{e1a6}"; // camera disabled + +/// Lucide glyph for tools that have no bundled SVG icon. `None` means the tool has a real SVG +/// icon in `src/assets/` and the caller should use that instead. +pub fn tool_glyph(tool: lightningbeam_core::tool::Tool) -> Option<&'static str> { + use lightningbeam_core::tool::Tool; + Some(match tool { + Tool::Pencil => PENCIL, + Tool::Pen => PEN_TOOL, + Tool::Airbrush => SPRAY_CAN, + Tool::CloneStamp => STAMP, + Tool::HealingBrush => BANDAGE, + Tool::PatternStamp => SHAPES, + Tool::DodgeBurn => SUN_MOON, + Tool::Sponge => DROPLETS, + Tool::BlurSharpen => CONTRAST, + Tool::Gradient => BLEND, + Tool::CustomShape => HEXAGON, + Tool::SelectEllipse => CIRCLE_DASHED, + Tool::MagicWand => WAND_SPARKLES, + Tool::QuickSelect => BRUSH, + Tool::Warp => SCALING, + Tool::Liquify => DROPLET, + Tool::SelectLasso => LASSO_SELECT, + _ => return None, + }) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index d3d0079..3506beb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -7,7 +7,7 @@ //! - Image Assets (static images) use eframe::egui; -use lightningbeam_core::clip::{AudioClipType, VectorClip}; +use lightningbeam_core::clip::{ResolvedContent, VectorClip}; use lightningbeam_core::document::Document; use lightningbeam_core::layer::AnyLayer; use std::collections::{HashMap, HashSet}; @@ -918,11 +918,11 @@ impl AssetLibraryPane { continue; } - let (extra_info, drag_clip_type) = match &clip.clip_type { - AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), - AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), - AudioClipType::Recording => { - // Skip recording-in-progress clips from asset library + let (extra_info, drag_clip_type) = match &clip.resolve() { + ResolvedContent::Audio { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), + ResolvedContent::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) from asset library continue; } }; @@ -1118,15 +1118,15 @@ impl AssetLibraryPane { for (id, clip) in &document.audio_clips { if !linked_audio_ids.contains(id) && clip.folder_id == current_folder { - let (extra_info, drag_clip_type) = match &clip.clip_type { - AudioClipType::Sampled { .. } => { + let (extra_info, drag_clip_type) = match &clip.resolve() { + ResolvedContent::Audio { .. } => { ("Sampled".to_string(), DragClipType::AudioSampled) } - AudioClipType::Midi { .. } => { + ResolvedContent::Midi { .. } => { ("MIDI".to_string(), DragClipType::AudioMidi) } - AudioClipType::Recording => { - // Skip recording-in-progress clips + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) continue; } }; @@ -1765,7 +1765,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { - if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() { shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) } else { @@ -1790,8 +1790,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { + match &clip.resolve() { + ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) @@ -1799,7 +1799,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -1807,7 +1807,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording in progress - show placeholder Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } @@ -2344,8 +2344,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { audio_pool_index } => { + match &clip.resolve() { + ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); @@ -2355,7 +2355,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -2363,7 +2363,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2481,8 +2481,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { audio_pool_index } => { + match &clip.resolve() { + ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); @@ -2492,7 +2492,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -2500,7 +2500,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2802,7 +2802,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { - if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() { let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); if waveform.is_some() { @@ -2842,8 +2842,8 @@ impl AssetLibraryPane { // Check if it's sampled or MIDI if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { + match &clip.resolve() { + ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id); @@ -2853,7 +2853,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let note_color = egui::Color32::from_rgb(100, 200, 100); @@ -2863,7 +2863,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -3187,7 +3187,7 @@ impl PaneRenderer for AssetLibraryPane { println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms); let mut invalidated_any = false; for (asset_id, clip) in &document_arc.audio_clips { - if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() { if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) { println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index); self.thumbnail_cache.invalidate(asset_id); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs index 3333f01..63481ec 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs @@ -160,27 +160,27 @@ pub fn gradient_stop_editor( delete_idx = Some(i); } - // Color picker popup — opens on click, closes on click-outside. - egui::containers::Popup::from_toggle_button_response(&resp) - .show(|ui| { - ui.spacing_mut().slider_width = 200.0; - let stop = &mut gradient.stops[i]; - let mut c32 = Color32::from_rgba_unmultiplied( - stop.color.r, stop.color.g, stop.color.b, stop.color.a, - ); - if egui::color_picker::color_picker_color32( - ui, &mut c32, egui::color_picker::Alpha::OnlyBlend, - ) { - // Color32 stores premultiplied RGB; unmultiply before storing - // as straight-alpha ShapeColor to avoid darkening on round-trip. - let [pr, pg, pb, a] = c32.to_array(); - let unpm = |c: u8| -> u8 { - if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } - }; - stop.color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); - changed = true; - } - }); + // Color picker popup — opens on click, closes on a press outside it. + let stop_color = gradient.stops[i].color; + let mut c32 = Color32::from_rgba_unmultiplied( + stop_color.r, stop_color.g, stop_color.b, stop_color.a, + ); + if crate::widgets::color_swatch::color_picker_popup( + ui, + resp.id.with("stop_color_popup"), + &resp, + &mut c32, + resp.rect, + ) { + // Color32 stores premultiplied RGB; unmultiply before storing as straight-alpha + // ShapeColor to avoid darkening on round-trip. + let [pr, pg, pb, a] = c32.to_array(); + let unpm = |c: u8| -> u8 { + if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } + }; + gradient.stops[i].color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); + changed = true; + } } // Apply drag to whichever stop selected_stop points at. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 1522f5b..3722674 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -28,14 +28,9 @@ pub struct InfopanelPane { tool_section_open: bool, /// Whether the shape properties section is expanded shape_section_open: bool, - /// Index of the selected paint brush preset (None = custom / unset) - selected_brush_preset: Option, - /// Whether the paint brush picker is expanded - brush_picker_expanded: bool, - /// Index of the selected eraser brush preset - selected_eraser_preset: Option, - /// Whether the eraser brush picker is expanded - eraser_picker_expanded: bool, + /// Whether each tool's brush picker is expanded. The *selected* preset lives on the tool's + /// `BrushSlot`, since it's part of the document-independent tool state, not panel chrome. + brush_picker_expanded: std::collections::HashMap, /// Cached preview textures, one per preset (populated lazily). brush_preview_textures: Vec, /// Selected stop index for gradient editor in shape section. @@ -61,15 +56,10 @@ impl InfopanelPane { // Kick off background loading of the font-picker fonts at startup so the dropdown // is ready without a hitch by the time the user opens it. lightningbeam_core::fonts::start_preload(); - let presets = bundled_brushes(); - let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); Self { tool_section_open: true, shape_section_open: true, - selected_brush_preset: None, - brush_picker_expanded: false, - selected_eraser_preset: default_eraser_idx, - eraser_picker_expanded: false, + brush_picker_expanded: std::collections::HashMap::new(), brush_preview_textures: Vec::new(), selected_shape_gradient_stop: None, selected_tool_gradient_stop: None, @@ -251,7 +241,7 @@ impl InfopanelPane { || is_raster_select || is_raster_shape || matches!( tool, Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect - | Tool::Warp | Tool::Liquify | Tool::Gradient + | Tool::Warp | Tool::Liquify | Tool::Gradient | Tool::Eyedropper ); if !has_options { @@ -285,12 +275,11 @@ impl InfopanelPane { return; } - // Raster paint tool: delegate to per-tool impl. + // Raster paint tool: tool-unique controls, then the shared brush controls + // (library, color, size/strength/hardness/spacing) that every brush tool gets. if let Some(def) = raster_tool_def { def.render_ui(ui, shared.raster_settings); - if def.show_brush_preset_picker() { - self.render_raster_tool_options(ui, shared, def.is_eraser()); - } + self.render_raster_tool_options(ui, shared, def); } match tool { @@ -344,7 +333,14 @@ impl InfopanelPane { ui.checkbox(shared.fill_enabled, "Fill Shape"); } + Tool::Eyedropper => { + // Which swatch the sampled color lands in. + render_color_slot_row(ui, &mut shared.raster_settings.eyedropper_use_fg); + } + Tool::PaintBucket => { + render_color_slot_row(ui, &mut shared.raster_settings.fill_use_fg); + if active_is_raster { use crate::tools::FillThresholdMode; ui.horizontal(|ui| { @@ -603,68 +599,66 @@ impl InfopanelPane { }); } - /// Render all options for a raster paint tool (brush picker + sliders). - /// `is_eraser` drives which shared state is read/written. + /// Render the shared controls every dab-painting tool has: the brush library, the FG/BG + /// color choice, and size/strength/hardness/spacing/angle. `def` supplies the tool's brush + /// slot and the label for the one slider whose meaning varies (opacity vs. exposure vs. + /// flow vs. strength). fn render_raster_tool_options( &mut self, ui: &mut Ui, shared: &mut SharedPaneState, - is_eraser: bool, + def: &'static dyn crate::tools::RasterToolDef, ) { - self.render_brush_preset_grid(ui, shared, is_eraser); + let kind = def.brush_kind(); + self.render_brush_preset_grid(ui, shared, kind); ui.add_space(2.0); - let rs = &mut shared.raster_settings; - - if !is_eraser { - ui.horizontal(|ui| { - ui.label("Color:"); - ui.selectable_value(&mut rs.brush_use_fg, true, "FG"); - ui.selectable_value(&mut rs.brush_use_fg, false, "BG"); - }); + if def.uses_color() { + render_color_slot_row(ui, &mut shared.raster_settings.brush_mut(kind).use_fg); } - macro_rules! field { - ($eraser:ident, $brush:ident) => { - if is_eraser { &mut rs.$eraser } else { &mut rs.$brush } - } - } + let slot = shared.raster_settings.brush_mut(kind); ui.horizontal(|ui| { ui.label("Size:"); - ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + ui.add(egui::Slider::new(&mut slot.radius, 1.0_f32..=500.0) + .logarithmic(true) + .suffix(" px")); }); ui.horizontal(|ui| { - ui.label("Opacity:"); - ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0) + ui.label(format!("{}:", def.strength_label())); + ui.add(egui::Slider::new(&mut slot.strength, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); }); ui.horizontal(|ui| { ui.label("Hardness:"); - ui.add(egui::Slider::new(field!(eraser_hardness, brush_hardness), 0.0_f32..=1.0) + ui.add(egui::Slider::new(&mut slot.hardness, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); }); ui.horizontal(|ui| { ui.label("Spacing:"); - ui.add(egui::Slider::new(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0) + ui.add(egui::Slider::new(&mut slot.spacing, 0.01_f32..=20.0) .logarithmic(true) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + .custom_formatter(|v, _| format!("{:.2}", v))); }); - let bs = if is_eraser { &rs.active_eraser_settings } else { &rs.active_brush_settings }; - if bs.elliptical_dab_ratio > 1.001 { + if slot.settings.elliptical_dab_ratio > 1.001 { ui.horizontal(|ui| { ui.label("Angle:"); - ui.add(egui::Slider::new(&mut rs.brush_angle_offset, -180.0_f32..=180.0) + ui.add(egui::Slider::new(&mut slot.angle_offset, -180.0_f32..=180.0) .suffix("°") .custom_formatter(|v, _| format!("{:.0}°", v))); }); } } - /// Render the brush preset thumbnail grid (collapsible). - /// `is_eraser` drives which picker state and which shared settings are updated. - fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) { + /// Render the brush preset thumbnail grid (collapsible) for one tool's brush slot. + fn render_brush_preset_grid( + &mut self, + ui: &mut Ui, + shared: &mut SharedPaneState, + kind: crate::tools::BrushKind, + ) { let presets = bundled_brushes(); if presets.is_empty() { return; } @@ -690,8 +684,8 @@ impl InfopanelPane { } // Read picker state into locals to avoid multiple &mut self borrows. - let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded }; - let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset }; + let mut expanded = self.brush_picker_expanded.get(&kind).copied().unwrap_or(false); + let selected = shared.raster_settings.brush(kind).preset; let gap = 3.0; let cols = 2usize; @@ -762,25 +756,14 @@ impl InfopanelPane { egui::FontId::proportional(9.5), if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) }); if resp.clicked() { - selected = Some(idx); expanded = false; - let s = &preset.settings; - let rs = &mut shared.raster_settings; - if is_eraser { - rs.eraser_opacity = s.opaque.clamp(0.0, 1.0); - rs.eraser_hardness = s.hardness.clamp(0.0, 1.0); - rs.eraser_spacing = s.dabs_per_radius; - rs.active_eraser_settings = s.clone(); - } else { - rs.brush_opacity = s.opaque.clamp(0.0, 1.0); - rs.brush_hardness = s.hardness.clamp(0.0, 1.0); - rs.brush_spacing = s.dabs_per_radius; - rs.active_brush_settings = s.clone(); - // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) - // and manually picked a different brush, revert to the generic tool. - if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) { - *shared.selected_tool = Tool::Draw; - } + shared.raster_settings.brush_mut(kind).apply_preset(idx, &preset.settings); + // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) + // and manually picked a different brush, revert to the generic tool. + if kind == crate::tools::BrushKind::Paint + && matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) + { + *shared.selected_tool = Tool::Draw; } } } @@ -789,14 +772,9 @@ impl InfopanelPane { } } - // Write back picker state. - if is_eraser { - self.eraser_picker_expanded = expanded; - self.selected_eraser_preset = selected; - } else { - self.brush_picker_expanded = expanded; - self.selected_brush_preset = selected; - } + // The selected preset lives on the slot itself (written by apply_preset above); only the + // expand/collapse state is panel-local. + self.brush_picker_expanded.insert(kind, expanded); } // Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms) @@ -1612,8 +1590,12 @@ impl InfopanelPane { ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64())); }); - let clip_dur = document.get_clip_duration(&ci.clip_id) - .unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds( + daw_backend::Seconds( + (ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(), + ), + )); let total_dur = ci.total_duration(clip_dur, document.tempo_map()); let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur) - document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64(); @@ -1622,10 +1604,16 @@ impl InfopanelPane { ui.label(format!("{:.2}s", total_dur_secs)); }); - if ci.trim_start > 0.0 { + if ci.trim_start > daw_backend::ContentTime::ZERO { ui.horizontal(|ui| { ui.label("Trim Start:"); - ui.label(format!("{:.2}s", ci.trim_start)); + // Content time in the clip's own domain: seconds for sampled + // audio/video/vector, beats for MIDI. Label it accordingly. + let unit = match clip_dur { + lightningbeam_core::clip::ClipDuration::Beats(_) => "beats", + lightningbeam_core::clip::ClipDuration::Seconds(_) => "s", + }; + ui.label(format!("{:.2}{}", ci.trim_start.raw(), unit)); }); } @@ -1933,3 +1921,17 @@ impl PaneRenderer for InfopanelPane { "Info Panel" } } + +/// The FG/BG selector shared by every tool that reads or writes a color (brush, paint bucket, +/// eyedropper, …), so the choice is made the same way and in the same place regardless of tool. +/// The wording stays neutral because the eyedropper *writes* to the chosen swatch while the +/// painting tools *read* from it. +fn render_color_slot_row(ui: &mut Ui, use_fg: &mut bool) { + ui.horizontal(|ui| { + ui.label("Color:"); + ui.selectable_value(use_fg, true, "FG") + .on_hover_text("Foreground (stroke) color"); + ui.selectable_value(use_fg, false, "BG") + .on_hover_text("Background (fill) color"); + }); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index d79eebe..ed31908 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -189,8 +189,6 @@ pub struct SharedPaneState<'a> { pub selected_tool: &'a mut Tool, pub fill_color: &'a mut egui::Color32, pub stroke_color: &'a mut egui::Color32, - /// Tracks which color (fill or stroke) was last interacted with, for eyedropper tool - pub active_color_mode: &'a mut ColorMode, pub pending_view_action: &'a mut Option, /// Tracks the priority of the best fallback pane for view actions /// Lower number = higher priority. None = no fallback pane seen yet diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 2505ef3..7a81053 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -465,9 +465,13 @@ impl PianoRollPane { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { for instance in &audio_layer.clip_instances { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { - if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { - let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); - clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); + // Resolve through the instance's active take, so a MIDI take folder edits + // whichever take it's actually playing. + if let Some(midi_clip_id) = instance.resolved_midi_clip_id(clip) { + let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); + // A MIDI clip's content time IS beats, which is what the piano roll's + // x-axis uses. + clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id)); } } } @@ -2463,7 +2467,8 @@ impl PianoRollPane { }); // Get sample rate from raw_audio_cache if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { - clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr)); + // A sampled clip's content time is seconds. + clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), *sr)); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 73dd94d..8d611ac 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6,7 +6,7 @@ use eframe::egui; use daw_backend::Seconds; use lightningbeam_core::action::Action; -use lightningbeam_core::clip::ClipInstance; +use lightningbeam_core::clip::{ClipDuration, ClipInstance}; use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter}; use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::renderer::RenderedLayerType; @@ -1854,7 +1854,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // For now, create a simple effect instance with default parameters let tempo_map = self.ctx.document.tempo_map(); let effect_end_beats = effect_instance.timeline_start - + effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + + effect_instance.effective_duration( + ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), + tempo_map, + ); let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), @@ -2209,7 +2212,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Calculate clip bounds for preview let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64(); - let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start; + let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start.raw(); let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) { vector_clip.calculate_content_bounds(&self.ctx.document, clip_time) } else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) { @@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { for &clip_id in self.ctx.selection.clip_instances() { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Skip clip instances not active at current time (compare in seconds). - let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO); + let clip_dur = ClipDuration::Seconds( + self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = self.ctx.document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let instance_end = tempo_map.beats_to_seconds( @@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } // Calculate clip-local time - let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) { @@ -2680,7 +2685,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Find clip instance visible at playback time let visible_clip = video_layer.clip_instances.iter().find(|inst| { - let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); + let clip_duration = ClipDuration::Seconds( + self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = self.ctx.document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); @@ -5715,9 +5722,14 @@ impl StagePane { screen_pos: egui::Pos2, shared: &mut SharedPaneState, ) { - // On click, store the screen position and color mode for sampling + // On click, store the screen position and which swatch the sample lands in. if self.rsp_clicked(response) { - self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode)); + let mode = if shared.raster_settings.eyedropper_use_fg { + super::ColorMode::Stroke + } else { + super::ColorMode::Fill + }; + self.pending_eyedropper_sample = Some((screen_pos, mode)); } } @@ -6485,15 +6497,16 @@ impl StagePane { b.dabs_per_radius = spacing; if matches!(blend_mode, RasterBlendMode::Smudge) { b.dabs_per_actual_radius = 0.0; - b.smudge_radius_log = shared.raster_settings.smudge_strength; + b.smudge_radius_log = + shared.raster_settings.brush(crate::tools::BrushKind::Smudge).strength; } if matches!(blend_mode, RasterBlendMode::BlurSharpen) { b.dabs_per_actual_radius = 0.0; } - let color = if matches!(blend_mode, RasterBlendMode::Erase) { + let color = if !def.uses_color() { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if shared.raster_settings.brush_use_fg { + let c = if shared.raster_settings.brush(def.brush_kind()).use_fg { *shared.stroke_color } else { *shared.fill_color @@ -6702,7 +6715,8 @@ impl StagePane { b.dabs_per_actual_radius = 0.0; // strength controls how far behind the stroke to sample (smudge_dist multiplier). // smudge_dist = radius * exp(smudge_radius_log), so log(strength) gives the ratio. - b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] strength + b.smudge_radius_log = + shared.raster_settings.brush(crate::tools::BrushKind::Smudge).strength; } if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::BlurSharpen) { // Zero dabs_per_actual_radius so the spacing slider is the sole density control. @@ -6711,10 +6725,14 @@ impl StagePane { b }; - let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) { + let color = if !def.uses_color() { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if shared.raster_settings.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; + let c = if shared.raster_settings.brush(def.brush_kind()).use_fg { + *shared.stroke_color + } else { + *shared.fill_color + }; let s2l = |v: u8| -> f32 { let f = v as f32 / 255.0; if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } @@ -7429,7 +7447,7 @@ impl StagePane { use lightningbeam_core::actions::PaintBucketAction; use vello::kurbo::Point; let click_point = Point::new(world_pos.x as f64, world_pos.y as f64); - let fill_color = ShapeColor::from_egui(*shared.fill_color); + let fill_color = ShapeColor::from_egui(bucket_color(shared)); let action = PaintBucketAction::new( active_layer_id, *shared.playback_time, @@ -7483,7 +7501,7 @@ impl StagePane { return; } - let fill_egui = *shared.fill_color; + let fill_egui = bucket_color(shared); let fill_color = [fill_egui.r(), fill_egui.g(), fill_egui.b(), fill_egui.a()]; let threshold = shared.raster_settings.fill_threshold; let softness = shared.raster_settings.fill_softness; @@ -10142,7 +10160,7 @@ impl StagePane { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time use vello::kurbo::Rect as KurboRect; @@ -10343,7 +10361,7 @@ impl StagePane { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) { @@ -11059,7 +11077,9 @@ impl StagePane { let document = shared.action_executor.document(); if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { video_layer.clip_instances.iter().find(|inst| { - let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); @@ -11580,7 +11600,11 @@ impl StagePane { // Handle tool input (only if not using Alt modifier for panning, and not while a // double-tap-drag marquee is actively dragging so the tool doesn't act during the gesture). - if !alt_held && !self.marquee_gesture_active { + // A pen barrel button bound to Pan suppresses the tool the same way Alt does — the pen + // tip is still down while panning, so without this the brush would paint as you pan. + let pen_panning = crate::tablet::active_button_action() + == Some(crate::config::TabletButtonAction::Pan); + if !alt_held && !pen_panning && !self.marquee_gesture_active { use lightningbeam_core::tool::Tool; // On a shape-tween in-between frame the active vector layer's geometry is an @@ -11743,9 +11767,21 @@ impl StagePane { self.pan_offset.y += scroll_delta.y; } - // Handle panning with Alt+Drag - if alt_held && response.dragged() { - // Alt+Click+Drag panning + // Middle-mouse drag pans, independent of the stage's own drag sense. + let middle_down = ui.input(|i| i.pointer.middle_down()); + if middle_down { + let delta = ui.input(|i| i.pointer.delta()); + self.pan_offset += delta; + self.is_panning = true; + } + + // Holding a pen barrel button bound to Pan turns the pen drag into a pan instead of + // a stroke (the tip is still down, so this is a normal primary drag). + let pen_pan = crate::tablet::active_button_action() + == Some(crate::config::TabletButtonAction::Pan); + + // Handle panning with Alt+Drag (or a pan-bound pen button) + if (alt_held || pen_pan) && response.dragged() { if let Some(last_pos) = self.last_pan_pos { if let Some(current_pos) = response.interact_pointer_pos() { let delta = current_pos - last_pos; @@ -11755,7 +11791,7 @@ impl StagePane { self.last_pan_pos = response.interact_pointer_pos(); self.is_panning = true; } else { - if !response.dragged() { + if !response.dragged() && !middle_down { self.is_panning = false; self.last_pan_pos = None; } @@ -12099,26 +12135,22 @@ impl StagePane { let r = shared.raster_settings.quick_select_radius; (r, r, 0.0_f32) } else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) { + // Every brush tool can carry an elliptical library brush, so shape the cursor from + // whichever slot the active tool paints with. + let slot = shared.raster_settings.brush(def.brush_kind()); let r = def.cursor_radius(shared.raster_settings); - // For the standard paint brush, also account for elliptical shape. - if matches!(*shared.selected_tool, - Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush) - { - let bs = &shared.raster_settings.active_brush_settings; - let ratio = bs.elliptical_dab_ratio.max(1.0); - let expand = 1.0 + bs.offset_by_random; - let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); - (r * expand, r * expand / ratio, angle) - } else { - (r, r, 0.0_f32) - } - } else { - let bs = &shared.raster_settings.active_brush_settings; - let r = shared.raster_settings.brush_radius; + let bs = &slot.settings; let ratio = bs.elliptical_dab_ratio.max(1.0); let expand = 1.0 + bs.offset_by_random; - let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); + let angle = (bs.elliptical_dab_angle + slot.angle_offset).to_radians(); (r * expand, r * expand / ratio, angle) + } else { + let slot = shared.raster_settings.brush(crate::tools::BrushKind::Paint); + let bs = &slot.settings; + let ratio = bs.elliptical_dab_ratio.max(1.0); + let expand = 1.0 + bs.offset_by_random; + let angle = (bs.elliptical_dab_angle + slot.angle_offset).to_radians(); + (slot.radius * expand, slot.radius * expand / ratio, angle) }; let a = a_world * self.zoom; // major semi-axis in screen pixels @@ -12163,9 +12195,84 @@ impl StagePane { impl PaneRenderer for StagePane { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { - ui.horizontal(|ui| { - // Zoom to fit button - if ui.button("⊡ Fit").on_hover_text("Zoom to fit canvas in view").clicked() { + // Fill most of the 40px header so the buttons are a comfortable tablet-sized target + // rather than the ~20px egui default. + const BTN_H: f32 = 27.0; + let btn_size = egui::vec2(32.0, BTN_H); + + // Lay the row out in a full-width band of exactly BTN_H centred in the header, so the + // buttons sit vertically centred instead of dropping to the bottom of the taller header + // rect. Horizontally they still start at the left, as before. + let avail = ui.max_rect(); + let band = egui::Rect::from_min_size( + egui::pos2(avail.min.x, avail.center().y - BTN_H / 2.0), + egui::vec2(avail.width(), BTN_H), + ); + + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(band) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + // Undo / redo — duplicated here so tablet users don't need the keyboard. + let can_undo = shared.action_executor.can_undo(); + let can_redo = shared.action_executor.can_redo(); + let icon_btn = |glyph: &str| { + egui::Button::new( + egui::RichText::new(glyph).font(crate::mobile::icons::font(16.0)), + ) + .min_size(btn_size) + }; + + if ui + .add_enabled(can_undo, icon_btn(crate::mobile::icons::UNDO_2)) + .on_hover_text("Undo") + .clicked() + { + shared.pending_menu_actions.push(crate::menu::MenuAction::Undo); + } + if ui + .add_enabled(can_redo, icon_btn(crate::mobile::icons::REDO_2)) + .on_hover_text("Redo") + .clicked() + { + shared.pending_menu_actions.push(crate::menu::MenuAction::Redo); + } + + ui.separator(); + + // Zoom to fit: Lucide "maximize" glyph followed by the label. Two fonts in one + // button means building the layout job by hand. + let fit_label = { + let mut job = egui::text::LayoutJob::default(); + let color = ui.visuals().widgets.inactive.fg_stroke.color; + job.append( + crate::mobile::icons::MAXIMIZE, + 0.0, + egui::TextFormat { + font_id: crate::mobile::icons::font(15.0), + color, + valign: egui::Align::Center, + ..Default::default() + }, + ); + job.append( + "Fit", + 6.0, + egui::TextFormat { + font_id: egui::TextStyle::Button.resolve(ui.style()), + color, + valign: egui::Align::Center, + ..Default::default() + }, + ); + job + }; + if ui + .add(egui::Button::new(fit_label).min_size(egui::vec2(0.0, BTN_H))) + .on_hover_text("Zoom to fit canvas in view") + .clicked() + { self.zoom_to_fit(shared); } @@ -12175,7 +12282,8 @@ impl PaneRenderer for StagePane { let text_style = shared.theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); ui.colored_label(text_color, format!("Zoom: {:.0}%", self.zoom * 100.0)); - }); + }, + ); true } @@ -13104,3 +13212,13 @@ impl PaneRenderer for StagePane { "Stage" } } + +/// The color the paint bucket fills with. Like the brush, the bucket chooses between the +/// foreground (stroke) and background (fill) swatch — see `RasterToolSettings::fill_use_fg`. +fn bucket_color(shared: &SharedPaneState) -> egui::Color32 { + if shared.raster_settings.fill_use_fg { + *shared.stroke_color + } else { + *shared.fill_color + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index c19b724..94bc05d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -7,11 +7,38 @@ /// - Basic layer visualization use eframe::egui; -use daw_backend::{Beats, Seconds}; -use lightningbeam_core::clip::ClipInstance; +use daw_backend::{Beats, ContentTime, Seconds}; +use lightningbeam_core::clip::{ClipDuration, ClipInstance}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; +use crate::mobile::icons; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; +/// A layer-row toggle button drawn as a Lucide glyph. +fn icon_button(glyph: &str) -> egui::Button<'static> { + egui::Button::new(egui::RichText::new(glyph).font(icons::font(13.0))) +} + +/// Label a layer-row slider with a Lucide glyph just to its left — the sliders are unlabelled +/// otherwise, and volume vs. opacity are indistinguishable on layers that have both. +fn draw_slider_icon( + ui: &egui::Ui, + theme: &crate::theme::Theme, + slider_rect: egui::Rect, + glyph: &str, +) { + ui.painter().text( + egui::pos2(slider_rect.min.x - 5.0, slider_rect.center().y), + egui::Align2::RIGHT_CENTER, + glyph, + icons::font(12.0), + theme.text_color( + &["#timeline", ".slider-icon"], + ui.ctx(), + egui::Color32::from_gray(140), + ), + ); +} + const RULER_HEIGHT: f32 = 30.0; const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEADER_WIDTH: f32 = 200.0; @@ -20,6 +47,18 @@ const MOBILE_LAYER_HEADER_WIDTH: f32 = LAYER_HEIGHT * 0.5; const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes const MAX_PIXELS_PER_SECOND: f32 = 500.0; const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles +/// Height of the cycle lane carved off the *bottom* of the ruler. Dragging here edits the cycle +/// region; the ruler above it still scrubs the playhead as before. It goes at the bottom so the +/// bar numbers (which are drawn at the top of the ruler) stay legible. +const CYCLE_LANE_HEIGHT: f32 = 11.0; + +// Snap "visual coarseness" profiles — the minimum on-screen spacing a grid line may have. +// See `Timeline::quantize_grid_size`. +/// Playhead scrubbing and clip edges: snap fine, down to 16ths when there's room. +const SNAP_PX_FINE: f64 = 15.0; +/// Cycle region: snap coarse, so loops land on whole bars rather than odd subdivisions. +/// Only drops to beats once you're zoomed in far enough to clearly be asking for it. +const SNAP_PX_CYCLE: f64 = 60.0; const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups) const AUTOMATION_LANE_HEIGHT: f32 = 40.0; @@ -77,7 +116,7 @@ fn compute_clip_stacking( let tempo_map = document.tempo_map(); // Stacking only needs relative overlap, so compare in the beats domain. let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { - let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); + let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO)); let start = ci.effective_start(); let end = start + ci.total_duration(clip_dur, tempo_map); (start.beats_to_f64(), end.beats_to_f64()) @@ -191,11 +230,14 @@ fn draw_video_thumbnail_strip( /// Get the effective clip duration for a clip instance on a given layer. /// For groups on vector layers, the duration spans all consecutive keyframes /// where the group is present. For regular clips, returns the clip's internal duration. +/// A clip's content duration **in its own domain** — seconds for vector/video/effect and sampled +/// audio, BEATS for MIDI. Returning a `ClipDuration` rather than bare `Seconds` is what lets the +/// instance's trim bounds (which are content times, in that same domain) be resolved correctly. fn effective_clip_duration( document: &lightningbeam_core::document::Document, layer: &AnyLayer, clip_instance: &ClipInstance, -) -> Option { +) -> Option { match layer { AnyLayer::Vector(vl) => { let vc = document.get_vector_clip(&clip_instance.clip_id)?; @@ -203,17 +245,21 @@ fn effective_clip_duration( let frame_duration = 1.0 / document.framerate; let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration); - Some(Seconds((end - start_secs).max(0.0))) + Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0)))) } else { // Movie clips: duration based on all internal content (keyframes + clip instances) - document.get_clip_duration(&clip_instance.clip_id) + document.get_clip_duration(&clip_instance.clip_id).map(ClipDuration::Seconds) } } - // Delegate to get_clip_duration so MIDI clips (whose `duration` is stored in beats, - // not seconds) are converted correctly rather than read as raw seconds. - AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id), - AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)), - AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), + // An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content), + // so ask the clip which it is rather than flattening both to seconds. + AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id), + AnyLayer::Video(_) => document + .get_video_clip(&clip_instance.clip_id) + .map(|c| ClipDuration::Seconds(Seconds(c.duration))), + AnyLayer::Effect(_) => Some(ClipDuration::Seconds(Seconds( + lightningbeam_core::effect::EFFECT_DURATION, + ))), AnyLayer::Group(_) => None, AnyLayer::Raster(_) => None, AnyLayer::Text(_) => None, @@ -230,6 +276,20 @@ enum ClipDragType { LoopExtendLeft, } +/// A drag on the cycle strip (the thin lane at the top of the ruler). +/// +/// Mirrors `ClipDragType`'s three-zone model (left edge / body / right edge), reusing +/// `EDGE_DETECTION_PIXELS` for the handles. +#[derive(Debug, Clone, Copy, PartialEq)] +enum CycleDrag { + /// Dragging out a brand-new region; `anchor` is the beat the drag started from. + Create { anchor: Beats }, + /// Sliding the whole region; `grab_offset` is where inside it the user grabbed. + Move { grab_offset: Beats }, + ResizeStart, + ResizeEnd, +} + use lightningbeam_core::document::TimelineMode; /// State for an in-progress layer header drag-to-reorder operation. @@ -260,6 +320,18 @@ pub struct TimelinePane { /// during the last `render_layers`. Used by `handle_input` (next frame) to snap the /// playhead exactly to a keyframe when its diamond is clicked. keyframe_diamond_hits: Vec<(egui::Rect, f64)>, + /// Take-badge click targets recorded during render: (badge rect, layer, instance, active take, + /// take count). Collected while painting, dispatched after — the usual two-phase pattern. + take_badge_hits: Vec<(egui::Rect, uuid::Uuid, uuid::Uuid, usize, usize)>, + /// The take-folder instance whose take menu is open, if any. + open_take_menu: Option<(uuid::Uuid, uuid::Uuid)>, + /// The (instance, take index) being renamed inline in the take menu. + renaming_take: Option<(uuid::Uuid, usize)>, + take_rename_buffer: String, + /// Seconds between the cycle region's start and where the current recording actually began. + /// Zero unless the user punched in mid-region. Used to line the live waveform preview up with + /// the region on each pass. + cycle_record_lead_secs: f64, /// Total duration of the animation duration: f64, @@ -267,6 +339,12 @@ pub struct TimelinePane { /// Is the user currently dragging the playhead? is_scrubbing: bool, + /// In-flight drag on the cycle strip, if any. + cycle_drag: Option, + /// Live preview of the cycle region while dragging. The document is only updated on release + /// (via one `SetCycleRegionAction`), so a drag doesn't spam the undo stack — same as clip drags. + cycle_preview: Option<(Beats, Beats)>, + /// Is the user panning the timeline? is_panning: bool, last_pan_pos: Option, @@ -691,8 +769,15 @@ impl TimelinePane { viewport_start_time: 0.0, viewport_scroll_y: 0.0, keyframe_diamond_hits: Vec::new(), + take_badge_hits: Vec::new(), + open_take_menu: None, + renaming_take: None, + take_rename_buffer: String::new(), + cycle_record_lead_secs: 0.0, duration: 10.0, // Default 10 seconds is_scrubbing: false, + cycle_drag: None, + cycle_preview: None, is_panning: false, last_pan_pos: None, lp_time: None, @@ -813,7 +898,9 @@ impl TimelinePane { .unwrap_or_default() .iter() .map(|k| crate::curve_editor::CurvePoint { - time: k.time, // beats (backend stores beats; curve editor x-axis is beats) + // The curve editor's x-axis is beats, same as the backend — unwrap at this + // boundary because CurvePoint stores a plain f64. + time: k.time.beats_to_f64(), value: k.value, interpolation: match k.interpolation.as_str() { "bezier" => crate::curve_editor::CurveInterpolation::Bezier, @@ -858,6 +945,35 @@ impl TimelinePane { self.automation_cache.insert(layer_id, lanes); } + /// Toggle the transport cycle (loop) on/off. + /// + /// This is the only way to arm looping; the cycle strip on the ruler is shown (and draggable) + /// only while armed. Arming with no region set seeds a default one at the playhead so the + /// button does something immediately rather than revealing an empty strip. + pub(crate) fn toggle_cycle(&mut self, shared: &mut SharedPaneState) { + let document = shared.action_executor.document(); + let arming = !document.cycle_enabled; + + let region = if arming && document.cycle_region.is_none() { + let tempo_map = document.tempo_map(); + let beats_per_bar = (document.time_signature.numerator.max(1)) as f64; + // Start at the bar containing the playhead, and run for a few bars. + let playhead_beats = tempo_map.seconds_to_beats(Seconds(*shared.playback_time)); + let bar = (playhead_beats.beats_to_f64() / beats_per_bar).floor().max(0.0); + let start = Beats(bar * beats_per_bar); + const DEFAULT_BARS: f64 = 4.0; + Some((start, start + Beats(beats_per_bar * DEFAULT_BARS))) + } else { + document.cycle_region + }; + + let action = + lightningbeam_core::actions::SetCycleRegionAction::new(document, region, arming); + if !action.is_noop() { + shared.pending_actions.push(Box::new(action)); + } + } + /// Toggle recording on/off /// In Auto mode, records to the active layer (audio or video with camera) pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) { @@ -986,7 +1102,41 @@ impl TimelinePane { true }); - let start_time = *shared.playback_time; + let mut start_time = *shared.playback_time; + + // With a cycle region armed, a recording is anchored at the REGION start rather than the + // playhead: every take spans the whole region, so the clip has to as well. + // + // Two ways in. From stopped, we also move the playhead to the region start, so recording + // begins with the loop (the count-in below then rolls in from a measure before it). Punching + // in while already rolling leaves the playhead where it is — the backend prepends silence to + // take 1's head to fill the gap back to the region start. + let cycle_start_secs = { + let doc = shared.action_executor.document(); + match (doc.cycle_enabled, doc.cycle_region) { + (true, Some((ls, le))) if le > ls => { + Some(doc.tempo_map().beats_to_seconds(ls).seconds_to_f64()) + } + _ => None, + } + }; + if let Some(ls_secs) = cycle_start_secs { + // How far into the region we punched in (zero when starting from stopped, since we jump + // the playhead to the region start below). The live waveform preview needs this to know + // where each pass begins inside the recording buffer. + self.cycle_record_lead_secs = if *shared.is_playing { + (*shared.playback_time - ls_secs).max(0.0) + } else { + 0.0 + }; + start_time = ls_secs; + if !*shared.is_playing { + if let Some(controller_arc) = shared.audio_controller { + controller_arc.lock().unwrap().seek(Seconds(ls_secs)); + } + *shared.playback_time = ls_secs; + } + } // Count-in: seek back N beats, start transport + metronome, defer ALL recording commands. // Must happen before Step 4 so no clips or backend recordings are created yet. @@ -1030,6 +1180,28 @@ impl TimelinePane { // The backend records in the beats domain; start_time is the seconds playhead. let start_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(start_time)); + // Does this layer already hold takes over the cycle region? If so this recording is another + // take, however short it runs — without this, stopping before the loop came round would land + // it as a separate overlapping clip instead of joining the take list. The engine can't work + // this out for itself: whether takes exist is document state. + let force_takes: std::collections::HashMap = { + let doc = shared.action_executor.document(); + let region = match (doc.cycle_enabled, doc.cycle_region) { + (true, Some((ls, le))) if le > ls => Some((ls, le - ls)), + _ => None, + }; + candidates + .iter() + .map(|&(layer_id, _, _)| { + let forced = region.is_some_and(|(loop_start, loop_len)| { + doc.take_folder_at(&layer_id, loop_start, loop_len, &uuid::Uuid::nil()) + .is_some() + }); + (layer_id, forced) + }) + .collect() + }; + // Step 4: Dispatch recording for each candidate for &(layer_id, ref cat, _) in &candidates { match cat { @@ -1053,7 +1225,7 @@ impl TimelinePane { } if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); - controller.start_recording(track_id, start_beats); + controller.start_recording(track_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false)); println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time); } shared.recording_layer_ids.push(layer_id); @@ -1066,7 +1238,7 @@ impl TimelinePane { if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); let clip_id = controller.create_midi_clip(track_id, start_beats, Beats::ZERO); - controller.start_midi_recording(track_id, clip_id, start_beats); + controller.start_midi_recording(track_id, clip_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false)); shared.recording_clips.insert(layer_id, clip_id); println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", track_id, start_time, clip_id); @@ -1332,8 +1504,10 @@ impl TimelinePane { let tempo_map = document.tempo_map(); for (_child_layer_id, ci) in &child_clips { - let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let start = ci.effective_start(); let end = start + ci.total_duration(clip_dur, tempo_map); @@ -1418,6 +1592,251 @@ impl TimelinePane { self.viewport_start_time = (self.viewport_start_time + time_delta as f64).max(0.0); } + /// The cycle lane: a thin band carved off the *bottom* of the ruler. Dragging here edits the + /// cycle region; the ruler above it still scrubs the playhead. Bottom rather than top so it + /// doesn't sit on the bar numbers. + fn cycle_lane_rect(ruler_rect: egui::Rect) -> egui::Rect { + let h = CYCLE_LANE_HEIGHT.min(ruler_rect.height()); + egui::Rect::from_min_max( + egui::pos2(ruler_rect.min.x, ruler_rect.max.y - h), + ruler_rect.max, + ) + } + + /// The cycle region currently being shown: the live drag preview if one is in flight, + /// otherwise whatever the document has. + fn shown_cycle_region( + &self, + document: &lightningbeam_core::document::Document, + ) -> Option<(Beats, Beats)> { + self.cycle_preview.or(document.cycle_region) + } + + /// Three-zone hit test on the cycle lane: left edge / body / right edge of the existing region, + /// mirroring how clips detect their trim handles. Anywhere else draws a fresh region. + /// + /// `pos_x` is in screen coords; `content_min_x` is the content area's left edge (which the + /// ruler shares, so beats→x lands in the same space). + fn cycle_drag_at( + &self, + pos_x: f32, + beat: Beats, + content_min_x: f32, + document: &lightningbeam_core::document::Document, + ) -> CycleDrag { + let Some((s, e)) = document.cycle_region else { + return CycleDrag::Create { anchor: beat }; + }; + let sx = content_min_x + self.beats_to_x(s, document.tempo_map()); + let ex = content_min_x + self.beats_to_x(e, document.tempo_map()); + if (pos_x - sx).abs() <= EDGE_DETECTION_PIXELS { + CycleDrag::ResizeStart + } else if (pos_x - ex).abs() <= EDGE_DETECTION_PIXELS { + CycleDrag::ResizeEnd + } else if pos_x > sx && pos_x < ex { + CycleDrag::Move { grab_offset: beat - s } + } else { + CycleDrag::Create { anchor: beat } + } + } + + /// Pixel x (relative to the content area) → beats, snapped on the coarse cycle grid. + fn x_to_beats_cycle_snapped( + &self, + x: f32, + document: &lightningbeam_core::document::Document, + ) -> Beats { + let secs = self.snap_to_grid( + self.x_to_time(x.max(0.0)).max(0.0), + document.tempo_map(), + &document.time_signature, + document.framerate, + SNAP_PX_CYCLE, + ); + document.tempo_map().seconds_to_beats(Seconds(secs.max(0.0))) + } + + /// Paint the cycle lane and the cycle region band, *inside* the ruler. + /// + /// Called from `render_ruler` right after the ruler's background and before its ticks/labels, + /// so the tick marks read through the band and the bar numbers (up top) are never covered. + /// + /// The lane only exists while looping is armed — with cycle off there's no lane and the ruler + /// is entirely the playhead scrubber, exactly as before this feature. + fn paint_cycle_lane( + &self, + ui: &egui::Ui, + ruler_rect: egui::Rect, + theme: &crate::theme::Theme, + tempo_map: &daw_backend::TempoMap, + region: Option<(Beats, Beats)>, + ) { + let painter = ui.painter(); + let lane = Self::cycle_lane_rect(ruler_rect); + + // A faint bed, so the lane still reads as a drag target when there's no region to grab. + painter.rect_filled( + lane, + 0.0, + theme.bg_color(&["#timeline", ".cycle-lane"], ui.ctx(), egui::Color32::from_gray(48)), + ); + + let Some((start, end)) = region else { return }; + if end <= start { + return; + } + + let sx = self.beats_to_x(start, tempo_map); + let ex = self.beats_to_x(end, tempo_map); + if ex < 0.0 || sx > ruler_rect.width() { + return; // off-screen + } + + let fill = theme.bg_color( + &["#timeline", ".cycle-region"], + ui.ctx(), + egui::Color32::from_rgb(230, 190, 60), + ); + + let band = egui::Rect::from_min_max( + egui::pos2((ruler_rect.min.x + sx).max(ruler_rect.min.x), lane.min.y), + egui::pos2((ruler_rect.min.x + ex).min(ruler_rect.max.x), lane.max.y), + ); + painter.rect_filled(band, 2.0, fill); + } + + /// Click handling + dropdown for the take badge painted on take-folder clips. + /// + /// Runs after rendering, off the hit rects collected during it: clicking a badge opens (or + /// closes) a list of the clip's takes, and picking one dispatches `SetActiveTakeAction`. + /// Selection is per-*instance*, so doing this to one half of a split clip and something else to + /// the other half is exactly how you comp. + fn render_take_menu( + &mut self, + ui: &mut egui::Ui, + document: &lightningbeam_core::document::Document, + pending_actions: &mut Vec>, + ) { + let click = ui.input(|i| { + i.pointer + .primary_pressed() + .then(|| i.pointer.interact_pos()) + .flatten() + }); + + if let Some(pos) = click { + if let Some((_, layer_id, instance_id, _, _)) = + self.take_badge_hits.iter().find(|(r, ..)| r.contains(pos)) + { + let key = (*layer_id, *instance_id); + // Clicking the badge of the open menu closes it again. + self.open_take_menu = (self.open_take_menu != Some(key)).then_some(key); + } + } + + let Some((layer_id, instance_id)) = self.open_take_menu else { + return; + }; + // The badge is only in the hit list while it's on screen; if the clip scrolled away, the + // menu has nothing to hang off, so drop it. + let Some((badge, _, _, active, count)) = self + .take_badge_hits + .iter() + .find(|(_, l, i, _, _)| *l == layer_id && *i == instance_id) + .copied() + else { + self.open_take_menu = None; + return; + }; + + let Some(instance) = document + .get_layer(&layer_id) + .and_then(|l| match l { + lightningbeam_core::layer::AnyLayer::Audio(al) => { + al.clip_instances.iter().find(|ci| ci.id == instance_id) + } + _ => None, + }) + else { + self.open_take_menu = None; + return; + }; + // The instance's *stored* selection, which is what rollback must restore — not `active`, + // which is that value clamped for display. + let old_take = instance.active_take; + let take_names: Vec = instance.takes.iter().map(|t| t.name.clone()).collect(); + + let mut close = false; + let area = egui::Area::new(ui.id().with(("take_menu", instance_id))) + .order(egui::Order::Foreground) + .fixed_pos(egui::pos2(badge.min.x, badge.max.y + 2.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + for i in 0..count { + let is_active = i == active; + let renaming = self.renaming_take == Some((instance_id, i)); + + ui.horizontal(|ui| { + if renaming { + let edit = ui.add( + egui::TextEdit::singleline(&mut self.take_rename_buffer) + .desired_width(110.0), + ); + edit.request_focus(); + // Commit on Enter or on clicking away; Escape abandons. + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if enter || edit.lost_focus() && !escape { + let name = self.take_rename_buffer.trim().to_string(); + if !name.is_empty() && name != take_names[i] { + pending_actions.push(Box::new( + lightningbeam_core::actions::RenameTakeAction::new( + layer_id, instance_id, i, name, + ), + )); + } + self.renaming_take = None; + } else if escape { + self.renaming_take = None; + } + return; + } + + let label = ui.selectable_label(is_active, &take_names[i]); + if label.clicked() { + if !is_active { + pending_actions.push(Box::new( + lightningbeam_core::actions::SetActiveTakeAction::new( + layer_id, instance_id, i, old_take, + ), + )); + } + close = true; + } + // Double-click a take to rename it in place. Deleting lives on the clip's + // right-click menu, not here — a trash icon per row is a small target + // sitting right next to the one you actually meant to click. + if label.double_clicked() { + self.renaming_take = Some((instance_id, i)); + self.take_rename_buffer = take_names[i].clone(); + } + }); + } + }); + }); + + // A press anywhere outside the menu (and outside the badge, which toggles) dismisses it. + if let Some(pos) = click { + if !badge.contains(pos) && !area.response.rect.contains(pos) { + close = true; + } + } + if close { + self.open_take_menu = None; + self.renaming_take = None; + } + } + /// Convert time (seconds) to pixel x-coordinate fn time_to_x(&self, time: f64) -> f32 { ((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32 @@ -1439,15 +1858,18 @@ impl TimelinePane { /// Effective on-timeline duration for a clip instance, in seconds. /// /// `total_duration` is in beats; converts to seconds using the current (preview) BPM. - fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 { - (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) + fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, tempo_map: &daw_backend::TempoMap) -> f64 { + (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_content, tempo_map)) - tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64() } - /// Returns the clip content start (trim_start) and duration in display seconds. - fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64()); - (ci.trim_start, (trim_end - ci.trim_start).max(0.0)) + /// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own + /// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below + /// works in that domain throughout, converting to the timeline only at the edges. + fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, _bpm: f64) -> (f64, f64) { + let trim_end = ci.trim_end.map_or(clip_content.native(), |t| t.raw()); + let start = ci.trim_start.raw(); + (start, (trim_end - start).max(0.0)) } /// Convert pixel x-coordinate to time (seconds) @@ -1459,11 +1881,19 @@ impl TimelinePane { /// - Measures mode: zoom-adaptive (coarser when zoomed out, None when very zoomed in) /// - Frames mode: always 1/framerate regardless of zoom /// - Seconds mode: no snapping + /// + /// `min_grid_px` is the **visual coarseness**: the smallest on-screen spacing a grid line is + /// allowed to have. The finest musical subdivision at least that wide wins, so a larger value + /// snaps to coarser units at the same zoom. Callers pick a profile: [`SNAP_PX_FINE`] for the + /// playhead and clip edges, [`SNAP_PX_CYCLE`] for the cycle region (you nearly always want to + /// loop whole bars — "four bars and an eighth" is essentially never intended; zoom in if you + /// really do want it). fn quantize_grid_size( &self, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, + min_grid_px: f64, ) -> Option { match self.time_display_format { TimelineMode::Frames => Some(1.0 / framerate), @@ -1472,17 +1902,17 @@ impl TimelinePane { let beat = beat_duration(0.0, tempo_map); let measure = measure_duration(0.0, tempo_map, time_sig); let pps = self.pixels_per_second as f64; - // Very zoomed in: 16th note > 40px → no snap - if pps * beat / 4.0 > 40.0 { return None; } - // Find finest subdivision with >= 15px spacing (finest → coarsest) - const MIN_PX: f64 = 15.0; + // So zoomed in that even the finest subdivision is a huge target → free positioning. + // Scaled off the coarseness so a coarse profile doesn't give up snapping early. + if pps * beat / 4.0 > min_grid_px * 2.5 { return None; } + // Find the finest subdivision with >= min_grid_px spacing (finest → coarsest) for &sub in &[beat / 4.0, beat / 2.0, beat, beat * 2.0, measure] { - if pps * sub >= MIN_PX { return Some(sub); } + if pps * sub >= min_grid_px { return Some(sub); } } // Very zoomed out: try 2x, 4x, ... multiples of a measure let mut m = measure * 2.0; for _ in 0..10 { - if pps * m >= MIN_PX { return Some(m); } + if pps * m >= min_grid_px { return Some(m); } m *= 2.0; } Some(measure) @@ -1492,14 +1922,16 @@ impl TimelinePane { } /// Snap a time value to the nearest quantization grid point (or return unchanged). + /// See [`Self::quantize_grid_size`] for `min_grid_px` (the visual coarseness). fn snap_to_grid( &self, t: f64, tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, + min_grid_px: f64, ) -> f64 { - match self.quantize_grid_size(tempo_map, time_sig, framerate) { + match self.quantize_grid_size(tempo_map, time_sig, framerate, min_grid_px) { Some(grid) => (t / grid).round() * grid, None => t, } @@ -1516,7 +1948,7 @@ impl TimelinePane { framerate: f64, ) -> Beats { let anchor = self.drag_anchor_start; // seconds - let target = match self.quantize_grid_size(tempo_map, time_sig, framerate) { + let target = match self.quantize_grid_size(tempo_map, time_sig, framerate, SNAP_PX_FINE) { Some(grid) => ((anchor + self.drag_offset) / grid).round() * grid, None => anchor + self.drag_offset, }; @@ -1564,7 +1996,8 @@ impl TimelinePane { /// Render the time ruler at the top fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme, - tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) { + tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64, + cycle: Option>) { let painter = ui.painter(); // Background @@ -1572,6 +2005,12 @@ impl TimelinePane { let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34)); painter.rect_filled(rect, 0.0, bg_color); + // Cycle lane goes under the ticks/labels: `Some(region)` when looping is armed (the region + // itself may still be `None`), `None` when it's off and the lane shouldn't exist at all. + if let Some(region) = cycle { + self.paint_cycle_lane(ui, rect, theme, tempo_map, region); + } + let text_style = theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); @@ -2170,32 +2609,53 @@ impl TimelinePane { let Some(layer_for_controls) = any_layer_for_controls else { continue; }; - // Layer controls: volume slider top-right, buttons below it + // Layer controls, right-aligned in three stacked tiers: volume, opacity, buttons. + // A layer only shows the sliders that mean something for it (a raster layer has no + // volume; an audio layer has no opacity), but the buttons sit at a fixed offset so + // they line up across rows regardless of which sliders are present. let controls_right = header_rect.max.x - 8.0; let button_size = egui::vec2(20.0, 20.0); let slider_width = 60.0; + let slider_height = 14.0; let volume_slider_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0), - egui::vec2(slider_width, 20.0), + egui::pos2(controls_right - slider_width, header_rect.min.y + 2.0), + egui::vec2(slider_width, slider_height), + ); + let opacity_slider_rect = egui::Rect::from_min_size( + egui::pos2(controls_right - slider_width, header_rect.min.y + 17.0), + egui::vec2(slider_width, slider_height), ); - // Buttons sit below the slider, right-aligned to match it - let buttons_top = volume_slider_rect.max.y + 4.0; - let lock_button_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - button_size.x, buttons_top), - button_size, - ); + // Which controls apply to this layer. Vector and Video layers can hold movie clips + // with audio *and* draw pixels, so they get both. + let (has_volume, has_opacity) = match layer_for_controls { + lightningbeam_core::layer::AnyLayer::Raster(_) + | lightningbeam_core::layer::AnyLayer::Text(_) => (false, true), + lightningbeam_core::layer::AnyLayer::Audio(_) => (true, false), + _ => (true, true), + }; + let is_video_layer = + matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_)); - let solo_button_rect = egui::Rect::from_min_size( - egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, buttons_top), - button_size, - ); + // Buttons are laid out right-to-left, and a layer only gets the ones that mean + // something for it: [eye] [mute | camera] [solo] [lock]. + let buttons_top = header_rect.min.y + 34.0; + let mut next_x = controls_right; + let mut next_button_rect = || { + next_x -= button_size.x; + let r = egui::Rect::from_min_size(egui::pos2(next_x, buttons_top), button_size); + next_x -= 4.0; + r + }; - let mute_button_rect = egui::Rect::from_min_size( - egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, buttons_top), - button_size, - ); + let lock_button_rect = next_button_rect(); + let solo_button_rect = has_volume.then(&mut next_button_rect); + // Video layers use this slot for the camera toggle instead of a mute button. + let mute_button_rect = + (has_volume && !is_video_layer).then(&mut next_button_rect); + let camera_button_rect = is_video_layer.then(&mut next_button_rect); + let visibility_button_rect = has_opacity.then(&mut next_button_rect); // Get layer ID and current property values from the layer we already have // Check if there's a Volume automation lane; use it to drive the slider @@ -2223,30 +2683,66 @@ impl TimelinePane { let is_soloed = layer_for_controls.soloed(); let is_locked = layer_for_controls.locked(); - // Mute button — or camera toggle for video layers - let is_video_layer = matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_)); - let camera_enabled = if let lightningbeam_core::layer::AnyLayer::Video(v) = layer_for_controls { - v.camera_enabled - } else { - false - }; + // Visibility toggle — on any layer that draws something. + if let Some(rect) = visibility_button_rect { + let is_visible = layer_for_controls.visible(); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if is_visible { icons::EYE } else { icons::EYE_OFF }) + .fill(if is_visible { + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) + } else { + theme.bg_color(&["#timeline", ".btn-hidden", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(120, 120, 120, 100)) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + .on_hover_text(if is_visible { "Hide layer" } else { "Show layer" }) + }).inner; - let first_btn_response = ui.scope_builder(egui::UiBuilder::new().max_rect(mute_button_rect), |ui| { - if is_video_layer { - // Camera toggle for video layers - let cam_text = if camera_enabled { "📹" } else { "📷" }; - let button = egui::Button::new(cam_text) + if response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Visible(!is_visible), + ) + )); + } + } + + // Camera toggle — video layers only. + if let Some(rect) = camera_button_rect { + let camera_enabled = + matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if camera_enabled { icons::VIDEO } else { icons::VIDEO_OFF }) .fill(if camera_enabled { theme.bg_color(&["#timeline", ".btn-toggle", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) } else { theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) }) .stroke(egui::Stroke::NONE); - ui.add(button) - } else { - // Mute button for non-video layers - let mute_text = if is_muted { "🔇" } else { "🔊" }; - let button = egui::Button::new(mute_text) + ui.add(button).on_hover_text(if camera_enabled { + "Disable camera preview" + } else { + "Enable camera preview" + }) + }).inner; + + if response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled), + ) + )); + } + } + + // Mute button — only where there's audio to mute. + if let Some(rect) = mute_button_rect { + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if is_muted { icons::VOLUME_X } else { icons::VOLUME_2 }) .fill(if is_muted { theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100)) } else { @@ -2254,19 +2750,11 @@ impl TimelinePane { }) .stroke(egui::Stroke::NONE); ui.add(button) - } - }).inner; + .on_hover_text(if is_muted { "Unmute layer" } else { "Mute layer" }) + }).inner; - if first_btn_response.clicked() { - self.layer_control_clicked = true; - if is_video_layer { - pending_actions.push(Box::new( - lightningbeam_core::actions::SetLayerPropertiesAction::new( - layer_id, - lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled), - ) - )); - } else { + if response.clicked() { + self.layer_control_clicked = true; pending_actions.push(Box::new( lightningbeam_core::actions::SetLayerPropertiesAction::new( layer_id, @@ -2277,33 +2765,34 @@ impl TimelinePane { } // Solo button - // TODO: Replace with SVG headphones icon - let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| { - let button = egui::Button::new("🎧") - .fill(if is_soloed { - theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) - } else { - theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) - }) - .stroke(egui::Stroke::NONE); - ui.add(button) - }).inner; + // Solo is an audio control, so it follows mute: only where there's audio. + if let Some(rect) = solo_button_rect { + let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(icons::HEADPHONES) + .fill(if is_soloed { + theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) + } else { + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + .on_hover_text(if is_soloed { "Unsolo layer" } else { "Solo layer" }) + }).inner; - if solo_response.clicked() { - self.layer_control_clicked = true; - pending_actions.push(Box::new( - lightningbeam_core::actions::SetLayerPropertiesAction::new( - layer_id, - lightningbeam_core::actions::LayerProperty::Soloed(!is_soloed), - ) - )); + if solo_response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Soloed(!is_soloed), + ) + )); + } } // Lock button - // TODO: Replace with SVG lock/lock-open icons let lock_response = ui.scope_builder(egui::UiBuilder::new().max_rect(lock_button_rect), |ui| { - let lock_text = if is_locked { "🔒" } else { "🔓" }; - let button = egui::Button::new(lock_text) + let button = icon_button(if is_locked { icons::LOCK } else { icons::LOCK_OPEN }) .fill(if is_locked { theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100)) } else { @@ -2311,6 +2800,7 @@ impl TimelinePane { }) .stroke(egui::Stroke::NONE); ui.add(button) + .on_hover_text(if is_locked { "Unlock layer" } else { "Lock layer (prevent edits)" }) }).inner; if lock_response.clicked() { @@ -2323,9 +2813,40 @@ impl TimelinePane { )); } + // Opacity slider. + if has_opacity { + draw_slider_icon(ui, theme, opacity_slider_rect, icons::BLEND); + let current_opacity = layer_for_controls.opacity() as f32; + let mut new_opacity = current_opacity; + let opacity_response = ui.scope_builder( + egui::UiBuilder::new().max_rect(opacity_slider_rect), + |ui| { + ui.spacing_mut().slider_width = slider_width; + ui.add(egui::Slider::new(&mut new_opacity, 0.0..=1.0).show_value(false)) + }, + ).inner + .on_hover_text(format!("Opacity: {:.0}%", current_opacity * 100.0)); + + // Block layer drag while interacting with the slider + if opacity_response.dragged() || opacity_response.has_focus() { + self.layer_control_clicked = true; + } + if opacity_response.changed() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Opacity(new_opacity as f64), + ) + )); + } + } + // Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume) // Disabled when the user has edited the Volume automation curve beyond the default single keyframe - let volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { + if has_volume { + draw_slider_icon(ui, theme, volume_slider_rect, icons::VOLUME_2); + let mut volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { ui.spacing_mut().slider_width = slider_width; // Map volume (0.0-2.0) to slider position (0.0-1.0) let slider_value = if current_volume <= 1.0 { @@ -2344,6 +2865,12 @@ impl TimelinePane { (response, temp_slider_value) }).inner; + volume_response.0 = volume_response.0.on_hover_text(if volume_is_automated { + format!("Volume: {:.0}% (automated)", current_volume * 100.0) + } else { + format!("Volume: {:.0}%", current_volume * 100.0) + }); + // Block layer drag while interacting with the slider if volume_response.0.dragged() || volume_response.0.has_focus() { self.layer_control_clicked = true; @@ -2397,13 +2924,11 @@ impl TimelinePane { } } - // Input gain slider for sampled audio layers (below volume slider) + // Input gain slider for sampled audio layers. Audio layers have no opacity, so this + // takes the opacity tier. if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls { if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled { - let gain_slider_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - slider_width, volume_slider_rect.max.y + 4.0), - egui::vec2(slider_width, 16.0), - ); + let gain_slider_rect = opacity_slider_rect; let current_gain = audio_layer.layer.input_gain; // Map gain (0.0-4.0) to slider (0.0-1.0): linear @@ -2431,8 +2956,8 @@ impl TimelinePane { // Label let label_rect = egui::Rect::from_min_size( - egui::pos2(gain_slider_rect.min.x - 26.0, volume_slider_rect.max.y + 4.0), - egui::vec2(24.0, 16.0), + egui::pos2(gain_slider_rect.min.x - 26.0, gain_slider_rect.min.y), + egui::vec2(24.0, gain_slider_rect.height()), ); ui.painter().text( label_rect.center(), @@ -2444,6 +2969,8 @@ impl TimelinePane { } } + } // end volume/gain sliders + // Per-layer VU meter bar (4px tall at bottom of header) { // Look up the track level for this layer @@ -2703,6 +3230,7 @@ impl TimelinePane { let mut pending_lane_renders: Vec = Vec::new(); // Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek. self.keyframe_diamond_hits.clear(); + self.take_badge_hits.clear(); // Collect video clip rects for hover detection (to avoid borrow conflicts) let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new(); @@ -2920,8 +3448,10 @@ impl TimelinePane { let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move); let mut ranges: Vec<(Beats, Beats)> = Vec::new(); for (_child_layer_id, ci) in &child_clips { - let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let mut start = ci.effective_start(); let dur = ci.total_duration(clip_dur, document.tempo_map()); @@ -2994,8 +3524,10 @@ impl TimelinePane { if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) { if let AnyLayer::Video(vl) = video_child { for ci in &vl.clip_instances { - let clip_dur = document.get_clip_duration(&ci.clip_id) - .unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .unwrap_or_else(|| ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + ))); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); @@ -3022,7 +3554,8 @@ impl TimelinePane { ); // 4th elem = clip's TRUE (unclamped) origin x, for correct // hover content time when scrolled partly off the left. - video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx)); + // Video content is wall-clock, so its content time IS seconds. + video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start.raw(), rect.min.x + sx)); let thumb_display_height = (thumb_y_max - span_y_min) - 4.0; if thumb_display_height > 8.0 { @@ -3035,7 +3568,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, ci.clip_id, - ci.trim_start, + ci.trim_start.raw(), rect.min.x + sx, ex - sx, ci_rect, @@ -3084,7 +3617,7 @@ impl TimelinePane { }; let audio_file_duration = total_frames as f64 / eff_sr as f64; - let clip_dur = audio_clip.content_duration().to_seconds(document.tempo_map()); + let clip_dur = audio_clip.content_duration(); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); @@ -3135,7 +3668,8 @@ impl TimelinePane { audio_duration: audio_file_duration as f32, sample_rate: eff_sr, clip_start_time: ci_screen_start, - trim_start: ci.trim_start as f32, + // A sampled clip's content time is seconds. + trim_start: ci.trim_start.raw() as f32, tex_width: crate::waveform_gpu::tex_width() as f32, total_frames: total_frames as f32, segment_start_frame: 0.0, @@ -3218,7 +3752,7 @@ impl TimelinePane { let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter() .filter(|ci| selection.contains_clip_instance(&ci.id)) .filter_map(|ci| { - let dur = document.get_clip_duration(&ci.clip_id)?; + let dur = document.clip_trim_duration(&ci.clip_id)?; Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map()))) }) .collect(); @@ -3242,8 +3776,11 @@ impl TimelinePane { let shift_beats = |anchor: Beats, secs: f64| tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs)); - let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); - let clip_dur_secs = clip_dur.seconds_to_f64(); + let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO)); + // Raw magnitudes in the clip's own content domain — the drag math below stays in + // that domain and only converts at the timeline edges. + let clip_dur_secs = clip_dur.native(); + let ci_trim_start = ci.trim_start.raw(); let mut start = ci.effective_start(); let mut duration = ci.total_duration(clip_dur, tmap); @@ -3265,37 +3802,37 @@ impl TimelinePane { } } ClipDragType::TrimLeft => { - let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate).max(0.0).min(clip_dur_secs); - let trim_offset_secs = new_trim - ci.trim_start; + let new_trim = self.snap_to_grid(ci_trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs); + let trim_offset_secs = new_trim - ci_trim_start; start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO); let dur_secs = if let Some(trim_end) = ci.trim_end { - (trim_end - new_trim).max(0.0) + (trim_end.raw() - new_trim).max(0.0) } else { (clip_dur_secs - new_trim).max(0.0) }; duration = secs_to_beats_at(start, dur_secs); } ClipDragType::TrimRight => { - let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur_secs); - let dur_secs = (new_trim_end - ci.trim_start).max(0.0); + let old_trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci_trim_start).min(clip_dur_secs); + let dur_secs = (new_trim_end - ci_trim_start).max(0.0); duration = secs_to_beats_at(start, dur_secs); } ClipDragType::LoopExtendRight => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.0); + let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let current_right = ci.timeline_duration.unwrap_or(content_window); let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset; - let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate); + let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE); let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs)); let new_right = (snapped_edge - ci.timeline_start).max(content_window); let loop_before = ci.loop_before.unwrap_or(Beats::ZERO); duration = loop_before + new_right; } ClipDragType::LoopExtendLeft => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.001); + let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO); // drag_offset (seconds) as a beats delta at this clip's start. @@ -3356,6 +3893,9 @@ impl TimelinePane { // Track preview trim values for note/waveform rendering. // In Measures mode, derive from beats so they track BPM during live drag. let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm()); + // The instance's trim start as a raw magnitude in the clip's content domain; the + // preview math below stays in that domain. + let ci_trim_start = clip_instance.trim_start.raw(); let mut preview_trim_start = base_trim_start; let mut preview_clip_duration = base_clip_duration; @@ -3371,11 +3911,11 @@ impl TimelinePane { } ClipDragType::TrimLeft => { // Trim left: calculate new trim_start with snap to adjacent clips - let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) + let desired_trim_start = self.snap_to_grid(ci_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) .max(0.0) - .min(clip_duration.seconds_to_f64()); + .min(clip_duration.native()); - let new_trim_start = if desired_trim_start < clip_instance.trim_start { + let new_trim_start = if desired_trim_start < ci_trim_start { // Extending left - limit is the content-seconds gap to the previous clip. let max_extend_secs = document.find_max_trim_extend_left( &layer.id(), @@ -3383,25 +3923,25 @@ impl TimelinePane { clip_instance.effective_start(), ).seconds_to_f64(); - let desired_extend = clip_instance.trim_start - desired_trim_start; + let desired_extend = ci_trim_start - desired_trim_start; let actual_extend = desired_extend.min(max_extend_secs); - clip_instance.trim_start - actual_extend + ci_trim_start - actual_extend } else { // Shrinking - no snap needed desired_trim_start }; - let actual_offset = new_trim_start - clip_instance.trim_start; + let actual_offset = new_trim_start - ci_trim_start; // Move start (display seconds) and reduce duration by the clamped offset. instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset) .max(0.0); - instance_duration = (clip_duration.seconds_to_f64() - new_trim_start).max(0.0); + instance_duration = (clip_duration.native() - new_trim_start).max(0.0); // Adjust for existing trim_end if let Some(trim_end) = clip_instance.trim_end { - instance_duration = (trim_end - new_trim_start).max(0.0); + instance_duration = (trim_end.raw() - new_trim_start).max(0.0); } // Update preview trim for waveform rendering @@ -3410,14 +3950,14 @@ impl TimelinePane { } ClipDragType::TrimRight => { // Trim right: extend or reduce duration with snap to adjacent clips - let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) - .max(clip_instance.trim_start) - .min(clip_duration.seconds_to_f64()); + let old_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) + .max(ci_trim_start) + .min(clip_duration.native()); let new_trim_end = if desired_trim_end > old_trim_end { // Extending right - limit is the content-seconds gap to the next clip. - let current_duration_secs = old_trim_end - clip_instance.trim_start; + let current_duration_secs = old_trim_end - ci_trim_start; let tmap = document.tempo_map(); let current_duration = tmap.seconds_to_beats( tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs) @@ -3437,7 +3977,7 @@ impl TimelinePane { desired_trim_end }; - instance_duration = (new_trim_end - clip_instance.trim_start).max(0.0); + instance_duration = (new_trim_end - ci_trim_start).max(0.0); // Update preview clip duration for waveform rendering // (the waveform system uses clip_duration to determine visible range) @@ -3445,8 +3985,8 @@ impl TimelinePane { } ClipDragType::LoopExtendRight => { // Loop extend right: extend clip beyond content window - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // content window and right-duration are beats-domain timeline spans. @@ -3454,7 +3994,7 @@ impl TimelinePane { let current_right = clip_instance.timeline_duration.unwrap_or(content_window); // Snap the right edge in the seconds/pixel domain (drag_offset is seconds). let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset; - let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate); + let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE); let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs)); let desired_right = (snapped_edge - ts).max(content_window); @@ -3481,8 +4021,8 @@ impl TimelinePane { ClipDragType::LoopExtendLeft => { // Loop extend left: extend loop_before (pre-loop region) // Snap to multiples of content_window so iterations align with backend - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // content window is a beats-domain span; guard against zero for division. @@ -3608,9 +4148,11 @@ impl TimelinePane { // AUDIO VISUALIZATION: Draw piano roll or waveform overlay if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer { if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) { - match &clip.clip_type { + // Resolve through the instance's active take, so a take folder draws + // whichever take it actually plays. + match &clip_instance.resolve(clip) { // MIDI: Draw piano roll (with loop iterations) - lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => { + lightningbeam_core::clip::ResolvedContent::Midi { midi_clip_id } => { if let Some(events) = midi_event_cache.get(midi_clip_id) { // Calculate content window for loop detection // preview_clip_duration accounts for TrimLeft/TrimRight drag previews @@ -3669,7 +4211,7 @@ impl TimelinePane { } } // Sampled Audio: Draw waveform via GPU - lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } => { + lightningbeam_core::clip::ResolvedContent::Audio { audio_pool_index } => { if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) { // Min/max overview pools: 4 f32/texel at rate sr/B. let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied(); @@ -3720,7 +4262,7 @@ impl TimelinePane { // Calculate content window for loop detection // Use trimmed content window (preview_trim_start accounts for TrimLeft drag) - let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); + let preview_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); let content_window = (preview_trim_end - preview_trim_start).max(0.0); let is_looping = instance_duration > content_window + 0.001; @@ -3798,7 +4340,7 @@ impl TimelinePane { } } // Recording in progress: show live waveform - lightningbeam_core::clip::AudioClipType::Recording => { + lightningbeam_core::clip::ResolvedContent::Recording => { let rec_pool_idx = usize::MAX; if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) { let total_frames = samples.len() / (*ch).max(1) as usize; @@ -3833,6 +4375,36 @@ impl TimelinePane { egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y), ); + // Cycle recording: the clip covers ONE region, but the + // recorded buffer keeps growing across passes. Show the + // *current* pass by offsetting into the buffer to where + // that pass began, so the waveform restarts at the region + // start on each wrap and fills in behind the playhead — + // rather than running on past the clip's end. + // + // The playhead gives the position within the region + // directly, so a punch-in (whose first pass starts partway + // in) needs no extra bookkeeping here. + let mut rec_trim_start = preview_trim_start; + if let (true, Some((ls, le))) = (document.cycle_enabled, document.cycle_region) { + let tm = document.tempo_map(); + let loop_len = (tm.beats_to_seconds(le) + - tm.beats_to_seconds(ls)) + .seconds_to_f64(); + if loop_len > 0.0 { + // Which pass we're on, straight from how much + // audio has been captured. Deriving this from + // the playhead instead would jitter: the + // playhead and the recording buffer advance on + // different clocks, so their difference wobbles + // frame to frame and the waveform slides + // horizontally. + let lead = self.cycle_record_lead_secs; + let pass = ((lead + audio_file_duration) / loop_len).floor(); + rec_trim_start = (pass * loop_len - lead).max(0.0); + } + } + if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 { let instance_id = clip_instance.id.as_u128() as u64; let callback = crate::waveform_gpu::WaveformCallback { @@ -3845,7 +4417,7 @@ impl TimelinePane { audio_duration: audio_file_duration as f32, sample_rate: *sr as f32, clip_start_time: clip_screen_start, - trim_start: preview_trim_start as f32, + trim_start: rec_trim_start as f32, tex_width: crate::waveform_gpu::tex_width() as f32, total_frames: total_frames as f32, segment_start_frame: 0.0, @@ -3887,7 +4459,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, clip_instance.clip_id, - clip_instance.trim_start, + clip_instance.trim_start.raw(), rect.min.x + start_x, end_x - start_x, clip_rect, @@ -3902,7 +4474,7 @@ impl TimelinePane { // clip's TRUE (unclamped) origin x so the hover content time is // correct even when the clip is scrolled partly off the left. if let lightningbeam_core::layer::AnyLayer::Video(_) = layer { - video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x)); + video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start.raw(), rect.min.x + start_x)); } // Draw border per segment (per loop iteration for looping clips) @@ -3959,6 +4531,66 @@ impl TimelinePane { ); } } + + // Take badge — "Take 2/4" in the clip's bottom-left, on take-folder clips + // only. Records a hit rect so the click that opens the take menu can be + // dispatched after rendering (the usual two-phase pattern), rather than + // mutating the document mid-paint. + // Only worth showing when there's actually a choice to make. + if clip_instance.takes.len() > 1 { + let take_count = clip_instance.takes.len(); + let active = clip_instance.active_take_index(); + let label = format!("Take {}/{}", active + 1, take_count); + let text_color = theme.text_color( + &["#timeline", ".take-badge"], + ui.ctx(), + egui::Color32::WHITE, + ); + let galley = painter.layout_no_wrap( + label, + egui::FontId::proportional(10.0), + text_color, + ); + let pad = egui::vec2(4.0, 2.0); + let size = galley.size() + pad * 2.0; + // Bottom-left of the clip, but only when the clip is wide enough that + // the badge wouldn't swamp it. + if clip_rect.width() > size.x + 10.0 && clip_rect.height() > size.y + 4.0 { + let badge = egui::Rect::from_min_size( + egui::pos2( + clip_rect.min.x + 4.0, + clip_rect.max.y - size.y - 3.0, + ), + size, + ); + let hovered = ui + .ctx() + .pointer_hover_pos() + .is_some_and(|p| badge.contains(p)); + let bg = if hovered { + theme.bg_color( + &["#timeline", ".take-badge:hover"], + ui.ctx(), + egui::Color32::from_black_alpha(210), + ) + } else { + theme.bg_color( + &["#timeline", ".take-badge"], + ui.ctx(), + egui::Color32::from_black_alpha(150), + ) + }; + painter.rect_filled(badge, 3.0, bg); + painter.galley(badge.min + pad, galley, text_color); + self.take_badge_hits.push(( + badge, + layer.id(), + clip_instance.id, + active, + take_count, + )); + } + } } } } @@ -4438,7 +5070,12 @@ impl TimelinePane { if !alt_held && !self.is_scrubbing && !self.is_panning { if response.drag_started() { // Use cached mousedown position for edge detection - if let Some(mousedown_pos) = self.mousedown_pos { + if let Some(mousedown_pos) = self + .mousedown_pos + // A press that landed on a take badge is opening the take menu, not grabbing + // the clip it sits on. + .filter(|p| !self.take_badge_hits.iter().any(|(r, ..)| r.contains(*p))) + { if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer( mousedown_pos, document, @@ -4575,18 +5212,23 @@ impl TimelinePane { for clip_instance in clip_instances { if selection.contains_clip_instance(&clip_instance.id) { let clip_duration = effective_clip_duration(document, layer, clip_instance); + // Raw magnitude in the clip's content domain; re-tagged as a + // ContentTime when it goes back into TrimData. + let ci_trim_start = clip_instance.trim_start.raw(); if let Some(clip_duration) = clip_duration { match drag_type { ClipDragType::TrimLeft => { - let old_trim_start = clip_instance.trim_start; + // Raw magnitude in the clip's content domain; re-tagged as a + // ContentTime when it goes back into TrimData below. + let old_trim_start = clip_instance.trim_start.raw(); let old_timeline_start = clip_instance.timeline_start; // New trim_start is snapped then clamped to valid range let desired_trim_start = self.snap_to_grid( - old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, - ).max(0.0).min(clip_duration.seconds_to_f64()); + old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, + ).max(0.0).min(clip_duration.native()); // Apply overlap prevention when extending left (content-seconds gap). let new_trim_start = if desired_trim_start < old_trim_start { @@ -4616,11 +5258,11 @@ impl TimelinePane { clip_instance.id, lightningbeam_core::actions::TrimType::TrimLeft, lightningbeam_core::actions::TrimData::left( - old_trim_start, + ContentTime(old_trim_start), old_timeline_start, ), lightningbeam_core::actions::TrimData::left( - new_trim_start, + ContentTime(new_trim_start), new_timeline_start, ), )); @@ -4631,10 +5273,10 @@ impl TimelinePane { // Calculate new trim_end based on current duration let current_duration = clip_instance.effective_duration(clip_duration, document.tempo_map()); - let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); + let old_trim_end_val = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); let desired_trim_end = self.snap_to_grid( - old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, - ).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64()); + old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, + ).max(ci_trim_start).min(clip_duration.native()); // Apply overlap prevention when extending right (content-seconds gap). let new_trim_end_val = if desired_trim_end > old_trim_end_val { @@ -4651,13 +5293,15 @@ impl TimelinePane { desired_trim_end }; - let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0); + let new_duration = (new_trim_end_val - ci_trim_start).max(0.0); // Convert new duration back to trim_end value - let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() { + let new_trim_end = if new_duration >= clip_duration.native() { None // Use full clip duration } else { - Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64())) + Some(ContentTime( + (ci_trim_start + new_duration).min(clip_duration.native()), + )) }; layer_trims @@ -4709,13 +5353,14 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let ci_trim_start = clip_instance.trim_start.raw(); + let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let current_right = clip_instance.timeline_duration.unwrap_or(content_window); // Snap the right edge in the seconds/pixel domain. let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset; - let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate); + let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE); let desired_right = tmap.seconds_to_beats(Seconds(snapped_edge_secs)) - ts; let new_right = if desired_right > current_right { @@ -4783,8 +5428,9 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let ci_trim_start = clip_instance.trim_start.raw(); + let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let cw = content_window.beats_to_f64().max(1e-9); let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO); @@ -4917,14 +5563,120 @@ impl TimelinePane { let visible_height = content_rect.height(); let max_scroll_y = (total_content_height - visible_height).max(0.0); - // Scrubbing (clicking/dragging on ruler, but only when not panning) - let cursor_over_ruler = ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default())); + // ---- Cycle region (the lane along the bottom of the ruler) ---- + // The lane only exists while looping is armed; with cycle off the ruler is entirely the + // playhead scrubber, as it was before this feature. + // + // The lane is driven straight off raw pointer state rather than an egui `Response`. It sits + // inside the timeline's content response — which spans the whole ruler + content and already + // drives scrubbing, clip drags and panning — so registering a second widget on the same + // pixels just makes the two contest hover every frame (the cursor visibly flickers and + // neither reliably owns the press). Reading the pointer directly sidesteps egui's widget + // layering entirely. The lane is also carved out of the scrub area below, so a loop drag + // never also yanks the playhead. + let cycle_armed = document.cycle_enabled; + let cycle_lane = Self::cycle_lane_rect(ruler_rect); + let hover_pos = ui.input(|i| i.pointer.hover_pos()); + let (primary_pressed, primary_down, interact_pos) = ui.input(|i| { + ( + i.pointer.primary_pressed(), + i.pointer.primary_down(), + i.pointer.interact_pos(), + ) + }); + + if cycle_armed { + // Begin: press inside the lane. Three-zone hit test picks resize / move / draw-new. + if self.cycle_drag.is_none() && !alt_held && !self.is_panning && primary_pressed { + if let Some(pos) = interact_pos.filter(|p| cycle_lane.contains(*p)) { + let beat = self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document); + self.cycle_drag = + Some(self.cycle_drag_at(pos.x, beat, content_rect.min.x, document)); + self.cycle_preview = document.cycle_region; + } + } + + // Telegraph what a press would do: resize at the edges, move over the body. + if let Some(pos) = hover_pos.filter(|p| cycle_lane.contains(*p)) { + let zone = self.cycle_drag.unwrap_or_else(|| { + let beat = self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document); + self.cycle_drag_at(pos.x, beat, content_rect.min.x, document) + }); + ui.output_mut(|o| { + o.cursor_icon = match zone { + CycleDrag::ResizeStart | CycleDrag::ResizeEnd => { + egui::CursorIcon::ResizeHorizontal + } + CycleDrag::Move { .. } => egui::CursorIcon::Grab, + CycleDrag::Create { .. } => egui::CursorIcon::Crosshair, + } + }); + } + + if let Some(drag) = self.cycle_drag { + if primary_down { + // Track the pointer even when it leaves the lane, like any other drag. + if let Some(pos) = interact_pos { + let beat = + self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document); + let base = self.cycle_preview.or(document.cycle_region); + self.cycle_preview = match drag { + CycleDrag::Create { anchor } => { + let (a, b) = + if beat < anchor { (beat, anchor) } else { (anchor, beat) }; + Some((a, b)) + } + CycleDrag::ResizeStart => base.map(|(_, e)| (beat.min(e), e)), + CycleDrag::ResizeEnd => base.map(|(s, _)| (s, beat.max(s))), + CycleDrag::Move { grab_offset } => base.map(|(s, e)| { + let len = e - s; + let ns = (beat - grab_offset).max(Beats::ZERO); + (ns, ns + len) + }), + }; + } + } else { + // Released — commit the gesture as ONE undoable action (the drag itself only + // ever touched `cycle_preview`, so the undo stack doesn't get a frame-by-frame + // trail). Looping is already armed (the lane wouldn't be there otherwise), so + // `cycle_enabled` is left alone. + let preview = self.cycle_preview.take(); + let collapsed = preview.map_or(true, |(s, e)| e <= s); + let drawing_new = matches!(drag, CycleDrag::Create { .. }); + self.cycle_drag = None; + + // A bare click on empty lane is a zero-width "draw" — treat it as nothing + // happened rather than silently wiping the region out from under the user. + // Collapsing an *existing* region by dragging an edge past the other one is + // still a deliberate "clear it". + if !(collapsed && drawing_new) { + let action = lightningbeam_core::actions::SetCycleRegionAction::new( + document, + preview.filter(|(s, e)| e > s), + document.cycle_enabled, + ); + if !action.is_noop() { + pending_actions.push(Box::new(action)); + } + } + } + } + } else if self.cycle_drag.is_some() { + // Looping was disarmed mid-drag — abandon the gesture rather than commit it. + self.cycle_drag = None; + self.cycle_preview = None; + } + + // Scrubbing (clicking/dragging on ruler, but only when not panning). + let cursor_over_ruler = hover_pos.map_or(false, |p| { + ruler_rect.contains(p) && !(cycle_armed && cycle_lane.contains(p)) + }) && self.cycle_drag.is_none(); // Start scrubbing if cursor is over ruler and we click/drag if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE); *playback_time = new_time; self.is_scrubbing = true; // Seek immediately so it works while playing @@ -4938,7 +5690,7 @@ impl TimelinePane { else if self.is_scrubbing && response.dragged() && !self.is_panning { if let Some(pos) = response.interact_pointer_pos() { let x = (pos.x - content_rect.min.x).max(0.0); - let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate); + let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE); *playback_time = new_time; if let Some(controller_arc) = audio_controller { let mut controller = controller_arc.lock().unwrap(); @@ -5179,6 +5931,27 @@ impl PaneRenderer for TimelinePane { self.toggle_recording(shared); } + // Cycle (loop) toggle. This is the only way to arm looping — the cycle strip on the + // ruler is only shown (and only draggable) while it's armed. + let cycle_on = shared.action_executor.document().cycle_enabled; + let cycle_color = if cycle_on { + egui::Color32::from_rgb(230, 190, 60) + } else { + egui::Color32::from_gray(140) + }; + let cycle_button = egui::Button::new( + egui::RichText::new(crate::mobile::icons::REPEAT) + .font(crate::mobile::icons::font(15.0)) + .color(cycle_color), + ); + if ui + .add_sized(button_size, cycle_button) + .on_hover_text("Cycle (loop region)") + .clicked() + { + self.toggle_cycle(shared); + } + // Request repaint while recording for pulse animation if *shared.is_recording { ui.ctx().request_repaint(); @@ -5475,8 +6248,14 @@ impl PaneRenderer for TimelinePane { // Split into layer header column (left) and timeline content (right). On mobile the header // column collapses to a minimal color-swatch width. + // + // Once the pane gets narrow enough that the track area would be a useless sliver, drop it + // entirely and give the whole pane to the layer headers — a mixer-style view. + let headers_only = !shared.is_mobile && rect.width() < LAYER_HEADER_WIDTH * 1.5; let header_width = if shared.is_mobile { MOBILE_LAYER_HEADER_WIDTH + } else if headers_only { + rect.width() } else { LAYER_HEADER_WIDTH }; @@ -5528,17 +6307,29 @@ impl PaneRenderer for TimelinePane { ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time, header_width, shared.is_mobile); - // Render time ruler (clip to ruler rect) - ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); - self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate); + // The track area only exists when the pane is wide enough for it. The interaction code + // further down is naturally inert in headers-only mode, since it all gates on + // `content_rect.contains(..)` and the rect is zero-width. + let (video_clip_hovers, pending_lane_renders) = if headers_only { + (Vec::new(), Vec::new()) + } else { + // Render time ruler (clip to ruler rect) + ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); + let cycle = document + .cycle_enabled + .then(|| self.shown_cycle_region(document)); + self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate, cycle); - // Render layer rows with clipping - ui.set_clip_rect(content_rect.intersect(original_clip_rect)); - let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, *shared.playback_time); + // Render layer rows with clipping + ui.set_clip_rect(content_rect.intersect(original_clip_rect)); + let rendered = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, *shared.playback_time); - // Render playhead on top (clip to timeline area) - ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); - self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time); + // Render playhead on top (clip to timeline area) + ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); + self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time); + + rendered + }; // Restore original clip rect ui.set_clip_rect(original_clip_rect); @@ -5728,6 +6519,8 @@ impl PaneRenderer for TimelinePane { editing_clip_id.as_ref(), ); + self.render_take_menu(ui, document, shared.pending_actions); + // Render automation lanes AFTER handle_input so our ui.interact registers last and wins // egui's interaction priority over handle_input's full-content-area allocation. // All automation lanes use beats as the x-axis; convert via the tempo map. @@ -5895,7 +6688,7 @@ impl PaneRenderer for TimelinePane { let instances = layer_clips(layer); for inst in instances { if !shared.selection.contains_clip_instance(&inst.id) { continue; } - if let Some(dur) = document.get_clip_duration(&inst.clip_id) { + if let Some(dur) = document.clip_trim_duration(&inst.clip_id) { let eff = inst.effective_duration(dur, document.tempo_map()); let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64(); @@ -5921,7 +6714,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter() .filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .all(|ci| { - if let Some(dur) = document.get_clip_duration(&ci.clip_id) { + if let Some(dur) = document.clip_trim_duration(&ci.clip_id) { let eff = ci.effective_duration(dur, document.tempo_map()); // Room to duplicate = seconds gap to the right ≥ this clip's own length. let max_extend_secs = document.find_max_trim_extend_right( @@ -5966,7 +6759,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter().all(|ci| { let paste_start = (ci.timeline_start + offset).max(Beats::ZERO); - if let Some(dur) = document.get_clip_duration(&ci.clip_id) { + if let Some(dur) = document.clip_trim_duration(&ci.clip_id) { let eff = ci.effective_duration(dur, document.tempo_map()); document .find_nearest_valid_position( @@ -5995,6 +6788,22 @@ impl PaneRenderer for TimelinePane { enabled }; + // Take management for the clip that was right-clicked. Only offered when there's more + // than one take — with a single take there's nothing to choose between, and "delete the + // only take" would leave the instance with nothing to play. + let take_target: Option<(uuid::Uuid, uuid::Uuid, usize, String)> = ctx_clip_id.and_then(|instance_id| { + let context_layers = document.context_layers(shared.editing_clip_id.as_ref()); + for (layer, instances) in all_layer_clip_instances(&context_layers) { + if let Some(ci) = instances.iter().find(|ci| ci.id == instance_id) { + if ci.takes.len() > 1 { + let active = ci.active_take_index(); + return Some((layer.id(), instance_id, active, ci.takes[active].name.clone())); + } + } + } + None + }); + let area_id = ui.id().with("clip_context_menu"); let mut item_clicked = false; let area_response = egui::Area::new(area_id) @@ -6060,6 +6869,34 @@ impl PaneRenderer for TimelinePane { shared.pending_menu_actions.push(crate::menu::MenuAction::Delete); item_clicked = true; } + + // Take management, on the clip that was right-clicked. Takes live on the + // INSTANCE, so on a comped split this prunes the half you clicked and leaves + // the other half's alternatives alone. + if let Some((take_layer_id, take_instance_id, active_index, active_name)) = &take_target { + ui.separator(); + // Deletes the take that's PLAYING — the one the badge is showing — so + // it's named rather than just "Delete Take". + if menu_item(ui, &format!("Delete \"{}\"", active_name), true) { + shared.pending_actions.push(Box::new( + lightningbeam_core::actions::DeleteTakeAction::new( + *take_layer_id, + *take_instance_id, + *active_index, + ), + )); + item_clicked = true; + } + if menu_item(ui, "Delete Unused Takes", true) { + shared.pending_actions.push(Box::new( + lightningbeam_core::actions::DeleteUnusedTakesAction::new( + *take_layer_id, + *take_instance_id, + ), + )); + item_clicked = true; + } + } }); }); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 28315bd..3d40366 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -29,10 +29,48 @@ impl PaneRenderer for ToolbarPane { path: &NodePath, shared: &mut SharedPaneState, ) { - let button_size = 60.0; - let button_padding = 8.0; - let button_spacing = 4.0; + // `ui` spans the whole window, not this pane — bind a child Ui to the pane's content rect + // first, or the ScrollArea would start at the window's top (under the pane header) and + // size itself against the window's height (so it would never need to scroll). + // Salt by path: widget ids inside are auto-generated from the Ui's id, so two toolbar + // panes would otherwise fight over the same ids. + let mut content_ui = ui.new_child( + egui::UiBuilder::new() + .id_salt(("toolbar", path)) + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + egui::ScrollArea::vertical() + .id_salt(("toolbar_scroll", path)) + .auto_shrink([false; 2]) + .show(&mut content_ui, |ui| { + self.render_toolbar(ui, path, shared); + }); + } + + fn name(&self) -> &str { + "Toolbar" + } +} + +const BUTTON_SIZE: f32 = 60.0; +const BUTTON_PADDING: f32 = 8.0; +const BUTTON_SPACING: f32 = 4.0; +const COLOR_BUTTON_SIZE: f32 = 50.0; +const COLOR_LABEL_WIDTH: f32 = 40.0; + +impl ToolbarPane { + /// Laid out with real egui widgets (`horizontal_wrapped` for the tool grid) rather than + /// absolute rect math, so the ScrollArea above can measure the content and scroll it when the + /// pane is too short. Buttons are still painted by hand — `allocate_exact_size` reserves the + /// space and gives us the Response, and we draw into the rect egui hands back. + fn render_toolbar( + &mut self, + ui: &mut egui::Ui, + path: &NodePath, + shared: &mut SharedPaneState, + ) { // Determine which tools to show based on the active layer type let active_layer_type: Option = shared.active_layer_id .and_then(|id| shared.action_executor.document().get_layer(&id)) @@ -52,320 +90,273 @@ impl PaneRenderer for ToolbarPane { *shared.selected_tool = Tool::Select; } - // Calculate how many columns we can fit - let available_width = rect.width() - (button_padding * 2.0); - let columns = - ((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize; - let columns = columns.max(1); // At least 1 column - let total_tools = tools.len(); - let total_rows = (total_tools + columns - 1) / columns; - - let mut y = rect.top() + button_padding; - - // Process tools row by row for centered layout - for row in 0..total_rows { - let start_idx = row * columns; - let end_idx = (start_idx + columns).min(total_tools); - let buttons_in_row = end_idx - start_idx; - - // Calculate the total width of buttons in this row - let row_width = (buttons_in_row as f32 * button_size) - + ((buttons_in_row.saturating_sub(1)) as f32 * button_spacing); - - // Center the row - let mut x = rect.left() + (rect.width() - row_width) / 2.0; - - for tool_idx in start_idx..end_idx { - let tool = &tools[tool_idx]; - let button_rect = - egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(button_size, button_size)); - - // Check if this is the selected tool - let is_selected = *shared.selected_tool == *tool; - - // Button background - let bg_color = if is_selected { - shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150)) - } else { - shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) - }; - ui.painter().rect_filled(button_rect, 4.0, bg_color); - - // Load and render tool icon - if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) { - let icon_rect = button_rect.shrink(8.0); // Padding inside button - ui.painter().image( - icon.id(), - icon_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - - // Draw sub-tool arrow indicator for tools with modes - let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); - if has_sub_tools { - let arrow_size = 6.0; - let margin = 4.0; - let corner = button_rect.right_bottom() - egui::vec2(margin, margin); - let tri = [ - corner, - corner - egui::vec2(arrow_size, 0.0), - corner - egui::vec2(0.0, arrow_size), - ]; - ui.painter().add(egui::Shape::convex_polygon( - tri.to_vec(), - shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)), - egui::Stroke::NONE, - )); - } - - // Make button interactive (include path to ensure unique IDs across panes) - let button_id = ui.id().with(("tool_button", path, *tool as usize)); - let response = ui.interact(button_rect, button_id, egui::Sense::click()); - - // Check for click first - if response.clicked() { - *shared.selected_tool = *tool; - // Preset-backed tools: auto-select the matching bundled brush. - let preset_name = match tool { - Tool::Pencil => Some("Pencil"), - Tool::Pen => Some("Pen"), - Tool::Airbrush => Some("Airbrush"), - _ => None, - }; - if let Some(name) = preset_name { - if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) { - let s = &preset.settings; - shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0); - shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0); - shared.raster_settings.brush_spacing = s.dabs_per_radius; - shared.raster_settings.active_brush_settings = s.clone(); - } - } - } - - // Right-click context menu for tools with sub-options - if has_sub_tools { - response.context_menu(|ui| { - match tool { - Tool::RegionSelect => { - ui.set_min_width(120.0); - if ui.selectable_label( - *shared.region_select_mode == RegionSelectMode::Rectangle, - "Rectangle", - ).clicked() { - *shared.region_select_mode = RegionSelectMode::Rectangle; - *shared.selected_tool = Tool::RegionSelect; - ui.close(); - } - if ui.selectable_label( - *shared.region_select_mode == RegionSelectMode::Lasso, - "Lasso", - ).clicked() { - *shared.region_select_mode = RegionSelectMode::Lasso; - *shared.selected_tool = Tool::RegionSelect; - ui.close(); - } - } - Tool::SelectLasso => { - ui.set_min_width(130.0); - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Freehand, - "Freehand", - ).clicked() { - *shared.lasso_mode = LassoMode::Freehand; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Polygonal, - "Polygonal", - ).clicked() { - *shared.lasso_mode = LassoMode::Polygonal; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Magnetic, - "Magnetic", - ).clicked() { - *shared.lasso_mode = LassoMode::Magnetic; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - } - _ => {} - } - }); - } - - if response.hovered() { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))), - egui::StrokeKind::Middle, - ); - } - - // Show tooltip with tool name and shortcut (consumes response). - // Hint text is pulled from the live keymap so it reflects user remappings. - let hint = tool_app_action(*tool) - .and_then(|action| shared.keymap.get(action)) - .map(|s| format!(" ({})", s.hint_text())) - .unwrap_or_default(); - let tooltip = if *tool == Tool::RegionSelect { - let mode = match *shared.region_select_mode { - RegionSelectMode::Rectangle => "Rectangle", - RegionSelectMode::Lasso => "Lasso", - }; - format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) - } else if *tool == Tool::SelectLasso { - let mode = match *shared.lasso_mode { - LassoMode::Freehand => "Freehand", - LassoMode::Polygonal => "Polygonal", - LassoMode::Magnetic => "Magnetic", - }; - format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) - } else { - format!("{}{}", tool.display_name(), hint) - }; - response.on_hover_text(tooltip); - - // Draw selection border - if is_selected { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))), - egui::StrokeKind::Middle, - ); - } - - // Move to next column in this row - x += button_size + button_spacing; - } - - // Move to next row - y += button_size + button_spacing; - } - let is_raster = matches!(active_layer_type, Some(LayerType::Raster)); let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster)); - // Add color pickers below the tool buttons - if show_colors { - y += button_spacing * 2.0; // Extra spacing + ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING); + ui.add_space(BUTTON_PADDING); - let fill_label_width = 40.0; - let color_button_size = 50.0; - let color_row_width = fill_label_width + color_button_size + button_spacing; - let color_x = rect.left() + (rect.width() - color_row_width) / 2.0; + // Centre the grid as a block. Work out how many columns fit, then lay the buttons out in + // a band of exactly that width, positioned to centre it. The band has to be an explicit + // max_rect on the child Ui — a `horizontal_wrapped` nested inside a `horizontal` inherits + // the parent's available width and wraps against that, not against the width we set. + let full_width = ui.available_width(); + let avail = full_width - BUTTON_PADDING * 2.0; + let columns = (((avail + BUTTON_SPACING) / (BUTTON_SIZE + BUTTON_SPACING)).floor() as usize) + .max(1) + .min(tools.len().max(1)); + let grid_width = + columns as f32 * BUTTON_SIZE + (columns.saturating_sub(1)) as f32 * BUTTON_SPACING; + let indent = ((full_width - grid_width) / 2.0).max(0.0); + let rows = (tools.len() + columns - 1) / columns; - // Two color swatches: + let cursor = ui.cursor().min; + let band = egui::Rect::from_min_size( + egui::pos2(cursor.x + indent, cursor.y), + egui::vec2(grid_width, rows as f32 * (BUTTON_SIZE + BUTTON_SPACING)), + ); + ui.scope_builder( + egui::UiBuilder::new().max_rect(band).layout( + egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), + ), + |ui| { + ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING); + for tool in tools.iter() { + self.render_tool_button(ui, tool, shared); + } + }, + ); + + // Colour swatches below the tools. // Stroke/FG always on top, Fill/BG always on bottom. // Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill". - { - let stroke_label = if is_raster { "FG" } else { "Stroke" }; - let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); - ui.painter().text( - egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), - egui::Align2::CENTER_CENTER, - stroke_label, - egui::FontId::proportional(14.0), - label_color, - ); + if show_colors { + ui.add_space(BUTTON_SPACING * 2.0); - let stroke_button_rect = egui::Rect::from_min_size( - egui::pos2(color_x + fill_label_width + button_spacing, y), - egui::vec2(color_button_size, color_button_size), - ); - let stroke_button_id = ui.id().with(("stroke_color_button", path)); - let stroke_response = ui.interact(stroke_button_rect, stroke_button_id, egui::Sense::click()); - draw_color_button(ui, stroke_button_rect, *shared.stroke_color); - egui::containers::Popup::from_toggle_button_response(&stroke_response) - .show(|ui| { - ui.spacing_mut().slider_width = 275.0; - let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); - if changed { - *shared.active_color_mode = super::ColorMode::Stroke; - } + let row_width = COLOR_LABEL_WIDTH + COLOR_BUTTON_SIZE + BUTTON_SPACING; + let color_indent = ((ui.available_width() - row_width) / 2.0).max(0.0); + + for (label, is_stroke) in [ + (if is_raster { "FG" } else { "Stroke" }, true), + (if is_raster { "BG" } else { "Fill" }, false), + ] { + ui.horizontal(|ui| { + ui.add_space(color_indent); + + let (label_rect, _) = ui.allocate_exact_size( + egui::vec2(COLOR_LABEL_WIDTH, COLOR_BUTTON_SIZE), + egui::Sense::hover(), + ); + let label_color = shared.theme.text_color( + &["#toolbar", ".text-secondary"], + ui.ctx(), + egui::Color32::from_gray(200), + ); + ui.painter().text( + label_rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(14.0), + label_color, + ); + + let (swatch_rect, _) = ui.allocate_exact_size( + egui::vec2(COLOR_BUTTON_SIZE, COLOR_BUTTON_SIZE), + egui::Sense::hover(), + ); + let (id_key, color) = if is_stroke { + ("stroke_color_button", &mut *shared.stroke_color) + } else { + ("fill_color_button", &mut *shared.fill_color) + }; + let button_id = ui.id().with((id_key, path)); + crate::widgets::color_swatch::color_swatch(ui, button_id, swatch_rect, color); }); - - y += color_button_size + button_spacing; + } } - // Fill/BG color swatch - { - let fill_label = if is_raster { "BG" } else { "Fill" }; - let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); + ui.add_space(BUTTON_PADDING); + } + + fn render_tool_button( + &mut self, + ui: &mut egui::Ui, + tool: &Tool, + shared: &mut SharedPaneState, + ) { + let (button_rect, response) = ui.allocate_exact_size( + egui::vec2(BUTTON_SIZE, BUTTON_SIZE), + egui::Sense::click(), + ); + + let is_selected = *shared.selected_tool == *tool; + + // Button background + let bg_color = if is_selected { + shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150)) + } else { + shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) + }; + ui.painter().rect_filled(button_rect, 4.0, bg_color); + + // Tool icon: tools without a bundled SVG fall back to a Lucide glyph rather than the + // shared TODO placeholder. + if let Some(glyph) = crate::mobile::icons::tool_glyph(*tool) { ui.painter().text( - egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), + button_rect.center(), egui::Align2::CENTER_CENTER, - fill_label, - egui::FontId::proportional(14.0), - label_color, - ); - - let fill_button_rect = egui::Rect::from_min_size( - egui::pos2(color_x + fill_label_width + button_spacing, y), - egui::vec2(color_button_size, color_button_size), - ); - let fill_button_id = ui.id().with(("fill_color_button", path)); - let fill_response = ui.interact(fill_button_rect, fill_button_id, egui::Sense::click()); - draw_color_button(ui, fill_button_rect, *shared.fill_color); - egui::containers::Popup::from_toggle_button_response(&fill_response) - .show(|ui| { - ui.spacing_mut().slider_width = 275.0; - let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); - if changed { - *shared.active_color_mode = super::ColorMode::Fill; - } - }); - } - } // end color pickers - } - - fn name(&self) -> &str { - "Toolbar" - } -} - -/// Draw a color button with checkerboard background for alpha channel -fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) { - // Draw checkerboard background - let checker_size = 5.0; - let cols = (rect.width() / checker_size).ceil() as usize; - let rows = (rect.height() / checker_size).ceil() as usize; - - for row in 0..rows { - for col in 0..cols { - let is_light = (row + col) % 2 == 0; - let checker_color = if is_light { - egui::Color32::from_gray(180) - } else { - egui::Color32::from_gray(120) - }; - let checker_rect = egui::Rect::from_min_size( - egui::pos2( - rect.min.x + col as f32 * checker_size, - rect.min.y + row as f32 * checker_size, + glyph, + crate::mobile::icons::font(26.0), + shared.theme.text_color( + &["#toolbar", ".tool-button"], + ui.ctx(), + egui::Color32::from_gray(220), ), - egui::vec2(checker_size, checker_size), - ).intersect(rect); - ui.painter().rect_filled(checker_rect, 0.0, checker_color); + ); + } else if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) { + let icon_rect = button_rect.shrink(8.0); // Padding inside button + ui.painter().image( + icon.id(), + icon_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); } + + // Draw sub-tool arrow indicator for tools with modes + let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); + if has_sub_tools { + let arrow_size = 6.0; + let margin = 4.0; + let corner = button_rect.right_bottom() - egui::vec2(margin, margin); + let tri = [ + corner, + corner - egui::vec2(arrow_size, 0.0), + corner - egui::vec2(0.0, arrow_size), + ]; + ui.painter().add(egui::Shape::convex_polygon( + tri.to_vec(), + shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)), + egui::Stroke::NONE, + )); + } + + if response.clicked() { + *shared.selected_tool = *tool; + // Preset-backed tools: auto-select the matching bundled brush. + let preset_name = match tool { + Tool::Pencil => Some("Pencil"), + Tool::Pen => Some("Pen"), + Tool::Airbrush => Some("Airbrush"), + _ => None, + }; + if let Some(name) = preset_name { + if let Some(idx) = bundled_brushes().iter().position(|p| p.name == name) { + let settings = bundled_brushes()[idx].settings.clone(); + shared + .raster_settings + .brush_mut(crate::tools::BrushKind::Paint) + .apply_preset(idx, &settings); + } + } + } + + // Right-click context menu for tools with sub-options + if has_sub_tools { + response.context_menu(|ui| { + match tool { + Tool::RegionSelect => { + ui.set_min_width(120.0); + if ui.selectable_label( + *shared.region_select_mode == RegionSelectMode::Rectangle, + "Rectangle", + ).clicked() { + *shared.region_select_mode = RegionSelectMode::Rectangle; + *shared.selected_tool = Tool::RegionSelect; + ui.close(); + } + if ui.selectable_label( + *shared.region_select_mode == RegionSelectMode::Lasso, + "Lasso", + ).clicked() { + *shared.region_select_mode = RegionSelectMode::Lasso; + *shared.selected_tool = Tool::RegionSelect; + ui.close(); + } + } + Tool::SelectLasso => { + ui.set_min_width(130.0); + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Freehand, + "Freehand", + ).clicked() { + *shared.lasso_mode = LassoMode::Freehand; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Polygonal, + "Polygonal", + ).clicked() { + *shared.lasso_mode = LassoMode::Polygonal; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Magnetic, + "Magnetic", + ).clicked() { + *shared.lasso_mode = LassoMode::Magnetic; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + } + _ => {} + } + }); + } + + if response.hovered() { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))), + egui::StrokeKind::Middle, + ); + } + + // Draw selection border + if is_selected { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))), + egui::StrokeKind::Middle, + ); + } + + // Show tooltip with tool name and shortcut (consumes response). + // Hint text is pulled from the live keymap so it reflects user remappings. + let hint = tool_app_action(*tool) + .and_then(|action| shared.keymap.get(action)) + .map(|s| format!(" ({})", s.hint_text())) + .unwrap_or_default(); + let tooltip = if *tool == Tool::RegionSelect { + let mode = match *shared.region_select_mode { + RegionSelectMode::Rectangle => "Rectangle", + RegionSelectMode::Lasso => "Lasso", + }; + format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) + } else if *tool == Tool::SelectLasso { + let mode = match *shared.lasso_mode { + LassoMode::Freehand => "Freehand", + LassoMode::Polygonal => "Polygonal", + LassoMode::Magnetic => "Magnetic", + }; + format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) + } else { + format!("{}{}", tool.display_name(), hint) + }; + response.on_hover_text(tooltip); } - - // Draw color on top - ui.painter().rect_filled(rect, 2.0, color); - - // Draw border - ui.painter().rect_stroke( - rect, - 2.0, - egui::Stroke::new(1.0, egui::Color32::from_gray(80)), - egui::StrokeKind::Middle, - ); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index 7ef5391..bbb4167 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use eframe::egui; -use crate::config::AppConfig; +use crate::config::{AppConfig, TabletButtonAction}; use crate::keymap::{self, AppAction, KeymapManager}; use crate::menu::{MenuSystem, Shortcut, ShortcutKey}; use crate::theme::{Theme, ThemeMode}; @@ -55,12 +55,15 @@ struct PreferencesState { file_height: u32, scroll_speed: f64, audio_buffer_size: u32, + cycle_midi_separate_takes: bool, reopen_last_session: bool, restore_layout_from_file: bool, debug: bool, waveform_stereo: bool, theme_mode: ThemeMode, large_media_default: LargeMediaMode, + tablet_button_lower: TabletButtonAction, + tablet_button_upper: TabletButtonAction, } impl From<(&AppConfig, &Theme)> for PreferencesState { @@ -72,12 +75,15 @@ impl From<(&AppConfig, &Theme)> for PreferencesState { file_height: config.file_height, scroll_speed: config.scroll_speed, audio_buffer_size: config.audio_buffer_size, + cycle_midi_separate_takes: config.cycle_midi_separate_takes, reopen_last_session: config.reopen_last_session, restore_layout_from_file: config.restore_layout_from_file, debug: config.debug, waveform_stereo: config.waveform_stereo, theme_mode: theme.mode(), large_media_default: config.large_media_default, + tablet_button_lower: config.tablet_button_lower, + tablet_button_upper: config.tablet_button_upper, } } } @@ -91,12 +97,15 @@ impl Default for PreferencesState { file_height: 600, scroll_speed: 1.0, audio_buffer_size: 256, + cycle_midi_separate_takes: false, reopen_last_session: false, restore_layout_from_file: true, debug: false, waveform_stereo: false, theme_mode: ThemeMode::System, large_media_default: LargeMediaMode::default(), + tablet_button_lower: TabletButtonAction::Pan, + tablet_button_upper: TabletButtonAction::Eyedropper, } } } @@ -239,6 +248,8 @@ impl PreferencesDialog { ui.add_space(8.0); self.render_startup_section(ui); ui.add_space(8.0); + self.render_tablet_section(ui); + ui.add_space(8.0); self.render_advanced_section(ui); }); } @@ -543,6 +554,39 @@ impl PreferencesDialog { }); ui.label("Requires app restart to take effect"); + + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Cycle MIDI recording:"); + + egui::ComboBox::from_id_salt("cycle_midi_mode") + .selected_text(if self.working_prefs.cycle_midi_separate_takes { + "Separate takes" + } else { + "Merge" + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + false, + "Merge", + ) + .on_hover_text( + "Every pass overdubs into one clip, and earlier passes play back as \ + you record so you can layer against them.", + ); + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + true, + "Separate takes", + ) + .on_hover_text( + "Each pass becomes its own take in a take folder, as audio always \ + does. Earlier passes stay silent — they're alternatives, not layers.", + ); + }); + }); }); } @@ -591,6 +635,41 @@ impl PreferencesDialog { }); } + fn render_tablet_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("Tablet") + .default_open(false) + .show(ui, |ui| { + ui.label("What each barrel button on the stylus does while held:"); + ui.add_space(4.0); + + let button_row = |ui: &mut egui::Ui, label: &str, id: &str, value: &mut TabletButtonAction| { + ui.horizontal(|ui| { + ui.label(label); + egui::ComboBox::from_id_salt(id) + .selected_text(value.label()) + .show_ui(ui, |ui| { + for action in TabletButtonAction::ALL { + ui.selectable_value(value, action, action.label()); + } + }); + }); + }; + + button_row( + ui, + "Lower button:", + "tablet_button_lower", + &mut self.working_prefs.tablet_button_lower, + ); + button_row( + ui, + "Upper button:", + "tablet_button_upper", + &mut self.working_prefs.tablet_button_upper, + ); + }); + } + fn render_advanced_section(&mut self, ui: &mut egui::Ui) { egui::CollapsingHeader::new("Advanced") .default_open(false) @@ -641,11 +720,14 @@ impl PreferencesDialog { temp_config.file_height = self.working_prefs.file_height; temp_config.scroll_speed = self.working_prefs.scroll_speed; temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; + temp_config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; temp_config.reopen_last_session = self.working_prefs.reopen_last_session; temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; temp_config.debug = self.working_prefs.debug; temp_config.waveform_stereo = self.working_prefs.waveform_stereo; temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); + temp_config.tablet_button_lower = self.working_prefs.tablet_button_lower; + temp_config.tablet_button_upper = self.working_prefs.tablet_button_upper; // Validate if let Err(err) = temp_config.validate() { @@ -675,16 +757,23 @@ impl PreferencesDialog { config.file_height = self.working_prefs.file_height; config.scroll_speed = self.working_prefs.scroll_speed; config.audio_buffer_size = self.working_prefs.audio_buffer_size; + config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; config.reopen_last_session = self.working_prefs.reopen_last_session; config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; config.debug = self.working_prefs.debug; config.waveform_stereo = self.working_prefs.waveform_stereo; config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); config.large_media_default = self.working_prefs.large_media_default; + config.tablet_button_lower = self.working_prefs.tablet_button_lower; + config.tablet_button_upper = self.working_prefs.tablet_button_upper; config.keybindings = keybinding_config; // Apply theme immediately theme.set_mode(self.working_prefs.theme_mode); + crate::tablet::set_button_actions( + self.working_prefs.tablet_button_lower, + self.working_prefs.tablet_button_upper, + ); // Save to disk config.save(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/tablet.rs b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs index 8186b4f..6d76770 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tablet.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs @@ -13,6 +13,8 @@ use std::sync::atomic::{AtomicU32, Ordering}; use eframe::egui; use lightningbeam_core::tool::Tool; +use crate::config::TabletButtonAction; + // --------------------------------------------------------------------------- // Global tablet state — read by make_stroke_point() on the UI thread // --------------------------------------------------------------------------- @@ -21,6 +23,58 @@ static TABLET_PRESSURE_BITS: AtomicU32 = AtomicU32::new(0x3f800000); // 1.0f32 static TABLET_TILT_X_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 static TABLET_TILT_Y_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 +/// Bit 0 = lower barrel button held, bit 1 = upper. Read on the UI thread by the stage to +/// decide whether the pen is currently panning. +static TABLET_BUTTONS: AtomicU32 = AtomicU32::new(0); + +fn button_bit(b: TabletButton) -> u32 { + match b { + TabletButton::Lower => 1, + TabletButton::Upper => 2, + } +} + +/// Is the given barrel button currently held? +pub fn button_held(b: TabletButton) -> bool { + TABLET_BUTTONS.load(Ordering::Relaxed) & button_bit(b) != 0 +} + +/// The configured action for each barrel button, mirrored from `AppConfig` so the stage can +/// read it without threading config through every call site. +static BUTTON_ACTIONS: std::sync::Mutex<(TabletButtonAction, TabletButtonAction)> = + std::sync::Mutex::new((TabletButtonAction::Pan, TabletButtonAction::Eyedropper)); + +/// Mirror the configured barrel-button actions. Call on startup and whenever preferences change. +pub fn set_button_actions(lower: TabletButtonAction, upper: TabletButtonAction) { + if let Ok(mut a) = BUTTON_ACTIONS.lock() { + *a = (lower, upper); + } +} + +fn action_for(b: TabletButton) -> TabletButtonAction { + let actions = BUTTON_ACTIONS + .lock() + .map(|a| *a) + .unwrap_or((TabletButtonAction::Pan, TabletButtonAction::Eyedropper)); + match b { + TabletButton::Lower => actions.0, + TabletButton::Upper => actions.1, + } +} + +/// The action of whichever barrel button is currently held (lower wins if both are). +pub fn active_button_action() -> Option { + for b in [TabletButton::Lower, TabletButton::Upper] { + if button_held(b) { + let a = action_for(b); + if a != TabletButtonAction::None { + return Some(a); + } + } + } + None +} + /// Current pen pressure (0.0–1.0). Falls back to 1.0 when no tablet is active. pub fn current_pressure() -> f32 { f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed)) @@ -57,10 +111,32 @@ pub enum RawTabletEvent { Tilt { x: f32, y: f32 }, TipDown, TipUp, + /// A barrel button on the pen was pressed or released. + Button { button: TabletButton, pressed: bool }, /// End of Wayland tablet event group; commit accumulated state. Frame, } +/// Which barrel button on the stylus. Most pens have two; some have a third. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabletButton { + Lower, + Upper, +} + +impl TabletButton { + /// Map a Linux evdev button code (used by both the Wayland tablet protocol and X11's + /// underlying device) to a barrel button. + fn from_evdev(code: u32) -> Option { + // BTN_STYLUS = 0x14b, BTN_STYLUS2 = 0x14c, BTN_STYLUS3 = 0x149. + match code { + 0x14b => Some(TabletButton::Lower), + 0x14c => Some(TabletButton::Upper), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum TabletToolType { #[default] @@ -91,9 +167,13 @@ pub struct TabletInput { /// On X11, clicks already come through winit via OS mouse emulation. inject_buttons: bool, - /// Pending tool switch (eraser in/out). Consumed by `EditorApp::update()`. + /// Pending tool switch (eraser in/out, barrel-button override). Consumed by `EditorApp::update()`. pub pending_tool_switch: Option, tool_before_eraser: Option, + /// Barrel-button state as of last frame, for press/release edge detection. + prev_buttons: u32, + /// Tool to restore when the barrel button driving a tool override is released. + tool_before_button: Option, /// One-shot sender used to hand the egui Context to the background thread /// on the first poll() call so it can call request_repaint(). @@ -125,6 +205,8 @@ impl TabletInput { inject_buttons, pending_tool_switch: None, tool_before_eraser: None, + prev_buttons: 0, + tool_before_button: None, repaint_sender, backend, } @@ -202,6 +284,9 @@ impl TabletInput { // Handle eraser tool switch. self.handle_tool_switch(current_tool); + // Handle barrel-button tool overrides (eyedropper / eraser held on the pen). + self.handle_button_tool_override(current_tool); + // Publish globals for make_stroke_point(). set_pressure(self.pressure); let (tx, ty) = self.tilt; @@ -267,6 +352,8 @@ impl TabletInput { RawTabletEvent::ProximityOut => { self.in_proximity = false; self.pressure = 0.0; + // Don't let a button stay latched if the pen leaves while it's held. + TABLET_BUTTONS.store(0, Ordering::Relaxed); } RawTabletEvent::Motion { x, y } => { // Wayland gives physical pixels; convert to egui logical points. @@ -289,6 +376,14 @@ impl TabletInput { RawTabletEvent::TipUp => { self.tip_down = Some(false); } + RawTabletEvent::Button { button, pressed } => { + let bit = button_bit(button); + if pressed { + TABLET_BUTTONS.fetch_or(bit, Ordering::Relaxed); + } else { + TABLET_BUTTONS.fetch_and(!bit, Ordering::Relaxed); + } + } RawTabletEvent::Frame => { // Frame is the commit signal; processing already happened in individual events. } @@ -318,6 +413,39 @@ impl TabletInput { } } + /// Barrel buttons bound to Eyedropper/Erase act as a spring-loaded tool override: switch + /// while held, restore on release. Pan is handled by the stage instead, since it needs the + /// drag delta rather than a tool. + fn handle_button_tool_override(&mut self, current_tool: Tool) { + let buttons = TABLET_BUTTONS.load(Ordering::Relaxed); + if buttons == self.prev_buttons { + return; + } + self.prev_buttons = buttons; + + let override_tool = active_button_action().and_then(|a| match a { + TabletButtonAction::Eyedropper => Some(Tool::Eyedropper), + TabletButtonAction::Erase => Some(Tool::Erase), + TabletButtonAction::Pan | TabletButtonAction::None => None, + }); + + match (override_tool, self.tool_before_button) { + // Button pressed: remember the tool we're overriding, then switch. + (Some(tool), None) => { + if current_tool != tool { + self.tool_before_button = Some(current_tool); + self.pending_tool_switch = Some(tool); + } + } + // Button released: restore. + (None, Some(prev)) => { + self.tool_before_button = None; + self.pending_tool_switch = Some(prev); + } + _ => {} + } + } + fn handle_tool_switch(&mut self, current_tool: Tool) { let just_entered = self.in_proximity && !self.was_in_proximity; let just_left = !self.in_proximity && self.was_in_proximity; @@ -343,7 +471,7 @@ impl TabletInput { #[cfg(target_os = "linux")] mod wayland { - use super::{RawTabletEvent, TabletToolType}; + use super::{RawTabletEvent, TabletButton, TabletToolType}; use eframe::egui; use std::collections::HashMap; use std::sync::mpsc; @@ -445,6 +573,7 @@ mod wayland { pending_motion: bool, pending_tip: Option, pending_proximity: Option, + pending_buttons: Vec<(TabletButton, bool)>, } // ----------------------------------------------------------------------- @@ -585,7 +714,20 @@ mod wayland { Event::Tilt { tilt_x, tilt_y } => { acc.pending_tilt = (tilt_x as f32, tilt_y as f32); } + Event::Button { button, state: btn_state, .. } => { + // `button` is a Linux evdev code (BTN_STYLUS / BTN_STYLUS2). + if let Some(b) = TabletButton::from_evdev(button) { + let pressed = matches!( + btn_state.into_result(), + Ok(zwp_tablet_tool_v2::ButtonState::Pressed) + ); + acc.pending_buttons.push((b, pressed)); + } + } Event::Frame { .. } => { + for (b, pressed) in acc.pending_buttons.drain(..) { + let _ = state.tx.send(RawTabletEvent::Button { button: b, pressed }); + } // Flush accumulated events to the channel. if let Some(prox) = acc.pending_proximity.take() { if prox { @@ -630,7 +772,7 @@ mod wayland { #[cfg(target_os = "linux")] mod x11 { - use super::{RawTabletEvent, TabletToolType}; + use super::{RawTabletEvent, TabletButton, TabletToolType}; use std::sync::mpsc; use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle}; @@ -880,17 +1022,44 @@ mod x11 { } Event::XinputRawButtonPress(raw) => { - if device_axes.contains_key(&raw.deviceid) && raw.detail == 1 { - let _ = tx.send(RawTabletEvent::TipDown); - let _ = tx.send(RawTabletEvent::Frame); + if device_axes.contains_key(&raw.deviceid) { + // X11 reports the tip as button 1 and the barrel buttons as 2 and 3. + match raw.detail { + 1 => { + let _ = tx.send(RawTabletEvent::TipDown); + let _ = tx.send(RawTabletEvent::Frame); + } + 2 | 3 => { + let button = if raw.detail == 2 { + TabletButton::Lower + } else { + TabletButton::Upper + }; + let _ = tx.send(RawTabletEvent::Button { button, pressed: true }); + let _ = tx.send(RawTabletEvent::Frame); + } + _ => {} + } } } Event::XinputRawButtonRelease(raw) => { if device_axes.contains_key(&raw.deviceid) { - if raw.detail == 1 { - let _ = tx.send(RawTabletEvent::TipUp); - let _ = tx.send(RawTabletEvent::Frame); + match raw.detail { + 1 => { + let _ = tx.send(RawTabletEvent::TipUp); + let _ = tx.send(RawTabletEvent::Frame); + } + 2 | 3 => { + let button = if raw.detail == 2 { + TabletButton::Lower + } else { + TabletButton::Upper + }; + let _ = tx.send(RawTabletEvent::Button { button, pressed: false }); + let _ = tx.send(RawTabletEvent::Frame); + } + _ => {} } // When all buttons released, synthesise proximity out. in_proximity.remove(&raw.deviceid); diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs index 876b5d6..367e0f6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct BlurSharpenTool; pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; @@ -8,19 +8,11 @@ pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; impl RasterToolDef for BlurSharpenTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen } fn header_label(&self) -> &'static str { "Blur / Sharpen" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.blur_sharpen_radius, - opacity: s.blur_sharpen_strength, - hardness: s.blur_sharpen_hardness, - spacing: s.blur_sharpen_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::BlurSharpen } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Strength" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() { @@ -30,31 +22,11 @@ impl RasterToolDef for BlurSharpenTool { s.blur_sharpen_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Strength:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_strength, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); ui.horizontal(|ui| { ui.label("Kernel:"); ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0) .logarithmic(true) .custom_formatter(|v, _| format!("{:.1} px", v))); }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs index bbfb9f2..c623ecc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -8,15 +8,7 @@ pub static CLONE_STAMP: CloneStampTool = CloneStampTool; impl RasterToolDef for CloneStampTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp } fn header_label(&self) -> &'static str { "Clone Stamp" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::CloneStamp } /// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time /// (offset = clone_source - stroke_start), not from settings directly. fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs index 7ab1968..2b8279d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct DodgeBurnTool; pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; @@ -8,19 +8,11 @@ pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; impl RasterToolDef for DodgeBurnTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn } fn header_label(&self) -> &'static str { "Dodge / Burn" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.dodge_burn_radius, - opacity: s.dodge_burn_exposure, - hardness: s.dodge_burn_hardness, - spacing: s.dodge_burn_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::DodgeBurn } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.dodge_burn_mode as f32, 0.0, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Exposure" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() { @@ -30,25 +22,5 @@ impl RasterToolDef for DodgeBurnTool { s.dodge_burn_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Exposure:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs index 361858a..a1f3102 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs @@ -1,5 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; +use super::{BrushKind, RasterToolDef}; use lightningbeam_core::raster_layer::RasterBlendMode; pub struct EraseTool; @@ -8,16 +7,6 @@ pub static ERASE: EraseTool = EraseTool; impl RasterToolDef for EraseTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase } fn header_label(&self) -> &'static str { "Eraser" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_eraser_settings.clone(), - radius: s.eraser_radius, - opacity: s.eraser_opacity, - hardness: s.eraser_hardness, - spacing: s.eraser_spacing, - } - } - fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn is_eraser(&self) -> bool { true } - fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + fn brush_kind(&self) -> BrushKind { BrushKind::Erase } + fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs index c6ae87a..1a920dc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -8,15 +8,7 @@ pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool; impl RasterToolDef for HealingBrushTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing } fn header_label(&self) -> &'static str { "Healing Brush" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::HealingBrush } /// tool_params are filled by stage.rs at stroke-start time (clone offset). fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } fn uses_alt_click(&self) -> bool { true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 80efc73..60cf8d7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -2,12 +2,17 @@ /// /// Each tool implements `RasterToolDef`. Adding a new tool requires: /// 1. A new file in this directory implementing `RasterToolDef`. -/// 2. One entry in `raster_tool_def()` below. +/// 2. A `BrushKind` variant (if it paints dabs) and one entry in `raster_tool_def()` below. /// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch. +/// +/// Every dab-painting tool owns a `BrushSlot`: its own size/strength/hardness/spacing, its own +/// brush from the shared `.myb` library, and its own FG/BG color choice. The blend mode is what +/// makes a tool a brush vs. an eraser vs. dodge/burn — the brush shape is orthogonal, so all of +/// them get the same library and the same controls. use eframe::egui; use lightningbeam_core::{ - brush_settings::BrushSettings, + brush_settings::{bundled_brushes, BrushSettings}, raster_layer::RasterBlendMode, tool::Tool, }; @@ -22,6 +27,86 @@ pub mod dodge_burn; pub mod sponge; pub mod blur_sharpen; +// --------------------------------------------------------------------------- +// Brush slots — one per dab-painting tool +// --------------------------------------------------------------------------- + +/// Identifies a tool's brush slot. Each slot remembers its own brush independently, so +/// switching from Dodge to Sponge doesn't clobber either one's size or preset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BrushKind { + Paint, + Erase, + Smudge, + CloneStamp, + HealingBrush, + PatternStamp, + DodgeBurn, + Sponge, + BlurSharpen, +} + +impl BrushKind { + fn index(self) -> usize { + match self { + BrushKind::Paint => 0, + BrushKind::Erase => 1, + BrushKind::Smudge => 2, + BrushKind::CloneStamp => 3, + BrushKind::HealingBrush => 4, + BrushKind::PatternStamp => 5, + BrushKind::DodgeBurn => 6, + BrushKind::Sponge => 7, + BrushKind::BlurSharpen => 8, + } + } +} + +/// One tool's brush: the shared controls every dab-painting tool has. +#[derive(Debug, Clone)] +pub struct BrushSlot { + pub radius: f32, + /// What this means is per-tool — opacity, exposure, flow, strength. The label comes from + /// [`RasterToolDef::strength_label`]. + pub strength: f32, + pub hardness: f32, + pub spacing: f32, + /// The brush shape, from the bundled `.myb` library. + pub settings: BrushSettings, + /// Index into `bundled_brushes()` of the selected preset, if any. + pub preset: Option, + /// Added to the preset's `elliptical_dab_angle` (degrees), so stock brushes can be + /// re-oriented without editing the file. + pub angle_offset: f32, + /// true = paint with the FG (stroke) color, false = BG (fill). Ignored when the tool + /// doesn't use a color (see [`RasterToolDef::uses_color`]). + pub use_fg: bool, +} + +impl BrushSlot { + fn new(radius: f32, strength: f32, hardness: f32, spacing: f32) -> Self { + Self { + radius, + strength, + hardness, + spacing, + settings: BrushSettings::default(), + preset: None, + angle_offset: 0.0, + use_fg: true, + } + } + + /// Adopt a brush preset, pulling its opacity/hardness/spacing along with the shape. + pub fn apply_preset(&mut self, index: usize, settings: &BrushSettings) { + self.preset = Some(index); + self.strength = settings.opaque.clamp(0.0, 1.0); + self.hardness = settings.hardness.clamp(0.0, 1.0); + self.spacing = settings.dabs_per_radius; + self.settings = settings.clone(); + } +} + // --------------------------------------------------------------------------- // Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields) // --------------------------------------------------------------------------- @@ -29,25 +114,8 @@ pub mod blur_sharpen; /// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed /// by `SharedPaneState` as a single `&'a mut RasterToolSettings`. pub struct RasterToolSettings { - // --- Paint brush --- - pub brush_radius: f32, - pub brush_opacity: f32, - pub brush_hardness: f32, - pub brush_spacing: f32, - /// true = paint with FG (stroke) color, false = BG (fill) color - pub brush_use_fg: bool, - pub active_brush_settings: BrushSettings, - // --- Eraser --- - pub eraser_radius: f32, - pub eraser_opacity: f32, - pub eraser_hardness: f32, - pub eraser_spacing: f32, - pub active_eraser_settings: BrushSettings, - // --- Smudge --- - pub smudge_radius: f32, - pub smudge_hardness: f32, - pub smudge_spacing: f32, - pub smudge_strength: f32, + /// Per-tool brush state, indexed by `BrushKind`. + brushes: [BrushSlot; 9], // --- Clone / Healing --- /// World-space source point set by Alt+click. pub clone_source: Option, @@ -55,24 +123,12 @@ pub struct RasterToolSettings { pub pattern_type: u32, pub pattern_scale: f32, // --- Dodge / Burn --- - pub dodge_burn_radius: f32, - pub dodge_burn_hardness: f32, - pub dodge_burn_spacing: f32, - pub dodge_burn_exposure: f32, /// 0 = dodge (lighten), 1 = burn (darken) pub dodge_burn_mode: u32, // --- Sponge --- - pub sponge_radius: f32, - pub sponge_hardness: f32, - pub sponge_spacing: f32, - pub sponge_flow: f32, /// 0 = saturate, 1 = desaturate pub sponge_mode: u32, // --- Blur / Sharpen --- - pub blur_sharpen_radius: f32, - pub blur_sharpen_hardness: f32, - pub blur_sharpen_spacing: f32, - pub blur_sharpen_strength: f32, /// Neighborhood kernel radius in canvas pixels (1–20) pub blur_sharpen_kernel: f32, /// 0 = blur, 1 = sharpen @@ -87,7 +143,7 @@ pub struct RasterToolSettings { // --- Quick Select --- /// Brush radius in canvas pixels for the quick-select tool. pub quick_select_radius: f32, - // --- Flood fill (Paint Bucket, raster) --- + // --- Flood fill (Paint Bucket) --- /// Color-distance threshold (Euclidean RGBA, 0–510). Pixels within this /// distance of the comparison color are included in the fill. pub fill_threshold: f32, @@ -96,6 +152,11 @@ pub struct RasterToolSettings { /// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS /// parent pixel (Relative, spreads across gradients). pub fill_threshold_mode: FillThresholdMode, + /// true = fill with the FG (stroke) color, false = BG (fill). Mirrors `BrushSlot::use_fg`. + pub fill_use_fg: bool, + // --- Eyedropper --- + /// true = sampled color replaces the FG (stroke) swatch, false = the BG (fill) swatch. + pub eyedropper_use_fg: bool, // --- Marquee select shape --- /// Whether the rectangular select tool draws a rect or an ellipse. pub select_shape: SelectionShape, @@ -109,10 +170,16 @@ pub struct RasterToolSettings { // --- Gradient --- pub gradient: lightningbeam_core::gradient::ShapeGradient, pub gradient_opacity: f32, - // --- Brush rotation offset --- - /// User-controlled angle offset added to the brush's elliptical_dab_angle (degrees). - /// Lets the user re-orient stock .myb brushes without editing the file. - pub brush_angle_offset: f32, +} + +impl RasterToolSettings { + pub fn brush(&self, kind: BrushKind) -> &BrushSlot { + &self.brushes[kind.index()] + } + + pub fn brush_mut(&mut self, kind: BrushKind) -> &mut BrushSlot { + &mut self.brushes[kind.index()] + } } /// Brush mode for the Liquify tool. @@ -158,43 +225,32 @@ pub enum FillThresholdMode { impl Default for RasterToolSettings { fn default() -> Self { + // The eraser defaults to the bundled "Brush" shape rather than a bare Gaussian. + let mut erase = BrushSlot::new(10.0, 1.0, 0.5, 0.1); + if let Some(idx) = bundled_brushes().iter().position(|p| p.name == "Brush") { + erase.apply_preset(idx, &bundled_brushes()[idx].settings); + // Keep the eraser's own size/opacity rather than the preset's. + erase.radius = 10.0; + erase.strength = 1.0; + } + Self { - brush_radius: 10.0, - brush_opacity: 1.0, - brush_hardness: 0.5, - brush_spacing: 0.1, - brush_use_fg: true, - active_brush_settings: BrushSettings::default(), - eraser_radius: 10.0, - eraser_opacity: 1.0, - eraser_hardness: 0.5, - eraser_spacing: 0.1, - active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes() - .iter() - .find(|p| p.name == "Brush") - .map(|p| p.settings.clone()) - .unwrap_or_default(), - smudge_radius: 15.0, - smudge_hardness: 0.8, - smudge_spacing: 8.0, - smudge_strength: 1.0, + brushes: [ + /* Paint */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* Erase */ erase, + /* Smudge */ BrushSlot::new(15.0, 1.0, 0.8, 8.0), + /* CloneStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* HealingBrush */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* PatternStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* DodgeBurn */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + /* Sponge */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + /* BlurSharpen */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + ], clone_source: None, pattern_type: 0, pattern_scale: 32.0, - dodge_burn_radius: 30.0, - dodge_burn_hardness: 0.5, - dodge_burn_spacing: 3.0, - dodge_burn_exposure: 0.5, dodge_burn_mode: 0, - sponge_radius: 30.0, - sponge_hardness: 0.5, - sponge_spacing: 3.0, - sponge_flow: 0.5, sponge_mode: 0, - blur_sharpen_radius: 30.0, - blur_sharpen_hardness: 0.5, - blur_sharpen_spacing: 3.0, - blur_sharpen_strength: 0.5, blur_sharpen_kernel: 5.0, blur_sharpen_mode: 0, wand_threshold: 15.0, @@ -203,6 +259,8 @@ impl Default for RasterToolSettings { fill_threshold: 15.0, fill_softness: 0.0, fill_threshold_mode: FillThresholdMode::Absolute, + fill_use_fg: true, + eyedropper_use_fg: true, quick_select_radius: 20.0, select_shape: SelectionShape::Rect, warp_grid_cols: 4, @@ -212,7 +270,6 @@ impl Default for RasterToolSettings { liquify_strength: 0.5, gradient: lightningbeam_core::gradient::ShapeGradient::default(), gradient_opacity: 1.0, - brush_angle_offset: 0.0, } } } @@ -229,6 +286,21 @@ pub struct BrushParams { pub spacing: f32, } +/// Read a slot straight into `BrushParams`. This is what `RasterToolDef::brush_params` does by +/// default; it's a free function so a tool that overrides `brush_params` can still build on it. +pub fn default_brush_params(kind: BrushKind, s: &RasterToolSettings) -> BrushParams { + let slot = s.brush(kind); + let mut base_settings = slot.settings.clone(); + base_settings.elliptical_dab_angle += slot.angle_offset; + BrushParams { + base_settings, + radius: slot.radius, + opacity: slot.strength, + hardness: slot.hardness, + spacing: slot.spacing, + } +} + // --------------------------------------------------------------------------- // RasterToolDef trait // --------------------------------------------------------------------------- @@ -236,19 +308,34 @@ pub struct BrushParams { pub trait RasterToolDef: Send + Sync { fn blend_mode(&self) -> RasterBlendMode; fn header_label(&self) -> &'static str; - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams; + /// Which brush slot this tool paints with. + fn brush_kind(&self) -> BrushKind; /// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`. fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4]; + + /// Brush shape + size for this stroke. The default pulls everything from the tool's slot; + /// override only if a tool needs to reinterpret one of the fields (see `smudge`). + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + default_brush_params(self.brush_kind(), s) + } + /// Cursor display radius (world pixels). fn cursor_radius(&self, s: &RasterToolSettings) -> f32 { self.brush_params(s).radius } - /// Render tool-specific controls in the infopanel (called before preset picker if any). - fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings); - /// Whether to show the brush preset picker after `render_ui`. - fn show_brush_preset_picker(&self) -> bool { true } - /// Whether this tool is the eraser (drives preset picker + color UI visibility). - fn is_eraser(&self) -> bool { false } + + /// Label for the slot's `strength` slider — the one field whose meaning is tool-specific. + fn strength_label(&self) -> &'static str { "Opacity" } + + /// Render tool-specific controls in the infopanel. The shared brush controls (color, size, + /// strength, hardness, spacing, angle, preset library) are rendered around this by the + /// infopanel — only put genuinely tool-unique widgets here. + fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + + /// Whether this tool paints with the FG/BG color. False for tools that only transform + /// pixels already on the canvas (erase, smudge, dodge/burn, sponge, blur, clone, heal). + fn uses_color(&self) -> bool { false } + /// Whether Alt+click sets a source point for this tool. fn uses_alt_click(&self) -> bool { false } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs index 6d3e192..7c632aa 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs @@ -1,5 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; +use super::{BrushKind, RasterToolDef}; use lightningbeam_core::raster_layer::RasterBlendMode; pub struct PaintTool; @@ -8,17 +7,7 @@ pub static PAINT: PaintTool = PaintTool; impl RasterToolDef for PaintTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } fn header_label(&self) -> &'static str { "Brush" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - let mut base_settings = s.active_brush_settings.clone(); - base_settings.elliptical_dab_angle += s.brush_angle_offset; - BrushParams { - base_settings, - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } - fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + fn brush_kind(&self) -> BrushKind { BrushKind::Paint } + fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn uses_color(&self) -> bool { true } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs index acd83d3..e3b8445 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -12,18 +12,12 @@ const PATTERN_NAMES: &[&str] = &[ impl RasterToolDef for PatternStampTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp } fn header_label(&self) -> &'static str { "Pattern Stamp" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::PatternStamp } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.pattern_type as f32, s.pattern_scale, 0.0, 0.0] } + /// The pattern is stamped in the brush color (see `brush_dab.wgsl`, blend mode 5). + fn uses_color(&self) -> bool { true } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { let selected_name = PATTERN_NAMES .get(s.pattern_type as usize) @@ -44,6 +38,5 @@ impl RasterToolDef for PatternStampTool { ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0) .logarithmic(true).suffix(" px")); }); - ui.add_space(4.0); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs index b79eeb8..0adf976 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs @@ -1,6 +1,5 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use super::{BrushKind, BrushParams, RasterToolDef, RasterToolSettings}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct SmudgeTool; pub static SMUDGE: SmudgeTool = SmudgeTool; @@ -8,37 +7,15 @@ pub static SMUDGE: SmudgeTool = SmudgeTool; impl RasterToolDef for SmudgeTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge } fn header_label(&self) -> &'static str { "Smudge" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.smudge_radius, - opacity: 1.0, // strength is a separate smudge_dist multiplier - hardness: s.smudge_hardness, - spacing: s.smudge_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::Smudge } fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn show_brush_preset_picker(&self) -> bool { false } - fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Strength:"); - ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.smudge_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.smudge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); + fn strength_label(&self) -> &'static str { "Strength" } + + /// Smudge's slot `strength` drives the smudge distance (applied by the stage as + /// `smudge_radius_log`), not dab opacity — dabs always composite fully. + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + let mut p = super::default_brush_params(self.brush_kind(), s); + p.opacity = 1.0; + p } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs index a410810..7af9486 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct SpongeTool; pub static SPONGE: SpongeTool = SpongeTool; @@ -8,19 +8,11 @@ pub static SPONGE: SpongeTool = SpongeTool; impl RasterToolDef for SpongeTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge } fn header_label(&self) -> &'static str { "Sponge" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.sponge_radius, - opacity: s.sponge_flow, - hardness: s.sponge_hardness, - spacing: s.sponge_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::Sponge } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.sponge_mode as f32, 0.0, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Flow" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() { @@ -30,25 +22,5 @@ impl RasterToolDef for SpongeTool { s.sponge_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Flow:"); - ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.sponge_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.sponge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs new file mode 100644 index 0000000..b67984c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs @@ -0,0 +1,102 @@ +//! Shared color swatch + picker popup used by the toolbar FG/BG swatches and the +//! gradient-stop editor. +//! +//! egui's default popup close behavior is `CloseOnClick`, which closes on a click *anywhere* — +//! including on the picker's own hue/alpha bars. And `CloseOnClickOutside` only reacts to a +//! click (press+release without movement), so painting a stroke on the stage — a drag — would +//! leave the popup open. Both call sites want the same thing, so it lives here once. + +use eframe::egui; + +/// Show a color-picker popup anchored to `toggle_response`. +/// +/// `swatch_rect` is excluded from the close-on-press check so the press that opens the popup +/// doesn't immediately close it again. +/// +/// Returns true if the color changed this frame. +pub fn color_picker_popup( + ui: &mut egui::Ui, + popup_id: egui::Id, + toggle_response: &egui::Response, + color: &mut egui::Color32, + swatch_rect: egui::Rect, +) -> bool { + let mut changed = false; + + let popup_response = egui::containers::Popup::from_toggle_button_response(toggle_response) + .id(popup_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.spacing_mut().slider_width = 275.0; + changed = egui::color_picker::color_picker_color32( + ui, + color, + egui::color_picker::Alpha::OnlyBlend, + ); + }); + + // Close on any pointer *press* outside the popup, not just a click, so that starting a + // brush stroke on the stage dismisses the picker. + if let Some(popup) = &popup_response { + let popup_rect = popup.response.rect; + let pressed_outside = ui.ctx().input(|i| { + i.pointer.any_pressed() + && i.pointer + .interact_pos() + .is_some_and(|p| !popup_rect.contains(p) && !swatch_rect.contains(p)) + }); + if pressed_outside { + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + + changed +} + +/// A color button (checkerboard under the color, so alpha reads correctly) that opens a color +/// picker popup when clicked. Returns true if the color changed this frame. +pub fn color_swatch( + ui: &mut egui::Ui, + id: egui::Id, + rect: egui::Rect, + color: &mut egui::Color32, +) -> bool { + let response = ui.interact(rect, id, egui::Sense::click()); + draw_color_button(ui, rect, *color); + color_picker_popup(ui, id.with("picker_popup"), &response, color, rect) +} + +/// Draw a color button with a checkerboard background so the alpha channel is visible. +pub fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) { + let checker_size = 5.0; + let cols = (rect.width() / checker_size).ceil() as usize; + let rows = (rect.height() / checker_size).ceil() as usize; + + for row in 0..rows { + for col in 0..cols { + let is_light = (row + col) % 2 == 0; + let checker_color = if is_light { + egui::Color32::from_gray(180) + } else { + egui::Color32::from_gray(120) + }; + let checker_rect = egui::Rect::from_min_size( + egui::pos2( + rect.min.x + col as f32 * checker_size, + rect.min.y + row as f32 * checker_size, + ), + egui::vec2(checker_size, checker_size), + ) + .intersect(rect); + ui.painter().rect_filled(checker_rect, 0.0, checker_color); + } + } + + ui.painter().rect_filled(rect, 2.0, color); + ui.painter().rect_stroke( + rect, + 2.0, + egui::Stroke::new(1.0, egui::Color32::from_gray(80)), + egui::StrokeKind::Middle, + ); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs index 1e47eb5..0186941 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs @@ -1,6 +1,7 @@ //! Reusable UI widgets for the editor mod text_field; +pub mod color_swatch; pub mod dropdown_list; pub use text_field::ImeTextField;