Add copy and paste
This commit is contained in:
parent
5164d7a0a9
commit
c6a8b944e5
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::sync::Arc;
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
/// Audio clip instance ID type
|
/// Audio clip instance ID type
|
||||||
|
|
@ -35,6 +36,13 @@ 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
|
||||||
|
|
@ -58,6 +66,7 @@ impl AudioClipInstance {
|
||||||
external_start,
|
external_start,
|
||||||
external_duration,
|
external_duration,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
|
read_ahead: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +87,7 @@ 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,11 +166,27 @@ 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]
|
||||||
|
|
@ -178,6 +194,12 @@ 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) {
|
||||||
|
|
@ -413,14 +435,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.
|
/// Start streaming a compressed file for a clip instance.
|
||||||
ActivateFile {
|
ActivateFile {
|
||||||
pool_index: usize,
|
reader_id: u64,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
buffer: Arc<ReadAheadBuffer>,
|
buffer: Arc<ReadAheadBuffer>,
|
||||||
},
|
},
|
||||||
/// Stop streaming a file.
|
/// Stop streaming for a clip instance.
|
||||||
DeactivateFile { pool_index: usize },
|
DeactivateFile { reader_id: u64 },
|
||||||
/// 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.
|
||||||
|
|
@ -491,7 +513,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<usize, (CompressedReader, Arc<ReadAheadBuffer>)> =
|
let mut active_files: HashMap<u64, (CompressedReader, Arc<ReadAheadBuffer>)> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
let mut decode_buf = Vec::with_capacity(8192);
|
let mut decode_buf = Vec::with_capacity(8192);
|
||||||
|
|
||||||
|
|
@ -500,14 +522,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 {
|
||||||
pool_index,
|
reader_id,
|
||||||
path,
|
path,
|
||||||
buffer,
|
buffer,
|
||||||
} => match CompressedReader::open(&path) {
|
} => match CompressedReader::open(&path) {
|
||||||
Ok(reader) => {
|
Ok(reader) => {
|
||||||
eprintln!("[DiskReader] Activated pool={}, ch={}, sr={}, path={:?}",
|
eprintln!("[DiskReader] Activated reader={}, ch={}, sr={}, path={:?}",
|
||||||
pool_index, reader.channels, reader.sample_rate, path);
|
reader_id, reader.channels, reader.sample_rate, path);
|
||||||
active_files.insert(pool_index, (reader, buffer));
|
active_files.insert(reader_id, (reader, buffer));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
@ -516,12 +538,12 @@ impl DiskReader {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DiskReaderCommand::DeactivateFile { pool_index } => {
|
DiskReaderCommand::DeactivateFile { reader_id } => {
|
||||||
active_files.remove(&pool_index);
|
active_files.remove(&reader_id);
|
||||||
}
|
}
|
||||||
DiskReaderCommand::Seek { frame } => {
|
DiskReaderCommand::Seek { frame } => {
|
||||||
for (_, (reader, buffer)) in active_files.iter_mut() {
|
for (_, (reader, buffer)) in active_files.iter_mut() {
|
||||||
buffer.set_target_frame(frame);
|
buffer.force_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);
|
||||||
|
|
@ -534,11 +556,15 @@ impl DiskReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill each active file's buffer ahead of its target frame.
|
// Fill each active reader's buffer ahead of its target frame.
|
||||||
// Each file's target_frame is set by the audio callback in
|
// Each clip instance has its own buffer and target_frame, set by
|
||||||
// render_from_file, giving the file-local frame being read.
|
// render_from_file during the audio callback.
|
||||||
// This is independent of the global engine playhead.
|
for (_reader_id, (reader, buffer)) in active_files.iter_mut() {
|
||||||
for (_pool_index, (reader, buffer)) in active_files.iter_mut() {
|
// Skip files where no clip is currently playing
|
||||||
|
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();
|
||||||
|
|
@ -578,8 +604,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] pool={}: first fill, {} frames, buf_start={}, valid={}",
|
eprintln!("[DiskReader] reader={}: first fill, {} frames, buf_start={}, valid={}",
|
||||||
_pool_index, frames, buffer.start_frame(), buffer.valid_frames_count());
|
_reader_id, frames, buffer.start_frame(), buffer.valid_frames_count());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,9 @@ 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
|
||||||
|
|
@ -798,6 +801,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -1754,7 +1763,7 @@ impl Engine {
|
||||||
(metadata.duration * metadata.sample_rate as f64).ceil() as u64
|
(metadata.duration * metadata.sample_rate as f64).ceil() as u64
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut audio_file = crate::audio::pool::AudioFile::from_compressed(
|
let 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,
|
||||||
|
|
@ -1762,25 +1771,11 @@ 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();
|
||||||
|
|
@ -2138,6 +2133,28 @@ 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,
|
||||||
|
|
@ -2146,6 +2163,7 @@ 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,9 +95,6 @@ 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 {
|
||||||
|
|
@ -111,7 +108,6 @@ impl AudioFile {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
frames,
|
frames,
|
||||||
original_format: None,
|
original_format: None,
|
||||||
read_ahead: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +121,6 @@ impl AudioFile {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
frames,
|
frames,
|
||||||
original_format,
|
original_format,
|
||||||
read_ahead: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,7 +152,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,7 +174,6 @@ impl AudioFile {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
frames: total_frames,
|
frames: total_frames,
|
||||||
original_format,
|
original_format,
|
||||||
read_ahead: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,6 +437,7 @@ 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,
|
||||||
|
|
@ -453,13 +447,14 @@ 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 = audio_file.read_ahead.as_deref();
|
let read_ahead = clip_read_ahead;
|
||||||
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,6 +484,19 @@ 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,6 +917,7 @@ 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
|
||||||
|
|
@ -951,6 +952,7 @@ impl AudioTrack {
|
||||||
combined_gain,
|
combined_gain,
|
||||||
sample_rate,
|
sample_rate,
|
||||||
channels,
|
channels,
|
||||||
|
clip.read_ahead.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
total_rendered += rendered;
|
total_rendered += rendered;
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,6 @@ 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,12 +226,14 @@ 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 duration = clip.duration;
|
let internal_start = self.clip_instance.trim_start;
|
||||||
|
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, duration, offset);
|
Query::AddAudioClipSync(*backend_track_id, *audio_pool_index, start_time, effective_duration, internal_start);
|
||||||
|
|
||||||
match controller.send_query(query)? {
|
match controller.send_query(query)? {
|
||||||
QueryResponse::AudioClipInstanceAdded(Ok(instance_id)) => {
|
QueryResponse::AudioClipInstanceAdded(Ok(instance_id)) => {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ 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;
|
||||||
|
|
@ -48,3 +50,5 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
//! 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,16 +415,18 @@ impl Action for SplitClipInstanceAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Add the new (right) instance
|
// 2. Add the new (right) instance
|
||||||
let duration = clip.duration;
|
let internal_start = new_instance.trim_start;
|
||||||
|
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,
|
||||||
duration,
|
effective_duration,
|
||||||
offset,
|
internal_start,
|
||||||
);
|
);
|
||||||
|
|
||||||
match controller.send_query(query)? {
|
match controller.send_query(query)? {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
//! 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,3 +40,4 @@ 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,6 +682,8 @@ 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)
|
||||||
|
|
@ -896,6 +898,7 @@ 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"
|
||||||
|
|
@ -1472,6 +1475,364 @@ 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) {
|
||||||
|
|
@ -1870,20 +2231,17 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Cut => {
|
MenuAction::Cut => {
|
||||||
println!("Menu: Cut");
|
self.clipboard_copy_selection();
|
||||||
// TODO: Implement cut
|
self.clipboard_delete_selection();
|
||||||
}
|
}
|
||||||
MenuAction::Copy => {
|
MenuAction::Copy => {
|
||||||
println!("Menu: Copy");
|
self.clipboard_copy_selection();
|
||||||
// TODO: Implement copy
|
|
||||||
}
|
}
|
||||||
MenuAction::Paste => {
|
MenuAction::Paste => {
|
||||||
println!("Menu: Paste");
|
self.clipboard_paste();
|
||||||
// TODO: Implement paste
|
|
||||||
}
|
}
|
||||||
MenuAction::Delete => {
|
MenuAction::Delete => {
|
||||||
println!("Menu: Delete");
|
self.clipboard_delete_selection();
|
||||||
// TODO: Implement delete
|
|
||||||
}
|
}
|
||||||
MenuAction::SelectAll => {
|
MenuAction::SelectAll => {
|
||||||
println!("Menu: Select All");
|
println!("Menu: Select All");
|
||||||
|
|
@ -4030,6 +4388,7 @@ impl eframe::App for EditorApp {
|
||||||
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,
|
pending_menu_actions: &mut pending_menu_actions,
|
||||||
|
clipboard_manager: &mut self.clipboard_manager,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
|
|
@ -4153,6 +4512,24 @@ 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) {
|
||||||
|
|
@ -4282,6 +4659,8 @@ struct RenderContext<'a> {
|
||||||
target_format: wgpu::TextureFormat,
|
target_format: wgpu::TextureFormat,
|
||||||
/// Menu actions queued by panes (e.g. context menus), processed after rendering
|
/// Menu actions queued by panes (e.g. context menus), processed after rendering
|
||||||
pending_menu_actions: &'a mut Vec<MenuAction>,
|
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
|
||||||
|
|
@ -4761,6 +5140,7 @@ fn render_pane(
|
||||||
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,
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -4829,6 +5209,7 @@ fn render_pane(
|
||||||
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,
|
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)
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,8 @@ pub struct SharedPaneState<'a> {
|
||||||
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
|
/// 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>,
|
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
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,9 @@ 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((clip_instance_id, position)) when a right-click menu is open
|
/// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open
|
||||||
context_menu_clip: Option<(uuid::Uuid, egui::Pos2)>,
|
/// 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
|
||||||
|
|
@ -2179,32 +2180,36 @@ impl PaneRenderer for TimelinePane {
|
||||||
shared.audio_controller,
|
shared.audio_controller,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clip context menu: detect right-click on clips
|
// Context menu: detect right-click on clips or empty timeline space
|
||||||
let mut just_opened_menu = false;
|
let mut just_opened_menu = false;
|
||||||
let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
|
let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
|
||||||
if secondary_clicked {
|
if secondary_clicked {
|
||||||
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
||||||
if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect) {
|
if content_rect.contains(pos) {
|
||||||
// Select the clip if not already selected
|
if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect) {
|
||||||
if !shared.selection.contains_clip_instance(&clip_id) {
|
// Right-clicked on a clip
|
||||||
shared.selection.select_only_clip_instance(clip_id);
|
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));
|
||||||
}
|
}
|
||||||
self.context_menu_clip = Some((clip_id, pos));
|
|
||||||
just_opened_menu = true;
|
just_opened_menu = true;
|
||||||
} else {
|
|
||||||
self.context_menu_clip = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render clip context menu
|
// Render context menu
|
||||||
if let Some((_ctx_clip_id, menu_pos)) = self.context_menu_clip {
|
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
|
// Determine which items are enabled
|
||||||
let playback_time = *shared.playback_time;
|
let playback_time = *shared.playback_time;
|
||||||
let min_split_px = 4.0_f32;
|
let min_split_px = 4.0_f32;
|
||||||
|
|
||||||
// Split: playhead must be over a selected clip, at least min_split_px from edges
|
// Split: playhead must be over a selected clip, at least min_split_px from edges
|
||||||
let split_enabled = {
|
let split_enabled = has_clip && {
|
||||||
let mut enabled = false;
|
let mut enabled = false;
|
||||||
if let Some(layer_id) = *shared.active_layer_id {
|
if let Some(layer_id) = *shared.active_layer_id {
|
||||||
if let Some(layer) = document.get_layer(&layer_id) {
|
if let Some(layer) = document.get_layer(&layer_id) {
|
||||||
|
|
@ -2233,7 +2238,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Duplicate: check if there's room to the right of each selected clip
|
// Duplicate: check if there's room to the right of each selected clip
|
||||||
let duplicate_enabled = {
|
let duplicate_enabled = has_clip && {
|
||||||
let mut enabled = false;
|
let mut enabled = false;
|
||||||
if let Some(layer_id) = *shared.active_layer_id {
|
if let Some(layer_id) = *shared.active_layer_id {
|
||||||
if let Some(layer) = document.get_layer(&layer_id) {
|
if let Some(layer) = document.get_layer(&layer_id) {
|
||||||
|
|
@ -2263,6 +2268,58 @@ impl PaneRenderer for TimelinePane {
|
||||||
enabled
|
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 area_id = ui.id().with("clip_context_menu");
|
||||||
let mut item_clicked = false;
|
let mut item_clicked = false;
|
||||||
let area_response = egui::Area::new(area_id)
|
let area_response = egui::Area::new(area_id)
|
||||||
|
|
@ -2311,16 +2368,20 @@ impl PaneRenderer for TimelinePane {
|
||||||
item_clicked = true;
|
item_clicked = true;
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if menu_item(ui, "Cut", true) {
|
if menu_item(ui, "Cut", has_clip) {
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Cut);
|
shared.pending_menu_actions.push(crate::menu::MenuAction::Cut);
|
||||||
item_clicked = true;
|
item_clicked = true;
|
||||||
}
|
}
|
||||||
if menu_item(ui, "Copy", true) {
|
if menu_item(ui, "Copy", has_clip) {
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Copy);
|
shared.pending_menu_actions.push(crate::menu::MenuAction::Copy);
|
||||||
item_clicked = true;
|
item_clicked = true;
|
||||||
}
|
}
|
||||||
|
if menu_item(ui, "Paste", paste_enabled) {
|
||||||
|
shared.pending_menu_actions.push(crate::menu::MenuAction::Paste);
|
||||||
|
item_clicked = true;
|
||||||
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if menu_item(ui, "Delete", true) {
|
if menu_item(ui, "Delete", has_clip) {
|
||||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Delete);
|
shared.pending_menu_actions.push(crate::menu::MenuAction::Delete);
|
||||||
item_clicked = true;
|
item_clicked = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue