Compare commits
No commits in common. "c6a8b944e5092c0c6b657def81842a80c24d593e" and "394e369122f031f39a0da4da6286f11ac9e23f5c" have entirely different histories.
c6a8b944e5
...
394e369122
|
|
@ -1,4 +1,3 @@
|
||||||
use std::sync::Arc;
|
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
/// Audio clip instance ID type
|
/// Audio clip instance ID type
|
||||||
|
|
@ -36,13 +35,6 @@ pub struct AudioClipInstance {
|
||||||
|
|
||||||
/// Clip-level gain
|
/// Clip-level gain
|
||||||
pub gain: f32,
|
pub gain: f32,
|
||||||
|
|
||||||
/// Per-instance read-ahead buffer for compressed audio streaming.
|
|
||||||
/// Each clip instance gets its own buffer so multiple instances of the
|
|
||||||
/// same file (on different tracks or at different positions) don't fight
|
|
||||||
/// over a single target_frame.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type alias for backwards compatibility
|
/// Type alias for backwards compatibility
|
||||||
|
|
@ -66,7 +58,6 @@ impl AudioClipInstance {
|
||||||
external_start,
|
external_start,
|
||||||
external_duration,
|
external_duration,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
read_ahead: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +78,6 @@ impl AudioClipInstance {
|
||||||
external_start: start_time,
|
external_start: start_time,
|
||||||
external_duration: duration,
|
external_duration: duration,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
read_ahead: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,27 +166,11 @@ impl ReadAheadBuffer {
|
||||||
|
|
||||||
/// Update the target frame — the file-local frame the audio callback
|
/// Update the target frame — the file-local frame the audio callback
|
||||||
/// is currently reading from. Called by `render_from_file` (consumer).
|
/// is currently reading from. Called by `render_from_file` (consumer).
|
||||||
/// Each clip instance has its own buffer, so a plain store is sufficient.
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn set_target_frame(&self, frame: u64) {
|
pub fn set_target_frame(&self, frame: u64) {
|
||||||
self.target_frame.store(frame, Ordering::Relaxed);
|
self.target_frame.store(frame, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the target frame to MAX before a new render cycle.
|
|
||||||
/// If no clip calls `set_target_frame` this cycle, `has_active_target()`
|
|
||||||
/// returns false, telling the disk reader to skip this buffer.
|
|
||||||
#[inline]
|
|
||||||
pub fn reset_target_frame(&self) {
|
|
||||||
self.target_frame.store(u64::MAX, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force-set the target frame to an exact value.
|
|
||||||
/// Used by the disk reader's seek command where we need an absolute position.
|
|
||||||
#[inline]
|
|
||||||
pub fn force_target_frame(&self, frame: u64) {
|
|
||||||
self.target_frame.store(frame, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the target frame set by the audio callback.
|
/// Get the target frame set by the audio callback.
|
||||||
/// Called by the disk reader thread (producer).
|
/// Called by the disk reader thread (producer).
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|
@ -194,12 +178,6 @@ impl ReadAheadBuffer {
|
||||||
self.target_frame.load(Ordering::Relaxed)
|
self.target_frame.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if any clip set a target this cycle (vs still at reset value).
|
|
||||||
#[inline]
|
|
||||||
pub fn has_active_target(&self) -> bool {
|
|
||||||
self.target_frame.load(Ordering::Relaxed) != u64::MAX
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the buffer to start at `new_start` with zero valid frames.
|
/// Reset the buffer to start at `new_start` with zero valid frames.
|
||||||
/// Called by the **disk reader thread** (producer) after a seek.
|
/// Called by the **disk reader thread** (producer) after a seek.
|
||||||
pub fn reset(&self, new_start: u64) {
|
pub fn reset(&self, new_start: u64) {
|
||||||
|
|
@ -435,14 +413,14 @@ impl CompressedReader {
|
||||||
|
|
||||||
/// Commands sent from the engine to the disk reader thread.
|
/// Commands sent from the engine to the disk reader thread.
|
||||||
pub enum DiskReaderCommand {
|
pub enum DiskReaderCommand {
|
||||||
/// Start streaming a compressed file for a clip instance.
|
/// Start streaming a compressed file.
|
||||||
ActivateFile {
|
ActivateFile {
|
||||||
reader_id: u64,
|
pool_index: usize,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
buffer: Arc<ReadAheadBuffer>,
|
buffer: Arc<ReadAheadBuffer>,
|
||||||
},
|
},
|
||||||
/// Stop streaming for a clip instance.
|
/// Stop streaming a file.
|
||||||
DeactivateFile { reader_id: u64 },
|
DeactivateFile { pool_index: usize },
|
||||||
/// The playhead has jumped — refill buffers from the new position.
|
/// The playhead has jumped — refill buffers from the new position.
|
||||||
Seek { frame: u64 },
|
Seek { frame: u64 },
|
||||||
/// Shut down the disk reader thread.
|
/// Shut down the disk reader thread.
|
||||||
|
|
@ -513,7 +491,7 @@ impl DiskReader {
|
||||||
mut command_rx: rtrb::Consumer<DiskReaderCommand>,
|
mut command_rx: rtrb::Consumer<DiskReaderCommand>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
let mut active_files: HashMap<u64, (CompressedReader, Arc<ReadAheadBuffer>)> =
|
let mut active_files: HashMap<usize, (CompressedReader, Arc<ReadAheadBuffer>)> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
let mut decode_buf = Vec::with_capacity(8192);
|
let mut decode_buf = Vec::with_capacity(8192);
|
||||||
|
|
||||||
|
|
@ -522,14 +500,14 @@ impl DiskReader {
|
||||||
while let Ok(cmd) = command_rx.pop() {
|
while let Ok(cmd) = command_rx.pop() {
|
||||||
match cmd {
|
match cmd {
|
||||||
DiskReaderCommand::ActivateFile {
|
DiskReaderCommand::ActivateFile {
|
||||||
reader_id,
|
pool_index,
|
||||||
path,
|
path,
|
||||||
buffer,
|
buffer,
|
||||||
} => match CompressedReader::open(&path) {
|
} => match CompressedReader::open(&path) {
|
||||||
Ok(reader) => {
|
Ok(reader) => {
|
||||||
eprintln!("[DiskReader] Activated reader={}, ch={}, sr={}, path={:?}",
|
eprintln!("[DiskReader] Activated pool={}, ch={}, sr={}, path={:?}",
|
||||||
reader_id, reader.channels, reader.sample_rate, path);
|
pool_index, reader.channels, reader.sample_rate, path);
|
||||||
active_files.insert(reader_id, (reader, buffer));
|
active_files.insert(pool_index, (reader, buffer));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
@ -538,12 +516,12 @@ impl DiskReader {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DiskReaderCommand::DeactivateFile { reader_id } => {
|
DiskReaderCommand::DeactivateFile { pool_index } => {
|
||||||
active_files.remove(&reader_id);
|
active_files.remove(&pool_index);
|
||||||
}
|
}
|
||||||
DiskReaderCommand::Seek { frame } => {
|
DiskReaderCommand::Seek { frame } => {
|
||||||
for (_, (reader, buffer)) in active_files.iter_mut() {
|
for (_, (reader, buffer)) in active_files.iter_mut() {
|
||||||
buffer.force_target_frame(frame);
|
buffer.set_target_frame(frame);
|
||||||
buffer.reset(frame);
|
buffer.reset(frame);
|
||||||
if let Err(e) = reader.seek(frame) {
|
if let Err(e) = reader.seek(frame) {
|
||||||
eprintln!("[DiskReader] Seek error: {}", e);
|
eprintln!("[DiskReader] Seek error: {}", e);
|
||||||
|
|
@ -556,15 +534,11 @@ impl DiskReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill each active reader's buffer ahead of its target frame.
|
// Fill each active file's buffer ahead of its target frame.
|
||||||
// Each clip instance has its own buffer and target_frame, set by
|
// Each file's target_frame is set by the audio callback in
|
||||||
// render_from_file during the audio callback.
|
// render_from_file, giving the file-local frame being read.
|
||||||
for (_reader_id, (reader, buffer)) in active_files.iter_mut() {
|
// This is independent of the global engine playhead.
|
||||||
// Skip files where no clip is currently playing
|
for (_pool_index, (reader, buffer)) in active_files.iter_mut() {
|
||||||
if !buffer.has_active_target() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = buffer.target_frame();
|
let target = buffer.target_frame();
|
||||||
let buf_start = buffer.start_frame();
|
let buf_start = buffer.start_frame();
|
||||||
let buf_valid = buffer.valid_frames_count();
|
let buf_valid = buffer.valid_frames_count();
|
||||||
|
|
@ -604,8 +578,8 @@ impl DiskReader {
|
||||||
let was_empty = buffer.valid_frames_count() == 0;
|
let was_empty = buffer.valid_frames_count() == 0;
|
||||||
buffer.write_samples(&decode_buf, frames);
|
buffer.write_samples(&decode_buf, frames);
|
||||||
if was_empty {
|
if was_empty {
|
||||||
eprintln!("[DiskReader] reader={}: first fill, {} frames, buf_start={}, valid={}",
|
eprintln!("[DiskReader] pool={}: first fill, {} frames, buf_start={}, valid={}",
|
||||||
_reader_id, frames, buffer.start_frame(), buffer.valid_frames_count());
|
_pool_index, frames, buffer.start_frame(), buffer.valid_frames_count());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -313,9 +313,6 @@ impl Engine {
|
||||||
// Convert playhead from frames to seconds for timeline-based rendering
|
// Convert playhead from frames to seconds for timeline-based rendering
|
||||||
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
|
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
|
||||||
|
|
||||||
// Reset per-clip read-ahead targets before rendering.
|
|
||||||
self.project.reset_read_ahead_targets();
|
|
||||||
|
|
||||||
// Render the entire project hierarchy into the mix buffer
|
// Render the entire project hierarchy into the mix buffer
|
||||||
// Note: We need to use a raw pointer to avoid borrow checker issues
|
// Note: We need to use a raw pointer to avoid borrow checker issues
|
||||||
// The midi_clip_pool is part of project, so we extract a reference before mutable borrow
|
// The midi_clip_pool is part of project, so we extract a reference before mutable borrow
|
||||||
|
|
@ -801,12 +798,6 @@ impl Engine {
|
||||||
let _ = self.project.remove_midi_clip(track_id, instance_id);
|
let _ = self.project.remove_midi_clip(track_id, instance_id);
|
||||||
}
|
}
|
||||||
Command::RemoveAudioClip(track_id, instance_id) => {
|
Command::RemoveAudioClip(track_id, instance_id) => {
|
||||||
// Deactivate the per-clip disk reader before removing
|
|
||||||
if let Some(ref mut dr) = self.disk_reader {
|
|
||||||
dr.send(crate::audio::disk_reader::DiskReaderCommand::DeactivateFile {
|
|
||||||
reader_id: instance_id as u64,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Remove an audio clip instance from a track (for undo/redo support)
|
// Remove an audio clip instance from a track (for undo/redo support)
|
||||||
let _ = self.project.remove_audio_clip(track_id, instance_id);
|
let _ = self.project.remove_audio_clip(track_id, instance_id);
|
||||||
}
|
}
|
||||||
|
|
@ -1763,7 +1754,7 @@ impl Engine {
|
||||||
(metadata.duration * metadata.sample_rate as f64).ceil() as u64
|
(metadata.duration * metadata.sample_rate as f64).ceil() as u64
|
||||||
});
|
});
|
||||||
|
|
||||||
let audio_file = crate::audio::pool::AudioFile::from_compressed(
|
let mut audio_file = crate::audio::pool::AudioFile::from_compressed(
|
||||||
path.to_path_buf(),
|
path.to_path_buf(),
|
||||||
metadata.channels,
|
metadata.channels,
|
||||||
metadata.sample_rate,
|
metadata.sample_rate,
|
||||||
|
|
@ -1771,11 +1762,25 @@ impl Engine {
|
||||||
ext,
|
ext,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let buffer = crate::audio::disk_reader::DiskReader::create_buffer(
|
||||||
|
metadata.sample_rate,
|
||||||
|
metadata.channels,
|
||||||
|
);
|
||||||
|
audio_file.read_ahead = Some(buffer.clone());
|
||||||
|
|
||||||
let idx = self.audio_pool.add_file(audio_file);
|
let idx = self.audio_pool.add_file(audio_file);
|
||||||
|
|
||||||
eprintln!("[ENGINE] Compressed: total_frames={}, pool_index={}, has_disk_reader={}",
|
eprintln!("[ENGINE] Compressed: total_frames={}, pool_index={}, has_disk_reader={}",
|
||||||
total_frames, idx, self.disk_reader.is_some());
|
total_frames, idx, self.disk_reader.is_some());
|
||||||
|
|
||||||
|
if let Some(ref mut dr) = self.disk_reader {
|
||||||
|
dr.send(crate::audio::disk_reader::DiskReaderCommand::ActivateFile {
|
||||||
|
pool_index: idx,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
buffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn background thread to decode file progressively for waveform display
|
// Spawn background thread to decode file progressively for waveform display
|
||||||
let bg_tx = self.chunk_generation_tx.clone();
|
let bg_tx = self.chunk_generation_tx.clone();
|
||||||
let bg_path = path.to_path_buf();
|
let bg_path = path.to_path_buf();
|
||||||
|
|
@ -2133,28 +2138,6 @@ impl Engine {
|
||||||
let instance_id = self.next_clip_id;
|
let instance_id = self.next_clip_id;
|
||||||
self.next_clip_id += 1;
|
self.next_clip_id += 1;
|
||||||
|
|
||||||
// For compressed files, create a per-clip read-ahead buffer
|
|
||||||
let read_ahead = if let Some(file) = self.audio_pool.get_file(pool_index) {
|
|
||||||
if matches!(file.storage, crate::audio::pool::AudioStorage::Compressed { .. }) {
|
|
||||||
let buffer = crate::audio::disk_reader::DiskReader::create_buffer(
|
|
||||||
file.sample_rate,
|
|
||||||
file.channels,
|
|
||||||
);
|
|
||||||
if let Some(ref mut dr) = self.disk_reader {
|
|
||||||
dr.send(crate::audio::disk_reader::DiskReaderCommand::ActivateFile {
|
|
||||||
reader_id: instance_id as u64,
|
|
||||||
path: file.path.clone(),
|
|
||||||
buffer: buffer.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some(buffer)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let clip = AudioClipInstance {
|
let clip = AudioClipInstance {
|
||||||
id: instance_id,
|
id: instance_id,
|
||||||
audio_pool_index: pool_index,
|
audio_pool_index: pool_index,
|
||||||
|
|
@ -2163,7 +2146,6 @@ impl Engine {
|
||||||
external_start: start_time,
|
external_start: start_time,
|
||||||
external_duration: duration,
|
external_duration: duration,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
read_ahead,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.project.add_clip(track_id, clip) {
|
match self.project.add_clip(track_id, clip) {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ pub struct AudioFile {
|
||||||
/// Original file format (mp3, ogg, wav, flac, etc.)
|
/// Original file format (mp3, ogg, wav, flac, etc.)
|
||||||
/// Used to determine if we should preserve lossy encoding during save
|
/// Used to determine if we should preserve lossy encoding during save
|
||||||
pub original_format: Option<String>,
|
pub original_format: Option<String>,
|
||||||
|
/// Read-ahead buffer for streaming playback (Compressed files).
|
||||||
|
/// When present, `render_from_file` reads from this buffer instead of `data()`.
|
||||||
|
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioFile {
|
impl AudioFile {
|
||||||
|
|
@ -108,6 +111,7 @@ impl AudioFile {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
frames,
|
frames,
|
||||||
original_format: None,
|
original_format: None,
|
||||||
|
read_ahead: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +125,7 @@ impl AudioFile {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
frames,
|
frames,
|
||||||
original_format,
|
original_format,
|
||||||
|
read_ahead: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,6 +157,7 @@ impl AudioFile {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
frames: total_frames,
|
frames: total_frames,
|
||||||
original_format: Some("wav".to_string()),
|
original_format: Some("wav".to_string()),
|
||||||
|
read_ahead: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,6 +180,7 @@ impl AudioFile {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
frames: total_frames,
|
frames: total_frames,
|
||||||
original_format,
|
original_format,
|
||||||
|
read_ahead: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,7 +444,6 @@ impl AudioClipPool {
|
||||||
|
|
||||||
/// Render audio from a file in the pool with high-quality windowed sinc interpolation
|
/// Render audio from a file in the pool with high-quality windowed sinc interpolation
|
||||||
/// start_time_seconds: position in the audio file to start reading from (in seconds)
|
/// start_time_seconds: position in the audio file to start reading from (in seconds)
|
||||||
/// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming
|
|
||||||
/// Returns the number of samples actually rendered
|
/// Returns the number of samples actually rendered
|
||||||
pub fn render_from_file(
|
pub fn render_from_file(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -447,14 +453,13 @@ impl AudioClipPool {
|
||||||
gain: f32,
|
gain: f32,
|
||||||
engine_sample_rate: u32,
|
engine_sample_rate: u32,
|
||||||
engine_channels: u32,
|
engine_channels: u32,
|
||||||
clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>,
|
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let Some(audio_file) = self.files.get(pool_index) else {
|
let Some(audio_file) = self.files.get(pool_index) else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
let audio_data = audio_file.data();
|
let audio_data = audio_file.data();
|
||||||
let read_ahead = clip_read_ahead;
|
let read_ahead = audio_file.read_ahead.as_deref();
|
||||||
let use_read_ahead = audio_data.is_empty();
|
let use_read_ahead = audio_data.is_empty();
|
||||||
let src_channels = audio_file.channels as usize;
|
let src_channels = audio_file.channels as usize;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -484,19 +484,6 @@ impl Project {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset all per-clip read-ahead target frames before a new render cycle.
|
|
||||||
pub fn reset_read_ahead_targets(&self) {
|
|
||||||
for track in self.tracks.values() {
|
|
||||||
if let TrackNode::Audio(audio_track) = track {
|
|
||||||
for clip in &audio_track.clips {
|
|
||||||
if let Some(ra) = clip.read_ahead.as_deref() {
|
|
||||||
ra.reset_target_frame();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop all notes on all MIDI tracks
|
/// Stop all notes on all MIDI tracks
|
||||||
pub fn stop_all_notes(&mut self) {
|
pub fn stop_all_notes(&mut self) {
|
||||||
for track in self.tracks.values_mut() {
|
for track in self.tracks.values_mut() {
|
||||||
|
|
|
||||||
|
|
@ -917,7 +917,6 @@ impl AudioTrack {
|
||||||
combined_gain,
|
combined_gain,
|
||||||
sample_rate,
|
sample_rate,
|
||||||
channels,
|
channels,
|
||||||
clip.read_ahead.as_deref(),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Looping case: need to handle wrap-around at loop boundaries
|
// Looping case: need to handle wrap-around at loop boundaries
|
||||||
|
|
@ -952,7 +951,6 @@ impl AudioTrack {
|
||||||
combined_gain,
|
combined_gain,
|
||||||
sample_rate,
|
sample_rate,
|
||||||
channels,
|
channels,
|
||||||
clip.read_ahead.as_deref(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
total_rendered += rendered;
|
total_rendered += rendered;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,3 @@ pathdiff = "0.2"
|
||||||
# Audio encoding for embedded files
|
# Audio encoding for embedded files
|
||||||
flacenc = "0.4" # For FLAC encoding (lossless)
|
flacenc = "0.4" # For FLAC encoding (lossless)
|
||||||
claxon = "0.4" # For FLAC decoding
|
claxon = "0.4" # For FLAC decoding
|
||||||
|
|
||||||
# System clipboard
|
|
||||||
arboard = "3"
|
|
||||||
|
|
|
||||||
|
|
@ -226,14 +226,12 @@ impl Action for AddClipInstanceAction {
|
||||||
// For sampled audio, send AddAudioClipSync query
|
// For sampled audio, send AddAudioClipSync query
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
use daw_backend::command::{Query, QueryResponse};
|
||||||
|
|
||||||
let internal_start = self.clip_instance.trim_start;
|
let duration = clip.duration;
|
||||||
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration);
|
|
||||||
let effective_duration = self.clip_instance.timeline_duration
|
|
||||||
.unwrap_or(internal_end - internal_start);
|
|
||||||
let start_time = self.clip_instance.timeline_start;
|
let start_time = self.clip_instance.timeline_start;
|
||||||
|
let offset = self.clip_instance.trim_start;
|
||||||
|
|
||||||
let query =
|
let query =
|
||||||
Query::AddAudioClipSync(*backend_track_id, *audio_pool_index, start_time, effective_duration, internal_start);
|
Query::AddAudioClipSync(*backend_track_id, *audio_pool_index, start_time, duration, offset);
|
||||||
|
|
||||||
match controller.send_query(query)? {
|
match controller.send_query(query)? {
|
||||||
QueryResponse::AudioClipInstanceAdded(Ok(instance_id)) => {
|
QueryResponse::AudioClipInstanceAdded(Ok(instance_id)) => {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ pub mod rename_folder;
|
||||||
pub mod delete_folder;
|
pub mod delete_folder;
|
||||||
pub mod move_asset_to_folder;
|
pub mod move_asset_to_folder;
|
||||||
pub mod update_midi_notes;
|
pub mod update_midi_notes;
|
||||||
pub mod remove_clip_instances;
|
|
||||||
pub mod remove_shapes;
|
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -50,5 +48,3 @@ pub use rename_folder::RenameFolderAction;
|
||||||
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
|
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
|
||||||
pub use move_asset_to_folder::MoveAssetToFolderAction;
|
pub use move_asset_to_folder::MoveAssetToFolderAction;
|
||||||
pub use update_midi_notes::UpdateMidiNotesAction;
|
pub use update_midi_notes::UpdateMidiNotesAction;
|
||||||
pub use remove_clip_instances::RemoveClipInstancesAction;
|
|
||||||
pub use remove_shapes::RemoveShapesAction;
|
|
||||||
|
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
//! Remove clip instances action
|
|
||||||
//!
|
|
||||||
//! Handles removing one or more clip instances from layers (for cut/delete).
|
|
||||||
|
|
||||||
use crate::action::{Action, BackendClipInstanceId, BackendContext};
|
|
||||||
use crate::clip::ClipInstance;
|
|
||||||
use crate::document::Document;
|
|
||||||
use crate::layer::AnyLayer;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Action that removes clip instances from layers
|
|
||||||
pub struct RemoveClipInstancesAction {
|
|
||||||
/// (layer_id, instance_id) pairs to remove
|
|
||||||
removals: Vec<(Uuid, Uuid)>,
|
|
||||||
/// Saved instances for rollback (layer_id -> ClipInstance)
|
|
||||||
saved: Vec<(Uuid, ClipInstance)>,
|
|
||||||
/// Saved backend mappings for rollback (instance_id -> BackendClipInstanceId)
|
|
||||||
saved_backend_ids: HashMap<Uuid, BackendClipInstanceId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RemoveClipInstancesAction {
|
|
||||||
/// Create a new remove clip instances action
|
|
||||||
pub fn new(removals: Vec<(Uuid, Uuid)>) -> Self {
|
|
||||||
Self {
|
|
||||||
removals,
|
|
||||||
saved: Vec::new(),
|
|
||||||
saved_backend_ids: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Action for RemoveClipInstancesAction {
|
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
|
||||||
self.saved.clear();
|
|
||||||
|
|
||||||
for (layer_id, instance_id) in &self.removals {
|
|
||||||
let layer = document
|
|
||||||
.get_layer_mut(layer_id)
|
|
||||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
|
||||||
|
|
||||||
let clip_instances = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find and remove the instance, saving it for rollback
|
|
||||||
if let Some(pos) = clip_instances.iter().position(|ci| ci.id == *instance_id) {
|
|
||||||
let removed = clip_instances.remove(pos);
|
|
||||||
self.saved.push((*layer_id, removed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
|
||||||
// Re-insert saved instances
|
|
||||||
for (layer_id, instance) in self.saved.drain(..).rev() {
|
|
||||||
let layer = document
|
|
||||||
.get_layer_mut(&layer_id)
|
|
||||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
|
||||||
|
|
||||||
let clip_instances = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
|
||||||
};
|
|
||||||
|
|
||||||
clip_instances.push(instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> String {
|
|
||||||
let count = self.removals.len();
|
|
||||||
if count == 1 {
|
|
||||||
"Delete clip instance".to_string()
|
|
||||||
} else {
|
|
||||||
format!("Delete {} clip instances", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (layer_id, instance_id) in &self.removals {
|
|
||||||
// Only process audio layers
|
|
||||||
let layer = match document.get_layer(layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let track_id = match backend.layer_to_track_map.get(layer_id) {
|
|
||||||
Some(id) => *id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove from backend using stored mapping
|
|
||||||
if let Some(backend_id) = backend.clip_instance_to_backend_map.remove(instance_id) {
|
|
||||||
self.saved_backend_ids.insert(*instance_id, backend_id.clone());
|
|
||||||
match backend_id {
|
|
||||||
BackendClipInstanceId::Midi(midi_id) => {
|
|
||||||
controller.remove_midi_clip(track_id, midi_id);
|
|
||||||
}
|
|
||||||
BackendClipInstanceId::Audio(audio_id) => {
|
|
||||||
controller.remove_audio_clip(track_id, audio_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback_backend(
|
|
||||||
&mut self,
|
|
||||||
backend: &mut BackendContext,
|
|
||||||
document: &Document,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use crate::clip::AudioClipType;
|
|
||||||
|
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-add clips that were removed from backend
|
|
||||||
for (layer_id, instance) in &self.saved {
|
|
||||||
let layer = match document.get_layer(layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let track_id = match backend.layer_to_track_map.get(layer_id) {
|
|
||||||
Some(id) => *id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let clip = match document.get_audio_clip(&instance.clip_id) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &clip.clip_type {
|
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
|
||||||
|
|
||||||
let internal_start = instance.trim_start;
|
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.duration);
|
|
||||||
let external_start = instance.timeline_start;
|
|
||||||
let external_duration = instance
|
|
||||||
.timeline_duration
|
|
||||||
.unwrap_or(internal_end - internal_start);
|
|
||||||
|
|
||||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
|
||||||
0,
|
|
||||||
*midi_clip_id,
|
|
||||||
internal_start,
|
|
||||||
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 } => {
|
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
|
||||||
|
|
||||||
let internal_start = instance.trim_start;
|
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.duration);
|
|
||||||
let effective_duration = instance.timeline_duration
|
|
||||||
.unwrap_or(internal_end - internal_start);
|
|
||||||
let start_time = instance.timeline_start;
|
|
||||||
|
|
||||||
let query = Query::AddAudioClipSync(
|
|
||||||
track_id,
|
|
||||||
*audio_pool_index,
|
|
||||||
start_time,
|
|
||||||
effective_duration,
|
|
||||||
internal_start,
|
|
||||||
);
|
|
||||||
if let Ok(QueryResponse::AudioClipInstanceAdded(Ok(new_id))) =
|
|
||||||
controller.send_query(query)
|
|
||||||
{
|
|
||||||
backend.clip_instance_to_backend_map.insert(
|
|
||||||
instance.id,
|
|
||||||
BackendClipInstanceId::Audio(new_id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioClipType::Recording => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear saved backend IDs
|
|
||||||
self.saved_backend_ids.clear();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::layer::VectorLayer;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_remove_clip_instances() {
|
|
||||||
let mut document = Document::new("Test");
|
|
||||||
|
|
||||||
let clip_id = Uuid::new_v4();
|
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
|
||||||
|
|
||||||
let mut ci1 = ClipInstance::new(clip_id);
|
|
||||||
ci1.timeline_start = 0.0;
|
|
||||||
let id1 = ci1.id;
|
|
||||||
|
|
||||||
let mut ci2 = ClipInstance::new(clip_id);
|
|
||||||
ci2.timeline_start = 5.0;
|
|
||||||
let id2 = ci2.id;
|
|
||||||
|
|
||||||
vector_layer.clip_instances.push(ci1);
|
|
||||||
vector_layer.clip_instances.push(ci2);
|
|
||||||
|
|
||||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
|
|
||||||
|
|
||||||
// Remove first clip instance
|
|
||||||
let mut action = RemoveClipInstancesAction::new(vec![(layer_id, id1)]);
|
|
||||||
action.execute(&mut document).unwrap();
|
|
||||||
|
|
||||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
|
||||||
assert_eq!(vl.clip_instances.len(), 1);
|
|
||||||
assert_eq!(vl.clip_instances[0].id, id2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rollback
|
|
||||||
action.rollback(&mut document).unwrap();
|
|
||||||
|
|
||||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
|
||||||
assert_eq!(vl.clip_instances.len(), 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
//! Remove shapes action
|
|
||||||
//!
|
|
||||||
//! Handles removing shapes and shape instances from a vector layer (for cut/delete).
|
|
||||||
|
|
||||||
use crate::action::Action;
|
|
||||||
use crate::document::Document;
|
|
||||||
use crate::layer::AnyLayer;
|
|
||||||
use crate::object::ShapeInstance;
|
|
||||||
use crate::shape::Shape;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Action that removes shapes and their instances from a vector layer
|
|
||||||
pub struct RemoveShapesAction {
|
|
||||||
/// Layer ID containing the shapes
|
|
||||||
layer_id: Uuid,
|
|
||||||
/// Shape IDs to remove
|
|
||||||
shape_ids: Vec<Uuid>,
|
|
||||||
/// Shape instance IDs to remove
|
|
||||||
instance_ids: Vec<Uuid>,
|
|
||||||
/// Saved shapes for rollback
|
|
||||||
saved_shapes: Vec<(Uuid, Shape)>,
|
|
||||||
/// Saved instances for rollback
|
|
||||||
saved_instances: Vec<ShapeInstance>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RemoveShapesAction {
|
|
||||||
/// Create a new remove shapes action
|
|
||||||
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, instance_ids: Vec<Uuid>) -> Self {
|
|
||||||
Self {
|
|
||||||
layer_id,
|
|
||||||
shape_ids,
|
|
||||||
instance_ids,
|
|
||||||
saved_shapes: Vec::new(),
|
|
||||||
saved_instances: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Action for RemoveShapesAction {
|
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
|
||||||
self.saved_shapes.clear();
|
|
||||||
self.saved_instances.clear();
|
|
||||||
|
|
||||||
let layer = document
|
|
||||||
.get_layer_mut(&self.layer_id)
|
|
||||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
|
||||||
|
|
||||||
let vector_layer = match layer {
|
|
||||||
AnyLayer::Vector(vl) => vl,
|
|
||||||
_ => return Err("Not a vector layer".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove and save shape instances
|
|
||||||
let mut remaining_instances = Vec::new();
|
|
||||||
for inst in vector_layer.shape_instances.drain(..) {
|
|
||||||
if self.instance_ids.contains(&inst.id) {
|
|
||||||
self.saved_instances.push(inst);
|
|
||||||
} else {
|
|
||||||
remaining_instances.push(inst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vector_layer.shape_instances = remaining_instances;
|
|
||||||
|
|
||||||
// Remove and save shape definitions
|
|
||||||
for shape_id in &self.shape_ids {
|
|
||||||
if let Some(shape) = vector_layer.shapes.remove(shape_id) {
|
|
||||||
self.saved_shapes.push((*shape_id, shape));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback(&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 vector_layer = match layer {
|
|
||||||
AnyLayer::Vector(vl) => vl,
|
|
||||||
_ => return Err("Not a vector layer".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore shapes
|
|
||||||
for (id, shape) in self.saved_shapes.drain(..) {
|
|
||||||
vector_layer.shapes.insert(id, shape);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore instances
|
|
||||||
for inst in self.saved_instances.drain(..) {
|
|
||||||
vector_layer.shape_instances.push(inst);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> String {
|
|
||||||
let count = self.instance_ids.len();
|
|
||||||
if count == 1 {
|
|
||||||
"Delete shape".to_string()
|
|
||||||
} else {
|
|
||||||
format!("Delete {} shapes", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::layer::VectorLayer;
|
|
||||||
use crate::object::ShapeInstance;
|
|
||||||
use crate::shape::Shape;
|
|
||||||
use vello::kurbo::BezPath;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_remove_shapes() {
|
|
||||||
let mut document = Document::new("Test");
|
|
||||||
|
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
|
||||||
|
|
||||||
// Add a shape and instance
|
|
||||||
let mut path = BezPath::new();
|
|
||||||
path.move_to((0.0, 0.0));
|
|
||||||
path.line_to((100.0, 100.0));
|
|
||||||
let shape = Shape::new(path);
|
|
||||||
let shape_id = shape.id;
|
|
||||||
let instance = ShapeInstance::new(shape_id);
|
|
||||||
let instance_id = instance.id;
|
|
||||||
|
|
||||||
vector_layer.shapes.insert(shape_id, shape);
|
|
||||||
vector_layer.shape_instances.push(instance);
|
|
||||||
|
|
||||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
|
|
||||||
|
|
||||||
// Remove
|
|
||||||
let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], vec![instance_id]);
|
|
||||||
action.execute(&mut document).unwrap();
|
|
||||||
|
|
||||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
|
||||||
assert!(vl.shapes.is_empty());
|
|
||||||
assert!(vl.shape_instances.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rollback
|
|
||||||
action.rollback(&mut document).unwrap();
|
|
||||||
|
|
||||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
|
||||||
assert_eq!(vl.shapes.len(), 1);
|
|
||||||
assert_eq!(vl.shape_instances.len(), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -415,18 +415,16 @@ impl Action for SplitClipInstanceAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Add the new (right) instance
|
// 2. Add the new (right) instance
|
||||||
let internal_start = new_instance.trim_start;
|
let duration = clip.duration;
|
||||||
let internal_end = new_instance.trim_end.unwrap_or(clip.duration);
|
|
||||||
let effective_duration = new_instance.timeline_duration
|
|
||||||
.unwrap_or(internal_end - internal_start);
|
|
||||||
let start_time = new_instance.timeline_start;
|
let start_time = new_instance.timeline_start;
|
||||||
|
let offset = new_instance.trim_start;
|
||||||
|
|
||||||
let query = Query::AddAudioClipSync(
|
let query = Query::AddAudioClipSync(
|
||||||
*backend_track_id,
|
*backend_track_id,
|
||||||
*audio_pool_index,
|
*audio_pool_index,
|
||||||
start_time,
|
start_time,
|
||||||
effective_duration,
|
duration,
|
||||||
internal_start,
|
offset,
|
||||||
);
|
);
|
||||||
|
|
||||||
match controller.send_query(query)? {
|
match controller.send_query(query)? {
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
//! Clipboard management for cut/copy/paste operations
|
|
||||||
//!
|
|
||||||
//! Supports multiple content types (clip instances, shapes) with
|
|
||||||
//! cross-platform clipboard integration via arboard.
|
|
||||||
|
|
||||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip};
|
|
||||||
use crate::layer::{AudioLayerType, AnyLayer};
|
|
||||||
use crate::object::ShapeInstance;
|
|
||||||
use crate::shape::Shape;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Layer type tag for clipboard, so paste knows where clips can go
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum ClipboardLayerType {
|
|
||||||
Vector,
|
|
||||||
Video,
|
|
||||||
AudioSampled,
|
|
||||||
AudioMidi,
|
|
||||||
Effect,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClipboardLayerType {
|
|
||||||
/// Determine the clipboard layer type from a document layer
|
|
||||||
pub fn from_layer(layer: &AnyLayer) -> Self {
|
|
||||||
match layer {
|
|
||||||
AnyLayer::Vector(_) => ClipboardLayerType::Vector,
|
|
||||||
AnyLayer::Video(_) => ClipboardLayerType::Video,
|
|
||||||
AnyLayer::Audio(al) => match al.audio_layer_type {
|
|
||||||
AudioLayerType::Sampled => ClipboardLayerType::AudioSampled,
|
|
||||||
AudioLayerType::Midi => ClipboardLayerType::AudioMidi,
|
|
||||||
},
|
|
||||||
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a layer is compatible with this clipboard layer type
|
|
||||||
pub fn is_compatible(&self, layer: &AnyLayer) -> bool {
|
|
||||||
match (self, layer) {
|
|
||||||
(ClipboardLayerType::Vector, AnyLayer::Vector(_)) => true,
|
|
||||||
(ClipboardLayerType::Video, AnyLayer::Video(_)) => true,
|
|
||||||
(ClipboardLayerType::AudioSampled, AnyLayer::Audio(al)) => {
|
|
||||||
al.audio_layer_type == AudioLayerType::Sampled
|
|
||||||
}
|
|
||||||
(ClipboardLayerType::AudioMidi, AnyLayer::Audio(al)) => {
|
|
||||||
al.audio_layer_type == AudioLayerType::Midi
|
|
||||||
}
|
|
||||||
(ClipboardLayerType::Effect, AnyLayer::Effect(_)) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Content stored in the clipboard
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "type")]
|
|
||||||
pub enum ClipboardContent {
|
|
||||||
/// Clip instances with their referenced clip definitions
|
|
||||||
ClipInstances {
|
|
||||||
/// Source layer type (for paste compatibility)
|
|
||||||
layer_type: ClipboardLayerType,
|
|
||||||
/// The clip instances (IDs will be regenerated on paste)
|
|
||||||
instances: Vec<ClipInstance>,
|
|
||||||
/// Referenced audio clip definitions
|
|
||||||
audio_clips: Vec<(Uuid, AudioClip)>,
|
|
||||||
/// Referenced video clip definitions
|
|
||||||
video_clips: Vec<(Uuid, VideoClip)>,
|
|
||||||
/// Referenced vector clip definitions
|
|
||||||
vector_clips: Vec<(Uuid, VectorClip)>,
|
|
||||||
/// Referenced image assets
|
|
||||||
image_assets: Vec<(Uuid, ImageAsset)>,
|
|
||||||
},
|
|
||||||
/// Shapes and shape instances from a vector layer
|
|
||||||
Shapes {
|
|
||||||
/// Shape definitions (id -> shape)
|
|
||||||
shapes: Vec<(Uuid, Shape)>,
|
|
||||||
/// Shape instances referencing the shapes above
|
|
||||||
instances: Vec<ShapeInstance>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClipboardContent {
|
|
||||||
/// Create a clone of this content with all UUIDs regenerated
|
|
||||||
/// Returns the new content and a mapping from old to new IDs
|
|
||||||
pub fn with_regenerated_ids(&self) -> (Self, HashMap<Uuid, Uuid>) {
|
|
||||||
let mut id_map = HashMap::new();
|
|
||||||
|
|
||||||
match self {
|
|
||||||
ClipboardContent::ClipInstances {
|
|
||||||
layer_type,
|
|
||||||
instances,
|
|
||||||
audio_clips,
|
|
||||||
video_clips,
|
|
||||||
vector_clips,
|
|
||||||
image_assets,
|
|
||||||
} => {
|
|
||||||
// Regenerate clip definition IDs
|
|
||||||
let new_audio_clips: Vec<(Uuid, AudioClip)> = audio_clips
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, clip)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_clip = clip.clone();
|
|
||||||
new_clip.id = new_id;
|
|
||||||
(new_id, new_clip)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let new_video_clips: Vec<(Uuid, VideoClip)> = video_clips
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, clip)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_clip = clip.clone();
|
|
||||||
new_clip.id = new_id;
|
|
||||||
(new_id, new_clip)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let new_vector_clips: Vec<(Uuid, VectorClip)> = vector_clips
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, clip)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_clip = clip.clone();
|
|
||||||
new_clip.id = new_id;
|
|
||||||
(new_id, new_clip)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let new_image_assets: Vec<(Uuid, ImageAsset)> = image_assets
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, asset)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_asset = asset.clone();
|
|
||||||
new_asset.id = new_id;
|
|
||||||
(new_id, new_asset)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Regenerate clip instance IDs and remap clip_id references
|
|
||||||
let new_instances: Vec<ClipInstance> = instances
|
|
||||||
.iter()
|
|
||||||
.map(|inst| {
|
|
||||||
let new_instance_id = Uuid::new_v4();
|
|
||||||
id_map.insert(inst.id, new_instance_id);
|
|
||||||
let mut new_inst = inst.clone();
|
|
||||||
new_inst.id = new_instance_id;
|
|
||||||
// Remap clip_id to new definition ID
|
|
||||||
if let Some(new_clip_id) = id_map.get(&inst.clip_id) {
|
|
||||||
new_inst.clip_id = *new_clip_id;
|
|
||||||
}
|
|
||||||
new_inst
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(
|
|
||||||
ClipboardContent::ClipInstances {
|
|
||||||
layer_type: layer_type.clone(),
|
|
||||||
instances: new_instances,
|
|
||||||
audio_clips: new_audio_clips,
|
|
||||||
video_clips: new_video_clips,
|
|
||||||
vector_clips: new_vector_clips,
|
|
||||||
image_assets: new_image_assets,
|
|
||||||
},
|
|
||||||
id_map,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ClipboardContent::Shapes { shapes, instances } => {
|
|
||||||
// Regenerate shape definition IDs
|
|
||||||
let new_shapes: Vec<(Uuid, Shape)> = shapes
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, shape)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_shape = shape.clone();
|
|
||||||
new_shape.id = new_id;
|
|
||||||
(new_id, new_shape)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Regenerate instance IDs and remap shape_id references
|
|
||||||
let new_instances: Vec<ShapeInstance> = instances
|
|
||||||
.iter()
|
|
||||||
.map(|inst| {
|
|
||||||
let new_instance_id = Uuid::new_v4();
|
|
||||||
id_map.insert(inst.id, new_instance_id);
|
|
||||||
let mut new_inst = inst.clone();
|
|
||||||
new_inst.id = new_instance_id;
|
|
||||||
// Remap shape_id to new definition ID
|
|
||||||
if let Some(new_shape_id) = id_map.get(&inst.shape_id) {
|
|
||||||
new_inst.shape_id = *new_shape_id;
|
|
||||||
}
|
|
||||||
new_inst
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(
|
|
||||||
ClipboardContent::Shapes {
|
|
||||||
shapes: new_shapes,
|
|
||||||
instances: new_instances,
|
|
||||||
},
|
|
||||||
id_map,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JSON prefix for clipboard text to identify Lightningbeam content
|
|
||||||
const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:";
|
|
||||||
|
|
||||||
/// Manages clipboard operations with internal + system clipboard
|
|
||||||
pub struct ClipboardManager {
|
|
||||||
/// Internal clipboard (preserves rich data without serialization loss)
|
|
||||||
internal: Option<ClipboardContent>,
|
|
||||||
/// System clipboard handle (lazy-initialized)
|
|
||||||
system: Option<arboard::Clipboard>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClipboardManager {
|
|
||||||
/// Create a new clipboard manager
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let system = arboard::Clipboard::new().ok();
|
|
||||||
Self {
|
|
||||||
internal: None,
|
|
||||||
system,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copy content to both internal and system clipboard
|
|
||||||
pub fn copy(&mut self, content: ClipboardContent) {
|
|
||||||
// Serialize to system clipboard as JSON text
|
|
||||||
if let Some(system) = self.system.as_mut() {
|
|
||||||
if let Ok(json) = serde_json::to_string(&content) {
|
|
||||||
let clipboard_text = format!("{}{}", CLIPBOARD_PREFIX, json);
|
|
||||||
let _ = system.set_text(clipboard_text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store internally for rich access
|
|
||||||
self.internal = Some(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to paste content
|
|
||||||
/// Returns internal clipboard if available, falls back to system clipboard JSON
|
|
||||||
pub fn paste(&mut self) -> Option<ClipboardContent> {
|
|
||||||
// Try internal clipboard first
|
|
||||||
if let Some(content) = &self.internal {
|
|
||||||
return Some(content.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to system clipboard
|
|
||||||
if let Some(system) = self.system.as_mut() {
|
|
||||||
if let Ok(text) = system.get_text() {
|
|
||||||
if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) {
|
|
||||||
if let Ok(content) = serde_json::from_str::<ClipboardContent>(json) {
|
|
||||||
return Some(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if there's content available to paste
|
|
||||||
pub fn has_content(&mut self) -> bool {
|
|
||||||
if self.internal.is_some() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(system) = self.system.as_mut() {
|
|
||||||
if let Ok(text) = system.get_text() {
|
|
||||||
return text.starts_with(CLIPBOARD_PREFIX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -40,4 +40,3 @@ pub mod planar_graph;
|
||||||
pub mod file_types;
|
pub mod file_types;
|
||||||
pub mod file_io;
|
pub mod file_io;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod clipboard;
|
|
||||||
|
|
|
||||||
|
|
@ -682,8 +682,6 @@ struct EditorApp {
|
||||||
recording_layer_id: Option<Uuid>, // Layer being recorded to (for creating clips)
|
recording_layer_id: Option<Uuid>, // Layer being recorded to (for creating clips)
|
||||||
// Asset drag-and-drop state
|
// Asset drag-and-drop state
|
||||||
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
||||||
// Clipboard
|
|
||||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager,
|
|
||||||
// Shader editor inter-pane communication
|
// Shader editor inter-pane communication
|
||||||
effect_to_load: Option<Uuid>, // Effect ID to load into shader editor (set by asset library)
|
effect_to_load: Option<Uuid>, // Effect ID to load into shader editor (set by asset library)
|
||||||
// Effect thumbnail invalidation queue (persists across frames until processed)
|
// Effect thumbnail invalidation queue (persists across frames until processed)
|
||||||
|
|
@ -898,7 +896,6 @@ impl EditorApp {
|
||||||
recording_start_time: 0.0, // Will be set when recording starts
|
recording_start_time: 0.0, // Will be set when recording starts
|
||||||
recording_layer_id: None, // Will be set when recording starts
|
recording_layer_id: None, // Will be set when recording starts
|
||||||
dragging_asset: None, // No asset being dragged initially
|
dragging_asset: None, // No asset being dragged initially
|
||||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
|
|
||||||
effect_to_load: None, // No effect to load initially
|
effect_to_load: None, // No effect to load initially
|
||||||
effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially
|
effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially
|
||||||
last_import_filter: ImportFilter::default(), // Default to "All Supported"
|
last_import_filter: ImportFilter::default(), // Default to "All Supported"
|
||||||
|
|
@ -1475,364 +1472,6 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the current selection to the clipboard
|
|
||||||
fn clipboard_copy_selection(&mut self) {
|
|
||||||
use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType};
|
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
|
||||||
|
|
||||||
// Check what's selected: clip instances take priority, then shapes
|
|
||||||
if !self.selection.clip_instances().is_empty() {
|
|
||||||
let active_layer_id = match self.active_layer_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let document = self.action_executor.document();
|
|
||||||
let layer = match document.get_layer(&active_layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let layer_type = ClipboardLayerType::from_layer(layer);
|
|
||||||
|
|
||||||
let instances: Vec<_> = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
|
||||||
}
|
|
||||||
.iter()
|
|
||||||
.filter(|ci| self.selection.contains_clip_instance(&ci.id))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if instances.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather referenced clip definitions
|
|
||||||
let mut audio_clips = Vec::new();
|
|
||||||
let mut video_clips = Vec::new();
|
|
||||||
let mut vector_clips = Vec::new();
|
|
||||||
let image_assets = Vec::new();
|
|
||||||
let mut seen_clip_ids = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
for inst in &instances {
|
|
||||||
if !seen_clip_ids.insert(inst.clip_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(clip) = document.get_audio_clip(&inst.clip_id) {
|
|
||||||
audio_clips.push((inst.clip_id, clip.clone()));
|
|
||||||
} else if let Some(clip) = document.get_video_clip(&inst.clip_id) {
|
|
||||||
video_clips.push((inst.clip_id, clip.clone()));
|
|
||||||
} else if let Some(clip) = document.get_vector_clip(&inst.clip_id) {
|
|
||||||
vector_clips.push((inst.clip_id, clip.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather image assets referenced by vector clips
|
|
||||||
// (Future: walk vector clip layers for image fill references)
|
|
||||||
|
|
||||||
let content = ClipboardContent::ClipInstances {
|
|
||||||
layer_type,
|
|
||||||
instances,
|
|
||||||
audio_clips,
|
|
||||||
video_clips,
|
|
||||||
vector_clips,
|
|
||||||
image_assets,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.clipboard_manager.copy(content);
|
|
||||||
} else if !self.selection.shape_instances().is_empty() {
|
|
||||||
let active_layer_id = match self.active_layer_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let document = self.action_executor.document();
|
|
||||||
let layer = match document.get_layer(&active_layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let vector_layer = match layer {
|
|
||||||
AnyLayer::Vector(vl) => vl,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gather selected shape instances and their shape definitions
|
|
||||||
let selected_instances: Vec<_> = vector_layer
|
|
||||||
.shape_instances
|
|
||||||
.iter()
|
|
||||||
.filter(|si| self.selection.contains_shape_instance(&si.id))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if selected_instances.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut shapes = Vec::new();
|
|
||||||
let mut seen_shape_ids = std::collections::HashSet::new();
|
|
||||||
for inst in &selected_instances {
|
|
||||||
if seen_shape_ids.insert(inst.shape_id) {
|
|
||||||
if let Some(shape) = vector_layer.shapes.get(&inst.shape_id) {
|
|
||||||
shapes.push((inst.shape_id, shape.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = ClipboardContent::Shapes {
|
|
||||||
shapes,
|
|
||||||
instances: selected_instances,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.clipboard_manager.copy(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete the current selection (for cut and delete operations)
|
|
||||||
fn clipboard_delete_selection(&mut self) {
|
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
|
||||||
|
|
||||||
if !self.selection.clip_instances().is_empty() {
|
|
||||||
let active_layer_id = match self.active_layer_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build removals list
|
|
||||||
let removals: Vec<(Uuid, Uuid)> = self
|
|
||||||
.selection
|
|
||||||
.clip_instances()
|
|
||||||
.iter()
|
|
||||||
.map(|&id| (active_layer_id, id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if removals.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = lightningbeam_core::actions::RemoveClipInstancesAction::new(removals);
|
|
||||||
|
|
||||||
if let Some(ref controller_arc) = self.audio_controller {
|
|
||||||
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!("Delete clip instances failed: {}", e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
|
||||||
eprintln!("Delete clip instances failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.selection.clear_clip_instances();
|
|
||||||
} else if !self.selection.shape_instances().is_empty() {
|
|
||||||
let active_layer_id = match self.active_layer_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let document = self.action_executor.document();
|
|
||||||
let layer = match document.get_layer(&active_layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let vector_layer = match layer {
|
|
||||||
AnyLayer::Vector(vl) => vl,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collect shape instance IDs and their shape IDs
|
|
||||||
let instance_ids: Vec<Uuid> = self.selection.shape_instances().to_vec();
|
|
||||||
let mut shape_ids: Vec<Uuid> = Vec::new();
|
|
||||||
let mut shape_id_set = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
for inst in &vector_layer.shape_instances {
|
|
||||||
if instance_ids.contains(&inst.id) {
|
|
||||||
if shape_id_set.insert(inst.shape_id) {
|
|
||||||
// Only remove shape definition if no other instances reference it
|
|
||||||
let other_refs = vector_layer
|
|
||||||
.shape_instances
|
|
||||||
.iter()
|
|
||||||
.any(|si| si.shape_id == inst.shape_id && !instance_ids.contains(&si.id));
|
|
||||||
if !other_refs {
|
|
||||||
shape_ids.push(inst.shape_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = lightningbeam_core::actions::RemoveShapesAction::new(
|
|
||||||
active_layer_id,
|
|
||||||
shape_ids,
|
|
||||||
instance_ids,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
|
||||||
eprintln!("Delete shapes failed: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.selection.clear_shape_instances();
|
|
||||||
self.selection.clear_shapes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paste from clipboard
|
|
||||||
fn clipboard_paste(&mut self) {
|
|
||||||
use lightningbeam_core::clipboard::ClipboardContent;
|
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
|
||||||
|
|
||||||
let content = match self.clipboard_manager.paste() {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Regenerate IDs for the paste
|
|
||||||
let (new_content, _id_map) = content.with_regenerated_ids();
|
|
||||||
|
|
||||||
match new_content {
|
|
||||||
ClipboardContent::ClipInstances {
|
|
||||||
layer_type,
|
|
||||||
mut instances,
|
|
||||||
audio_clips,
|
|
||||||
video_clips,
|
|
||||||
vector_clips,
|
|
||||||
image_assets,
|
|
||||||
} => {
|
|
||||||
let active_layer_id = match self.active_layer_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify layer compatibility
|
|
||||||
{
|
|
||||||
let document = self.action_executor.document();
|
|
||||||
let layer = match document.get_layer(&active_layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
if !layer_type.is_compatible(layer) {
|
|
||||||
eprintln!("Cannot paste: incompatible layer type");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add clip definitions to document (they have new IDs from regeneration)
|
|
||||||
{
|
|
||||||
let document = self.action_executor.document_mut();
|
|
||||||
for (_id, clip) in &audio_clips {
|
|
||||||
document.audio_clips.insert(clip.id, clip.clone());
|
|
||||||
}
|
|
||||||
for (_id, clip) in &video_clips {
|
|
||||||
document.video_clips.insert(clip.id, clip.clone());
|
|
||||||
}
|
|
||||||
for (_id, clip) in &vector_clips {
|
|
||||||
document.vector_clips.insert(clip.id, clip.clone());
|
|
||||||
}
|
|
||||||
for (_id, asset) in &image_assets {
|
|
||||||
document.image_assets.insert(asset.id, asset.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position instances at playhead, preserving relative offsets
|
|
||||||
if !instances.is_empty() {
|
|
||||||
let min_start = instances
|
|
||||||
.iter()
|
|
||||||
.map(|i| i.timeline_start)
|
|
||||||
.fold(f64::INFINITY, f64::min);
|
|
||||||
let offset = self.playback_time - min_start;
|
|
||||||
for inst in &mut instances {
|
|
||||||
inst.timeline_start = (inst.timeline_start + offset).max(0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add each instance via action (handles overlap avoidance)
|
|
||||||
let new_ids: Vec<Uuid> = instances.iter().map(|i| i.id).collect();
|
|
||||||
|
|
||||||
for instance in instances {
|
|
||||||
let action = lightningbeam_core::actions::AddClipInstanceAction::new(
|
|
||||||
active_layer_id,
|
|
||||||
instance,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(ref controller_arc) = self.audio_controller {
|
|
||||||
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!("Paste clip failed: {}", e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
|
||||||
eprintln!("Paste clip failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select pasted clips
|
|
||||||
self.selection.clear_clip_instances();
|
|
||||||
for id in new_ids {
|
|
||||||
self.selection.add_clip_instance(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ClipboardContent::Shapes {
|
|
||||||
shapes,
|
|
||||||
instances,
|
|
||||||
} => {
|
|
||||||
let active_layer_id = match self.active_layer_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add shapes and instances to the active vector layer
|
|
||||||
let document = self.action_executor.document_mut();
|
|
||||||
let layer = match document.get_layer_mut(&active_layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let vector_layer = match layer {
|
|
||||||
AnyLayer::Vector(vl) => vl,
|
|
||||||
_ => {
|
|
||||||
eprintln!("Cannot paste shapes: not a vector layer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_instance_ids: Vec<Uuid> = instances.iter().map(|i| i.id).collect();
|
|
||||||
|
|
||||||
for (id, shape) in shapes {
|
|
||||||
vector_layer.shapes.insert(id, shape);
|
|
||||||
}
|
|
||||||
for inst in instances {
|
|
||||||
vector_layer.shape_instances.push(inst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select pasted shapes
|
|
||||||
self.selection.clear_shape_instances();
|
|
||||||
for id in new_instance_ids {
|
|
||||||
self.selection.add_shape_instance(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Duplicate the selected clip instances on the active layer.
|
/// Duplicate the selected clip instances on the active layer.
|
||||||
/// Each duplicate is placed immediately after the original clip.
|
/// Each duplicate is placed immediately after the original clip.
|
||||||
fn duplicate_selected_clips(&mut self) {
|
fn duplicate_selected_clips(&mut self) {
|
||||||
|
|
@ -1879,8 +1518,6 @@ impl EditorApp {
|
||||||
duplicate
|
duplicate
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
let new_ids: Vec<uuid::Uuid> = duplicates.iter().map(|d| d.id).collect();
|
|
||||||
|
|
||||||
for duplicate in duplicates {
|
for duplicate in duplicates {
|
||||||
let action = AddClipInstanceAction::new(active_layer_id, duplicate);
|
let action = AddClipInstanceAction::new(active_layer_id, duplicate);
|
||||||
|
|
||||||
|
|
@ -1900,12 +1537,6 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the new duplicates instead of the originals
|
|
||||||
self.selection.clear_clip_instances();
|
|
||||||
for id in new_ids {
|
|
||||||
self.selection.add_clip_instance(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn switch_layout(&mut self, index: usize) {
|
fn switch_layout(&mut self, index: usize) {
|
||||||
|
|
@ -2231,17 +1862,20 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Cut => {
|
MenuAction::Cut => {
|
||||||
self.clipboard_copy_selection();
|
println!("Menu: Cut");
|
||||||
self.clipboard_delete_selection();
|
// TODO: Implement cut
|
||||||
}
|
}
|
||||||
MenuAction::Copy => {
|
MenuAction::Copy => {
|
||||||
self.clipboard_copy_selection();
|
println!("Menu: Copy");
|
||||||
|
// TODO: Implement copy
|
||||||
}
|
}
|
||||||
MenuAction::Paste => {
|
MenuAction::Paste => {
|
||||||
self.clipboard_paste();
|
println!("Menu: Paste");
|
||||||
|
// TODO: Implement paste
|
||||||
}
|
}
|
||||||
MenuAction::Delete => {
|
MenuAction::Delete => {
|
||||||
self.clipboard_delete_selection();
|
println!("Menu: Delete");
|
||||||
|
// TODO: Implement delete
|
||||||
}
|
}
|
||||||
MenuAction::SelectAll => {
|
MenuAction::SelectAll => {
|
||||||
println!("Menu: Select All");
|
println!("Menu: Select All");
|
||||||
|
|
@ -4333,9 +3967,6 @@ impl eframe::App for EditorApp {
|
||||||
// Registry for actions to execute after rendering (two-phase dispatch)
|
// Registry for actions to execute after rendering (two-phase dispatch)
|
||||||
let mut pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>> = Vec::new();
|
let mut pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>> = Vec::new();
|
||||||
|
|
||||||
// Menu actions queued by pane context menus
|
|
||||||
let mut pending_menu_actions: Vec<MenuAction> = Vec::new();
|
|
||||||
|
|
||||||
// Queue for effect thumbnail requests (collected during rendering)
|
// Queue for effect thumbnail requests (collected during rendering)
|
||||||
let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new();
|
let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new();
|
||||||
// Empty cache fallback if generator not initialized
|
// Empty cache fallback if generator not initialized
|
||||||
|
|
@ -4387,8 +4018,6 @@ impl eframe::App for EditorApp {
|
||||||
.unwrap_or(&empty_thumbnail_cache),
|
.unwrap_or(&empty_thumbnail_cache),
|
||||||
effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate,
|
effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate,
|
||||||
target_format: self.target_format,
|
target_format: self.target_format,
|
||||||
pending_menu_actions: &mut pending_menu_actions,
|
|
||||||
clipboard_manager: &mut self.clipboard_manager,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
|
|
@ -4455,11 +4084,6 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process menu actions queued by pane context menus
|
|
||||||
for action in pending_menu_actions {
|
|
||||||
self.handle_menu_action(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cursor based on hover state
|
// Set cursor based on hover state
|
||||||
if let Some((_, is_horizontal)) = self.hovered_divider {
|
if let Some((_, is_horizontal)) = self.hovered_divider {
|
||||||
if is_horizontal {
|
if is_horizontal {
|
||||||
|
|
@ -4512,24 +4136,6 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.input(|i| {
|
ctx.input(|i| {
|
||||||
// Handle clipboard events (Ctrl+C/X/V) — winit converts these to
|
|
||||||
// Event::Copy/Cut/Paste instead of regular key events, so
|
|
||||||
// check_shortcuts won't see them via key_pressed().
|
|
||||||
for event in &i.events {
|
|
||||||
match event {
|
|
||||||
egui::Event::Copy => {
|
|
||||||
self.handle_menu_action(MenuAction::Copy);
|
|
||||||
}
|
|
||||||
egui::Event::Cut => {
|
|
||||||
self.handle_menu_action(MenuAction::Cut);
|
|
||||||
}
|
|
||||||
egui::Event::Paste(_) => {
|
|
||||||
self.handle_menu_action(MenuAction::Paste);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing
|
// Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing
|
||||||
// But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano)
|
// But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano)
|
||||||
if let Some(action) = MenuSystem::check_shortcuts(i) {
|
if let Some(action) = MenuSystem::check_shortcuts(i) {
|
||||||
|
|
@ -4657,10 +4263,6 @@ struct RenderContext<'a> {
|
||||||
effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
|
effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
|
||||||
/// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform)
|
/// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform)
|
||||||
target_format: wgpu::TextureFormat,
|
target_format: wgpu::TextureFormat,
|
||||||
/// Menu actions queued by panes (e.g. context menus), processed after rendering
|
|
||||||
pending_menu_actions: &'a mut Vec<MenuAction>,
|
|
||||||
/// Clipboard manager for paste availability checks
|
|
||||||
clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively render a layout node with drag support
|
/// Recursively render a layout node with drag support
|
||||||
|
|
@ -5139,8 +4741,6 @@ fn render_pane(
|
||||||
effect_thumbnail_cache: ctx.effect_thumbnail_cache,
|
effect_thumbnail_cache: ctx.effect_thumbnail_cache,
|
||||||
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
|
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
|
||||||
target_format: ctx.target_format,
|
target_format: ctx.target_format,
|
||||||
pending_menu_actions: ctx.pending_menu_actions,
|
|
||||||
clipboard_manager: ctx.clipboard_manager,
|
|
||||||
};
|
};
|
||||||
pane_instance.render_header(&mut header_ui, &mut shared);
|
pane_instance.render_header(&mut header_ui, &mut shared);
|
||||||
}
|
}
|
||||||
|
|
@ -5208,8 +4808,6 @@ fn render_pane(
|
||||||
effect_thumbnail_cache: ctx.effect_thumbnail_cache,
|
effect_thumbnail_cache: ctx.effect_thumbnail_cache,
|
||||||
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
|
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
|
||||||
target_format: ctx.target_format,
|
target_format: ctx.target_format,
|
||||||
pending_menu_actions: ctx.pending_menu_actions,
|
|
||||||
clipboard_manager: ctx.clipboard_manager,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render pane content (header was already rendered above)
|
// Render pane content (header was already rendered above)
|
||||||
|
|
|
||||||
|
|
@ -211,10 +211,6 @@ pub struct SharedPaneState<'a> {
|
||||||
pub effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
|
pub effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
|
||||||
/// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform)
|
/// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform)
|
||||||
pub target_format: wgpu::TextureFormat,
|
pub target_format: wgpu::TextureFormat,
|
||||||
/// Menu actions queued by panes (e.g. context menu items), processed by main after rendering
|
|
||||||
pub pending_menu_actions: &'a mut Vec<crate::menu::MenuAction>,
|
|
||||||
/// Clipboard manager for cut/copy/paste operations
|
|
||||||
pub clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for pane rendering
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,6 @@ pub struct TimelinePane {
|
||||||
|
|
||||||
/// Track if a layer control widget was clicked this frame
|
/// Track if a layer control widget was clicked this frame
|
||||||
layer_control_clicked: bool,
|
layer_control_clicked: bool,
|
||||||
|
|
||||||
/// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open
|
|
||||||
/// clip_id is None when right-clicking on empty timeline space
|
|
||||||
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a clip type can be dropped on a layer type
|
/// Check if a clip type can be dropped on a layer type
|
||||||
|
|
@ -124,7 +120,6 @@ impl TimelinePane {
|
||||||
drag_offset: 0.0,
|
drag_offset: 0.0,
|
||||||
mousedown_pos: None,
|
mousedown_pos: None,
|
||||||
layer_control_clicked: false,
|
layer_control_clicked: false,
|
||||||
context_menu_clip: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2180,226 +2175,6 @@ impl PaneRenderer for TimelinePane {
|
||||||
shared.audio_controller,
|
shared.audio_controller,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Context menu: detect right-click on clips or empty timeline space
|
|
||||||
let mut just_opened_menu = false;
|
|
||||||
let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
|
|
||||||
if secondary_clicked {
|
|
||||||
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
|
||||||
if content_rect.contains(pos) {
|
|
||||||
if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect) {
|
|
||||||
// Right-clicked on a clip
|
|
||||||
if !shared.selection.contains_clip_instance(&clip_id) {
|
|
||||||
shared.selection.select_only_clip_instance(clip_id);
|
|
||||||
}
|
|
||||||
self.context_menu_clip = Some((Some(clip_id), pos));
|
|
||||||
} else {
|
|
||||||
// Right-clicked on empty timeline space
|
|
||||||
self.context_menu_clip = Some((None, pos));
|
|
||||||
}
|
|
||||||
just_opened_menu = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render context menu
|
|
||||||
if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip {
|
|
||||||
let has_clip = ctx_clip_id.is_some();
|
|
||||||
// Determine which items are enabled
|
|
||||||
let playback_time = *shared.playback_time;
|
|
||||||
let min_split_px = 4.0_f32;
|
|
||||||
|
|
||||||
// Split: playhead must be over a selected clip, at least min_split_px from edges
|
|
||||||
let split_enabled = has_clip && {
|
|
||||||
let mut enabled = false;
|
|
||||||
if let Some(layer_id) = *shared.active_layer_id {
|
|
||||||
if let Some(layer) = document.get_layer(&layer_id) {
|
|
||||||
let instances: &[ClipInstance] = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
|
||||||
};
|
|
||||||
for inst in instances {
|
|
||||||
if !shared.selection.contains_clip_instance(&inst.id) { continue; }
|
|
||||||
if let Some(dur) = document.get_clip_duration(&inst.clip_id) {
|
|
||||||
let eff = inst.effective_duration(dur);
|
|
||||||
let start = inst.timeline_start;
|
|
||||||
let end = start + eff;
|
|
||||||
let min_dist = min_split_px as f64 / self.pixels_per_second as f64;
|
|
||||||
if playback_time > start + min_dist && playback_time < end - min_dist {
|
|
||||||
enabled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enabled
|
|
||||||
};
|
|
||||||
|
|
||||||
// Duplicate: check if there's room to the right of each selected clip
|
|
||||||
let duplicate_enabled = has_clip && {
|
|
||||||
let mut enabled = false;
|
|
||||||
if let Some(layer_id) = *shared.active_layer_id {
|
|
||||||
if let Some(layer) = document.get_layer(&layer_id) {
|
|
||||||
let instances: &[ClipInstance] = match layer {
|
|
||||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Audio(al) => &al.clip_instances,
|
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
|
||||||
};
|
|
||||||
// Check each selected clip
|
|
||||||
enabled = instances.iter()
|
|
||||||
.filter(|ci| shared.selection.contains_clip_instance(&ci.id))
|
|
||||||
.all(|ci| {
|
|
||||||
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
|
|
||||||
let eff = ci.effective_duration(dur);
|
|
||||||
let max_extend = document.find_max_trim_extend_right(
|
|
||||||
&layer_id, &ci.id, ci.timeline_start, eff,
|
|
||||||
);
|
|
||||||
max_extend >= eff
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
&& instances.iter().any(|ci| shared.selection.contains_clip_instance(&ci.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enabled
|
|
||||||
};
|
|
||||||
|
|
||||||
// Paste: check if clipboard has content and there's room at playhead
|
|
||||||
let paste_enabled = {
|
|
||||||
let mut enabled = false;
|
|
||||||
if shared.clipboard_manager.has_content() {
|
|
||||||
if let Some(layer_id) = *shared.active_layer_id {
|
|
||||||
if let Some(content) = shared.clipboard_manager.paste() {
|
|
||||||
if let lightningbeam_core::clipboard::ClipboardContent::ClipInstances {
|
|
||||||
ref layer_type,
|
|
||||||
ref instances,
|
|
||||||
..
|
|
||||||
} = content
|
|
||||||
{
|
|
||||||
if let Some(layer) = document.get_layer(&layer_id) {
|
|
||||||
if layer_type.is_compatible(layer) && !instances.is_empty() {
|
|
||||||
// Check if each pasted clip would fit at playhead
|
|
||||||
let min_start = instances
|
|
||||||
.iter()
|
|
||||||
.map(|i| i.timeline_start)
|
|
||||||
.fold(f64::INFINITY, f64::min);
|
|
||||||
let offset = *shared.playback_time - min_start;
|
|
||||||
|
|
||||||
enabled = instances.iter().all(|ci| {
|
|
||||||
let paste_start = (ci.timeline_start + offset).max(0.0);
|
|
||||||
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
|
|
||||||
let eff = ci.effective_duration(dur);
|
|
||||||
document
|
|
||||||
.find_nearest_valid_position(
|
|
||||||
&layer_id,
|
|
||||||
paste_start,
|
|
||||||
eff,
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.is_some()
|
|
||||||
} else {
|
|
||||||
// Clip def not in document yet (from external paste) — allow
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Shapes paste — always enabled if layer is vector
|
|
||||||
if let Some(layer) = document.get_layer(&layer_id) {
|
|
||||||
enabled = matches!(layer, AnyLayer::Vector(_));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
enabled
|
|
||||||
};
|
|
||||||
|
|
||||||
let area_id = ui.id().with("clip_context_menu");
|
|
||||||
let mut item_clicked = false;
|
|
||||||
let area_response = egui::Area::new(area_id)
|
|
||||||
.order(egui::Order::Foreground)
|
|
||||||
.fixed_pos(menu_pos)
|
|
||||||
.interactable(true)
|
|
||||||
.show(ui.ctx(), |ui| {
|
|
||||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
|
||||||
ui.set_min_width(160.0);
|
|
||||||
|
|
||||||
// Helper: full-width menu item with optional enabled state
|
|
||||||
let menu_item = |ui: &mut egui::Ui, label: &str, enabled: bool| -> bool {
|
|
||||||
let desired_width = ui.available_width();
|
|
||||||
let (rect, response) = ui.allocate_exact_size(
|
|
||||||
egui::vec2(desired_width, ui.spacing().interact_size.y),
|
|
||||||
if enabled { egui::Sense::click() } else { egui::Sense::hover() },
|
|
||||||
);
|
|
||||||
if ui.is_rect_visible(rect) {
|
|
||||||
if enabled && response.hovered() {
|
|
||||||
ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill);
|
|
||||||
}
|
|
||||||
let text_color = if !enabled {
|
|
||||||
ui.visuals().weak_text_color()
|
|
||||||
} else if response.hovered() {
|
|
||||||
ui.visuals().widgets.hovered.text_color()
|
|
||||||
} else {
|
|
||||||
ui.visuals().widgets.inactive.text_color()
|
|
||||||
};
|
|
||||||
ui.painter().text(
|
|
||||||
rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0),
|
|
||||||
egui::Align2::LEFT_TOP,
|
|
||||||
label,
|
|
||||||
egui::FontId::proportional(14.0),
|
|
||||||
text_color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
enabled && response.clicked()
|
|
||||||
};
|
|
||||||
|
|
||||||
if menu_item(ui, "Split Clip", split_enabled) {
|
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::SplitClip);
|
|
||||||
item_clicked = true;
|
|
||||||
}
|
|
||||||
if menu_item(ui, "Duplicate Clip", duplicate_enabled) {
|
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::DuplicateClip);
|
|
||||||
item_clicked = true;
|
|
||||||
}
|
|
||||||
ui.separator();
|
|
||||||
if menu_item(ui, "Cut", has_clip) {
|
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Cut);
|
|
||||||
item_clicked = true;
|
|
||||||
}
|
|
||||||
if menu_item(ui, "Copy", has_clip) {
|
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Copy);
|
|
||||||
item_clicked = true;
|
|
||||||
}
|
|
||||||
if menu_item(ui, "Paste", paste_enabled) {
|
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Paste);
|
|
||||||
item_clicked = true;
|
|
||||||
}
|
|
||||||
ui.separator();
|
|
||||||
if menu_item(ui, "Delete", has_clip) {
|
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Delete);
|
|
||||||
item_clicked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close on item click or click outside (skip on the frame we just opened)
|
|
||||||
if !just_opened_menu {
|
|
||||||
let any_click = ui.input(|i| {
|
|
||||||
i.pointer.button_clicked(egui::PointerButton::Primary)
|
|
||||||
|| i.pointer.button_clicked(egui::PointerButton::Secondary)
|
|
||||||
});
|
|
||||||
if item_clicked || (any_click && !area_response.response.contains_pointer()) {
|
|
||||||
self.context_menu_clip = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// VIDEO HOVER DETECTION: Handle video clip hover tooltips AFTER input handling
|
// VIDEO HOVER DETECTION: Handle video clip hover tooltips AFTER input handling
|
||||||
// This ensures hover events aren't consumed by the main input handler
|
// This ensures hover events aren't consumed by the main input handler
|
||||||
for (clip_rect, clip_id, trim_start, instance_start) in video_clip_hovers {
|
for (clip_rect, clip_id, trim_start, instance_start) in video_clip_hovers {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue