Release 1.0.10-alpha

This commit is contained in:
Skyler Lehmkuhl 2026-07-14 13:42:01 -04:00
commit a09b0c1a93
61 changed files with 6099 additions and 1962 deletions

5
.gitignore vendored
View File

@ -38,4 +38,7 @@ packaging/AppDir/
packaging/squashfs-root/ packaging/squashfs-root/
# Wrapper script (generated, not needed with static FFmpeg) # Wrapper script (generated, not needed with static FFmpeg)
lightningbeam-ui/lightningbeam-editor/debian/ lightningbeam-ui/lightningbeam-editor/debian/
# Personal working notes (not version-controlled)
TODO.md

View File

@ -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: # 1.0.9-alpha:
Bugfixes: 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 - 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

View File

@ -58,13 +58,21 @@ Lightningbeam is developed on the `main` branch. The project has been rewritten
- ✅ Audio engine with node graph processing - ✅ Audio engine with node graph processing
- ✅ GPU waveform rendering with mipmaps - ✅ GPU waveform rendering with mipmaps
- ✅ Video decoding integration - ✅ Video decoding integration
- 🚧 Export system (in progress) - ✅ Export system (video, image, audio, animated GIF, SVG)
- 🚧 Node editor UI (planned) - ✅ Node editor UI
- 🚧 Piano roll editor (planned) - 🚧 Piano roll editor (in progress)
## Getting Started ## 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/)) - Rust (stable toolchain via [rustup](https://rustup.rs/))
- System dependencies: - 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. See [docs/BUILDING.md](docs/BUILDING.md) for detailed setup instructions.
### Building and Running #### Building and Running
```bash ```bash
# Clone the repository # Clone the repository

25
TODO.md
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -211,6 +211,7 @@ pub fn render_to_memory(
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
None, // export never runs with a recording in flight
); );
// Calculate how many samples we actually need from this chunk // Calculate how many samples we actually need from this chunk
@ -557,6 +558,7 @@ fn export_mp3<P: AsRef<Path>>(
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
None, // export never runs with a recording in flight
); );
// Calculate how many samples we need from this chunk // Calculate how many samples we need from this chunk
@ -727,6 +729,7 @@ fn export_aac<P: AsRef<Path>>(
settings.sample_rate, settings.sample_rate,
settings.channels, settings.channels,
false, false,
None, // export never runs with a recording in flight
); );
// Calculate how many samples we need from this chunk // Calculate how many samples we need from this chunk

View File

@ -383,6 +383,7 @@ impl Project {
sample_rate: u32, sample_rate: u32,
channels: u32, channels: u32,
live_only: bool, live_only: bool,
recording_midi: Option<(TrackId, MidiClipId)>,
) { ) {
output.fill(0.0); output.fill(0.0);
@ -391,6 +392,7 @@ impl Project {
// Create initial render context // Create initial render context
let ctx = RenderContext { let ctx = RenderContext {
live_only, live_only,
recording_midi,
..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len())
}; };

View File

@ -5,6 +5,61 @@ use crate::time::{Beats, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; 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<WaveformPeak> {
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 /// State of an active recording session
pub struct RecordingState { pub struct RecordingState {
/// Track being recorded to /// Track being recorded to
@ -35,6 +90,8 @@ pub struct RecordingState {
pub frames_per_peak: usize, pub frames_per_peak: usize,
/// All recorded audio data accumulated in memory (written to disk at finalization) /// All recorded audio data accumulated in memory (written to disk at finalization)
pub audio_data: Vec<f32>, pub audio_data: Vec<f32>,
/// Cycle-recording bookkeeping, when a cycle region was armed at record start.
pub cycle: Option<CycleRecordInfo>,
} }
impl RecordingState { impl RecordingState {
@ -69,9 +126,69 @@ impl RecordingState {
waveform_buffer: Vec::new(), waveform_buffer: Vec::new(),
frames_per_peak, frames_per_peak,
audio_data: Vec::new(), 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<Vec<Vec<f32>>> {
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<f32>> = 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 /// Add samples to the accumulation buffer
/// Returns true if a flush occurred /// Returns true if a flush occurred
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> { pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
@ -189,6 +306,26 @@ pub struct MidiRecordingState {
active_notes: HashMap<u8, ActiveMidiNote>, active_notes: HashMap<u8, ActiveMidiNote>,
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats /// Completed notes: (time_offset, note, velocity, duration) — all times in beats
pub completed_notes: Vec<(Beats, u8, u8, 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<Beats>,
/// 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<usize>,
} }
impl MidiRecordingState { impl MidiRecordingState {
@ -199,9 +336,28 @@ impl MidiRecordingState {
start_time, start_time,
active_notes: HashMap::new(), active_notes: HashMap::new(),
completed_notes: Vec::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) { 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 }); self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time });
} }
@ -211,16 +367,38 @@ impl MidiRecordingState {
if absolute_time <= self.start_time { if absolute_time <= self.start_time {
return; return;
} }
let note_start = active_note.start_time.max(self.start_time); self.push_completed(&active_note, absolute_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
absolute_time - note_start,
));
} }
} }
/// 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<Vec<(Beats, u8, u8, Beats)>> {
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)] { pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
&self.completed_notes &self.completed_notes
} }
@ -229,18 +407,28 @@ impl MidiRecordingState {
self.completed_notes.len() 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. /// 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)> { pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
let mut notes = self.completed_notes.clone(); let mut notes = self.completed_notes.clone();
for active in self.active_notes.values() { notes.extend(self.active_notes_with_provisional_end(current_time));
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 notes
} }
@ -252,13 +440,259 @@ impl MidiRecordingState {
let active_notes: Vec<_> = self.active_notes.drain().collect(); let active_notes: Vec<_> = self.active_notes.drain().collect();
for (_note_num, active_note) in active_notes { for (_note_num, active_note) in active_notes {
let note_start = active_note.start_time.max(self.start_time); self.push_completed(&active_note, end_time);
self.completed_notes.push((
note_start - self.start_time,
active_note.note,
active_note.velocity,
end_time - note_start,
));
} }
} }
/// 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<f32>, 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<f32>, 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<f32> = (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<f32> = (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<f32> = (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<f32> = (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<f32> = (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<f32> = (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<Vec<u8>> = 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);
}
} }

View File

@ -1,6 +1,6 @@
use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; use super::automation::{AutomationLane, AutomationLaneId, ParameterId};
use super::clip::{AudioClipInstance, AudioClipInstanceId}; 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::midi_pool::MidiClipPool;
use super::node_graph::AudioGraph; use super::node_graph::AudioGraph;
use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; 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 /// Used after pause/stop to route note-off tails through the normal group hierarchy
/// without re-triggering notes from clips at the paused position. /// without re-triggering notes from clips at the paused position.
pub live_only: bool, 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> { impl<'a> RenderContext<'a> {
@ -61,6 +68,7 @@ impl<'a> RenderContext<'a> {
buffer_size, buffer_size,
time_stretch: 1.0, time_stretch: 1.0,
live_only: false, live_only: false,
recording_midi: None,
} }
} }
@ -864,9 +872,21 @@ impl MidiTrack {
let playhead_beats = ctx.playhead_beats(); let playhead_beats = ctx.playhead_beats();
let buffer_end_beats = ctx.buffer_end_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 // Collect MIDI events from all clip instances that overlap with current beat range
let mut currently_active = HashSet::new(); let mut currently_active = HashSet::new();
for instance in &self.clip_instances { 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) { if instance.overlaps_range(playhead_beats, buffer_end_beats) {
currently_active.insert(instance.id); currently_active.insert(instance.id);
} }

View File

@ -1,3 +1,3 @@
pub mod types; pub mod types;
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse}; pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse, TrimRange};

View File

@ -8,6 +8,21 @@ use crate::audio::node_graph::nodes::LoopMode;
use crate::io::WaveformPeak; use crate::io::WaveformPeak;
use crate::time::{Beats, Seconds}; 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 /// Commands sent from UI/control thread to audio thread
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Command { pub enum Command {
@ -19,7 +34,7 @@ pub enum Command {
/// Pause playback (maintains position) /// Pause playback (maintains position)
Pause, Pause,
/// Seek to a specific position in seconds /// Seek to a specific position in seconds
Seek(f64), Seek(Seconds),
// Track management commands // Track management commands
/// Set track volume (0.0 = silence, 1.0 = unity gain) /// Set track volume (0.0 = silence, 1.0 = unity gain)
@ -31,13 +46,12 @@ pub enum Command {
// Clip management commands // Clip management commands
/// Move a clip to a new timeline position (track_id, clip_id, new_external_start) /// Move a clip to a new timeline position (track_id, clip_id, new_external_start)
MoveClip(TrackId, ClipId, f64), MoveClip(TrackId, ClipId, Beats),
/// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) /// Trim a clip's internal boundaries — which portion of the source content is used.
/// This changes which portion of the source content is used TrimClip(TrackId, ClipId, TrimRange),
TrimClip(TrackId, ClipId, f64, f64),
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
/// If duration > internal duration, the clip will loop /// If duration > internal duration, the clip will loop
ExtendClip(TrackId, ClipId, f64), ExtendClip(TrackId, ClipId, Beats),
// Metatrack management commands // Metatrack management commands
/// Create a new metatrack with a name and optional parent group /// Create a new metatrack with a name and optional parent group
@ -53,15 +67,15 @@ pub enum Command {
SetTimeStretch(TrackId, f32), SetTimeStretch(TrackId, f32),
/// Set metatrack time offset in seconds (track_id, offset) /// Set metatrack time offset in seconds (track_id, offset)
/// Positive = shift content later, negative = shift earlier /// 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 /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
SetPitchShift(TrackId, f32), SetPitchShift(TrackId, f32),
/// Set metatrack trim start in seconds (track_id, trim_start) /// Set metatrack trim start in seconds (track_id, trim_start)
/// Children won't hear content before this point /// Children won't hear content before this point
SetTrimStart(TrackId, f64), SetTrimStart(TrackId, Seconds),
/// Set metatrack trim end in seconds (track_id, trim_end) /// Set metatrack trim end in seconds (track_id, trim_end)
/// None means no end trim /// None means no end trim
SetTrimEnd(TrackId, Option<f64>), SetTrimEnd(TrackId, Option<Seconds>),
// Audio track commands // Audio track commands
/// Create a new audio track with a name and optional parent group /// 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 /// Add a MIDI clip to the pool without placing it on a track
AddMidiClipToPool(MidiClip), AddMidiClipToPool(MidiClip),
/// Create a new MIDI clip on a track (track_id, start_time, duration) /// 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) /// 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) /// 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)>) /// 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 /// 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. /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>), UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support /// 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) /// Create a new automation lane on a track (track_id, parameter_id)
CreateAutomationLane(TrackId, ParameterId), CreateAutomationLane(TrackId, ParameterId),
/// Add an automation point to a lane (track_id, lane_id, time, value, curve) /// 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) /// 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) /// Clear all automation points from a lane (track_id, lane_id)
ClearAutomationLane(TrackId, AutomationLaneId), ClearAutomationLane(TrackId, AutomationLaneId),
/// Remove an automation lane (track_id, lane_id) /// 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) /// Enable/disable an automation lane (track_id, lane_id, enabled)
SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool), 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 // Recording commands
/// Start recording on a track (track_id, start_time) /// 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 /// Stop the current recording
StopRecording, StopRecording,
/// Pause the current recording /// Pause the current recording
@ -125,7 +154,8 @@ pub enum Command {
// MIDI Recording commands // MIDI Recording commands
/// Start MIDI recording on a track (track_id, clip_id, start_time) /// 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 /// Stop the current MIDI recording
StopMidiRecording, StopMidiRecording,
@ -237,9 +267,9 @@ pub enum Command {
// Automation Input Node commands // Automation Input Node commands
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) /// 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) /// 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) /// Set the display name of an AutomationInput node (track_id, node_id, name)
AutomationSetName(TrackId, u32, String), AutomationSetName(TrackId, u32, String),
@ -271,7 +301,7 @@ pub enum Command {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AudioEvent { pub enum AudioEvent {
/// Current playback position in seconds /// Current playback position in seconds
PlaybackPosition(f64), PlaybackPosition(Seconds),
/// Playback has stopped (reached end of audio) /// Playback has stopped (reached end of audio)
PlaybackStopped, PlaybackStopped,
/// Audio buffer underrun detected /// Audio buffer underrun detected
@ -292,6 +322,35 @@ pub enum AudioEvent {
RecordingProgress(ClipId, Seconds), RecordingProgress(ClipId, Seconds),
/// Recording stopped (clip_id, pool_index, waveform) /// Recording stopped (clip_id, pool_index, waveform)
RecordingStopped(ClipId, usize, Vec<WaveformPeak>), RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
/// 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<MidiClipId>,
/// 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<WaveformPeak>)>,
/// 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) /// Recording error (error_message)
RecordingError(String), RecordingError(String),
/// MIDI recording stopped (track_id, clip_id, note_count) /// MIDI recording stopped (track_id, clip_id, note_count)
@ -342,7 +401,7 @@ pub enum AudioEvent {
WaveformChunksReady { WaveformChunksReady {
pool_index: usize, pool_index: usize,
detail_level: u8, detail_level: u8,
chunks: Vec<(u32, (f64, f64), Vec<WaveformPeak>)>, chunks: Vec<(u32, (Seconds, Seconds), Vec<WaveformPeak>)>,
}, },
/// An audio file has been imported and is ready for playback. /// An audio file has been imported and is ready for playback.
@ -353,7 +412,7 @@ pub enum AudioEvent {
path: String, path: String,
channels: u32, channels: u32,
sample_rate: u32, sample_rate: u32,
duration: f64, duration: Seconds,
format: crate::io::audio_file::AudioFormat, format: crate::io::audio_file::AudioFormat,
}, },
@ -427,7 +486,7 @@ pub enum Query {
/// Export audio to file (settings, output_path) /// Export audio to file (settings, output_path)
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID /// 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 /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
/// The clip must already exist in the MidiClipPool /// The clip must already exist in the MidiClipPool
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
@ -472,16 +531,21 @@ pub struct OscilloscopeData {
} }
/// MIDI clip data for serialization /// 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)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipData { pub struct MidiClipData {
pub duration: f64, /// MIDI content length is musical, so beats.
pub duration: Beats,
pub events: Vec<crate::audio::midi::MidiEvent>, pub events: Vec<crate::audio::midi::MidiEvent>,
} }
/// Automation keyframe data for serialization /// Automation keyframe data for serialization
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AutomationKeyframeData { pub struct AutomationKeyframeData {
pub time: f64, /// Automation x-axes are all beats.
pub time: Beats,
pub value: f32, pub value: f32,
pub interpolation: String, pub interpolation: String,
pub ease_out: (f32, f32), pub ease_out: (f32, f32),
@ -518,7 +582,7 @@ pub enum QueryResponse {
/// Pool waveform data /// Pool waveform data
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>), PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
/// Pool file info (duration, sample_rate, channels) /// Pool file info (duration, sample_rate, channels)
PoolFileInfo(Result<(f64, u32, u32), String>), PoolFileInfo(Result<(Seconds, u32, u32), String>),
/// Audio exported /// Audio exported
AudioExported(Result<(), String>), AudioExported(Result<(), String>),
/// MIDI clip instance added (returns instance ID) /// MIDI clip instance added (returns instance ID)

View File

@ -20,7 +20,7 @@ pub use audio::{
TrackNode, TrackNode,
}; };
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode}; 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 tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
pub use command::{AudioEvent, Command, OscilloscopeData}; pub use command::{AudioEvent, Command, OscilloscopeData};
pub use command::types::AutomationKeyframeData; pub use command::types::AutomationKeyframeData;

View File

@ -16,6 +16,50 @@ pub struct Beats(pub f64);
#[serde(transparent)] #[serde(transparent)]
pub struct Seconds(pub f64); 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 { impl Beats {
pub const ZERO: Self = Self(0.0); pub const ZERO: Self = Self(0.0);

View File

@ -556,7 +556,7 @@ pub fn run_tui(
while let Ok(event) = rx.pop() { while let Ok(event) = rx.pop() {
match event { match event {
AudioEvent::PlaybackPosition(pos) => { AudioEvent::PlaybackPosition(pos) => {
app.update_playback_position(pos); app.update_playback_position(pos.seconds_to_f64());
} }
AudioEvent::PlaybackStopped => { AudioEvent::PlaybackStopped => {
app.set_playing(false); app.set_playing(false);

View File

@ -3628,7 +3628,7 @@ dependencies = [
[[package]] [[package]]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.8-alpha" version = "1.0.9-alpha"
dependencies = [ dependencies = [
"beamdsp", "beamdsp",
"bytemuck", "bytemuck",

View File

@ -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<Vec<u8>>`. 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.

View File

@ -47,6 +47,113 @@ pub struct BackendContext<'a> {
// Future: pub video_controller: Option<&'a mut VideoController>, // 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<Option<(daw_backend::TrackId, BackendClipInstanceId)>, 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 /// Action trait for undo/redo operations
/// ///
/// Each action must be able to execute (apply changes) and rollback (undo changes). /// Each action must be able to execute (apply changes) and rollback (undo changes).

View File

@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction {
// `get_clip_duration` is the content length in seconds; the placement span // `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 // must be beats (the timeline is beats-domain), so convert via the clip's
// typed helper rather than treating the seconds span as beats. // 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))?; .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
let effective_duration = self.clip_instance 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 // Auto-adjust position for audio/video layers to avoid overlaps
let adjusted_start = document.find_nearest_valid_position( let adjusted_start = document.find_nearest_valid_position(
@ -196,113 +197,23 @@ impl Action for AddClipInstanceAction {
return Ok(()); return Ok(());
} }
// Look up the clip from the document // Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so
let clip = document // the trim/duration conversions (and take-folder resolution) live in exactly one place.
.get_audio_clip(&self.clip_instance.clip_id) if let Some((track_id, backend_id)) =
.ok_or_else(|| "Audio clip not found".to_string())?; backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)?
{
// Look up backend track ID from layer mapping self.backend_track_id = Some(track_id);
let backend_track_id = backend match backend_id {
.layer_to_track_map crate::action::BackendClipInstanceId::Midi(id) => {
.get(&self.layer_id) self.backend_midi_instance_id = Some(id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; }
crate::action::BackendClipInstanceId::Audio(id) => {
// Get audio controller self.backend_audio_instance_id = Some(id)
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()),
} }
} }
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> { fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {

View File

@ -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<AudioTake>,
// Stored during execute for rollback.
old_take_count: usize,
old_active_take: Option<usize>,
executed: bool,
}
impl AppendTakesAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, new_takes: Vec<AudioTake>) -> 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<BackendClipInstanceId> = 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)
}
}

View File

@ -91,7 +91,7 @@ impl Action for LoopClipInstancesAction {
impl LoopClipInstancesAction { impl LoopClipInstancesAction {
fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> { 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() { let controller = match backend.audio_controller.as_mut() {
Some(c) => c, Some(c) => c,
@ -128,26 +128,22 @@ impl LoopClipInstancesAction {
(new_dur, new_lb) (new_dur, new_lb)
}; };
let content_window = { // Natural content length as a beats span (the fallback when no explicit
let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native()); // timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats
(trim_end - instance.trim_start).max(0.0) // seconds // content carries over directly rather than being read as seconds.
}; let content_window_beats = instance.effective_duration_beats(
// Natural content length as a beats span at the clip's start (the clip.content_duration(),
// fallback when no explicit timeline_duration is set). document.tempo_map(),
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;
let right_duration = target_duration.unwrap_or(content_window_beats); let right_duration = target_duration.unwrap_or(content_window_beats);
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO);
let external_duration = left_duration + right_duration; let external_duration = left_duration + right_duration;
let external_start = instance.timeline_start - left_duration; let external_start = instance.timeline_start - left_duration;
let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> { let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> {
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id), ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id),
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
let backend_id = backend.clip_instance_to_backend_map.get(inst_id) let backend_id = backend.clip_instance_to_backend_map.get(inst_id)
.ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?;
match backend_id { match backend_id {
@ -155,7 +151,7 @@ impl LoopClipInstancesAction {
_ => Err("Expected audio instance ID for sampled clip".to_string()), _ => 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()),
} }
}; };

View File

@ -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<BackendClipInstanceId> = 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<AudioTake>,
old_active_take: Option<usize>,
}
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<AudioTake>,
old_active_take: Option<usize>,
}
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");
}
}

View File

@ -14,6 +14,10 @@ pub mod move_clip_instances;
pub mod reorder_clip_instances; pub mod reorder_clip_instances;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; 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_document_properties;
pub mod set_instance_properties; pub mod set_instance_properties;
pub mod set_layer_properties; pub mod set_layer_properties;
@ -50,6 +54,10 @@ pub mod set_text_content;
pub mod resize_text_box; pub mod resize_text_box;
pub use add_clip_instance::AddClipInstanceAction; 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_effect::AddEffectAction;
pub use add_layer::AddLayerAction; pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; pub use add_shape::AddShapeAction;

View File

@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction {
let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| { let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| {
let inst = clip_instances.iter().find(|ci| &ci.id == id)?; 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()); let eff = inst.effective_duration_beats(dur, document.tempo_map());
Some((*id, *old_start, eff)) Some((*id, *old_start, eff))
}).collect(); }).collect();
@ -190,7 +190,7 @@ impl Action for MoveClipInstancesAction {
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { let controller = match backend.audio_controller.as_mut() {
@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction {
// Check if this clip has a metatrack // Check if this clip has a metatrack
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_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(*new_start)); 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)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Handle move based on clip type // Handle move based on clip type
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: move_clip expects the pool clip ID // For MIDI: move_clip expects the pool clip ID
controller.move_clip(*track_id, *midi_clip_id, *new_start); controller.move_clip(*track_id, *midi_clip_id, *new_start);
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
// For sampled audio: move_clip expects the instance ID // For sampled audio: move_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .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()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be moved - skip // 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> { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { 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(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) { 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_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Handle move based on clip type (restore old position) // Handle move based on clip type (restore old position)
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: move_clip expects the pool clip ID // For MIDI: move_clip expects the pool clip ID
controller.move_clip(*track_id, *midi_clip_id, *old_start); controller.move_clip(*track_id, *midi_clip_id, *old_start);
} }
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
// For sampled audio: move_clip expects the instance ID // For sampled audio: move_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .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()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be moved - skip // Recording clips cannot be moved - skip
} }
} }

View File

@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction {
backend: &mut BackendContext, backend: &mut BackendContext,
document: &Document, document: &Document,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::clip::AudioClipType; if backend.audio_controller.is_none() {
return Ok(());
}
let controller = match backend.audio_controller.as_mut() { // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same
Some(c) => c, // helper the add and split actions use, so the trim/duration conversions (and take-folder
None => return Ok(()), // 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);
// Re-add clips that were removed from backend for (layer_id, instance) in &saved {
for (layer_id, instance) in &self.saved { if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) {
let layer = match document.get_layer(layer_id) {
Some(l) => l,
None => continue,
};
if !matches!(layer, AnyLayer::Audio(_)) {
continue; continue;
} }
// A missing track/clip just means there's nothing to restore on the backend.
let track_id = match backend.layer_to_track_map.get(layer_id) { let _ = backend.add_clip_instance(document, layer_id, instance);
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 => {}
}
} }
self.saved = saved;
// Clear saved backend IDs // Clear saved backend IDs
self.saved_backend_ids.clear(); self.saved_backend_ids.clear();

View File

@ -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<usize>,
old_take: Option<usize>,
/// The backend track/clip the instance was on before we swapped, so rollback can undo it.
backend_track_id: Option<daw_backend::TrackId>,
}
impl SetActiveTakeAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, new_take: usize, old_take: Option<usize>) -> 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<usize>) -> 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<BackendClipInstanceId> = 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)
}
}

View File

@ -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(())
}
}

View File

@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext};
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use daw_backend::ContentTime;
use uuid::Uuid; use uuid::Uuid;
/// Action that splits a clip instance at a specific timeline position /// Action that splits a clip instance at a specific timeline position
@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction {
// Stored during execute for rollback // Stored during execute for rollback
/// Original trim_end value of the left (original) instance /// Original trim_end value of the left (original) instance
original_trim_end: Option<f64>, original_trim_end: Option<ContentTime>,
/// Original timeline_duration value of the left (original) instance (beats) /// Original timeline_duration value of the left (original) instance (beats)
original_timeline_duration: Option<daw_backend::Beats>, original_timeline_duration: Option<daw_backend::Beats>,
/// ID of the new (right) instance created by the split /// 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) .find(|ci| ci.id == self.instance_id)
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
// Get the clip's duration // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for
let clip_duration = document // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain
.get_clip_duration(&instance.clip_id) // 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))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
// Calculate the effective duration and timeline end (both in beats) // 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; let timeline_end = instance.timeline_start + effective_duration;
// Validate: split_time must be strictly within the clip's timeline span // 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_trim_end = instance.trim_end;
self.original_timeline_duration = instance.timeline_duration; 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 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). // Timeline split point (beats).
let time_into_clip = self.split_time - instance.timeline_start; let time_into_clip = self.split_time - instance.timeline_start;
let left_duration = time_into_clip; let left_duration = time_into_clip;
let right_duration = effective_duration - left_duration; 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 tempo_map = document.tempo_map();
let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time) let time_into_content = ContentTime(match trim_duration {
- tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64(); 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) // Calculate the content split point (content domain).
let content_split_time = if is_looping { let content_split_time = if is_looping && content_duration > ContentTime::ZERO {
// For looping clips, wrap around content // For looping clips, wrap around content
instance.trim_start + (time_into_clip_secs % content_duration) instance.trim_start + (time_into_content % content_duration)
} else { } else {
instance.trim_start + time_into_clip_secs instance.trim_start + time_into_content
}; };
// Clone the instance for the right side // Clone the instance for the right side
@ -357,119 +363,63 @@ impl Action for SplitClipInstanceAction {
.get_audio_clip(&new_instance.clip_id) .get_audio_clip(&new_instance.clip_id)
.ok_or_else(|| "Audio clip not found".to_string())?; .ok_or_else(|| "Audio clip not found".to_string())?;
// Look up backend track ID from layer mapping use crate::clip::ResolvedContent;
let backend_track_id = backend 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 .layer_to_track_map
.get(&self.layer_id) .get(&self.layer_id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", 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 let controller = backend
.audio_controller .audio_controller
.as_mut() .as_mut()
.ok_or_else(|| "Audio controller not available".to_string())?; .ok_or_else(|| "Audio controller not available".to_string())?;
match left_backend_id {
// Handle different clip types Some(crate::action::BackendClipInstanceId::Midi(id))
use crate::clip::AudioClipType; | Some(crate::action::BackendClipInstanceId::Audio(id)) => {
match &clip.clip_type { controller.trim_clip(backend_track_id, id, left_trim);
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);
} }
None => {}
// 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())
} }
} }
// 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( 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(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) {
if let Some(clip) = document.get_audio_clip(&instance.clip_id) { if let Some(clip) = document.get_audio_clip(&instance.clip_id) {
let orig_internal_start = instance.trim_start; 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 // Restore based on clip type
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { .. } => { ResolvedContent::Midi { .. } => {
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_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)) = if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_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 // Recording clips - nothing to rollback
} }
} }
@ -554,8 +506,8 @@ mod tests {
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds) // Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
let mut clip_instance = ClipInstance::new(clip_id); let mut clip_instance = ClipInstance::new(clip_id);
clip_instance.timeline_start = daw_backend::Beats::ZERO; clip_instance.timeline_start = daw_backend::Beats::ZERO;
clip_instance.trim_start = 0.0; clip_instance.trim_start = ContentTime::ZERO;
clip_instance.trim_end = Some(10.0); clip_instance.trim_end = Some(ContentTime(10.0));
let instance_id = clip_instance.id; let instance_id = clip_instance.id;
vector_layer.clip_instances.push(clip_instance); 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)); let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0));
assert_eq!(action.description(), "Split clip instance"); 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");
}
} }

View File

@ -6,7 +6,7 @@ use crate::action::Action;
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, ContentTime, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction {
pub struct TrimData { pub struct TrimData {
/// For TrimLeft: trim_start value /// For TrimLeft: trim_start value
/// For TrimRight: trim_end value (Option because it can be None) /// For TrimRight: trim_end value (Option because it can be None)
pub trim_value: Option<f64>, ///
/// 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<ContentTime>,
/// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats)
/// For TrimRight: unused (None) /// For TrimRight: unused (None)
pub timeline_start: Option<Beats>, pub timeline_start: Option<Beats>,
} }
/// 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 { impl TrimData {
/// Create TrimData for left trim /// 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 { Self {
trim_value: Some(trim_start), trim_value: Some(trim_start),
timeline_start: Some(timeline_start), timeline_start: Some(timeline_start),
@ -48,7 +90,7 @@ impl TrimData {
} }
/// Create TrimData for right trim /// Create TrimData for right trim
pub fn right(trim_end: Option<f64>) -> Self { pub fn right(trim_end: Option<ContentTime>) -> Self {
Self { Self {
trim_value: trim_end, trim_value: trim_end,
timeline_start: None, timeline_start: None,
@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction {
.find(|ci| &ci.id == instance_id) .find(|ci| &ci.id == instance_id)
.ok_or_else(|| format!("Instance {} not found", 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))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
let mut clamped_new = new.clone(); let mut clamped_new = new.clone();
@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction {
{ {
// If extending to the left (new_trim < old_trim) // If extending to the left (new_trim < old_trim)
if should_validate && 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(); 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 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); .max(Beats::ZERO);
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction {
} }
} }
TrimType::TrimRight => { TrimType::TrimRight => {
let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); let content_end = ContentTime(clip_content.native());
let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); 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 extending to the right (new_trim_end > old_trim_end)
if should_validate && new_trim_end > old_trim_end { if should_validate && new_trim_end > old_trim_end {
let tempo_map = document.tempo_map(); 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). // How long the clip currently occupies the timeline, in beats. Resolved
let max_extend_secs = document.find_max_trim_extend_right( // in the clip's own domain, so a MIDI clip's beats content isn't run
layer_id, // through the seconds→beats conversion a second time.
instance_id, 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, instance.timeline_start,
current_effective_duration, tempo_map,
).seconds_to_f64(); );
// Calculate how much we want to extend (content seconds)
let desired_extend = new_trim_end - old_trim_end; let desired_extend = new_trim_end - old_trim_end;
let actual_extend = desired_extend.min(max_extend);
// Clamp to max allowed
let actual_extend = desired_extend.min(max_extend_secs);
let clamped_trim_end = old_trim_end + actual_extend; let clamped_trim_end = old_trim_end + actual_extend;
// Don't exceed clip duration // Don't exceed the clip's content.
let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); let final_trim_end = clamped_trim_end.min(content_end);
clamped_new = TrimData::right(Some(final_trim_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> { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { 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) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
// Instance already has new values after execute() // Instance already has new values after execute()
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); 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)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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 // Calculate new internal_start and internal_end for backend
// Note: instance already has the new trim values after execute() // Note: instance already has the new trim values after execute()
let internal_start = instance.trim_start; 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 // Handle trim based on clip type
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: trim_clip expects the pool 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 // For sampled audio: trim_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
match backend_instance_id { match backend_instance_id {
crate::action::BackendClipInstanceId::Audio(audio_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()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be trimmed - skip // 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> { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::clip::AudioClipType; use crate::clip::ResolvedContent;
// Get audio controller // Get audio controller
let controller = match backend.audio_controller.as_mut() { 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) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
// Instance already has old values after rollback() // Instance already has old values after rollback()
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); 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)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::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))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Calculate old internal_start and internal_end for backend // Calculate old internal_start and internal_end for backend
let content_end = ContentTime(clip.content_duration().native());
let internal_start = match trim_type { 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 TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed
}; };
let internal_end = match trim_type { let internal_end = match trim_type {
TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed
TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()), TrimType::TrimRight => old.trim_value.unwrap_or(content_end),
}; };
// Handle trim based on clip type // Handle trim based on clip type
match &clip.clip_type { match &instance.resolve(clip) {
AudioClipType::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
// For MIDI: trim_clip expects the pool 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 // For sampled audio: trim_clip expects the instance ID
let backend_instance_id = backend.clip_instance_to_backend_map.get(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))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
match backend_instance_id { match backend_instance_id {
crate::action::BackendClipInstanceId::Audio(audio_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()), _ => return Err("Expected audio instance ID for sampled clip".to_string()),
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording clips cannot be trimmed - skip // Recording clips cannot be trimmed - skip
} }
} }
@ -569,7 +631,7 @@ mod tests {
let mut clip_instance = ClipInstance::new(clip_id); let mut clip_instance = ClipInstance::new(clip_id);
clip_instance.timeline_start = Beats::ZERO; clip_instance.timeline_start = Beats::ZERO;
clip_instance.trim_start = 0.0; clip_instance.trim_start = ContentTime::ZERO;
let instance_id = clip_instance.id; let instance_id = clip_instance.id;
vector_layer.clip_instances.push(clip_instance); vector_layer.clip_instances.push(clip_instance);
@ -582,8 +644,8 @@ mod tests {
vec![( vec![(
instance_id, instance_id,
TrimType::TrimLeft, TrimType::TrimLeft,
TrimData::left(0.0, Beats::ZERO), TrimData::left(ContentTime::ZERO, Beats::ZERO),
TrimData::left(2.0, Beats(2.0)), TrimData::left(ContentTime(2.0), Beats(2.0)),
)], )],
); );
@ -599,7 +661,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
assert_eq!(instance.timeline_start, Beats(2.0)); assert_eq!(instance.timeline_start, Beats(2.0));
} }
@ -613,7 +675,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_start, 0.0); assert_eq!(instance.trim_start, ContentTime::ZERO);
assert_eq!(instance.timeline_start, Beats::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO);
} }
} }
@ -644,7 +706,7 @@ mod tests {
instance_id, instance_id,
TrimType::TrimRight, TrimType::TrimRight,
TrimData::right(None), TrimData::right(None),
TrimData::right(Some(8.0)), TrimData::right(Some(ContentTime(8.0))),
)], )],
); );
@ -660,7 +722,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_end, Some(8.0)); assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
} }
// Rollback // Rollback

View File

@ -14,7 +14,7 @@
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::layer_tree::LayerTree; use crate::layer_tree::LayerTree;
use crate::object::Transform; use crate::object::Transform;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, ContentTime, Seconds};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
@ -130,10 +130,14 @@ impl VectorClip {
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
ci.timeline_start + td_beats ci.timeline_start + td_beats
} else if let Some(te) = ci.trim_end { } 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)) 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) { } 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)) tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
} else { } else {
continue; continue;
@ -201,7 +205,9 @@ impl VectorClip {
// Convert parent clip time (seconds) to nested clip local time (seconds). // Convert parent clip time (seconds) to nested clip local time (seconds).
// timeline_start is in beats; convert to seconds using document BPM. // 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 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 // Look up the nested clip definition
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
@ -468,6 +474,25 @@ pub enum AudioClipType {
Recording, 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. /// A clip's content duration, tagged by its native unit.
/// ///
/// Sampled/recording audio and video measure content in wall-clock **seconds**; MIDI measures /// 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(), 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 /// Audio clip
@ -532,11 +568,10 @@ impl AudioClip {
/// The clip's content duration, tagged with its native domain (seconds for sampled/recording, /// 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. /// beats for MIDI). This is the only sanctioned way to read the raw `duration` field.
pub fn content_duration(&self) -> ClipDuration { pub fn content_duration(&self) -> ClipDuration {
match self.clip_type { if self.is_midi_domain() {
AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)), ClipDuration::Beats(Beats(self.duration))
AudioClipType::Sampled { .. } | AudioClipType::Recording => { } else {
ClipDuration::Seconds(Seconds(self.duration)) ClipDuration::Seconds(Seconds(self.duration))
}
} }
} }
@ -545,15 +580,19 @@ impl AudioClip {
pub fn set_content_duration(&mut self, duration: ClipDuration) { pub fn set_content_duration(&mut self, duration: ClipDuration) {
debug_assert!( debug_assert!(
matches!( matches!(
(&self.clip_type, duration), (self.is_midi_domain(), duration),
(AudioClipType::Midi { .. }, ClipDuration::Beats(_)) (true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_))
| (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_))
), ),
"clip duration domain must match clip type", "clip duration domain must match clip type",
); );
self.duration = duration.native(); 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 /// Create a new sampled audio clip
/// ///
/// # Arguments /// # Arguments
@ -637,6 +676,73 @@ impl AudioClip {
_ => None, _ => 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 /// Unified clip enum for polymorphic handling
@ -715,16 +821,17 @@ pub struct ClipInstance {
/// Default: None (use trimmed clip duration, no looping) /// Default: None (use trimmed clip duration, no looping)
pub timeline_duration: Option<Beats>, pub timeline_duration: Option<Beats>,
/// Trim start: offset into the clip's internal content, in **seconds**. /// Trim start: offset into the clip's internal content.
/// - For audio: byte-offset into the audio file ///
/// - For video: seek position in the video file /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled
/// - For vector: time offset into the animation /// 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 /// 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) /// Default: None (use full clip duration)
pub trim_end: Option<f64>, pub trim_end: Option<ContentTime>,
/// Playback speed multiplier /// Playback speed multiplier
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
@ -741,6 +848,35 @@ pub struct ClipInstance {
/// Default: None (no pre-loop) /// Default: None (no pre-loop)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_before: Option<Beats>, pub loop_before: Option<Beats>,
/// 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<AudioTake>,
/// 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<usize>,
/// 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<Beats>,
} }
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
@ -794,11 +930,14 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
takes: Vec::new(),
active_take: None,
recorded_loop_beats: None,
} }
} }
@ -812,11 +951,14 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, 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) /// Set trimming (start and end time within the clip's internal content)
pub fn with_trimming(mut self, trim_start: f64, trim_end: Option<f64>) -> Self { pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option<ContentTime>) -> Self {
self.trim_start = trim_start; self.trim_start = trim_start;
self.trim_end = trim_end; self.trim_end = trim_end;
self self
@ -876,24 +1018,89 @@ impl ClipInstance {
self 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<usize> {
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<u32> {
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. /// Used for internal looping calculations.
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); let end = self.trim_end.map_or(clip_content.native(), |t| t.raw());
Seconds((end - self.trim_start).max(0.0)) 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**. /// How long this instance appears on the timeline, in **beats**.
/// ///
/// If `timeline_duration` is set, returns that (enabling content looping). /// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip
/// Otherwise converts the content window from seconds to beats using the tempo map. /// occupies its content window — converted to beats *in the clip's own domain*:
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { ///
/// - 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 { if let Some(td) = self.timeline_duration {
return td; return td;
} }
let window = self.content_window_secs(clip_duration_secs); match self.content_window(clip_content) {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start); ClipDuration::Beats(b) => b,
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start 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**. /// 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**. /// 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 { 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_duration_secs, tempo_map) 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**). /// 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`. /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { /// 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<Seconds> {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start); 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); let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
if time < start_secs || time >= end_secs { if time < start_secs || time >= end_secs {
return None; 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_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 { if content_window == Seconds::ZERO {
return Some(Seconds(self.trim_start)); return Some(trim_start_secs);
} }
let looped = if content_time > content_window { let looped = if content_time > content_window {
@ -931,19 +1143,19 @@ impl ClipInstance {
content_time content_time
}; };
Some(Seconds(self.trim_start) + looped) Some(trim_start_secs + looped)
} }
/// Alias for `remap_time_secs`. /// Alias for `remap_time_secs`.
#[inline] #[inline]
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
self.remap_time_secs(time, clip_duration_secs, tempo_map) self.remap_time_secs(time, clip_content, tempo_map)
} }
/// Alias for `effective_duration_beats`. /// Alias for `effective_duration_beats`.
#[inline] #[inline]
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
self.effective_duration_beats(clip_duration_secs, tempo_map) self.effective_duration_beats(clip_content, tempo_map)
} }
/// Convert to affine transform /// Convert to affine transform
@ -1020,7 +1232,7 @@ mod tests {
assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.clip_id, clip_id);
assert_eq!(instance.opacity, 1.0); assert_eq!(instance.opacity, 1.0);
assert_eq!(instance.timeline_start, Beats::ZERO); 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.trim_end, None);
assert_eq!(instance.playback_speed, 1.0); assert_eq!(instance.playback_speed, 1.0);
assert_eq!(instance.gain, 1.0); assert_eq!(instance.gain, 1.0);
@ -1030,27 +1242,52 @@ mod tests {
fn test_clip_instance_trimming() { fn test_clip_instance_trimming() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) 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_start, ContentTime(2.0));
assert_eq!(instance.trim_end, Some(8.0)); assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the // At 60 BPM the tempo map is identity (1 beat == 1 second), so the
// beats-domain effective duration equals the seconds content window. // beats-domain effective duration equals the seconds content window.
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); 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] #[test]
fn test_clip_instance_no_end_trim() { fn test_clip_instance_no_end_trim() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) 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); assert_eq!(instance.trim_end, None);
// At 60 BPM the tempo map is identity (1 beat == 1 second). // At 60 BPM the tempo map is identity (1 beat == 1 second).
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); 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] #[test]
@ -1070,4 +1307,109 @@ mod tests {
assert_eq!(instance.playback_speed, 2.0); assert_eq!(instance.playback_speed, 2.0);
assert_eq!(instance.gain, 0.8); 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);
}
} }

View File

@ -201,6 +201,17 @@ pub struct Document {
#[serde(default)] #[serde(default)]
pub time_signature: TimeSignature, 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). /// Master track (master bus + tempo automation lane).
/// Stored separately from the root layer tree; shown in timeline when /// Stored separately from the root layer tree; shown in timeline when
/// `show_master_track` is enabled in the editor state. /// `show_master_track` is enabled in the editor state.
@ -297,6 +308,8 @@ impl Default for Document {
height: 1080.0, height: 1080.0,
framerate: 60.0, framerate: 60.0,
time_signature: TimeSignature::default(), time_signature: TimeSignature::default(),
cycle_region: None,
cycle_enabled: false,
master_layer: { master_layer: {
let mut ml = GroupLayer::new_master(120.0); let mut ml = GroupLayer::new_master(120.0);
ml.layer.id = uuid::Uuid::new_v4(); 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 { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
instance.timeline_start + timeline_duration instance.timeline_start + timeline_duration
} else { } else {
let trim_end = instance.trim_end.unwrap_or(clip_duration); // `clip_duration` arrives as seconds (the recursive helper's signature), so this
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); // 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); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) 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. /// 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)> { pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> {
self.audio_clips.iter() 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)) .map(|(&id, c)| (id, c))
} }
/// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID. /// 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)> { pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> {
self.audio_clips.iter() 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)) .map(|(&id, c)| (id, c))
} }
@ -898,6 +916,81 @@ impl Document {
/// Searches through all clip libraries to find the clip and return its duration. /// Searches through all clip libraries to find the clip and return its duration.
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
/// have infinite internal duration. /// 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<crate::clip::ClipDuration> {
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<Uuid> {
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<crate::clip::ClipDuration> {
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<Seconds> { pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
if let Some(clip) = self.vector_clips.get(clip_id) { if let Some(clip) = self.vector_clips.get(clip_id) {
if clip.is_group { if clip.is_group {
@ -951,9 +1044,9 @@ impl Document {
}; };
let instance = instances.iter().find(|inst| &inst.id == instance_id)?; let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
let clip_duration = self.get_clip_duration(&instance.clip_id)?; // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
// End position on the timeline, in beats (convert the seconds content window via tempo map). let clip_content = self.clip_trim_duration(&instance.clip_id)?;
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) 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 /// Check if a time range overlaps with any existing clip on the layer
@ -993,13 +1086,14 @@ impl Document {
continue; continue;
} }
// Calculate instance extent (accounting for loop_before) // Calculate instance extent (accounting for loop_before). Content duration in the clip's
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { // own domain, so the trims resolve correctly for MIDI.
let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else {
continue; continue;
}; };
let instance_start = instance.effective_start(); 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 // Check overlap: start_a < end_b AND start_b < end_a
if start_time < instance_end && instance_start < end_time { if start_time < instance_end && instance_start < end_time {
@ -1056,7 +1150,7 @@ impl Document {
continue; 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_start = instance.effective_start();
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
occupied_ranges.push((inst_start, inst_end, instance.id)); occupied_ranges.push((inst_start, inst_end, instance.id));
@ -1152,7 +1246,7 @@ impl Document {
if group_ids.contains(&inst.id) { if group_ids.contains(&inst.id) {
continue; 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 start = inst.effective_start();
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
non_group.push((start, end)); non_group.push((start, end));
@ -1226,9 +1320,8 @@ impl Document {
} }
// Calculate other clip's extent (accounting for loop_before) // 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()); 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 this clip is to the left and closer than current nearest
if other_end <= current_timeline_start && other_end > nearest_end { if other_end <= current_timeline_start && other_end > nearest_end {
@ -1326,7 +1419,7 @@ impl Document {
continue; 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()); let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
if other_end <= current_effective_start && other_end > nearest_end { if other_end <= current_effective_start && other_end > nearest_end {

View File

@ -152,7 +152,12 @@ impl EffectLayer {
self.clip_instances self.clip_instances
.iter() .iter()
.filter(|e| { .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 time_beats >= e.timeline_start && time_beats < end
}) })
.collect() .collect()

View File

@ -3,7 +3,7 @@
//! Provides functions for testing if points or rectangles intersect with //! Provides functions for testing if points or rectangles intersect with
//! vector graph elements and clip instances, taking into account transform hierarchies. //! 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::vector_graph::{VertexId, EdgeId, FillId};
use crate::layer::VectorLayer; use crate::layer::VectorLayer;
use crate::shape::Shape; use crate::shape::Shape;
@ -260,7 +260,10 @@ pub fn hit_test_clip_instances(
for clip_instance in clip_instances.iter().rev() { for clip_instance in clip_instances.iter().rev() {
// Check time bounds: skip clip instances not active at this time // Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. // 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 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)); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { 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) // 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 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) { let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time) 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 { for clip_instance in clip_instances {
// Check time bounds: skip clip instances not active at this time // Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. // 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 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)); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { 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 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) { let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time) vector_clip.calculate_content_bounds(document, clip_time)

View File

@ -9,7 +9,7 @@
//! The compositing mode enables proper per-layer opacity, blend modes, and effects. //! The compositing mode enables proper per-layer opacity, blend modes, and effects.
use crate::animation::TransformProperty; use crate::animation::TransformProperty;
use crate::clip::{ClipInstance, ImageAsset}; use crate::clip::{ClipDuration, ClipInstance, ImageAsset};
use crate::document::Document; use crate::document::Document;
use daw_backend::Seconds; use daw_backend::Seconds;
use crate::gpu::BlendMode; use crate::gpu::BlendMode;
@ -568,7 +568,7 @@ pub fn render_layer_isolated(
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
for clip_instance in &video_layer.clip_instances { for clip_instance in &video_layer.clip_instances {
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; 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 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 }; 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 0.0
} else { } 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 { let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else {
return; // Clip instance not active at this time return; // Clip instance not active at this time
}; };
@ -1174,7 +1177,7 @@ fn render_video_layer(
// Remap timeline time to clip's internal time // Remap timeline time to clip's internal time
let tempo_map = document.tempo_map(); 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 continue; // Clip instance not active at this time
}; };
let clip_time = clip_time.seconds_to_f64(); let clip_time = clip_time.seconds_to_f64();
@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu(
if time < start_secs || time >= end { return; } if time < start_secs || time >= end { return; }
0.0 0.0
} else { } 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 }; let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
t.seconds_to_f64() t.seconds_to_f64()
}; };

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.9-alpha" version = "1.0.10-alpha"
edition = "2021" edition = "2021"
description = "Multimedia editor for audio, video and 2D animation" description = "Multimedia editor for audio, video and 2D animation"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@ -35,6 +35,18 @@ pub struct AppConfig {
#[serde(default = "defaults::audio_buffer_size")] #[serde(default = "defaults::audio_buffer_size")]
pub audio_buffer_size: u32, 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 /// Reopen last session on startup
#[serde(default = "defaults::reopen_last_session")] #[serde(default = "defaults::reopen_last_session")]
pub reopen_last_session: bool, pub reopen_last_session: bool,
@ -78,6 +90,47 @@ pub struct AppConfig {
/// Last-used audio-export "Album" tag, remembered so it prefills next time. /// Last-used audio-export "Album" tag, remembered so it prefills next time.
#[serde(default)] #[serde(default)]
pub last_audio_album: String, 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 { impl Default for AppConfig {
@ -90,6 +143,7 @@ impl Default for AppConfig {
file_height: defaults::file_height(), file_height: defaults::file_height(),
scroll_speed: defaults::scroll_speed(), scroll_speed: defaults::scroll_speed(),
audio_buffer_size: defaults::audio_buffer_size(), audio_buffer_size: defaults::audio_buffer_size(),
cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(),
reopen_last_session: defaults::reopen_last_session(), reopen_last_session: defaults::reopen_last_session(),
restore_layout_from_file: defaults::restore_layout_from_file(), restore_layout_from_file: defaults::restore_layout_from_file(),
debug: defaults::debug(), debug: defaults::debug(),
@ -100,6 +154,8 @@ impl Default for AppConfig {
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
last_audio_artist: String::new(), last_audio_artist: String::new(),
last_audio_album: 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) /// Default values for preferences (matches JS implementation)
mod defaults { 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 bpm() -> u32 { 120 }
pub fn framerate() -> u32 { 24 } pub fn framerate() -> u32 { 24 }
pub fn file_width() -> u32 { 800 } pub fn file_width() -> u32 { 800 }
pub fn file_height() -> u32 { 600 } pub fn file_height() -> u32 { 600 }
pub fn scroll_speed() -> f64 { 1.0 } pub fn scroll_speed() -> f64 { 1.0 }
pub fn audio_buffer_size() -> u32 { 256 } pub fn audio_buffer_size() -> u32 { 256 }
pub fn cycle_midi_separate_takes() -> bool { false }
pub fn reopen_last_session() -> bool { false } pub fn reopen_last_session() -> bool { false }
pub fn restore_layout_from_file() -> bool { true } pub fn restore_layout_from_file() -> bool { true }
pub fn debug() -> bool { false } pub fn debug() -> bool { false }

View File

@ -1,205 +1,380 @@
//! Custom cursor system //! Custom cursor system
//! //!
//! Provides SVG-based custom cursors beyond egui's built-in system cursors. //! Tool cursors are drawn from the bundled Lucide icon font. A tool falls into one of three
//! When a custom cursor is active, the system cursor is hidden and the SVG //! kinds, because "draw the tool's icon at the pointer" only works for icons that actually have
//! cursor image is drawn at the pointer position. //! 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 eframe::egui;
use egui::TextureHandle;
use lightningbeam_core::tool::Tool; use lightningbeam_core::tool::Tool;
use std::collections::HashMap; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CustomCursor { pub enum CustomCursor {
// Stage tool cursors Tool(Tool),
Select, /// Timeline: dragging the end of the loop region.
Draw,
Transform,
Rectangle,
Ellipse,
PaintBucket,
Eyedropper,
Line,
Polygon,
BezierEdit,
Text,
// Timeline cursors
LoopExtend, LoopExtend,
} }
impl CustomCursor { impl CustomCursor {
/// Convert a Tool enum to the corresponding custom cursor
pub fn from_tool(tool: Tool) -> Self { 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 { match tool {
Tool::Select => CustomCursor::Select, // The OS knows these better than we do.
Tool::Draw => CustomCursor::Draw, Tool::Select => CursorKind::System(Sys::Default),
Tool::Transform => CustomCursor::Transform, Tool::Transform => CursorKind::System(Sys::Move),
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,
}
}
/// Hotspot offset — the "click point" relative to the image top-left // Glyphs with a real tip: the icon is the cursor.
pub fn hotspot(&self) -> egui::Vec2 { Tool::Draw => precise(icons::BRUSH, TIP_BOTTOM_LEFT),
match self { Tool::Pencil => precise(icons::PENCIL, TIP_BOTTOM_LEFT),
// Select cursor: pointer tip at top-left Tool::Pen => precise(icons::PEN_TOOL, TIP_BOTTOM_LEFT),
CustomCursor::Select => egui::vec2(3.0, 1.0), Tool::Eyedropper => precise(icons::PIPETTE, TIP_BOTTOM_LEFT),
// Drawing tools: tip at bottom-left Tool::PaintBucket => precise(icons::PAINT_BUCKET, TIP_BOTTOM_LEFT),
CustomCursor::Draw => egui::vec2(1.0, 23.0), Tool::Text => precise(icons::TEXT_CURSOR, TIP_CENTER),
// 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),
}
}
/// Get the embedded SVG data for this cursor // Shape tools: crosshair for precision, glyph to say which shape.
fn svg_data(&self) -> &'static [u8] { Tool::Rectangle => badge(icons::SQUARE),
match self { Tool::Ellipse => badge(icons::CIRCLE),
CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"), Tool::Line => badge(icons::MINUS),
CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"), Tool::Polygon => badge(icons::HEXAGON),
CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"), Tool::CustomShape => badge(icons::SHAPES),
CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"),
CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"), // Selection tools.
CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"), Tool::RegionSelect => badge(icons::SQUARE_DASHED),
CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"), Tool::SelectEllipse => badge(icons::CIRCLE_DASHED),
CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"), Tool::SelectLasso => badge(icons::LASSO_SELECT),
CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"), Tool::MagicWand => badge(icons::WAND_SPARKLES),
CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"), Tool::QuickSelect => badge(icons::BRUSH),
CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"), Tool::Split => badge(icons::SCISSORS),
CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"),
// 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 { pub struct CursorCache {
/// Black cursor for the main image images: HashMap<(CustomCursor, u32), Option<egui::CursorImage>>,
textures: HashMap<CustomCursor, TextureHandle>, next_id: u64,
/// White cursor for the outline
outline_textures: HashMap<CustomCursor, TextureHandle>,
} }
impl CursorCache { impl CursorCache {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self::default()
textures: HashMap::new(), }
outline_textures: HashMap::new(),
fn get_or_build(
&mut self,
ctx: &egui::Context,
cursor: CustomCursor,
ppp: f32,
) -> Option<egui::CursorImage> {
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 let id = self.next_id;
pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { self.next_id += 1;
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")
})
}
/// Get or lazily load the white (outline) cursor texture let built = build_cursor_image(ctx, cursor.kind(), ppp, id);
pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { self.images.insert(key, built.clone());
self.outline_textures.entry(cursor).or_insert_with(|| { built
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")
})
} }
} }
const CURSOR_SIZE: u32 = 24; /// An alpha coverage mask being composed, in physical pixels.
const OUTLINE_OFFSET: f32 = 1.0; struct Mask {
w: usize,
h: usize,
a: Vec<f32>,
}
/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg) impl Mask {
fn rasterize_cursor_svg( fn new(w: usize, h: usize) -> Self {
svg_data: &[u8], Self { w, h, a: vec![0.0; w * h] }
name: &str, }
render_size: u32,
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, ctx: &egui::Context,
) -> Option<TextureHandle> { kind: CursorKind,
let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?; ppp: f32,
let pixmap_size = tree.size().to_int_size(); id: u64,
let scale_x = render_size as f32 / pixmap_size.width() as f32; ) -> Option<egui::CursorImage> {
let scale_y = render_size as f32 / pixmap_size.height() as f32; let (glyph, glyph_origin, hotspot, crosshair) = match kind {
let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?; CursorKind::System(_) => return None,
resvg::render( CursorKind::Precise { glyph, hotspot } => {
&tree, let scale = GLYPH_SIZE / ICON_GRID;
resvg::tiny_skia::Transform::from_scale(scale_x, scale_y), (
&mut pixmap.as_mut(), glyph,
); egui::vec2(MARGIN, MARGIN),
let rgba_data = pixmap.data().to_vec(); egui::pos2(MARGIN, MARGIN) + hotspot * scale,
let color_image = egui::ColorImage::from_rgba_unmultiplied( false,
[render_size as usize, render_size as usize], )
&rgba_data, }
); CursorKind::Badge { glyph } => {
Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR)) // 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 --- // --- Per-frame cursor slot using egui context data ---
/// Key for storing the active custom cursor in egui's per-frame data
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct ActiveCustomCursor(CustomCursor); struct ActiveCustomCursor(CustomCursor);
/// Set the custom cursor for this frame. Call from any pane during rendering. /// 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) { pub fn set(ctx: &egui::Context, cursor: CustomCursor) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor))); 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) { pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) {
// Take and remove the cursor so it doesn't persist to the next frame // Take and remove the cursor so it doesn't persist to the next frame
let id = egui::Id::new("active_custom_cursor"); let id = egui::Id::new("active_custom_cursor");
@ -209,43 +384,24 @@ pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) {
val val
}); });
if let Some(ActiveCustomCursor(cursor)) = cursor { let Some(ActiveCustomCursor(cursor)) = cursor else { return };
// 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;
}
// Hide the system cursor // If a widget explicitly asked for a system cursor (resize handles, text inputs, ...), let it
ctx.set_cursor_icon(egui::CursorIcon::None); // 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()) { match cursor.kind() {
let hotspot = cursor.hotspot(); CursorKind::System(icon) => ctx.set_cursor_icon(icon),
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 ppp = ctx.pixels_per_point();
let painter = ctx.debug_painter(); if let Some(image) = cache.get_or_build(ctx, cursor, ppp) {
ctx.set_cursor_image(Some(image));
// Draw white outline: render white version offset in 8 directions } else {
let outline_tex = cache.get_or_load_outline(cursor, ctx); // Rasterization failed — better a crosshair than an invisible cursor.
let outline_id = outline_tex.id(); ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
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);
} }
// 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);
} }
} }
} }

View File

@ -1064,7 +1064,12 @@ fn composite_document_to_hdr(
} }
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let effect_end_beats = effect_instance.timeline_start 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( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),

View File

@ -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<lightningbeam_core::action::BackendClipInstanceId>,
loop_start: Beats,
loop_len: Beats,
takes: Vec<lightningbeam_core::clip::AudioTake>,
}
struct EditorApp { struct EditorApp {
layouts: Vec<LayoutDefinition>, layouts: Vec<LayoutDefinition>,
current_layout_index: usize, current_layout_index: usize,
@ -1136,7 +1153,6 @@ struct EditorApp {
selected_tool: Tool, // Currently selected drawing tool selected_tool: Tool, // Currently selected drawing tool
fill_color: egui::Color32, // Fill color for drawing fill_color: egui::Color32, // Fill color for drawing
stroke_color: egui::Color32, // Stroke 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<NodePath, PaneInstance>, // Pane instances per path pane_instances: HashMap<NodePath, PaneInstance>, // Pane instances per path
menu_system: Option<MenuSystem>, // Native menu system for event checking menu_system: Option<MenuSystem>, // Native menu system for event checking
pending_view_action: Option<MenuAction>, // Pending view action (zoom, recenter) to be handled by hovered pane pending_view_action: Option<MenuAction>, // 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 metronome_enabled: bool, // Whether metronome clicks during recording
count_in_enabled: bool, // Whether count-in fires before recording count_in_enabled: bool, // Whether count-in fires before recording
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording recording_clips: HashMap<Uuid, u32>, // 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<PendingTakeAppend>,
recording_start_time: f64, // Playback time when recording started recording_start_time: f64, // Playback time when recording started
recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips) recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips)
// Asset drag-and-drop state // Asset drag-and-drop state
@ -1351,9 +1371,9 @@ struct EditorApp {
effect_thumbnail_generator: Option<EffectThumbnailGenerator>, effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
/// Custom cursor cache for SVG cursors /// Custom cursor cache for SVG cursors
cursor_cache: custom_cursor::CursorCache,
/// Cross-platform graphics tablet (pen/stylus) input state /// Cross-platform graphics tablet (pen/stylus) input state
tablet: tablet::TabletInput, tablet: tablet::TabletInput,
cursor_cache: custom_cursor::CursorCache,
/// Debug test mode (F5) — input recording, panic capture & visual replay /// Debug test mode (F5) — input recording, panic capture & visual replay
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
test_mode: test_mode::TestModeState, test_mode: test_mode::TestModeState,
@ -1410,6 +1430,10 @@ impl EditorApp {
// Load application config // Load application config
let mut config = AppConfig::load(); 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 // 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 // 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. // Recent or get auto-reopened below.
@ -1532,7 +1556,6 @@ impl EditorApp {
selected_tool: Tool::Select, // Default tool selected_tool: Tool::Select, // Default tool
fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill
stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke 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 pane_instances: HashMap::new(), // Initialize empty, panes created on-demand
menu_system, menu_system,
pending_view_action: None, pending_view_action: None,
@ -1581,6 +1604,7 @@ impl EditorApp {
metronome_enabled: false, // Metronome off by default metronome_enabled: false, // Metronome off by default
count_in_enabled: false, // Count-in off by default count_in_enabled: false, // Count-in off by default
recording_clips: HashMap::new(), // No active recording clips 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_start_time: 0.0, // Will be set when recording starts
recording_layer_ids: Vec::new(), // Will be populated when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts
dragging_asset: None, // No asset being dragged initially 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), test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying, pending_geometry),
// Debug overlay (F3) // Debug overlay (F3)
cursor_cache: custom_cursor::CursorCache::new(),
tablet: tablet::TabletInput::new(cc), tablet: tablet::TabletInput::new(cc),
cursor_cache: custom_cursor::CursorCache::new(),
debug_overlay_visible: false, debug_overlay_visible: false,
debug_stats_collector: debug_overlay::DebugStatsCollector::new(), debug_stats_collector: debug_overlay::DebugStatsCollector::new(),
gpu_info, gpu_info,
@ -2036,9 +2060,113 @@ impl EditorApp {
/// 2. For MIDI: Loads the default instrument /// 2. For MIDI: Loads the default instrument
/// 3. Stores the bidirectional mapping /// 3. Stores the bidirectional mapping
/// 4. Syncs any existing clips on the layer /// 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<lightningbeam_core::action::BackendClipInstanceId>,
loop_start: Beats,
loop_len: Beats,
takes: Vec<lightningbeam_core::clip::AudioTake>,
) -> 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) { fn sync_audio_layers_to_backend(&mut self) {
use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; 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. // Ensure the master layer has a backend group track.
let master_layer_id = self.action_executor.document().master_layer.layer.id; let master_layer_id = self.action_executor.document().master_layer.layer.id;
if !self.layer_to_track_map.contains_key(&master_layer_id) { if !self.layer_to_track_map.contains_key(&master_layer_id) {
@ -2371,7 +2499,7 @@ impl EditorApp {
) -> Vec<uuid::Uuid> { ) -> Vec<uuid::Uuid> {
let mut result = Vec::new(); let mut result = Vec::new();
for instance in clip_instances { 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 effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
@ -3260,7 +3388,9 @@ impl EditorApp {
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| { let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
let mut duplicate = original.clone(); let mut duplicate = original.clone();
duplicate.id = uuid::Uuid::new_v4(); 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()); let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
duplicate.timeline_start = original.timeline_start + effective_duration; duplicate.timeline_start = original.timeline_start + effective_duration;
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { 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). // matches the video clip exactly).
let (_dur, sample_rate, channels) = controller let (_dur, sample_rate, channels) = controller
.get_pool_file_info(pool_index) .get_pool_file_info(pool_index)
.unwrap_or((video_duration, 0, 0)); .unwrap_or((Seconds(video_duration), 0, 0));
drop(controller); drop(controller);
let audio_clip_name = format!("{} (Audio)", video_name); let audio_clip_name = format!("{} (Audio)", video_name);
@ -6368,7 +6498,9 @@ impl eframe::App for EditorApp {
use daw_backend::AudioEvent; use daw_backend::AudioEvent;
match event { match event {
AudioEvent::PlaybackPosition(time) => { 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 => { AudioEvent::PlaybackStopped => {
self.is_playing = false; 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) // Then update the clip duration (mutable borrow)
if let Some(doc_clip_id) = doc_clip_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) { 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(); 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<lightningbeam_core::clip::AudioTake> = 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) => { AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => {
eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index); 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(); let mut controller = controller_arc.lock().unwrap();
match controller.get_pool_file_info(pool_index) { match controller.get_pool_file_info(pool_index) {
Ok((dur, _, _)) => { Ok((dur, _, _)) => {
let dur = dur.seconds_to_f64();
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
self.audio_duration_cache.insert(pool_index, dur); self.audio_duration_cache.insert(pool_index, dur);
dur dur
@ -6543,7 +6847,7 @@ impl eframe::App for EditorApp {
None 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() { 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) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
clip.set_content_duration(ClipDuration::Beats(duration)); clip.set_content_duration(ClipDuration::Beats(duration));
} }
@ -6687,6 +6995,144 @@ impl eframe::App for EditorApp {
} }
ctx.request_repaint(); 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<lightningbeam_core::clip::AudioTake> = 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) => { AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => {
println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes", println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes",
track_id, clip_id, note_count); track_id, clip_id, note_count);
@ -6705,7 +7151,7 @@ impl eframe::App for EditorApp {
.map(|(id, _)| id); .map(|(id, _)| id);
if let Some(doc_clip_id) = doc_clip_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) { 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); 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) // 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| { 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 { if result.buffer_size_changed {
println!("⚠️ Audio buffer size will be applied on next app restart"); 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 // Apply new keybindings if changed
if let Some(new_keymap) = result.new_keymap { if let Some(new_keymap) = result.new_keymap {
self.keymap = new_keymap; self.keymap = new_keymap;
@ -7949,7 +8414,6 @@ impl EditorApp {
selected_tool: &mut self.selected_tool, selected_tool: &mut self.selected_tool,
fill_color: &mut self.fill_color, fill_color: &mut self.fill_color,
stroke_color: &mut self.stroke_color, stroke_color: &mut self.stroke_color,
active_color_mode: &mut self.active_color_mode,
pending_view_action: &mut self.pending_view_action, pending_view_action: &mut self.pending_view_action,
fallback_pane_priority: &mut scratch.fallback_pane_priority, fallback_pane_priority: &mut scratch.fallback_pane_priority,
pending_handlers: &mut scratch.pending_handlers, pending_handlers: &mut scratch.pending_handlers,

View File

@ -42,6 +42,8 @@ pub const GRIP_HORIZONTAL: &str = "\u{e0ea}";
pub const CHEVRONS_UP: &str = "\u{e074}"; pub const CHEVRONS_UP: &str = "\u{e074}";
pub const PLAY: &str = "\u{e13c}"; pub const PLAY: &str = "\u{e13c}";
pub const PAUSE: &str = "\u{e12e}"; 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 SETTINGS: &str = "\u{e154}";
pub const SEARCH: &str = "\u{e151}"; pub const SEARCH: &str = "\u{e151}";
pub const PLUS: &str = "\u{e13d}"; 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 COPY: &str = "\u{e09e}";
pub const LAYERS: &str = "\u{e529}"; pub const LAYERS: &str = "\u{e529}";
pub const ELLIPSIS: &str = "\u{e0b6}"; 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,
})
}

View File

@ -7,7 +7,7 @@
//! - Image Assets (static images) //! - Image Assets (static images)
use eframe::egui; use eframe::egui;
use lightningbeam_core::clip::{AudioClipType, VectorClip}; use lightningbeam_core::clip::{ResolvedContent, VectorClip};
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -918,11 +918,11 @@ impl AssetLibraryPane {
continue; continue;
} }
let (extra_info, drag_clip_type) = match &clip.clip_type { let (extra_info, drag_clip_type) = match &clip.resolve() {
AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), ResolvedContent::Audio { .. } => ("Sampled".to_string(), DragClipType::AudioSampled),
AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), ResolvedContent::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi),
AudioClipType::Recording => { ResolvedContent::Recording => {
// Skip recording-in-progress clips from asset library // Skip recording-in-progress clips (and empty take folders) from asset library
continue; continue;
} }
}; };
@ -1118,15 +1118,15 @@ impl AssetLibraryPane {
for (id, clip) in &document.audio_clips { for (id, clip) in &document.audio_clips {
if !linked_audio_ids.contains(id) && clip.folder_id == current_folder { if !linked_audio_ids.contains(id) && clip.folder_id == current_folder {
let (extra_info, drag_clip_type) = match &clip.clip_type { let (extra_info, drag_clip_type) = match &clip.resolve() {
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
("Sampled".to_string(), DragClipType::AudioSampled) ("Sampled".to_string(), DragClipType::AudioSampled)
} }
AudioClipType::Midi { .. } => { ResolvedContent::Midi { .. } => {
("MIDI".to_string(), DragClipType::AudioMidi) ("MIDI".to_string(), DragClipType::AudioMidi)
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Skip recording-in-progress clips // Skip recording-in-progress clips (and empty take folders)
continue; continue;
} }
}; };
@ -1765,7 +1765,7 @@ impl AssetLibraryPane {
let prefetched_waveform: Option<Vec<(f32, f32)>> = let prefetched_waveform: Option<Vec<(f32, f32)>> =
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
if let Some(clip) = document.audio_clips.get(&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) shared.raw_audio_cache.get(audio_pool_index)
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize))
} else { } else {
@ -1790,8 +1790,8 @@ impl AssetLibraryPane {
AssetCategory::Audio => { AssetCategory::Audio => {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(ref peaks) = prefetched_waveform { if let Some(ref peaks) = prefetched_waveform {
Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) Some(generate_waveform_thumbnail(peaks, bg_color, wave_color))
@ -1799,7 +1799,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) 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); let note_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { 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)) 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)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
// Recording in progress - show placeholder // Recording in progress - show placeholder
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
@ -2344,8 +2344,8 @@ impl AssetLibraryPane {
AssetCategory::Audio => { AssetCategory::Audio => {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { audio_pool_index } => { ResolvedContent::Audio { audio_pool_index } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index) let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
@ -2355,7 +2355,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) 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); let note_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { 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)) 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)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
@ -2481,8 +2481,8 @@ impl AssetLibraryPane {
AssetCategory::Audio => { AssetCategory::Audio => {
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { audio_pool_index } => { ResolvedContent::Audio { audio_pool_index } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index) let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
@ -2492,7 +2492,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) 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); let note_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { 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)) 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)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
@ -2802,7 +2802,7 @@ impl AssetLibraryPane {
let prefetched_waveform: Option<Vec<(f32, f32)>> = let prefetched_waveform: Option<Vec<(f32, f32)>> =
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
if let Some(clip) = document.audio_clips.get(&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<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index) let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
if waveform.is_some() { if waveform.is_some() {
@ -2842,8 +2842,8 @@ impl AssetLibraryPane {
// Check if it's sampled or MIDI // Check if it's sampled or MIDI
if let Some(clip) = document.audio_clips.get(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) {
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
match &clip.clip_type { match &clip.resolve() {
AudioClipType::Sampled { .. } => { ResolvedContent::Audio { .. } => {
let wave_color = egui::Color32::from_rgb(100, 200, 100); let wave_color = egui::Color32::from_rgb(100, 200, 100);
if let Some(ref peaks) = prefetched_waveform { if let Some(ref peaks) = prefetched_waveform {
println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id); 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)) 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 bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
let note_color = egui::Color32::from_rgb(100, 200, 100); let note_color = egui::Color32::from_rgb(100, 200, 100);
@ -2863,7 +2863,7 @@ impl AssetLibraryPane {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
} }
} }
AudioClipType::Recording => { ResolvedContent::Recording => {
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) 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); println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms);
let mut invalidated_any = false; let mut invalidated_any = false;
for (asset_id, clip) in &document_arc.audio_clips { 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) { if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) {
println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index); println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index);
self.thumbnail_cache.invalidate(asset_id); self.thumbnail_cache.invalidate(asset_id);

View File

@ -160,27 +160,27 @@ pub fn gradient_stop_editor(
delete_idx = Some(i); delete_idx = Some(i);
} }
// Color picker popup — opens on click, closes on click-outside. // Color picker popup — opens on click, closes on a press outside it.
egui::containers::Popup::from_toggle_button_response(&resp) let stop_color = gradient.stops[i].color;
.show(|ui| { let mut c32 = Color32::from_rgba_unmultiplied(
ui.spacing_mut().slider_width = 200.0; stop_color.r, stop_color.g, stop_color.b, stop_color.a,
let stop = &mut gradient.stops[i]; );
let mut c32 = Color32::from_rgba_unmultiplied( if crate::widgets::color_swatch::color_picker_popup(
stop.color.r, stop.color.g, stop.color.b, stop.color.a, ui,
); resp.id.with("stop_color_popup"),
if egui::color_picker::color_picker_color32( &resp,
ui, &mut c32, egui::color_picker::Alpha::OnlyBlend, &mut c32,
) { resp.rect,
// Color32 stores premultiplied RGB; unmultiply before storing ) {
// as straight-alpha ShapeColor to avoid darkening on round-trip. // Color32 stores premultiplied RGB; unmultiply before storing as straight-alpha
let [pr, pg, pb, a] = c32.to_array(); // ShapeColor to avoid darkening on round-trip.
let unpm = |c: u8| -> u8 { let [pr, pg, pb, a] = c32.to_array();
if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } 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; gradient.stops[i].color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a);
} changed = true;
}); }
} }
// Apply drag to whichever stop selected_stop points at. // Apply drag to whichever stop selected_stop points at.

View File

@ -28,14 +28,9 @@ pub struct InfopanelPane {
tool_section_open: bool, tool_section_open: bool,
/// Whether the shape properties section is expanded /// Whether the shape properties section is expanded
shape_section_open: bool, shape_section_open: bool,
/// Index of the selected paint brush preset (None = custom / unset) /// Whether each tool's brush picker is expanded. The *selected* preset lives on the tool's
selected_brush_preset: Option<usize>, /// `BrushSlot`, since it's part of the document-independent tool state, not panel chrome.
/// Whether the paint brush picker is expanded brush_picker_expanded: std::collections::HashMap<crate::tools::BrushKind, bool>,
brush_picker_expanded: bool,
/// Index of the selected eraser brush preset
selected_eraser_preset: Option<usize>,
/// Whether the eraser brush picker is expanded
eraser_picker_expanded: bool,
/// Cached preview textures, one per preset (populated lazily). /// Cached preview textures, one per preset (populated lazily).
brush_preview_textures: Vec<egui::TextureHandle>, brush_preview_textures: Vec<egui::TextureHandle>,
/// Selected stop index for gradient editor in shape section. /// 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 // 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. // is ready without a hitch by the time the user opens it.
lightningbeam_core::fonts::start_preload(); lightningbeam_core::fonts::start_preload();
let presets = bundled_brushes();
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
Self { Self {
tool_section_open: true, tool_section_open: true,
shape_section_open: true, shape_section_open: true,
selected_brush_preset: None, brush_picker_expanded: std::collections::HashMap::new(),
brush_picker_expanded: false,
selected_eraser_preset: default_eraser_idx,
eraser_picker_expanded: false,
brush_preview_textures: Vec::new(), brush_preview_textures: Vec::new(),
selected_shape_gradient_stop: None, selected_shape_gradient_stop: None,
selected_tool_gradient_stop: None, selected_tool_gradient_stop: None,
@ -251,7 +241,7 @@ impl InfopanelPane {
|| is_raster_select || is_raster_shape || matches!( || is_raster_select || is_raster_shape || matches!(
tool, tool,
Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect 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 { if !has_options {
@ -285,12 +275,11 @@ impl InfopanelPane {
return; 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 { if let Some(def) = raster_tool_def {
def.render_ui(ui, shared.raster_settings); def.render_ui(ui, shared.raster_settings);
if def.show_brush_preset_picker() { self.render_raster_tool_options(ui, shared, def);
self.render_raster_tool_options(ui, shared, def.is_eraser());
}
} }
match tool { match tool {
@ -344,7 +333,14 @@ impl InfopanelPane {
ui.checkbox(shared.fill_enabled, "Fill Shape"); 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 => { Tool::PaintBucket => {
render_color_slot_row(ui, &mut shared.raster_settings.fill_use_fg);
if active_is_raster { if active_is_raster {
use crate::tools::FillThresholdMode; use crate::tools::FillThresholdMode;
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -603,68 +599,66 @@ impl InfopanelPane {
}); });
} }
/// Render all options for a raster paint tool (brush picker + sliders). /// Render the shared controls every dab-painting tool has: the brush library, the FG/BG
/// `is_eraser` drives which shared state is read/written. /// 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( fn render_raster_tool_options(
&mut self, &mut self,
ui: &mut Ui, ui: &mut Ui,
shared: &mut SharedPaneState, 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); ui.add_space(2.0);
let rs = &mut shared.raster_settings; if def.uses_color() {
render_color_slot_row(ui, &mut shared.raster_settings.brush_mut(kind).use_fg);
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");
});
} }
macro_rules! field { let slot = shared.raster_settings.brush_mut(kind);
($eraser:ident, $brush:ident) => {
if is_eraser { &mut rs.$eraser } else { &mut rs.$brush }
}
}
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Size:"); 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.horizontal(|ui| {
ui.label("Opacity:"); ui.label(format!("{}:", def.strength_label()));
ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0) ui.add(egui::Slider::new(&mut slot.strength, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Hardness:"); 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))); .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Spacing:"); 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) .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 slot.settings.elliptical_dab_ratio > 1.001 {
if bs.elliptical_dab_ratio > 1.001 {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Angle:"); 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("°") .suffix("°")
.custom_formatter(|v, _| format!("{:.0}°", v))); .custom_formatter(|v, _| format!("{:.0}°", v)));
}); });
} }
} }
/// Render the brush preset thumbnail grid (collapsible). /// Render the brush preset thumbnail grid (collapsible) for one tool's brush slot.
/// `is_eraser` drives which picker state and which shared settings are updated. fn render_brush_preset_grid(
fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) { &mut self,
ui: &mut Ui,
shared: &mut SharedPaneState,
kind: crate::tools::BrushKind,
) {
let presets = bundled_brushes(); let presets = bundled_brushes();
if presets.is_empty() { return; } if presets.is_empty() { return; }
@ -690,8 +684,8 @@ impl InfopanelPane {
} }
// Read picker state into locals to avoid multiple &mut self borrows. // 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 expanded = self.brush_picker_expanded.get(&kind).copied().unwrap_or(false);
let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset }; let selected = shared.raster_settings.brush(kind).preset;
let gap = 3.0; let gap = 3.0;
let cols = 2usize; let cols = 2usize;
@ -762,25 +756,14 @@ impl InfopanelPane {
egui::FontId::proportional(9.5), egui::FontId::proportional(9.5),
if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) }); if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) });
if resp.clicked() { if resp.clicked() {
selected = Some(idx);
expanded = false; expanded = false;
let s = &preset.settings; shared.raster_settings.brush_mut(kind).apply_preset(idx, &preset.settings);
let rs = &mut shared.raster_settings; // If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
if is_eraser { // and manually picked a different brush, revert to the generic tool.
rs.eraser_opacity = s.opaque.clamp(0.0, 1.0); if kind == crate::tools::BrushKind::Paint
rs.eraser_hardness = s.hardness.clamp(0.0, 1.0); && matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush)
rs.eraser_spacing = s.dabs_per_radius; {
rs.active_eraser_settings = s.clone(); *shared.selected_tool = Tool::Draw;
} 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;
}
} }
} }
} }
@ -789,14 +772,9 @@ impl InfopanelPane {
} }
} }
// Write back picker state. // The selected preset lives on the slot itself (written by apply_preset above); only the
if is_eraser { // expand/collapse state is panel-local.
self.eraser_picker_expanded = expanded; self.brush_picker_expanded.insert(kind, expanded);
self.selected_eraser_preset = selected;
} else {
self.brush_picker_expanded = expanded;
self.selected_brush_preset = selected;
}
} }
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms) // 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())); 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) let clip_dur = document.clip_trim_duration(&ci.clip_id)
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); .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 = ci.total_duration(clip_dur, document.tempo_map());
let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur) 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(); - 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)); 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.horizontal(|ui| {
ui.label("Trim Start:"); 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" "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");
});
}

View File

@ -189,8 +189,6 @@ pub struct SharedPaneState<'a> {
pub selected_tool: &'a mut Tool, pub selected_tool: &'a mut Tool,
pub fill_color: &'a mut egui::Color32, pub fill_color: &'a mut egui::Color32,
pub stroke_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<crate::menu::MenuAction>, pub pending_view_action: &'a mut Option<crate::menu::MenuAction>,
/// Tracks the priority of the best fallback pane for view actions /// Tracks the priority of the best fallback pane for view actions
/// Lower number = higher priority. None = no fallback pane seen yet /// Lower number = higher priority. None = no fallback pane seen yet

View File

@ -465,9 +465,13 @@ impl PianoRollPane {
if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) {
for instance in &audio_layer.clip_instances { for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { // Resolve through the instance's active take, so a MIDI take folder edits
let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); // whichever take it's actually playing.
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); 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 // Get sample rate from raw_audio_cache
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { 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));
} }
} }
} }

View File

@ -6,7 +6,7 @@
use eframe::egui; use eframe::egui;
use daw_backend::Seconds; use daw_backend::Seconds;
use lightningbeam_core::action::Action; 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::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::layer::{AnyLayer, AudioLayer};
use lightningbeam_core::renderer::RenderedLayerType; 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 // For now, create a simple effect instance with default parameters
let tempo_map = self.ctx.document.tempo_map(); let tempo_map = self.ctx.document.tempo_map();
let effect_end_beats = effect_instance.timeline_start 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( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), 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 // Calculate clip bounds for preview
let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64(); 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) { 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) 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) { } 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() { 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) { 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). // 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 tempo_map = self.ctx.document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let instance_end = tempo_map.beats_to_seconds( let instance_end = tempo_map.beats_to_seconds(
@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
} }
// Calculate clip-local time // 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 // 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) { 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 // Find clip instance visible at playback time
let visible_clip = video_layer.clip_instances.iter().find(|inst| { 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 tempo_map = self.ctx.document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); 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(); 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, screen_pos: egui::Pos2,
shared: &mut SharedPaneState, 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) { 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; b.dabs_per_radius = spacing;
if matches!(blend_mode, RasterBlendMode::Smudge) { if matches!(blend_mode, RasterBlendMode::Smudge) {
b.dabs_per_actual_radius = 0.0; 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) { if matches!(blend_mode, RasterBlendMode::BlurSharpen) {
b.dabs_per_actual_radius = 0.0; 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] [1.0f32, 1.0, 1.0, 1.0]
} else { } 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 *shared.stroke_color
} else { } else {
*shared.fill_color *shared.fill_color
@ -6702,7 +6715,8 @@ impl StagePane {
b.dabs_per_actual_radius = 0.0; b.dabs_per_actual_radius = 0.0;
// strength controls how far behind the stroke to sample (smudge_dist multiplier). // 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. // 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) { if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::BlurSharpen) {
// Zero dabs_per_actual_radius so the spacing slider is the sole density control. // Zero dabs_per_actual_radius so the spacing slider is the sole density control.
@ -6711,10 +6725,14 @@ impl StagePane {
b 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] [1.0f32, 1.0, 1.0, 1.0]
} else { } 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 s2l = |v: u8| -> f32 {
let f = v as f32 / 255.0; let f = v as f32 / 255.0;
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } 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 lightningbeam_core::actions::PaintBucketAction;
use vello::kurbo::Point; use vello::kurbo::Point;
let click_point = Point::new(world_pos.x as f64, world_pos.y as f64); 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( let action = PaintBucketAction::new(
active_layer_id, active_layer_id,
*shared.playback_time, *shared.playback_time,
@ -7483,7 +7501,7 @@ impl StagePane {
return; 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 fill_color = [fill_egui.r(), fill_egui.g(), fill_egui.b(), fill_egui.a()];
let threshold = shared.raster_settings.fill_threshold; let threshold = shared.raster_settings.fill_threshold;
let softness = shared.raster_settings.fill_softness; 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) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Calculate clip-local time // Calculate clip-local time
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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 // Get dynamic clip bounds from content at current time
use vello::kurbo::Rect as KurboRect; 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) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
// Calculate clip-local time // Calculate clip-local time
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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 // 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) { 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(); let document = shared.action_executor.document();
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
video_layer.clip_instances.iter().find(|inst| { 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 tempo_map = document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); 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(); 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 // 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). // 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; use lightningbeam_core::tool::Tool;
// On a shape-tween in-between frame the active vector layer's geometry is an // 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; self.pan_offset.y += scroll_delta.y;
} }
// Handle panning with Alt+Drag // Middle-mouse drag pans, independent of the stage's own drag sense.
if alt_held && response.dragged() { let middle_down = ui.input(|i| i.pointer.middle_down());
// Alt+Click+Drag panning 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(last_pos) = self.last_pan_pos {
if let Some(current_pos) = response.interact_pointer_pos() { if let Some(current_pos) = response.interact_pointer_pos() {
let delta = current_pos - last_pos; let delta = current_pos - last_pos;
@ -11755,7 +11791,7 @@ impl StagePane {
self.last_pan_pos = response.interact_pointer_pos(); self.last_pan_pos = response.interact_pointer_pos();
self.is_panning = true; self.is_panning = true;
} else { } else {
if !response.dragged() { if !response.dragged() && !middle_down {
self.is_panning = false; self.is_panning = false;
self.last_pan_pos = None; self.last_pan_pos = None;
} }
@ -12099,26 +12135,22 @@ impl StagePane {
let r = shared.raster_settings.quick_select_radius; let r = shared.raster_settings.quick_select_radius;
(r, r, 0.0_f32) (r, r, 0.0_f32)
} else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) { } 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); let r = def.cursor_radius(shared.raster_settings);
// For the standard paint brush, also account for elliptical shape. let bs = &slot.settings;
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 ratio = bs.elliptical_dab_ratio.max(1.0); let ratio = bs.elliptical_dab_ratio.max(1.0);
let expand = 1.0 + bs.offset_by_random; 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) (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 let a = a_world * self.zoom; // major semi-axis in screen pixels
@ -12163,9 +12195,84 @@ impl StagePane {
impl PaneRenderer for StagePane { impl PaneRenderer for StagePane {
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
ui.horizontal(|ui| { // Fill most of the 40px header so the buttons are a comfortable tablet-sized target
// Zoom to fit button // rather than the ~20px egui default.
if ui.button("⊡ Fit").on_hover_text("Zoom to fit canvas in view").clicked() { 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); 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_style = shared.theme.style(".text-primary", ui.ctx());
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); 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)); ui.colored_label(text_color, format!("Zoom: {:.0}%", self.zoom * 100.0));
}); },
);
true true
} }
@ -13104,3 +13212,13 @@ impl PaneRenderer for StagePane {
"Stage" "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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -29,10 +29,48 @@ impl PaneRenderer for ToolbarPane {
path: &NodePath, path: &NodePath,
shared: &mut SharedPaneState, shared: &mut SharedPaneState,
) { ) {
let button_size = 60.0; // `ui` spans the whole window, not this pane — bind a child Ui to the pane's content rect
let button_padding = 8.0; // first, or the ScrollArea would start at the window's top (under the pane header) and
let button_spacing = 4.0; // 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 // Determine which tools to show based on the active layer type
let active_layer_type: Option<LayerType> = shared.active_layer_id let active_layer_type: Option<LayerType> = shared.active_layer_id
.and_then(|id| shared.action_executor.document().get_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; *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 is_raster = matches!(active_layer_type, Some(LayerType::Raster));
let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster)); let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster));
// Add color pickers below the tool buttons ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING);
if show_colors { ui.add_space(BUTTON_PADDING);
y += button_spacing * 2.0; // Extra spacing
let fill_label_width = 40.0; // Centre the grid as a block. Work out how many columns fit, then lay the buttons out in
let color_button_size = 50.0; // a band of exactly that width, positioned to centre it. The band has to be an explicit
let color_row_width = fill_label_width + color_button_size + button_spacing; // max_rect on the child Ui — a `horizontal_wrapped` nested inside a `horizontal` inherits
let color_x = rect.left() + (rect.width() - color_row_width) / 2.0; // 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. // Stroke/FG always on top, Fill/BG always on bottom.
// Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill". // Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill".
{ if show_colors {
let stroke_label = if is_raster { "FG" } else { "Stroke" }; ui.add_space(BUTTON_SPACING * 2.0);
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,
);
let stroke_button_rect = egui::Rect::from_min_size( let row_width = COLOR_LABEL_WIDTH + COLOR_BUTTON_SIZE + BUTTON_SPACING;
egui::pos2(color_x + fill_label_width + button_spacing, y), let color_indent = ((ui.available_width() - row_width) / 2.0).max(0.0);
egui::vec2(color_button_size, color_button_size),
); for (label, is_stroke) in [
let stroke_button_id = ui.id().with(("stroke_color_button", path)); (if is_raster { "FG" } else { "Stroke" }, true),
let stroke_response = ui.interact(stroke_button_rect, stroke_button_id, egui::Sense::click()); (if is_raster { "BG" } else { "Fill" }, false),
draw_color_button(ui, stroke_button_rect, *shared.stroke_color); ] {
egui::containers::Popup::from_toggle_button_response(&stroke_response) ui.horizontal(|ui| {
.show(|ui| { ui.add_space(color_indent);
ui.spacing_mut().slider_width = 275.0;
let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); let (label_rect, _) = ui.allocate_exact_size(
if changed { egui::vec2(COLOR_LABEL_WIDTH, COLOR_BUTTON_SIZE),
*shared.active_color_mode = super::ColorMode::Stroke; 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 ui.add_space(BUTTON_PADDING);
{ }
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)); 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( 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, egui::Align2::CENTER_CENTER,
fill_label, glyph,
egui::FontId::proportional(14.0), crate::mobile::icons::font(26.0),
label_color, shared.theme.text_color(
); &["#toolbar", ".tool-button"],
ui.ctx(),
let fill_button_rect = egui::Rect::from_min_size( egui::Color32::from_gray(220),
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,
), ),
egui::vec2(checker_size, checker_size), );
).intersect(rect); } else if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) {
ui.painter().rect_filled(checker_rect, 0.0, checker_color); 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,
);
} }

View File

@ -5,7 +5,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use eframe::egui; use eframe::egui;
use crate::config::AppConfig; use crate::config::{AppConfig, TabletButtonAction};
use crate::keymap::{self, AppAction, KeymapManager}; use crate::keymap::{self, AppAction, KeymapManager};
use crate::menu::{MenuSystem, Shortcut, ShortcutKey}; use crate::menu::{MenuSystem, Shortcut, ShortcutKey};
use crate::theme::{Theme, ThemeMode}; use crate::theme::{Theme, ThemeMode};
@ -55,12 +55,15 @@ struct PreferencesState {
file_height: u32, file_height: u32,
scroll_speed: f64, scroll_speed: f64,
audio_buffer_size: u32, audio_buffer_size: u32,
cycle_midi_separate_takes: bool,
reopen_last_session: bool, reopen_last_session: bool,
restore_layout_from_file: bool, restore_layout_from_file: bool,
debug: bool, debug: bool,
waveform_stereo: bool, waveform_stereo: bool,
theme_mode: ThemeMode, theme_mode: ThemeMode,
large_media_default: LargeMediaMode, large_media_default: LargeMediaMode,
tablet_button_lower: TabletButtonAction,
tablet_button_upper: TabletButtonAction,
} }
impl From<(&AppConfig, &Theme)> for PreferencesState { impl From<(&AppConfig, &Theme)> for PreferencesState {
@ -72,12 +75,15 @@ impl From<(&AppConfig, &Theme)> for PreferencesState {
file_height: config.file_height, file_height: config.file_height,
scroll_speed: config.scroll_speed, scroll_speed: config.scroll_speed,
audio_buffer_size: config.audio_buffer_size, audio_buffer_size: config.audio_buffer_size,
cycle_midi_separate_takes: config.cycle_midi_separate_takes,
reopen_last_session: config.reopen_last_session, reopen_last_session: config.reopen_last_session,
restore_layout_from_file: config.restore_layout_from_file, restore_layout_from_file: config.restore_layout_from_file,
debug: config.debug, debug: config.debug,
waveform_stereo: config.waveform_stereo, waveform_stereo: config.waveform_stereo,
theme_mode: theme.mode(), theme_mode: theme.mode(),
large_media_default: config.large_media_default, 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, file_height: 600,
scroll_speed: 1.0, scroll_speed: 1.0,
audio_buffer_size: 256, audio_buffer_size: 256,
cycle_midi_separate_takes: false,
reopen_last_session: false, reopen_last_session: false,
restore_layout_from_file: true, restore_layout_from_file: true,
debug: false, debug: false,
waveform_stereo: false, waveform_stereo: false,
theme_mode: ThemeMode::System, theme_mode: ThemeMode::System,
large_media_default: LargeMediaMode::default(), 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); ui.add_space(8.0);
self.render_startup_section(ui); self.render_startup_section(ui);
ui.add_space(8.0); ui.add_space(8.0);
self.render_tablet_section(ui);
ui.add_space(8.0);
self.render_advanced_section(ui); self.render_advanced_section(ui);
}); });
} }
@ -543,6 +554,39 @@ impl PreferencesDialog {
}); });
ui.label("Requires app restart to take effect"); 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) { fn render_advanced_section(&mut self, ui: &mut egui::Ui) {
egui::CollapsingHeader::new("Advanced") egui::CollapsingHeader::new("Advanced")
.default_open(false) .default_open(false)
@ -641,11 +720,14 @@ impl PreferencesDialog {
temp_config.file_height = self.working_prefs.file_height; temp_config.file_height = self.working_prefs.file_height;
temp_config.scroll_speed = self.working_prefs.scroll_speed; temp_config.scroll_speed = self.working_prefs.scroll_speed;
temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; 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.reopen_last_session = self.working_prefs.reopen_last_session;
temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
temp_config.debug = self.working_prefs.debug; temp_config.debug = self.working_prefs.debug;
temp_config.waveform_stereo = self.working_prefs.waveform_stereo; temp_config.waveform_stereo = self.working_prefs.waveform_stereo;
temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); 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 // Validate
if let Err(err) = temp_config.validate() { if let Err(err) = temp_config.validate() {
@ -675,16 +757,23 @@ impl PreferencesDialog {
config.file_height = self.working_prefs.file_height; config.file_height = self.working_prefs.file_height;
config.scroll_speed = self.working_prefs.scroll_speed; config.scroll_speed = self.working_prefs.scroll_speed;
config.audio_buffer_size = self.working_prefs.audio_buffer_size; 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.reopen_last_session = self.working_prefs.reopen_last_session;
config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
config.debug = self.working_prefs.debug; config.debug = self.working_prefs.debug;
config.waveform_stereo = self.working_prefs.waveform_stereo; config.waveform_stereo = self.working_prefs.waveform_stereo;
config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
config.large_media_default = self.working_prefs.large_media_default; 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; config.keybindings = keybinding_config;
// Apply theme immediately // Apply theme immediately
theme.set_mode(self.working_prefs.theme_mode); 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 // Save to disk
config.save(); config.save();

View File

@ -13,6 +13,8 @@ use std::sync::atomic::{AtomicU32, Ordering};
use eframe::egui; use eframe::egui;
use lightningbeam_core::tool::Tool; use lightningbeam_core::tool::Tool;
use crate::config::TabletButtonAction;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Global tablet state — read by make_stroke_point() on the UI thread // 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_X_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32
static TABLET_TILT_Y_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<TabletButtonAction> {
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.01.0). Falls back to 1.0 when no tablet is active. /// Current pen pressure (0.01.0). Falls back to 1.0 when no tablet is active.
pub fn current_pressure() -> f32 { pub fn current_pressure() -> f32 {
f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed)) f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed))
@ -57,10 +111,32 @@ pub enum RawTabletEvent {
Tilt { x: f32, y: f32 }, Tilt { x: f32, y: f32 },
TipDown, TipDown,
TipUp, 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. /// End of Wayland tablet event group; commit accumulated state.
Frame, 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<Self> {
// 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)] #[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum TabletToolType { pub enum TabletToolType {
#[default] #[default]
@ -91,9 +167,13 @@ pub struct TabletInput {
/// On X11, clicks already come through winit via OS mouse emulation. /// On X11, clicks already come through winit via OS mouse emulation.
inject_buttons: bool, 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>, pub pending_tool_switch: Option<Tool>,
tool_before_eraser: Option<Tool>, tool_before_eraser: Option<Tool>,
/// 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<Tool>,
/// One-shot sender used to hand the egui Context to the background thread /// One-shot sender used to hand the egui Context to the background thread
/// on the first poll() call so it can call request_repaint(). /// on the first poll() call so it can call request_repaint().
@ -125,6 +205,8 @@ impl TabletInput {
inject_buttons, inject_buttons,
pending_tool_switch: None, pending_tool_switch: None,
tool_before_eraser: None, tool_before_eraser: None,
prev_buttons: 0,
tool_before_button: None,
repaint_sender, repaint_sender,
backend, backend,
} }
@ -202,6 +284,9 @@ impl TabletInput {
// Handle eraser tool switch. // Handle eraser tool switch.
self.handle_tool_switch(current_tool); 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(). // Publish globals for make_stroke_point().
set_pressure(self.pressure); set_pressure(self.pressure);
let (tx, ty) = self.tilt; let (tx, ty) = self.tilt;
@ -267,6 +352,8 @@ impl TabletInput {
RawTabletEvent::ProximityOut => { RawTabletEvent::ProximityOut => {
self.in_proximity = false; self.in_proximity = false;
self.pressure = 0.0; 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 } => { RawTabletEvent::Motion { x, y } => {
// Wayland gives physical pixels; convert to egui logical points. // Wayland gives physical pixels; convert to egui logical points.
@ -289,6 +376,14 @@ impl TabletInput {
RawTabletEvent::TipUp => { RawTabletEvent::TipUp => {
self.tip_down = Some(false); 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 => { RawTabletEvent::Frame => {
// Frame is the commit signal; processing already happened in individual events. // 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) { fn handle_tool_switch(&mut self, current_tool: Tool) {
let just_entered = self.in_proximity && !self.was_in_proximity; let just_entered = self.in_proximity && !self.was_in_proximity;
let just_left = !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")] #[cfg(target_os = "linux")]
mod wayland { mod wayland {
use super::{RawTabletEvent, TabletToolType}; use super::{RawTabletEvent, TabletButton, TabletToolType};
use eframe::egui; use eframe::egui;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::mpsc; use std::sync::mpsc;
@ -445,6 +573,7 @@ mod wayland {
pending_motion: bool, pending_motion: bool,
pending_tip: Option<bool>, pending_tip: Option<bool>,
pending_proximity: Option<bool>, pending_proximity: Option<bool>,
pending_buttons: Vec<(TabletButton, bool)>,
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -585,7 +714,20 @@ mod wayland {
Event::Tilt { tilt_x, tilt_y } => { Event::Tilt { tilt_x, tilt_y } => {
acc.pending_tilt = (tilt_x as f32, tilt_y as f32); 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 { .. } => { 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. // Flush accumulated events to the channel.
if let Some(prox) = acc.pending_proximity.take() { if let Some(prox) = acc.pending_proximity.take() {
if prox { if prox {
@ -630,7 +772,7 @@ mod wayland {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod x11 { mod x11 {
use super::{RawTabletEvent, TabletToolType}; use super::{RawTabletEvent, TabletButton, TabletToolType};
use std::sync::mpsc; use std::sync::mpsc;
use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle}; use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle};
@ -880,17 +1022,44 @@ mod x11 {
} }
Event::XinputRawButtonPress(raw) => { Event::XinputRawButtonPress(raw) => {
if device_axes.contains_key(&raw.deviceid) && raw.detail == 1 { if device_axes.contains_key(&raw.deviceid) {
let _ = tx.send(RawTabletEvent::TipDown); // X11 reports the tip as button 1 and the barrel buttons as 2 and 3.
let _ = tx.send(RawTabletEvent::Frame); 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) => { Event::XinputRawButtonRelease(raw) => {
if device_axes.contains_key(&raw.deviceid) { if device_axes.contains_key(&raw.deviceid) {
if raw.detail == 1 { match raw.detail {
let _ = tx.send(RawTabletEvent::TipUp); 1 => {
let _ = tx.send(RawTabletEvent::Frame); 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. // When all buttons released, synthesise proximity out.
in_proximity.remove(&raw.deviceid); in_proximity.remove(&raw.deviceid);

View File

@ -1,6 +1,6 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef, RasterToolSettings};
use eframe::egui; use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct BlurSharpenTool; pub struct BlurSharpenTool;
pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
@ -8,19 +8,11 @@ pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
impl RasterToolDef for BlurSharpenTool { impl RasterToolDef for BlurSharpenTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen }
fn header_label(&self) -> &'static str { "Blur / Sharpen" } fn header_label(&self) -> &'static str { "Blur / Sharpen" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::BlurSharpen }
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 tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0] [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) { fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() { if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() {
@ -30,31 +22,11 @@ impl RasterToolDef for BlurSharpenTool {
s.blur_sharpen_mode = 1; 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.horizontal(|ui| {
ui.label("Kernel:"); ui.label("Kernel:");
ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0) ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0)
.logarithmic(true) .logarithmic(true)
.custom_formatter(|v, _| format!("{:.1} px", v))); .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)));
});
} }
} }

View File

@ -1,4 +1,4 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef, RasterToolSettings};
use eframe::egui; use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode; use lightningbeam_core::raster_layer::RasterBlendMode;
@ -8,15 +8,7 @@ pub static CLONE_STAMP: CloneStampTool = CloneStampTool;
impl RasterToolDef for CloneStampTool { impl RasterToolDef for CloneStampTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp }
fn header_label(&self) -> &'static str { "Clone Stamp" } fn header_label(&self) -> &'static str { "Clone Stamp" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::CloneStamp }
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
/// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time /// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time
/// (offset = clone_source - stroke_start), not from settings directly. /// (offset = clone_source - stroke_start), not from settings directly.
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }

View File

@ -1,6 +1,6 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef, RasterToolSettings};
use eframe::egui; use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct DodgeBurnTool; pub struct DodgeBurnTool;
pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
@ -8,19 +8,11 @@ pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
impl RasterToolDef for DodgeBurnTool { impl RasterToolDef for DodgeBurnTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn }
fn header_label(&self) -> &'static str { "Dodge / Burn" } fn header_label(&self) -> &'static str { "Dodge / Burn" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::DodgeBurn }
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 tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.dodge_burn_mode as f32, 0.0, 0.0, 0.0] [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) { fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() { if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() {
@ -30,25 +22,5 @@ impl RasterToolDef for DodgeBurnTool {
s.dodge_burn_mode = 1; 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)));
});
} }
} }

View File

@ -1,5 +1,4 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode; use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct EraseTool; pub struct EraseTool;
@ -8,16 +7,6 @@ pub static ERASE: EraseTool = EraseTool;
impl RasterToolDef for EraseTool { impl RasterToolDef for EraseTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase }
fn header_label(&self) -> &'static str { "Eraser" } fn header_label(&self) -> &'static str { "Eraser" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::Erase }
BrushParams { fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] }
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) {}
} }

View File

@ -1,4 +1,4 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef, RasterToolSettings};
use eframe::egui; use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode; use lightningbeam_core::raster_layer::RasterBlendMode;
@ -8,15 +8,7 @@ pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool;
impl RasterToolDef for HealingBrushTool { impl RasterToolDef for HealingBrushTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing }
fn header_label(&self) -> &'static str { "Healing Brush" } fn header_label(&self) -> &'static str { "Healing Brush" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::HealingBrush }
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
/// tool_params are filled by stage.rs at stroke-start time (clone offset). /// 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 tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn uses_alt_click(&self) -> bool { true } fn uses_alt_click(&self) -> bool { true }

View File

@ -2,12 +2,17 @@
/// ///
/// Each tool implements `RasterToolDef`. Adding a new tool requires: /// Each tool implements `RasterToolDef`. Adding a new tool requires:
/// 1. A new file in this directory implementing `RasterToolDef`. /// 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. /// 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 eframe::egui;
use lightningbeam_core::{ use lightningbeam_core::{
brush_settings::BrushSettings, brush_settings::{bundled_brushes, BrushSettings},
raster_layer::RasterBlendMode, raster_layer::RasterBlendMode,
tool::Tool, tool::Tool,
}; };
@ -22,6 +27,86 @@ pub mod dodge_burn;
pub mod sponge; pub mod sponge;
pub mod blur_sharpen; 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<usize>,
/// 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) // 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 /// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed
/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`. /// by `SharedPaneState` as a single `&'a mut RasterToolSettings`.
pub struct RasterToolSettings { pub struct RasterToolSettings {
// --- Paint brush --- /// Per-tool brush state, indexed by `BrushKind`.
pub brush_radius: f32, brushes: [BrushSlot; 9],
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,
// --- Clone / Healing --- // --- Clone / Healing ---
/// World-space source point set by Alt+click. /// World-space source point set by Alt+click.
pub clone_source: Option<egui::Vec2>, pub clone_source: Option<egui::Vec2>,
@ -55,24 +123,12 @@ pub struct RasterToolSettings {
pub pattern_type: u32, pub pattern_type: u32,
pub pattern_scale: f32, pub pattern_scale: f32,
// --- Dodge / Burn --- // --- 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) /// 0 = dodge (lighten), 1 = burn (darken)
pub dodge_burn_mode: u32, pub dodge_burn_mode: u32,
// --- Sponge --- // --- Sponge ---
pub sponge_radius: f32,
pub sponge_hardness: f32,
pub sponge_spacing: f32,
pub sponge_flow: f32,
/// 0 = saturate, 1 = desaturate /// 0 = saturate, 1 = desaturate
pub sponge_mode: u32, pub sponge_mode: u32,
// --- Blur / Sharpen --- // --- 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 (120) /// Neighborhood kernel radius in canvas pixels (120)
pub blur_sharpen_kernel: f32, pub blur_sharpen_kernel: f32,
/// 0 = blur, 1 = sharpen /// 0 = blur, 1 = sharpen
@ -87,7 +143,7 @@ pub struct RasterToolSettings {
// --- Quick Select --- // --- Quick Select ---
/// Brush radius in canvas pixels for the quick-select tool. /// Brush radius in canvas pixels for the quick-select tool.
pub quick_select_radius: f32, pub quick_select_radius: f32,
// --- Flood fill (Paint Bucket, raster) --- // --- Flood fill (Paint Bucket) ---
/// Color-distance threshold (Euclidean RGBA, 0510). Pixels within this /// Color-distance threshold (Euclidean RGBA, 0510). Pixels within this
/// distance of the comparison color are included in the fill. /// distance of the comparison color are included in the fill.
pub fill_threshold: f32, 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 /// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS
/// parent pixel (Relative, spreads across gradients). /// parent pixel (Relative, spreads across gradients).
pub fill_threshold_mode: FillThresholdMode, 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 --- // --- Marquee select shape ---
/// Whether the rectangular select tool draws a rect or an ellipse. /// Whether the rectangular select tool draws a rect or an ellipse.
pub select_shape: SelectionShape, pub select_shape: SelectionShape,
@ -109,10 +170,16 @@ pub struct RasterToolSettings {
// --- Gradient --- // --- Gradient ---
pub gradient: lightningbeam_core::gradient::ShapeGradient, pub gradient: lightningbeam_core::gradient::ShapeGradient,
pub gradient_opacity: f32, 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. impl RasterToolSettings {
pub brush_angle_offset: f32, 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. /// Brush mode for the Liquify tool.
@ -158,43 +225,32 @@ pub enum FillThresholdMode {
impl Default for RasterToolSettings { impl Default for RasterToolSettings {
fn default() -> Self { 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 { Self {
brush_radius: 10.0, brushes: [
brush_opacity: 1.0, /* Paint */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
brush_hardness: 0.5, /* Erase */ erase,
brush_spacing: 0.1, /* Smudge */ BrushSlot::new(15.0, 1.0, 0.8, 8.0),
brush_use_fg: true, /* CloneStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
active_brush_settings: BrushSettings::default(), /* HealingBrush */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
eraser_radius: 10.0, /* PatternStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
eraser_opacity: 1.0, /* DodgeBurn */ BrushSlot::new(30.0, 0.5, 0.5, 3.0),
eraser_hardness: 0.5, /* Sponge */ BrushSlot::new(30.0, 0.5, 0.5, 3.0),
eraser_spacing: 0.1, /* BlurSharpen */ BrushSlot::new(30.0, 0.5, 0.5, 3.0),
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,
clone_source: None, clone_source: None,
pattern_type: 0, pattern_type: 0,
pattern_scale: 32.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, dodge_burn_mode: 0,
sponge_radius: 30.0,
sponge_hardness: 0.5,
sponge_spacing: 3.0,
sponge_flow: 0.5,
sponge_mode: 0, 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_kernel: 5.0,
blur_sharpen_mode: 0, blur_sharpen_mode: 0,
wand_threshold: 15.0, wand_threshold: 15.0,
@ -203,6 +259,8 @@ impl Default for RasterToolSettings {
fill_threshold: 15.0, fill_threshold: 15.0,
fill_softness: 0.0, fill_softness: 0.0,
fill_threshold_mode: FillThresholdMode::Absolute, fill_threshold_mode: FillThresholdMode::Absolute,
fill_use_fg: true,
eyedropper_use_fg: true,
quick_select_radius: 20.0, quick_select_radius: 20.0,
select_shape: SelectionShape::Rect, select_shape: SelectionShape::Rect,
warp_grid_cols: 4, warp_grid_cols: 4,
@ -212,7 +270,6 @@ impl Default for RasterToolSettings {
liquify_strength: 0.5, liquify_strength: 0.5,
gradient: lightningbeam_core::gradient::ShapeGradient::default(), gradient: lightningbeam_core::gradient::ShapeGradient::default(),
gradient_opacity: 1.0, gradient_opacity: 1.0,
brush_angle_offset: 0.0,
} }
} }
} }
@ -229,6 +286,21 @@ pub struct BrushParams {
pub spacing: f32, 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 // RasterToolDef trait
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -236,19 +308,34 @@ pub struct BrushParams {
pub trait RasterToolDef: Send + Sync { pub trait RasterToolDef: Send + Sync {
fn blend_mode(&self) -> RasterBlendMode; fn blend_mode(&self) -> RasterBlendMode;
fn header_label(&self) -> &'static str; 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`. /// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`.
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4]; 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). /// Cursor display radius (world pixels).
fn cursor_radius(&self, s: &RasterToolSettings) -> f32 { fn cursor_radius(&self, s: &RasterToolSettings) -> f32 {
self.brush_params(s).radius 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); /// Label for the slot's `strength` slider — the one field whose meaning is tool-specific.
/// Whether to show the brush preset picker after `render_ui`. fn strength_label(&self) -> &'static str { "Opacity" }
fn show_brush_preset_picker(&self) -> bool { true }
/// Whether this tool is the eraser (drives preset picker + color UI visibility). /// Render tool-specific controls in the infopanel. The shared brush controls (color, size,
fn is_eraser(&self) -> bool { false } /// 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. /// Whether Alt+click sets a source point for this tool.
fn uses_alt_click(&self) -> bool { false } fn uses_alt_click(&self) -> bool { false }
} }

View File

@ -1,5 +1,4 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode; use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct PaintTool; pub struct PaintTool;
@ -8,17 +7,7 @@ pub static PAINT: PaintTool = PaintTool;
impl RasterToolDef for PaintTool { impl RasterToolDef for PaintTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal }
fn header_label(&self) -> &'static str { "Brush" } fn header_label(&self) -> &'static str { "Brush" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::Paint }
let mut base_settings = s.active_brush_settings.clone(); fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] }
base_settings.elliptical_dab_angle += s.brush_angle_offset; fn uses_color(&self) -> bool { true }
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) {}
} }

View File

@ -1,4 +1,4 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef, RasterToolSettings};
use eframe::egui; use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode; use lightningbeam_core::raster_layer::RasterBlendMode;
@ -12,18 +12,12 @@ const PATTERN_NAMES: &[&str] = &[
impl RasterToolDef for PatternStampTool { impl RasterToolDef for PatternStampTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp }
fn header_label(&self) -> &'static str { "Pattern Stamp" } fn header_label(&self) -> &'static str { "Pattern Stamp" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::PatternStamp }
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 tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.pattern_type as f32, s.pattern_scale, 0.0, 0.0] [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) { fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
let selected_name = PATTERN_NAMES let selected_name = PATTERN_NAMES
.get(s.pattern_type as usize) .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) ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0)
.logarithmic(true).suffix(" px")); .logarithmic(true).suffix(" px"));
}); });
ui.add_space(4.0);
} }
} }

View File

@ -1,6 +1,5 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct SmudgeTool; pub struct SmudgeTool;
pub static SMUDGE: SmudgeTool = SmudgeTool; pub static SMUDGE: SmudgeTool = SmudgeTool;
@ -8,37 +7,15 @@ pub static SMUDGE: SmudgeTool = SmudgeTool;
impl RasterToolDef for SmudgeTool { impl RasterToolDef for SmudgeTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge }
fn header_label(&self) -> &'static str { "Smudge" } fn header_label(&self) -> &'static str { "Smudge" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::Smudge }
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 tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
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| { /// Smudge's slot `strength` drives the smudge distance (applied by the stage as
ui.label("Size:"); /// `smudge_radius_log`), not dab opacity — dabs always composite fully.
ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
}); let mut p = super::default_brush_params(self.brush_kind(), s);
ui.horizontal(|ui| { p.opacity = 1.0;
ui.label("Strength:"); p
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)));
});
} }
} }

View File

@ -1,6 +1,6 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings}; use super::{BrushKind, RasterToolDef, RasterToolSettings};
use eframe::egui; use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct SpongeTool; pub struct SpongeTool;
pub static SPONGE: SpongeTool = SpongeTool; pub static SPONGE: SpongeTool = SpongeTool;
@ -8,19 +8,11 @@ pub static SPONGE: SpongeTool = SpongeTool;
impl RasterToolDef for SpongeTool { impl RasterToolDef for SpongeTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge } fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge }
fn header_label(&self) -> &'static str { "Sponge" } fn header_label(&self) -> &'static str { "Sponge" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { fn brush_kind(&self) -> BrushKind { BrushKind::Sponge }
BrushParams {
base_settings: BrushSettings::default(),
radius: s.sponge_radius,
opacity: s.sponge_flow,
hardness: s.sponge_hardness,
spacing: s.sponge_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.sponge_mode as f32, 0.0, 0.0, 0.0] [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) { fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() { if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() {
@ -30,25 +22,5 @@ impl RasterToolDef for SpongeTool {
s.sponge_mode = 1; 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)));
});
} }
} }

View File

@ -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,
);
}

View File

@ -1,6 +1,7 @@
//! Reusable UI widgets for the editor //! Reusable UI widgets for the editor
mod text_field; mod text_field;
pub mod color_swatch;
pub mod dropdown_list; pub mod dropdown_list;
pub use text_field::ImeTextField; pub use text_field::ImeTextField;