Release 1.0.10-alpha
This commit is contained in:
commit
a09b0c1a93
|
|
@ -39,3 +39,6 @@ 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
|
||||||
22
Changelog.md
22
Changelog.md
|
|
@ -1,3 +1,25 @@
|
||||||
|
# 1.0.10-alpha:
|
||||||
|
Changes:
|
||||||
|
- Cycle recording: highlight a range on the timeline ruler (the cycle strip, shown when looping is armed) and the transport loops it. Recording round the loop turns each pass into a separate take you can choose between afterwards, and recording again over the same region adds more takes rather than stacking a second clip on top. Starting playback from outside the region jumps to its start
|
||||||
|
- Takes: a "Take N/M" badge on the clip picks which take plays. Splitting a clip lets each half play a different take, so you can comp a part together from the best bits of several passes. Right-click a clip to delete the take that's playing or to delete all the unused ones; double-click a take in the list to rename it
|
||||||
|
- MIDI cycle recording can either merge every pass into one clip (the default — earlier passes play back as you record, so you can layer a hi-hat over a kick) or keep each pass as a separate take, like audio does. Set it in Preferences → Audio
|
||||||
|
- Painting: every colour-using tool (brush, paint bucket, eyedropper) now shares one Foreground/Background swatch row, and the eyedropper has an explicit toggle for which of the two it fills
|
||||||
|
- Painting: the effect brushes (dodge/burn, sponge, blur, smudge, clone stamp, healing brush, pattern stamp) were stuck on a single fixed brush shape. They now each have their own size, strength, hardness and spacing, and can use any brush from the library
|
||||||
|
- Tablet: stylus barrel buttons now work, with actions you can bind in Preferences (Pan, Eyedropper, or Eraser). Middle-mouse panning added too
|
||||||
|
- Timeline: raster and text layers get an opacity slider; each layer row now shows only the controls that mean anything for it (volume for audio, opacity for raster/text, both for vector and video), and layers gain a visibility (eye) toggle
|
||||||
|
- The stage gains undo/redo buttons, sized for touch and tablet use
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Splitting an audio clip made it play back at the wrong length (a clip split at 1 second cut off after half a second at 120 BPM)
|
||||||
|
- Trimmed MIDI clips were the wrong length on the timeline at any tempo other than 60 BPM
|
||||||
|
- The paint bucket always filled with the background colour, whatever the brush was set to
|
||||||
|
- Where an eyedropper sample landed (foreground or background) depended on which colour picker you had opened last
|
||||||
|
- Stylus barrel buttons were silently ignored on Wayland
|
||||||
|
- The opacity slider on a raster layer actually changed its volume, which means nothing on a raster layer
|
||||||
|
- Tool cursors lagged behind the pointer while drawing; they're now drawn by the system rather than into the canvas
|
||||||
|
- Colour picker popups wouldn't close when you dragged on the stage, so drawing left one hanging open, and clicking inside a picker's own hue slider closed it
|
||||||
|
- The toolbar could not be scrolled when the pane was too short to show every tool
|
||||||
|
|
||||||
# 1.0.9-alpha:
|
# 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
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -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
25
TODO.md
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all completed notes plus currently-held notes with a provisional duration.
|
/// The still-held notes, given a provisional duration running to `current_time`.
|
||||||
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
|
///
|
||||||
let mut notes = self.completed_notes.clone();
|
/// These belong to whatever pass is in progress, so a per-pass view can append them as-is.
|
||||||
for active in self.active_notes.values() {
|
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);
|
let note_start = active.start_time.max(self.start_time);
|
||||||
notes.push((
|
(
|
||||||
note_start - self.start_time,
|
note_start - self.start_time,
|
||||||
active.note,
|
active.note,
|
||||||
active.velocity,
|
active.velocity,
|
||||||
(current_time - note_start).max(Beats::ZERO),
|
(current_time - note_start).max(Beats::ZERO),
|
||||||
));
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all completed notes plus currently-held notes with a provisional duration.
|
||||||
|
pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> {
|
||||||
|
let mut notes = self.completed_notes.clone();
|
||||||
|
notes.extend(self.active_notes_with_provisional_end(current_time));
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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,114 +197,24 @@ 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(())
|
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
|
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
|
||||||
// Remove clip from backend if it was added
|
// Remove clip from backend if it was added
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,120 +363,64 @@ 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};
|
None => {}
|
||||||
|
}
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Add the new (right) instance
|
// 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction
|
||||||
let internal_start = new_instance.trim_start;
|
// uses, so the trim/duration conversions live in exactly one place.
|
||||||
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
|
if let Some((track_id, backend_id)) =
|
||||||
let external_start = new_instance.timeline_start;
|
backend.add_clip_instance(document, &self.layer_id, &new_instance)?
|
||||||
// MIDI trims are beats-domain, so the fallback span is beats too.
|
{
|
||||||
let external_duration = new_instance
|
self.backend_track_id = Some(track_id);
|
||||||
.timeline_duration
|
match backend_id {
|
||||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
crate::action::BackendClipInstanceId::Midi(id) => {
|
||||||
|
self.backend_midi_instance_id = Some(id)
|
||||||
let instance = daw_backend::MidiClipInstance::new(
|
}
|
||||||
0,
|
crate::action::BackendClipInstanceId::Audio(id) => {
|
||||||
*midi_clip_id,
|
self.backend_audio_instance_id = Some(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(())
|
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback_backend(
|
fn rollback_backend(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 tempo_map = document.tempo_map();
|
||||||
let max_extend_secs = document.find_max_trim_extend_left(
|
|
||||||
|
// 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,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.timeline_start,
|
instance.timeline_start,
|
||||||
).seconds_to_f64();
|
),
|
||||||
|
clip_content,
|
||||||
|
instance.timeline_start,
|
||||||
|
tempo_map,
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate how much we want to extend (content seconds)
|
|
||||||
let desired_extend = old_trim - new_trim;
|
let desired_extend = old_trim - new_trim;
|
||||||
|
let actual_extend = desired_extend.min(max_extend);
|
||||||
// Clamp to max allowed
|
|
||||||
let actual_extend = desired_extend.min(max_extend_secs);
|
|
||||||
let clamped_trim_start = old_trim - actual_extend;
|
let clamped_trim_start = old_trim - actual_extend;
|
||||||
// Move the timeline left by the same wall-clock seconds.
|
|
||||||
let tempo_map = document.tempo_map();
|
// 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
|
||||||
|
// through the seconds→beats conversion a second time.
|
||||||
|
let current_effective_duration = instance
|
||||||
|
.effective_duration_beats(clip_content, tempo_map);
|
||||||
|
|
||||||
|
// Max rightward extension: the gap's wall-clock span, in content domain.
|
||||||
|
let max_extend = gap_to_content(
|
||||||
|
document.find_max_trim_extend_right(
|
||||||
layer_id,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.timeline_start,
|
instance.timeline_start,
|
||||||
current_effective_duration,
|
current_effective_duration,
|
||||||
).seconds_to_f64();
|
),
|
||||||
|
clip_content,
|
||||||
|
instance.timeline_start,
|
||||||
|
tempo_map,
|
||||||
|
);
|
||||||
|
|
||||||
// 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
|
||||||
|
|
|
||||||
|
|
@ -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,28 +568,31 @@ 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the content duration. Debug-asserts the value's domain matches the clip type so a
|
/// Set the content duration. Debug-asserts the value's domain matches the clip type so a
|
||||||
/// beats duration can't be stored on a seconds clip (or vice-versa).
|
/// beats duration can't be stored on a seconds clip (or vice-versa).
|
||||||
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) {
|
||||||
|
ClipDuration::Beats(b) => b,
|
||||||
|
ClipDuration::Seconds(s) => {
|
||||||
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||||
tempo_map.seconds_to_beats(start_secs + window) - 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get or lazily load the black (fill) cursor texture
|
fn get_or_build(
|
||||||
pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle {
|
&mut self,
|
||||||
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
|
|
||||||
pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle {
|
|
||||||
self.outline_textures.entry(cursor).or_insert_with(|| {
|
|
||||||
let svg_data = cursor.svg_data();
|
|
||||||
let svg_string = String::from_utf8_lossy(svg_data);
|
|
||||||
// Replace all colors with white for the outline
|
|
||||||
let svg_white = svg_string
|
|
||||||
.replace("currentColor", "#ffffff")
|
|
||||||
.replace("#000000", "#ffffff")
|
|
||||||
.replace("#000", "#ffffff");
|
|
||||||
rasterize_cursor_svg(svg_white.as_bytes(), &format!("cursor_{:?}_outline", cursor), CURSOR_SIZE, ctx)
|
|
||||||
.expect("Failed to rasterize cursor SVG outline")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CURSOR_SIZE: u32 = 24;
|
|
||||||
const OUTLINE_OFFSET: f32 = 1.0;
|
|
||||||
|
|
||||||
/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg)
|
|
||||||
fn rasterize_cursor_svg(
|
|
||||||
svg_data: &[u8],
|
|
||||||
name: &str,
|
|
||||||
render_size: u32,
|
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
) -> Option<TextureHandle> {
|
cursor: CustomCursor,
|
||||||
let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?;
|
ppp: f32,
|
||||||
let pixmap_size = tree.size().to_int_size();
|
) -> Option<egui::CursorImage> {
|
||||||
let scale_x = render_size as f32 / pixmap_size.width() as f32;
|
let key = (cursor, ppp.to_bits());
|
||||||
let scale_y = render_size as f32 / pixmap_size.height() as f32;
|
if let Some(cached) = self.images.get(&key) {
|
||||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?;
|
return cached.clone();
|
||||||
resvg::render(
|
}
|
||||||
&tree,
|
|
||||||
resvg::tiny_skia::Transform::from_scale(scale_x, scale_y),
|
let id = self.next_id;
|
||||||
&mut pixmap.as_mut(),
|
self.next_id += 1;
|
||||||
);
|
|
||||||
let rgba_data = pixmap.data().to_vec();
|
let built = build_cursor_image(ctx, cursor.kind(), ppp, id);
|
||||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
self.images.insert(key, built.clone());
|
||||||
[render_size as usize, render_size as usize],
|
built
|
||||||
&rgba_data,
|
}
|
||||||
);
|
}
|
||||||
Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR))
|
|
||||||
|
/// An alpha coverage mask being composed, in physical pixels.
|
||||||
|
struct Mask {
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
a: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mask {
|
||||||
|
fn new(w: usize, h: usize) -> Self {
|
||||||
|
Self { w, h, a: vec![0.0; w * h] }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(&mut self, x: usize, y: usize, coverage: f32) {
|
||||||
|
if x < self.w && y < self.h {
|
||||||
|
let p = &mut self.a[y * self.w + x];
|
||||||
|
*p = (*p + coverage).min(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, x: isize, y: isize) -> f32 {
|
||||||
|
if x < 0 || y < 0 || x as usize >= self.w || y as usize >= self.h {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.a[y as usize * self.w + x as usize]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill an axis-aligned rect (physical px, may be fractional) with full coverage.
|
||||||
|
fn fill_rect(&mut self, rect: egui::Rect) {
|
||||||
|
let x0 = rect.min.x.floor().max(0.0) as usize;
|
||||||
|
let y0 = rect.min.y.floor().max(0.0) as usize;
|
||||||
|
let x1 = (rect.max.x.ceil() as usize).min(self.w);
|
||||||
|
let y1 = (rect.max.y.ceil() as usize).min(self.h);
|
||||||
|
for y in y0..y1 {
|
||||||
|
for x in x0..x1 {
|
||||||
|
self.add(x, y, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterize a cursor into a premultiplied-RGBA image: black artwork with a white outline, so it
|
||||||
|
/// reads against both light and dark backgrounds.
|
||||||
|
fn build_cursor_image(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
kind: CursorKind,
|
||||||
|
ppp: f32,
|
||||||
|
id: u64,
|
||||||
|
) -> Option<egui::CursorImage> {
|
||||||
|
let (glyph, glyph_origin, hotspot, crosshair) = match kind {
|
||||||
|
CursorKind::System(_) => return None,
|
||||||
|
CursorKind::Precise { glyph, hotspot } => {
|
||||||
|
let scale = GLYPH_SIZE / ICON_GRID;
|
||||||
|
(
|
||||||
|
glyph,
|
||||||
|
egui::vec2(MARGIN, MARGIN),
|
||||||
|
egui::pos2(MARGIN, MARGIN) + hotspot * scale,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CursorKind::Badge { glyph } => {
|
||||||
|
// The crosshair centre is the click point; place it a margin + arm in from the corner.
|
||||||
|
let centre = egui::pos2(MARGIN + CROSSHAIR_ARM, MARGIN + CROSSHAIR_ARM);
|
||||||
|
(glyph, centre.to_vec2() + BADGE_OFFSET, centre, true)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lay the glyph out exactly as `painter.text(.., Align2::LEFT_TOP, ..)` would, so we inherit
|
||||||
|
// epaint's placement rather than re-deriving it from font metrics.
|
||||||
|
let galley = ctx.fonts_mut(|f| {
|
||||||
|
f.layout_no_wrap(
|
||||||
|
glyph.to_owned(),
|
||||||
|
icons::font(GLYPH_SIZE),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Canvas must cover the glyph, and the crosshair too when there is one.
|
||||||
|
let mut content = egui::Rect::from_min_size(glyph_origin.to_pos2(), galley.size());
|
||||||
|
if crosshair {
|
||||||
|
content = content.union(egui::Rect::from_center_size(
|
||||||
|
hotspot,
|
||||||
|
egui::Vec2::splat(2.0 * CROSSHAIR_ARM),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let canvas = content.expand(MARGIN);
|
||||||
|
|
||||||
|
let w = (canvas.width() * ppp).ceil() as usize;
|
||||||
|
let h = (canvas.height() * ppp).ceil() as usize;
|
||||||
|
if w == 0 || h == 0 || w > 2048 || h > 2048 {
|
||||||
|
return None; // winit caps cursors at 2048px.
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mask = Mask::new(w, h);
|
||||||
|
// Everything is positioned relative to the canvas origin.
|
||||||
|
let to_px = |p: egui::Pos2| ((p - canvas.min.to_vec2()).to_vec2() * ppp).to_pos2();
|
||||||
|
|
||||||
|
// --- Crosshair ---
|
||||||
|
if crosshair {
|
||||||
|
let c = to_px(hotspot);
|
||||||
|
let thickness = ppp.max(1.0); // one physical pixel, at least
|
||||||
|
let arm = CROSSHAIR_ARM * ppp;
|
||||||
|
let gap = CROSSHAIR_GAP * ppp;
|
||||||
|
// Four arms, leaving a gap at the centre so the exact click point stays visible.
|
||||||
|
for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] {
|
||||||
|
let from = egui::pos2(c.x + dx * gap, c.y + dy * gap);
|
||||||
|
let to = egui::pos2(c.x + dx * arm, c.y + dy * arm);
|
||||||
|
let rect = egui::Rect::from_two_pos(from, to).expand2(if dx == 0.0 {
|
||||||
|
egui::vec2(thickness / 2.0, 0.0)
|
||||||
|
} else {
|
||||||
|
egui::vec2(0.0, thickness / 2.0)
|
||||||
|
});
|
||||||
|
mask.fill_rect(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Glyph, copied out of egui's font atlas ---
|
||||||
|
let atlas = ctx.fonts_mut(|f| f.image());
|
||||||
|
let atlas_w = atlas.size[0];
|
||||||
|
for row in &galley.rows {
|
||||||
|
for g in &row.glyphs {
|
||||||
|
let uv = g.uv_rect;
|
||||||
|
if uv.is_nothing() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Same maths as epaint's text tessellator: the glyph's top-left in points is
|
||||||
|
// `glyph.pos + uv_rect.offset`, relative to the galley's top-left.
|
||||||
|
let left_top = to_px((glyph_origin + (g.pos + uv.offset).to_vec2()).to_pos2());
|
||||||
|
let src_w = (uv.max[0] - uv.min[0]) as usize;
|
||||||
|
let src_h = (uv.max[1] - uv.min[1]) as usize;
|
||||||
|
|
||||||
|
for sy in 0..src_h {
|
||||||
|
for sx in 0..src_w {
|
||||||
|
let src_i = (uv.min[1] as usize + sy) * atlas_w + (uv.min[0] as usize + sx);
|
||||||
|
let Some(px) = atlas.pixels.get(src_i) else { continue };
|
||||||
|
let coverage = px.a() as f32 / 255.0;
|
||||||
|
if coverage <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dx = (left_top.x.round() as isize) + sx as isize;
|
||||||
|
let dy = (left_top.y.round() as isize) + sy as isize;
|
||||||
|
if dx >= 0 && dy >= 0 {
|
||||||
|
mask.add(dx as usize, dy as usize, coverage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Compose: black fill over a white outline (dilate the mask by one pixel) ---
|
||||||
|
let mut rgba = vec![0u8; w * h * 4];
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let fill = mask.get(x as isize, y as isize);
|
||||||
|
|
||||||
|
let mut outline: f32 = 0.0;
|
||||||
|
for oy in -1..=1_isize {
|
||||||
|
for ox in -1..=1_isize {
|
||||||
|
outline = outline.max(mask.get(x as isize + ox, y as isize + oy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// White outline underneath, black fill on top; premultiplied.
|
||||||
|
let white = outline * (1.0 - fill);
|
||||||
|
let alpha = fill + outline * (1.0 - fill);
|
||||||
|
let c = (white * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||||
|
|
||||||
|
let i = (y * w + x) * 4;
|
||||||
|
rgba[i] = c;
|
||||||
|
rgba[i + 1] = c;
|
||||||
|
rgba[i + 2] = c;
|
||||||
|
rgba[i + 3] = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hot = to_px(hotspot);
|
||||||
|
Some(egui::CursorImage {
|
||||||
|
id,
|
||||||
|
rgba: Arc::new(rgba),
|
||||||
|
size: (w as u16, h as u16),
|
||||||
|
hotspot: (
|
||||||
|
hot.x.round().clamp(0.0, w as f32 - 1.0) as u16,
|
||||||
|
hot.y.round().clamp(0.0, h as f32 - 1.0) as u16,
|
||||||
|
),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Per-frame cursor slot using egui context data ---
|
// --- 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
|
// If a widget explicitly asked for a system cursor (resize handles, text inputs, ...), let it
|
||||||
let system_cursor = ctx.output(|o| o.cursor_icon);
|
// win — it knows something about the hover target that we don't.
|
||||||
if system_cursor != egui::CursorIcon::Default {
|
if ctx.output(|o| o.cursor_icon) != egui::CursorIcon::Default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the system cursor
|
match cursor.kind() {
|
||||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
CursorKind::System(icon) => ctx.set_cursor_icon(icon),
|
||||||
|
_ => {
|
||||||
if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) {
|
let ppp = ctx.pixels_per_point();
|
||||||
let hotspot = cursor.hotspot();
|
if let Some(image) = cache.get_or_build(ctx, cursor, ppp) {
|
||||||
let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32);
|
ctx.set_cursor_image(Some(image));
|
||||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
} else {
|
||||||
let painter = ctx.debug_painter();
|
// Rasterization failed — better a crosshair than an invisible cursor.
|
||||||
|
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||||
// Draw white outline: render white version offset in 8 directions
|
}
|
||||||
let outline_tex = cache.get_or_load_outline(cursor, ctx);
|
|
||||||
let outline_id = outline_tex.id();
|
|
||||||
for &(dx, dy) in &[
|
|
||||||
(-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0),
|
|
||||||
(0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET),
|
|
||||||
(-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET),
|
|
||||||
(-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET),
|
|
||||||
] {
|
|
||||||
let offset_rect = egui::Rect::from_min_size(
|
|
||||||
pos - hotspot + egui::vec2(dx, dy),
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
|
||||||
ui.spacing_mut().slider_width = 200.0;
|
|
||||||
let stop = &mut gradient.stops[i];
|
|
||||||
let mut c32 = Color32::from_rgba_unmultiplied(
|
let mut c32 = Color32::from_rgba_unmultiplied(
|
||||||
stop.color.r, stop.color.g, stop.color.b, stop.color.a,
|
stop_color.r, stop_color.g, stop_color.b, stop_color.a,
|
||||||
);
|
);
|
||||||
if egui::color_picker::color_picker_color32(
|
if crate::widgets::color_swatch::color_picker_popup(
|
||||||
ui, &mut c32, egui::color_picker::Alpha::OnlyBlend,
|
ui,
|
||||||
|
resp.id.with("stop_color_popup"),
|
||||||
|
&resp,
|
||||||
|
&mut c32,
|
||||||
|
resp.rect,
|
||||||
) {
|
) {
|
||||||
// Color32 stores premultiplied RGB; unmultiply before storing
|
// Color32 stores premultiplied RGB; unmultiply before storing as straight-alpha
|
||||||
// as straight-alpha ShapeColor to avoid darkening on round-trip.
|
// ShapeColor to avoid darkening on round-trip.
|
||||||
let [pr, pg, pb, a] = c32.to_array();
|
let [pr, pg, pb, a] = c32.to_array();
|
||||||
let unpm = |c: u8| -> 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 }
|
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);
|
gradient.stops[i].color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply drag to whichever stop selected_stop points at.
|
// Apply drag to whichever stop selected_stop points at.
|
||||||
|
|
|
||||||
|
|
@ -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,41 +756,25 @@ 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 is_eraser {
|
|
||||||
rs.eraser_opacity = s.opaque.clamp(0.0, 1.0);
|
|
||||||
rs.eraser_hardness = s.hardness.clamp(0.0, 1.0);
|
|
||||||
rs.eraser_spacing = s.dabs_per_radius;
|
|
||||||
rs.active_eraser_settings = s.clone();
|
|
||||||
} else {
|
|
||||||
rs.brush_opacity = s.opaque.clamp(0.0, 1.0);
|
|
||||||
rs.brush_hardness = s.hardness.clamp(0.0, 1.0);
|
|
||||||
rs.brush_spacing = s.dabs_per_radius;
|
|
||||||
rs.active_brush_settings = s.clone();
|
|
||||||
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
|
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
|
||||||
// and manually picked a different brush, revert to the generic tool.
|
// and manually picked a different brush, revert to the generic tool.
|
||||||
if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) {
|
if kind == crate::tools::BrushKind::Paint
|
||||||
|
&& matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush)
|
||||||
|
{
|
||||||
*shared.selected_tool = Tool::Draw;
|
*shared.selected_tool = Tool::Draw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
ui.add_space(gap);
|
ui.add_space(gap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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 {
|
} else {
|
||||||
(r, r, 0.0_f32)
|
let slot = shared.raster_settings.brush(crate::tools::BrushKind::Paint);
|
||||||
}
|
let bs = &slot.settings;
|
||||||
} 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)
|
(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
|
|
@ -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,35 +90,105 @@ impl PaneRenderer for ToolbarPane {
|
||||||
*shared.selected_tool = Tool::Select;
|
*shared.selected_tool = Tool::Select;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate how many columns we can fit
|
let is_raster = matches!(active_layer_type, Some(LayerType::Raster));
|
||||||
let available_width = rect.width() - (button_padding * 2.0);
|
let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster));
|
||||||
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;
|
ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING);
|
||||||
|
ui.add_space(BUTTON_PADDING);
|
||||||
|
|
||||||
// Process tools row by row for centered layout
|
// Centre the grid as a block. Work out how many columns fit, then lay the buttons out in
|
||||||
for row in 0..total_rows {
|
// a band of exactly that width, positioned to centre it. The band has to be an explicit
|
||||||
let start_idx = row * columns;
|
// max_rect on the child Ui — a `horizontal_wrapped` nested inside a `horizontal` inherits
|
||||||
let end_idx = (start_idx + columns).min(total_tools);
|
// the parent's available width and wraps against that, not against the width we set.
|
||||||
let buttons_in_row = end_idx - start_idx;
|
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;
|
||||||
|
|
||||||
// Calculate the total width of buttons in this row
|
let cursor = ui.cursor().min;
|
||||||
let row_width = (buttons_in_row as f32 * button_size)
|
let band = egui::Rect::from_min_size(
|
||||||
+ ((buttons_in_row.saturating_sub(1)) as f32 * button_spacing);
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Center the row
|
// Colour swatches below the tools.
|
||||||
let mut x = rect.left() + (rect.width() - row_width) / 2.0;
|
// Stroke/FG always on top, Fill/BG always on bottom.
|
||||||
|
// Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill".
|
||||||
|
if show_colors {
|
||||||
|
ui.add_space(BUTTON_SPACING * 2.0);
|
||||||
|
|
||||||
for tool_idx in start_idx..end_idx {
|
let row_width = COLOR_LABEL_WIDTH + COLOR_BUTTON_SIZE + BUTTON_SPACING;
|
||||||
let tool = &tools[tool_idx];
|
let color_indent = ((ui.available_width() - row_width) / 2.0).max(0.0);
|
||||||
let button_rect =
|
|
||||||
egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(button_size, button_size));
|
for (label, is_stroke) in [
|
||||||
|
(if is_raster { "FG" } else { "Stroke" }, true),
|
||||||
|
(if is_raster { "BG" } else { "Fill" }, false),
|
||||||
|
] {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(color_indent);
|
||||||
|
|
||||||
|
let (label_rect, _) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(COLOR_LABEL_WIDTH, COLOR_BUTTON_SIZE),
|
||||||
|
egui::Sense::hover(),
|
||||||
|
);
|
||||||
|
let label_color = shared.theme.text_color(
|
||||||
|
&["#toolbar", ".text-secondary"],
|
||||||
|
ui.ctx(),
|
||||||
|
egui::Color32::from_gray(200),
|
||||||
|
);
|
||||||
|
ui.painter().text(
|
||||||
|
label_rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
label,
|
||||||
|
egui::FontId::proportional(14.0),
|
||||||
|
label_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (swatch_rect, _) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(COLOR_BUTTON_SIZE, COLOR_BUTTON_SIZE),
|
||||||
|
egui::Sense::hover(),
|
||||||
|
);
|
||||||
|
let (id_key, color) = if is_stroke {
|
||||||
|
("stroke_color_button", &mut *shared.stroke_color)
|
||||||
|
} else {
|
||||||
|
("fill_color_button", &mut *shared.fill_color)
|
||||||
|
};
|
||||||
|
let button_id = ui.id().with((id_key, path));
|
||||||
|
crate::widgets::color_swatch::color_swatch(ui, button_id, swatch_rect, color);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(BUTTON_PADDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tool_button(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
tool: &Tool,
|
||||||
|
shared: &mut SharedPaneState,
|
||||||
|
) {
|
||||||
|
let (button_rect, response) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(BUTTON_SIZE, BUTTON_SIZE),
|
||||||
|
egui::Sense::click(),
|
||||||
|
);
|
||||||
|
|
||||||
// Check if this is the selected tool
|
|
||||||
let is_selected = *shared.selected_tool == *tool;
|
let is_selected = *shared.selected_tool == *tool;
|
||||||
|
|
||||||
// Button background
|
// Button background
|
||||||
|
|
@ -91,8 +199,21 @@ impl PaneRenderer for ToolbarPane {
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(button_rect, 4.0, bg_color);
|
ui.painter().rect_filled(button_rect, 4.0, bg_color);
|
||||||
|
|
||||||
// Load and render tool icon
|
// Tool icon: tools without a bundled SVG fall back to a Lucide glyph rather than the
|
||||||
if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) {
|
// shared TODO placeholder.
|
||||||
|
if let Some(glyph) = crate::mobile::icons::tool_glyph(*tool) {
|
||||||
|
ui.painter().text(
|
||||||
|
button_rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
glyph,
|
||||||
|
crate::mobile::icons::font(26.0),
|
||||||
|
shared.theme.text_color(
|
||||||
|
&["#toolbar", ".tool-button"],
|
||||||
|
ui.ctx(),
|
||||||
|
egui::Color32::from_gray(220),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) {
|
||||||
let icon_rect = button_rect.shrink(8.0); // Padding inside button
|
let icon_rect = button_rect.shrink(8.0); // Padding inside button
|
||||||
ui.painter().image(
|
ui.painter().image(
|
||||||
icon.id(),
|
icon.id(),
|
||||||
|
|
@ -120,11 +241,6 @@ impl PaneRenderer for ToolbarPane {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
if response.clicked() {
|
||||||
*shared.selected_tool = *tool;
|
*shared.selected_tool = *tool;
|
||||||
// Preset-backed tools: auto-select the matching bundled brush.
|
// Preset-backed tools: auto-select the matching bundled brush.
|
||||||
|
|
@ -135,12 +251,12 @@ impl PaneRenderer for ToolbarPane {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(name) = preset_name {
|
if let Some(name) = preset_name {
|
||||||
if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) {
|
if let Some(idx) = bundled_brushes().iter().position(|p| p.name == name) {
|
||||||
let s = &preset.settings;
|
let settings = bundled_brushes()[idx].settings.clone();
|
||||||
shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0);
|
shared
|
||||||
shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0);
|
.raster_settings
|
||||||
shared.raster_settings.brush_spacing = s.dabs_per_radius;
|
.brush_mut(crate::tools::BrushKind::Paint)
|
||||||
shared.raster_settings.active_brush_settings = s.clone();
|
.apply_preset(idx, &settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -209,6 +325,16 @@ impl PaneRenderer for ToolbarPane {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
// Show tooltip with tool name and shortcut (consumes response).
|
||||||
// Hint text is pulled from the live keymap so it reflects user remappings.
|
// Hint text is pulled from the live keymap so it reflects user remappings.
|
||||||
let hint = tool_app_action(*tool)
|
let hint = tool_app_action(*tool)
|
||||||
|
|
@ -232,140 +358,5 @@ impl PaneRenderer for ToolbarPane {
|
||||||
format!("{}{}", tool.display_name(), hint)
|
format!("{}{}", tool.display_name(), hint)
|
||||||
};
|
};
|
||||||
response.on_hover_text(tooltip);
|
response.on_hover_text(tooltip);
|
||||||
|
|
||||||
// Draw selection border
|
|
||||||
if is_selected {
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
button_rect,
|
|
||||||
4.0,
|
|
||||||
egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))),
|
|
||||||
egui::StrokeKind::Middle,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to next column in this row
|
|
||||||
x += button_size + button_spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to next row
|
|
||||||
y += button_size + button_spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_raster = matches!(active_layer_type, Some(LayerType::Raster));
|
|
||||||
let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster));
|
|
||||||
|
|
||||||
// Add color pickers below the tool buttons
|
|
||||||
if show_colors {
|
|
||||||
y += button_spacing * 2.0; // Extra spacing
|
|
||||||
|
|
||||||
let fill_label_width = 40.0;
|
|
||||||
let color_button_size = 50.0;
|
|
||||||
let color_row_width = fill_label_width + color_button_size + button_spacing;
|
|
||||||
let color_x = rect.left() + (rect.width() - color_row_width) / 2.0;
|
|
||||||
|
|
||||||
// Two color swatches:
|
|
||||||
// Stroke/FG always on top, Fill/BG always on bottom.
|
|
||||||
// Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill".
|
|
||||||
{
|
|
||||||
let stroke_label = if is_raster { "FG" } else { "Stroke" };
|
|
||||||
let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200));
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
stroke_label,
|
|
||||||
egui::FontId::proportional(14.0),
|
|
||||||
label_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
let stroke_button_rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(color_x + fill_label_width + button_spacing, y),
|
|
||||||
egui::vec2(color_button_size, color_button_size),
|
|
||||||
);
|
|
||||||
let stroke_button_id = ui.id().with(("stroke_color_button", path));
|
|
||||||
let stroke_response = ui.interact(stroke_button_rect, stroke_button_id, egui::Sense::click());
|
|
||||||
draw_color_button(ui, stroke_button_rect, *shared.stroke_color);
|
|
||||||
egui::containers::Popup::from_toggle_button_response(&stroke_response)
|
|
||||||
.show(|ui| {
|
|
||||||
ui.spacing_mut().slider_width = 275.0;
|
|
||||||
let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend);
|
|
||||||
if changed {
|
|
||||||
*shared.active_color_mode = super::ColorMode::Stroke;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
y += color_button_size + button_spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill/BG color swatch
|
|
||||||
{
|
|
||||||
let fill_label = if is_raster { "BG" } else { "Fill" };
|
|
||||||
let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200));
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
|
|
||||||
egui::Align2::CENTER_CENTER,
|
|
||||||
fill_label,
|
|
||||||
egui::FontId::proportional(14.0),
|
|
||||||
label_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
let fill_button_rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(color_x + fill_label_width + button_spacing, y),
|
|
||||||
egui::vec2(color_button_size, color_button_size),
|
|
||||||
);
|
|
||||||
let fill_button_id = ui.id().with(("fill_color_button", path));
|
|
||||||
let fill_response = ui.interact(fill_button_rect, fill_button_id, egui::Sense::click());
|
|
||||||
draw_color_button(ui, fill_button_rect, *shared.fill_color);
|
|
||||||
egui::containers::Popup::from_toggle_button_response(&fill_response)
|
|
||||||
.show(|ui| {
|
|
||||||
ui.spacing_mut().slider_width = 275.0;
|
|
||||||
let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend);
|
|
||||||
if changed {
|
|
||||||
*shared.active_color_mode = super::ColorMode::Fill;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} // end color pickers
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"Toolbar"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a color button with checkerboard background for alpha channel
|
|
||||||
fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) {
|
|
||||||
// Draw checkerboard background
|
|
||||||
let checker_size = 5.0;
|
|
||||||
let cols = (rect.width() / checker_size).ceil() as usize;
|
|
||||||
let rows = (rect.height() / checker_size).ceil() as usize;
|
|
||||||
|
|
||||||
for row in 0..rows {
|
|
||||||
for col in 0..cols {
|
|
||||||
let is_light = (row + col) % 2 == 0;
|
|
||||||
let checker_color = if is_light {
|
|
||||||
egui::Color32::from_gray(180)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_gray(120)
|
|
||||||
};
|
|
||||||
let checker_rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(
|
|
||||||
rect.min.x + col as f32 * checker_size,
|
|
||||||
rect.min.y + row as f32 * checker_size,
|
|
||||||
),
|
|
||||||
egui::vec2(checker_size, checker_size),
|
|
||||||
).intersect(rect);
|
|
||||||
ui.painter().rect_filled(checker_rect, 0.0, checker_color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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.0–1.0). Falls back to 1.0 when no tablet is active.
|
/// Current pen pressure (0.0–1.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,18 +1022,45 @@ 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) {
|
||||||
|
// X11 reports the tip as button 1 and the barrel buttons as 2 and 3.
|
||||||
|
match raw.detail {
|
||||||
|
1 => {
|
||||||
let _ = tx.send(RawTabletEvent::TipDown);
|
let _ = tx.send(RawTabletEvent::TipDown);
|
||||||
let _ = tx.send(RawTabletEvent::Frame);
|
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 {
|
||||||
|
1 => {
|
||||||
let _ = tx.send(RawTabletEvent::TipUp);
|
let _ = tx.send(RawTabletEvent::TipUp);
|
||||||
let _ = tx.send(RawTabletEvent::Frame);
|
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);
|
||||||
let _ = tx.send(RawTabletEvent::ProximityOut);
|
let _ = tx.send(RawTabletEvent::ProximityOut);
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] }
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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 (1–20)
|
/// Neighborhood kernel radius in canvas pixels (1–20)
|
||||||
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, 0–510). Pixels within this
|
/// Color-distance threshold (Euclidean RGBA, 0–510). 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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue