Timeline types stage 1: make lightningbeam-core Beats/Seconds-typed
Type ClipInstance.timeline_start/timeline_duration/loop_before as Beats and thread Beats/Seconds through core so the compiler catches the seconds-vs-beats mismatches behind the audio-clip placement/drag/trim bugs. Fixes latent mixups surfaced by the types: - add/remove/split clip instance: the audio "effective duration" fallback was the content-seconds span treated as beats (clips stopped early off 60 BPM); now converted via the tempo map at the clip's start. - trim validation: extend-left/right clamped a content-seconds delta against a timeline-beats gap (and moved trim_start + timeline_start by the same raw amount, assuming 1:1). Now the gap is converted to content seconds and the timeline moves by the beats-equivalent. - split content-split point mixed beats into a seconds trim value. - set_keyframe: visibility-start fallback returned beats where seconds expected. - hit_test: timeline_time (s) compared against beats without conversion. Typed backend boundaries that were passing beats/seconds as bare f64: add_audio_clip (start/dur Beats, offset Seconds), move_clip/extend_clip (Beats), set_offset (Seconds). Command enum transport stays f64. Serialization is unchanged (Beats/Seconds are #[serde(transparent)]). lightningbeam-core builds; 299 core tests pass. The editor call sites (recording/drop/paste/drag placement) are updated in stage 2.
This commit is contained in:
parent
b6f43d2e72
commit
053a77cfa1
|
|
@ -1001,20 +1001,20 @@ impl Engine {
|
||||||
let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path));
|
let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path));
|
||||||
}
|
}
|
||||||
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
|
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
|
||||||
// Create a new clip instance with the pre-assigned clip_id
|
// Create a new clip instance with the pre-assigned clip_id.
|
||||||
// start_time and duration are in beats; offset (internal_start) is seconds
|
// start_time/duration are beats; offset (internal_start) is seconds.
|
||||||
let start_beats = Beats(start_time);
|
let start_beats = start_time;
|
||||||
let end_beats = Beats(start_time + duration);
|
let end_beats = start_time + duration;
|
||||||
let start_secs = self.tempo_map.beats_to_seconds(start_beats);
|
let start_secs = self.tempo_map.beats_to_seconds(start_beats);
|
||||||
let end_secs = self.tempo_map.beats_to_seconds(end_beats);
|
let end_secs = self.tempo_map.beats_to_seconds(end_beats);
|
||||||
let content_dur_secs = (end_secs - start_secs).seconds_to_f64();
|
let content_dur_secs = (end_secs - start_secs).seconds_to_f64();
|
||||||
let mut clip = AudioClipInstance::new(
|
let mut clip = AudioClipInstance::new(
|
||||||
clip_id,
|
clip_id,
|
||||||
pool_index,
|
pool_index,
|
||||||
Seconds(offset),
|
offset,
|
||||||
Seconds(offset + content_dur_secs),
|
offset + Seconds(content_dur_secs),
|
||||||
start_beats,
|
start_beats,
|
||||||
Beats(duration),
|
duration,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the source is streamed (a compressed audio file, or a video's
|
// If the source is streamed (a compressed audio file, or a video's
|
||||||
|
|
@ -3380,8 +3380,8 @@ impl EngineController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a clip to a new timeline position (changes external_start)
|
/// Move a clip to a new timeline position (changes external_start)
|
||||||
pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: f64) {
|
pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) {
|
||||||
let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time));
|
let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time.beats_to_f64()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trim a clip's internal boundaries (changes which portion of source content is used)
|
/// Trim a clip's internal boundaries (changes which portion of source content is used)
|
||||||
|
|
@ -3391,8 +3391,8 @@ impl EngineController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extend or shrink a clip's external duration (enables looping if > internal duration)
|
/// Extend or shrink a clip's external duration (enables looping if > internal duration)
|
||||||
pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: f64) {
|
pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) {
|
||||||
let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration));
|
let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a generic command to the audio thread
|
/// Send a generic command to the audio thread
|
||||||
|
|
@ -3440,8 +3440,8 @@ impl EngineController {
|
||||||
|
|
||||||
/// Set metatrack time offset in seconds
|
/// Set metatrack time offset in seconds
|
||||||
/// Positive = shift content later, negative = shift earlier
|
/// Positive = shift content later, negative = shift earlier
|
||||||
pub fn set_offset(&mut self, track_id: TrackId, offset: f64) {
|
pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) {
|
||||||
let _ = self.command_tx.push(Command::SetOffset(track_id, offset));
|
let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set metatrack pitch shift in semitones (for future use)
|
/// Set metatrack pitch shift in semitones (for future use)
|
||||||
|
|
@ -3525,14 +3525,14 @@ impl EngineController {
|
||||||
|
|
||||||
/// Add a clip to an audio track (async, fire-and-forget)
|
/// Add a clip to an audio track (async, fire-and-forget)
|
||||||
/// Returns the pre-assigned clip instance ID so callers can track the clip without a sync round-trip
|
/// Returns the pre-assigned clip instance ID so callers can track the clip without a sync round-trip
|
||||||
pub fn add_audio_clip(&mut self, track_id: TrackId, pool_index: usize, start_time: f64, duration: f64, offset: f64) -> AudioClipInstanceId {
|
pub fn add_audio_clip(&mut self, track_id: TrackId, pool_index: usize, start_time: Beats, duration: Beats, offset: Seconds) -> AudioClipInstanceId {
|
||||||
let clip_id = self.next_audio_clip_id.fetch_add(1, Ordering::Relaxed);
|
let clip_id = self.next_audio_clip_id.fetch_add(1, Ordering::Relaxed);
|
||||||
let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset));
|
let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset));
|
||||||
clip_id
|
clip_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a clip to an audio track with a pre-assigned ID (for undo/redo, restoring deleted clips)
|
/// Add a clip to an audio track with a pre-assigned ID (for undo/redo, restoring deleted clips)
|
||||||
pub fn add_audio_clip_with_id(&mut self, track_id: TrackId, clip_id: AudioClipInstanceId, pool_index: usize, start_time: f64, duration: f64, offset: f64) {
|
pub fn add_audio_clip_with_id(&mut self, track_id: TrackId, clip_id: AudioClipInstanceId, pool_index: usize, start_time: Beats, duration: Beats, offset: Seconds) {
|
||||||
let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset));
|
let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@ pub enum Command {
|
||||||
AddAudioFile(String, Vec<f32>, u32, u32),
|
AddAudioFile(String, Vec<f32>, u32, u32),
|
||||||
/// Add a clip to an audio track (track_id, clip_id, pool_index, start_time, duration, offset)
|
/// Add a clip to an audio track (track_id, clip_id, pool_index, start_time, duration, offset)
|
||||||
/// The clip_id is pre-assigned by the caller (via EngineController::next_audio_clip_id())
|
/// The clip_id is pre-assigned by the caller (via EngineController::next_audio_clip_id())
|
||||||
AddAudioClip(TrackId, AudioClipInstanceId, usize, f64, f64, f64),
|
/// (track, clip_id, pool_index, start_time [beats], duration [beats], offset [seconds])
|
||||||
|
AddAudioClip(TrackId, AudioClipInstanceId, usize, Beats, Beats, Seconds),
|
||||||
|
|
||||||
// MIDI commands
|
// MIDI commands
|
||||||
/// Create a new MIDI track with a name and optional parent group
|
/// Create a new MIDI track with a name and optional parent group
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,14 @@ impl AddClipInstanceAction {
|
||||||
|
|
||||||
impl Action for AddClipInstanceAction {
|
impl Action for AddClipInstanceAction {
|
||||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
// Calculate the clip's effective duration
|
// Calculate the clip's effective duration in BEATS for overlap testing.
|
||||||
|
// `get_clip_duration` is the content length in seconds; the placement span
|
||||||
|
// must be beats (the timeline is beats-domain), so convert via the clip's
|
||||||
|
// typed helper rather than treating the seconds span as beats.
|
||||||
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id)
|
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id)
|
||||||
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
|
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
|
||||||
|
let effective_duration = self.clip_instance
|
||||||
let trim_start = self.clip_instance.trim_start;
|
.effective_duration_beats(clip_duration, document.tempo_map());
|
||||||
let trim_end = self.clip_instance.trim_end.unwrap_or(clip_duration);
|
|
||||||
let effective_duration = trim_end - trim_start;
|
|
||||||
|
|
||||||
// Auto-adjust position for audio/video layers to avoid overlaps
|
// Auto-adjust position for audio/video layers to avoid overlaps
|
||||||
let adjusted_start = document.find_nearest_valid_position(
|
let adjusted_start = document.find_nearest_valid_position(
|
||||||
|
|
@ -200,9 +201,10 @@ impl Action for AddClipInstanceAction {
|
||||||
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration);
|
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration);
|
||||||
let external_start = self.clip_instance.timeline_start;
|
let external_start = self.clip_instance.timeline_start;
|
||||||
|
|
||||||
// Calculate external duration (for looping if timeline_duration is set)
|
// Calculate external duration (for looping if timeline_duration is set).
|
||||||
|
// MIDI trims are beats-domain, so the fallback span is beats too.
|
||||||
let external_duration = self.clip_instance.timeline_duration
|
let external_duration = self.clip_instance.timeline_duration
|
||||||
.unwrap_or(internal_end - internal_start);
|
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||||
|
|
||||||
// Create MidiClipInstance
|
// Create MidiClipInstance
|
||||||
let instance = daw_backend::MidiClipInstance::new(
|
let instance = daw_backend::MidiClipInstance::new(
|
||||||
|
|
@ -210,8 +212,8 @@ impl Action for AddClipInstanceAction {
|
||||||
*midi_clip_id,
|
*midi_clip_id,
|
||||||
daw_backend::Beats(internal_start),
|
daw_backend::Beats(internal_start),
|
||||||
daw_backend::Beats(internal_end),
|
daw_backend::Beats(internal_end),
|
||||||
daw_backend::Beats(external_start),
|
external_start,
|
||||||
daw_backend::Beats(external_duration),
|
external_duration,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send query to add instance and get instance ID
|
// Send query to add instance and get instance ID
|
||||||
|
|
@ -247,8 +249,8 @@ impl Action for AddClipInstanceAction {
|
||||||
// the seconds-as-beats bug that made clips stop early off 60 BPM).
|
// the seconds-as-beats bug that made clips stop early off 60 BPM).
|
||||||
let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| {
|
let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| {
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let content_secs = internal_end - internal_start;
|
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
||||||
tempo_map.inverse_transform(tempo_map.transform(start_time) + content_secs)
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
||||||
- start_time
|
- start_time
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -257,7 +259,7 @@ impl Action for AddClipInstanceAction {
|
||||||
*audio_pool_index,
|
*audio_pool_index,
|
||||||
start_time,
|
start_time,
|
||||||
effective_duration,
|
effective_duration,
|
||||||
internal_start,
|
daw_backend::Seconds(internal_start),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.backend_track_id = Some(*backend_track_id);
|
self.backend_track_id = Some(*backend_track_id);
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ mod tests {
|
||||||
fn test_add_effect() {
|
fn test_add_effect() {
|
||||||
let (mut document, layer_id, def) = create_test_setup();
|
let (mut document, layer_id, def) = create_test_setup();
|
||||||
|
|
||||||
let instance = def.create_instance(0.0, 10.0);
|
let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let instance_id = instance.id;
|
let instance_id = instance.id;
|
||||||
|
|
||||||
let mut action = AddEffectAction::new(layer_id, instance);
|
let mut action = AddEffectAction::new(layer_id, instance);
|
||||||
|
|
@ -181,7 +181,7 @@ mod tests {
|
||||||
fn test_add_effect_rollback() {
|
fn test_add_effect_rollback() {
|
||||||
let (mut document, layer_id, def) = create_test_setup();
|
let (mut document, layer_id, def) = create_test_setup();
|
||||||
|
|
||||||
let instance = def.create_instance(0.0, 10.0);
|
let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
|
|
||||||
let mut action = AddEffectAction::new(layer_id, instance);
|
let mut action = AddEffectAction::new(layer_id, instance);
|
||||||
action.execute(&mut document).unwrap();
|
action.execute(&mut document).unwrap();
|
||||||
|
|
@ -201,19 +201,19 @@ mod tests {
|
||||||
let (mut document, layer_id, def) = create_test_setup();
|
let (mut document, layer_id, def) = create_test_setup();
|
||||||
|
|
||||||
// Add first effect
|
// Add first effect
|
||||||
let instance1 = def.create_instance(0.0, 10.0);
|
let instance1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id1 = instance1.id;
|
let id1 = instance1.id;
|
||||||
let mut action1 = AddEffectAction::new(layer_id, instance1);
|
let mut action1 = AddEffectAction::new(layer_id, instance1);
|
||||||
action1.execute(&mut document).unwrap();
|
action1.execute(&mut document).unwrap();
|
||||||
|
|
||||||
// Add second effect
|
// Add second effect
|
||||||
let instance2 = def.create_instance(0.0, 10.0);
|
let instance2 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id2 = instance2.id;
|
let id2 = instance2.id;
|
||||||
let mut action2 = AddEffectAction::new(layer_id, instance2);
|
let mut action2 = AddEffectAction::new(layer_id, instance2);
|
||||||
action2.execute(&mut document).unwrap();
|
action2.execute(&mut document).unwrap();
|
||||||
|
|
||||||
// Insert third effect at index 1 (between first and second)
|
// Insert third effect at index 1 (between first and second)
|
||||||
let instance3 = def.create_instance(0.0, 10.0);
|
let instance3 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id3 = instance3.id;
|
let id3 = instance3.id;
|
||||||
let mut action3 = AddEffectAction::at_index(layer_id, instance3, 1);
|
let mut action3 = AddEffectAction::at_index(layer_id, instance3, 1);
|
||||||
action3.execute(&mut document).unwrap();
|
action3.execute(&mut document).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ use crate::layer::AnyLayer;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before)
|
/// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before).
|
||||||
pub type LoopEntry = (Uuid, Option<f64>, Option<f64>, Option<f64>, Option<f64>);
|
/// All durations/offsets are in beats.
|
||||||
|
pub type LoopEntry = (Uuid, Option<daw_backend::Beats>, Option<daw_backend::Beats>, Option<daw_backend::Beats>, Option<daw_backend::Beats>);
|
||||||
|
|
||||||
/// Action that changes the loop duration of clip instances
|
/// Action that changes the loop duration of clip instances
|
||||||
pub struct LoopClipInstancesAction {
|
pub struct LoopClipInstancesAction {
|
||||||
|
|
@ -129,10 +130,17 @@ impl LoopClipInstancesAction {
|
||||||
|
|
||||||
let content_window = {
|
let content_window = {
|
||||||
let trim_end = instance.trim_end.unwrap_or(clip.duration);
|
let trim_end = instance.trim_end.unwrap_or(clip.duration);
|
||||||
(trim_end - instance.trim_start).max(0.0)
|
(trim_end - instance.trim_start).max(0.0) // seconds
|
||||||
};
|
};
|
||||||
let right_duration = target_duration.unwrap_or(content_window);
|
// Natural content length as a beats span at the clip's start (the
|
||||||
let left_duration = target_loop_before.unwrap_or(0.0);
|
// fallback when no explicit timeline_duration is set).
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let content_window_beats = tempo_map.seconds_to_beats(
|
||||||
|
tempo_map.beats_to_seconds(instance.timeline_start)
|
||||||
|
+ daw_backend::Seconds(content_window),
|
||||||
|
) - instance.timeline_start;
|
||||||
|
let right_duration = target_duration.unwrap_or(content_window_beats);
|
||||||
|
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO);
|
||||||
let external_duration = left_duration + right_duration;
|
let external_duration = left_duration + right_duration;
|
||||||
let external_start = instance.timeline_start - left_duration;
|
let external_start = instance.timeline_start - left_duration;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,15 @@ use crate::action::Action;
|
||||||
use crate::clip::ClipInstance;
|
use crate::clip::ClipInstance;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
|
use daw_backend::Beats;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Action that moves clip instances to new timeline positions
|
/// Action that moves clip instances to new timeline positions
|
||||||
pub struct MoveClipInstancesAction {
|
pub struct MoveClipInstancesAction {
|
||||||
/// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start)
|
/// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start).
|
||||||
layer_moves: HashMap<Uuid, Vec<(Uuid, f64, f64)>>,
|
/// Timeline positions are in beats.
|
||||||
|
layer_moves: HashMap<Uuid, Vec<(Uuid, Beats, Beats)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MoveClipInstancesAction {
|
impl MoveClipInstancesAction {
|
||||||
|
|
@ -20,8 +22,8 @@ impl MoveClipInstancesAction {
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `layer_moves` - Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start)
|
/// * `layer_moves` - Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) in beats
|
||||||
pub fn new(layer_moves: HashMap<Uuid, Vec<(Uuid, f64, f64)>>) -> Self {
|
pub fn new(layer_moves: HashMap<Uuid, Vec<(Uuid, Beats, Beats)>>) -> Self {
|
||||||
Self { layer_moves }
|
Self { layer_moves }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +44,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
|
|
||||||
// Check if this instance is in a group
|
// Check if this instance is in a group
|
||||||
if let Some(group) = document.find_group_for_instance(instance_id) {
|
if let Some(group) = document.find_group_for_instance(instance_id) {
|
||||||
let offset = new_start - old_start;
|
let offset = *new_start - *old_start;
|
||||||
|
|
||||||
// Add all group members to the move list
|
// Add all group members to the move list
|
||||||
for (member_layer_id, member_instance_id) in group.get_members() {
|
for (member_layer_id, member_instance_id) in group.get_members() {
|
||||||
|
|
@ -77,7 +79,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-adjust moves to avoid overlaps
|
// Auto-adjust moves to avoid overlaps
|
||||||
let mut adjusted_moves: HashMap<Uuid, Vec<(Uuid, f64, f64)>> = HashMap::new();
|
let mut adjusted_moves: HashMap<Uuid, Vec<(Uuid, Beats, Beats)>> = HashMap::new();
|
||||||
|
|
||||||
for (layer_id, moves) in &expanded_moves {
|
for (layer_id, moves) in &expanded_moves {
|
||||||
let layer = document.get_layer(layer_id)
|
let layer = document.get_layer(layer_id)
|
||||||
|
|
@ -101,10 +103,10 @@ impl Action for MoveClipInstancesAction {
|
||||||
AnyLayer::Text(_) => &[],
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| {
|
let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| {
|
||||||
let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
|
let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
|
||||||
let dur = document.get_clip_duration(&inst.clip_id)?;
|
let dur = document.get_clip_duration(&inst.clip_id)?;
|
||||||
let eff = inst.trim_end.unwrap_or(dur) - inst.trim_start;
|
let eff = inst.effective_duration_beats(dur, document.tempo_map());
|
||||||
Some((*id, *old_start, eff))
|
Some((*id, *old_start, eff))
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
|
|
@ -112,7 +114,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
let clamped = document.clamp_group_move_offset(layer_id, &group, desired_offset);
|
let clamped = document.clamp_group_move_offset(layer_id, &group, desired_offset);
|
||||||
|
|
||||||
for (instance_id, old_start, _) in moves {
|
for (instance_id, old_start, _) in moves {
|
||||||
adjusted_layer_moves.push((*instance_id, *old_start, (*old_start + clamped).max(0.0)));
|
adjusted_layer_moves.push((*instance_id, *old_start, (*old_start + clamped).max(Beats::ZERO)));
|
||||||
}
|
}
|
||||||
|
|
||||||
adjusted_moves.insert(*layer_id, adjusted_layer_moves);
|
adjusted_moves.insert(*layer_id, adjusted_layer_moves);
|
||||||
|
|
@ -208,7 +210,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
||||||
// Check if this clip has a metatrack
|
// Check if this clip has a metatrack
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
controller.set_offset(metatrack_id, *new_start);
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start));
|
||||||
controller.set_trim_start(metatrack_id, instance.trim_start);
|
controller.set_trim_start(metatrack_id, instance.trim_start);
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end);
|
controller.set_trim_end(metatrack_id, instance.trim_end);
|
||||||
}
|
}
|
||||||
|
|
@ -292,7 +294,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
for (instance_id, old_start, _new_start) in moves {
|
for (instance_id, old_start, _new_start) in moves {
|
||||||
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
controller.set_offset(metatrack_id, *old_start);
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start));
|
||||||
controller.set_trim_start(metatrack_id, instance.trim_start);
|
controller.set_trim_start(metatrack_id, instance.trim_start);
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end);
|
controller.set_trim_end(metatrack_id, instance.trim_end);
|
||||||
}
|
}
|
||||||
|
|
@ -373,15 +375,15 @@ mod tests {
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
|
|
||||||
let mut clip_instance = ClipInstance::new(clip_id);
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
clip_instance.timeline_start = 1.0; // Start at 1 second
|
clip_instance.timeline_start = Beats(1.0); // Start at beat 1
|
||||||
let instance_id = clip_instance.id;
|
let instance_id = clip_instance.id;
|
||||||
vector_layer.clip_instances.push(clip_instance);
|
vector_layer.clip_instances.push(clip_instance);
|
||||||
|
|
||||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
// Create move action: move from 1.0 to 5.0 seconds
|
// Create move action: move from beat 1 to beat 5
|
||||||
let mut layer_moves = HashMap::new();
|
let mut layer_moves = HashMap::new();
|
||||||
layer_moves.insert(layer_id, vec![(instance_id, 1.0, 5.0)]);
|
layer_moves.insert(layer_id, vec![(instance_id, Beats(1.0), Beats(5.0))]);
|
||||||
|
|
||||||
let mut action = MoveClipInstancesAction::new(layer_moves);
|
let mut action = MoveClipInstancesAction::new(layer_moves);
|
||||||
|
|
||||||
|
|
@ -395,7 +397,7 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ci| ci.id == instance_id)
|
.find(|ci| ci.id == instance_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(instance.timeline_start, 5.0);
|
assert_eq!(instance.timeline_start, Beats(5.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rollback
|
// Rollback
|
||||||
|
|
@ -408,7 +410,7 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ci| ci.id == instance_id)
|
.find(|ci| ci.id == instance_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(instance.timeline_start, 1.0);
|
assert_eq!(instance.timeline_start, Beats(1.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,17 +172,18 @@ impl Action for RemoveClipInstancesAction {
|
||||||
let internal_start = instance.trim_start;
|
let internal_start = instance.trim_start;
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.duration);
|
let internal_end = instance.trim_end.unwrap_or(clip.duration);
|
||||||
let external_start = instance.timeline_start;
|
let external_start = instance.timeline_start;
|
||||||
|
// MIDI trims are beats-domain, so the fallback span is beats too.
|
||||||
let external_duration = instance
|
let external_duration = instance
|
||||||
.timeline_duration
|
.timeline_duration
|
||||||
.unwrap_or(internal_end - internal_start);
|
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||||
|
|
||||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||||
0,
|
0,
|
||||||
*midi_clip_id,
|
*midi_clip_id,
|
||||||
daw_backend::Beats(internal_start),
|
daw_backend::Beats(internal_start),
|
||||||
daw_backend::Beats(internal_end),
|
daw_backend::Beats(internal_end),
|
||||||
daw_backend::Beats(external_start),
|
external_start,
|
||||||
daw_backend::Beats(external_duration),
|
external_duration,
|
||||||
);
|
);
|
||||||
|
|
||||||
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
||||||
|
|
@ -198,16 +199,22 @@ impl Action for RemoveClipInstancesAction {
|
||||||
AudioClipType::Sampled { audio_pool_index } => {
|
AudioClipType::Sampled { audio_pool_index } => {
|
||||||
let internal_start = instance.trim_start;
|
let internal_start = instance.trim_start;
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.duration);
|
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 start_time = instance.timeline_start;
|
||||||
|
// Fallback span is the content seconds converted to beats at the
|
||||||
|
// clip's start (not the seconds span treated as beats).
|
||||||
|
let effective_duration = instance.timeline_duration.unwrap_or_else(|| {
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
||||||
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
||||||
|
- start_time
|
||||||
|
});
|
||||||
|
|
||||||
let new_id = controller.add_audio_clip(
|
let new_id = controller.add_audio_clip(
|
||||||
track_id,
|
track_id,
|
||||||
*audio_pool_index,
|
*audio_pool_index,
|
||||||
start_time,
|
start_time,
|
||||||
effective_duration,
|
effective_duration,
|
||||||
internal_start,
|
daw_backend::Seconds(internal_start),
|
||||||
);
|
);
|
||||||
backend.clip_instance_to_backend_map.insert(
|
backend.clip_instance_to_backend_map.insert(
|
||||||
instance.id,
|
instance.id,
|
||||||
|
|
@ -238,11 +245,11 @@ mod tests {
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
|
|
||||||
let mut ci1 = ClipInstance::new(clip_id);
|
let mut ci1 = ClipInstance::new(clip_id);
|
||||||
ci1.timeline_start = 0.0;
|
ci1.timeline_start = daw_backend::Beats::ZERO;
|
||||||
let id1 = ci1.id;
|
let id1 = ci1.id;
|
||||||
|
|
||||||
let mut ci2 = ClipInstance::new(clip_id);
|
let mut ci2 = ClipInstance::new(clip_id);
|
||||||
ci2.timeline_start = 5.0;
|
ci2.timeline_start = daw_backend::Beats(5.0);
|
||||||
let id2 = ci2.id;
|
let id2 = ci2.id;
|
||||||
|
|
||||||
vector_layer.clip_instances.push(ci1);
|
vector_layer.clip_instances.push(ci1);
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ mod tests {
|
||||||
let (mut document, layer_id, def) = create_test_setup();
|
let (mut document, layer_id, def) = create_test_setup();
|
||||||
|
|
||||||
// Add an effect first
|
// Add an effect first
|
||||||
let instance = def.create_instance(0.0, 10.0);
|
let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let instance_id = instance.id;
|
let instance_id = instance.id;
|
||||||
|
|
||||||
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) {
|
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) {
|
||||||
|
|
@ -161,7 +161,7 @@ mod tests {
|
||||||
let (mut document, layer_id, def) = create_test_setup();
|
let (mut document, layer_id, def) = create_test_setup();
|
||||||
|
|
||||||
// Add an effect first
|
// Add an effect first
|
||||||
let instance = def.create_instance(0.0, 10.0);
|
let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let instance_id = instance.id;
|
let instance_id = instance.id;
|
||||||
|
|
||||||
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) {
|
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) {
|
||||||
|
|
@ -185,11 +185,11 @@ mod tests {
|
||||||
let (mut document, layer_id, def) = create_test_setup();
|
let (mut document, layer_id, def) = create_test_setup();
|
||||||
|
|
||||||
// Add three effects
|
// Add three effects
|
||||||
let instance1 = def.create_instance(0.0, 10.0);
|
let instance1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id1 = instance1.id;
|
let id1 = instance1.id;
|
||||||
let instance2 = def.create_instance(0.0, 10.0);
|
let instance2 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id2 = instance2.id;
|
let id2 = instance2.id;
|
||||||
let instance3 = def.create_instance(0.0, 10.0);
|
let instance3 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id3 = instance3.id;
|
let id3 = instance3.id;
|
||||||
|
|
||||||
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) {
|
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) {
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,11 @@ impl Action for SetKeyframeAction {
|
||||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&self.layer_id) {
|
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&self.layer_id) {
|
||||||
for clip_id in &self.clip_instance_ids {
|
for clip_id in &self.clip_instance_ids {
|
||||||
if let Some(ci) = vl.clip_instances.iter().find(|c| c.id == *clip_id) {
|
if let Some(ci) = vl.clip_instances.iter().find(|c| c.id == *clip_id) {
|
||||||
|
// `start` is a keyframe time in seconds; group_visibility_start returns seconds,
|
||||||
|
// so the fallback must convert the clip's beats start to seconds too.
|
||||||
let start = vl
|
let start = vl
|
||||||
.group_visibility_start(clip_id, self.time)
|
.group_visibility_start(clip_id, self.time)
|
||||||
.unwrap_or(ci.timeline_start);
|
.unwrap_or_else(|| document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64());
|
||||||
clip_info.insert(*clip_id, (ci.transform.clone(), ci.opacity, start));
|
clip_info.insert(*clip_id, (ci.transform.clone(), ci.opacity, start));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ pub struct SplitClipInstanceAction {
|
||||||
/// The clip instance to split
|
/// The clip instance to split
|
||||||
instance_id: Uuid,
|
instance_id: Uuid,
|
||||||
|
|
||||||
/// Timeline time where to split (in seconds)
|
/// Timeline position where to split (in beats)
|
||||||
split_time: f64,
|
split_time: daw_backend::Beats,
|
||||||
|
|
||||||
/// Whether the action has been executed (for rollback)
|
/// Whether the action has been executed (for rollback)
|
||||||
executed: bool,
|
executed: bool,
|
||||||
|
|
@ -26,8 +26,8 @@ pub struct SplitClipInstanceAction {
|
||||||
// Stored during execute for rollback
|
// Stored during execute for rollback
|
||||||
/// Original trim_end value of the left (original) instance
|
/// Original trim_end value of the left (original) instance
|
||||||
original_trim_end: Option<f64>,
|
original_trim_end: Option<f64>,
|
||||||
/// Original timeline_duration value of the left (original) instance
|
/// Original timeline_duration value of the left (original) instance (beats)
|
||||||
original_timeline_duration: Option<f64>,
|
original_timeline_duration: Option<daw_backend::Beats>,
|
||||||
/// ID of the new (right) instance created by the split
|
/// ID of the new (right) instance created by the split
|
||||||
new_instance_id: Option<Uuid>,
|
new_instance_id: Option<Uuid>,
|
||||||
|
|
||||||
|
|
@ -47,8 +47,8 @@ impl SplitClipInstanceAction {
|
||||||
///
|
///
|
||||||
/// * `layer_id` - The ID of the layer containing the clip instance
|
/// * `layer_id` - The ID of the layer containing the clip instance
|
||||||
/// * `instance_id` - The ID of the clip instance to split
|
/// * `instance_id` - The ID of the clip instance to split
|
||||||
/// * `split_time` - The timeline time (in seconds) where to split
|
/// * `split_time` - The timeline position (in beats) where to split
|
||||||
pub fn new(layer_id: Uuid, instance_id: Uuid, split_time: f64) -> Self {
|
pub fn new(layer_id: Uuid, instance_id: Uuid, split_time: daw_backend::Beats) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer_id,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
|
|
@ -72,9 +72,9 @@ impl SplitClipInstanceAction {
|
||||||
///
|
///
|
||||||
/// * `layer_id` - The ID of the layer containing the clip instance
|
/// * `layer_id` - The ID of the layer containing the clip instance
|
||||||
/// * `instance_id` - The ID of the clip instance to split
|
/// * `instance_id` - The ID of the clip instance to split
|
||||||
/// * `split_time` - The timeline time (in seconds) where to split
|
/// * `split_time` - The timeline position (in beats) where to split
|
||||||
/// * `new_instance_id` - The UUID to use for the new (right) clip instance
|
/// * `new_instance_id` - The UUID to use for the new (right) clip instance
|
||||||
pub fn with_new_instance_id(layer_id: Uuid, instance_id: Uuid, split_time: f64, new_instance_id: Uuid) -> Self {
|
pub fn with_new_instance_id(layer_id: Uuid, instance_id: Uuid, split_time: daw_backend::Beats, new_instance_id: Uuid) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer_id,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
|
|
@ -132,9 +132,9 @@ impl Action for SplitClipInstanceAction {
|
||||||
let timeline_end = instance.timeline_start + effective_duration;
|
let timeline_end = instance.timeline_start + effective_duration;
|
||||||
|
|
||||||
// Validate: split_time must be strictly within the clip's timeline span
|
// Validate: split_time must be strictly within the clip's timeline span
|
||||||
const EPSILON: f64 = 0.001; // 1ms tolerance
|
let epsilon = daw_backend::Beats(0.001); // ~1ms tolerance
|
||||||
if self.split_time <= instance.timeline_start + EPSILON
|
if self.split_time <= instance.timeline_start + epsilon
|
||||||
|| self.split_time >= timeline_end - EPSILON
|
|| self.split_time >= timeline_end - epsilon
|
||||||
{
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Split time {} must be within clip bounds ({} to {})",
|
"Split time {} must be within clip bounds ({} to {})",
|
||||||
|
|
@ -146,21 +146,27 @@ impl Action for SplitClipInstanceAction {
|
||||||
self.original_trim_end = instance.trim_end;
|
self.original_trim_end = instance.trim_end;
|
||||||
self.original_timeline_duration = instance.timeline_duration;
|
self.original_timeline_duration = instance.timeline_duration;
|
||||||
|
|
||||||
// Check if this is a looping clip
|
// Check if this is a looping clip. `content_duration` is a trim-domain
|
||||||
|
// span (seconds), so `clip_duration` must be unwrapped as seconds.
|
||||||
let is_looping = instance.timeline_duration.is_some();
|
let is_looping = instance.timeline_duration.is_some();
|
||||||
let content_duration = instance.trim_end.unwrap_or(clip_duration) - instance.trim_start;
|
let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start;
|
||||||
|
|
||||||
// Calculate the split point
|
// Timeline split point (beats).
|
||||||
let time_into_clip = self.split_time - instance.timeline_start;
|
let time_into_clip = self.split_time - instance.timeline_start;
|
||||||
let left_duration = time_into_clip;
|
let left_duration = time_into_clip;
|
||||||
let right_duration = effective_duration - left_duration;
|
let right_duration = effective_duration - left_duration;
|
||||||
|
|
||||||
// Calculate content split time
|
// How far the split lands into the clip's *content* (seconds, trim domain).
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time)
|
||||||
|
- tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64();
|
||||||
|
|
||||||
|
// Calculate content split time (seconds)
|
||||||
let content_split_time = if is_looping {
|
let content_split_time = if is_looping {
|
||||||
// For looping clips, wrap around content
|
// For looping clips, wrap around content
|
||||||
instance.trim_start + (time_into_clip % content_duration)
|
instance.trim_start + (time_into_clip_secs % content_duration)
|
||||||
} else {
|
} else {
|
||||||
instance.trim_start + time_into_clip
|
instance.trim_start + time_into_clip_secs
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clone the instance for the right side
|
// Clone the instance for the right side
|
||||||
|
|
@ -384,17 +390,18 @@ impl Action for SplitClipInstanceAction {
|
||||||
let internal_start = new_instance.trim_start;
|
let internal_start = new_instance.trim_start;
|
||||||
let internal_end = new_instance.trim_end.unwrap_or(clip.duration);
|
let internal_end = new_instance.trim_end.unwrap_or(clip.duration);
|
||||||
let external_start = new_instance.timeline_start;
|
let external_start = new_instance.timeline_start;
|
||||||
|
// MIDI trims are beats-domain, so the fallback span is beats too.
|
||||||
let external_duration = new_instance
|
let external_duration = new_instance
|
||||||
.timeline_duration
|
.timeline_duration
|
||||||
.unwrap_or(internal_end - internal_start);
|
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||||
|
|
||||||
let instance = daw_backend::MidiClipInstance::new(
|
let instance = daw_backend::MidiClipInstance::new(
|
||||||
0,
|
0,
|
||||||
*midi_clip_id,
|
*midi_clip_id,
|
||||||
daw_backend::Beats(internal_start),
|
daw_backend::Beats(internal_start),
|
||||||
daw_backend::Beats(internal_end),
|
daw_backend::Beats(internal_end),
|
||||||
daw_backend::Beats(external_start),
|
external_start,
|
||||||
daw_backend::Beats(external_duration),
|
external_duration,
|
||||||
);
|
);
|
||||||
|
|
||||||
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
||||||
|
|
@ -430,16 +437,22 @@ impl Action for SplitClipInstanceAction {
|
||||||
// 2. Add the new (right) instance
|
// 2. Add the new (right) instance
|
||||||
let internal_start = new_instance.trim_start;
|
let internal_start = new_instance.trim_start;
|
||||||
let internal_end = new_instance.trim_end.unwrap_or(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;
|
||||||
|
// Fallback span is the content seconds converted to beats at the
|
||||||
|
// clip's start (not the seconds span treated as beats).
|
||||||
|
let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| {
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
||||||
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
||||||
|
- start_time
|
||||||
|
});
|
||||||
|
|
||||||
let instance_id = controller.add_audio_clip(
|
let instance_id = controller.add_audio_clip(
|
||||||
*backend_track_id,
|
*backend_track_id,
|
||||||
*audio_pool_index,
|
*audio_pool_index,
|
||||||
start_time,
|
start_time,
|
||||||
effective_duration,
|
effective_duration,
|
||||||
internal_start,
|
daw_backend::Seconds(internal_start),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.backend_track_id = Some(*backend_track_id);
|
self.backend_track_id = Some(*backend_track_id);
|
||||||
|
|
@ -540,7 +553,7 @@ mod tests {
|
||||||
|
|
||||||
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
|
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
|
||||||
let mut clip_instance = ClipInstance::new(clip_id);
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
clip_instance.timeline_start = 0.0;
|
clip_instance.timeline_start = daw_backend::Beats::ZERO;
|
||||||
clip_instance.trim_start = 0.0;
|
clip_instance.trim_start = 0.0;
|
||||||
clip_instance.trim_end = Some(10.0);
|
clip_instance.trim_end = Some(10.0);
|
||||||
let instance_id = clip_instance.id;
|
let instance_id = clip_instance.id;
|
||||||
|
|
@ -549,7 +562,7 @@ mod tests {
|
||||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
// Split at timeline 5.0
|
// Split at timeline 5.0
|
||||||
let mut action = SplitClipInstanceAction::new(layer_id, instance_id, 5.0);
|
let mut action = SplitClipInstanceAction::new(layer_id, instance_id, daw_backend::Beats(5.0));
|
||||||
|
|
||||||
// Execute - this will fail because we don't have a real clip in the document
|
// Execute - this will fail because we don't have a real clip in the document
|
||||||
// In a real test, we'd need to add a VectorClip first
|
// In a real test, we'd need to add a VectorClip first
|
||||||
|
|
@ -559,7 +572,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_split_action_description() {
|
fn test_split_action_description() {
|
||||||
let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), 5.0);
|
let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0));
|
||||||
assert_eq!(action.description(), "Split clip instance");
|
assert_eq!(action.description(), "Split clip instance");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use crate::action::Action;
|
||||||
use crate::clip::ClipInstance;
|
use crate::clip::ClipInstance;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
|
use daw_backend::{Beats, Seconds};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -32,14 +33,14 @@ pub struct TrimData {
|
||||||
/// For TrimLeft: trim_start value
|
/// For TrimLeft: trim_start value
|
||||||
/// For TrimRight: trim_end value (Option because it can be None)
|
/// For TrimRight: trim_end value (Option because it can be None)
|
||||||
pub trim_value: Option<f64>,
|
pub trim_value: Option<f64>,
|
||||||
/// For TrimLeft: timeline_start value (where the clip appears on timeline)
|
/// For TrimLeft: timeline_start value (where the clip appears on timeline, beats)
|
||||||
/// For TrimRight: unused (None)
|
/// For TrimRight: unused (None)
|
||||||
pub timeline_start: Option<f64>,
|
pub timeline_start: Option<Beats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrimData {
|
impl TrimData {
|
||||||
/// Create TrimData for left trim
|
/// Create TrimData for left trim
|
||||||
pub fn left(trim_start: f64, timeline_start: f64) -> Self {
|
pub fn left(trim_start: f64, timeline_start: Beats) -> Self {
|
||||||
Self {
|
Self {
|
||||||
trim_value: Some(trim_start),
|
trim_value: Some(trim_start),
|
||||||
timeline_start: Some(timeline_start),
|
timeline_start: Some(timeline_start),
|
||||||
|
|
@ -203,51 +204,73 @@ impl Action for TrimClipInstancesAction {
|
||||||
{
|
{
|
||||||
// If extending to the left (new_trim < old_trim)
|
// If extending to the left (new_trim < old_trim)
|
||||||
if should_validate && new_trim < old_trim {
|
if should_validate && new_trim < old_trim {
|
||||||
// Find the maximum we can extend left
|
// Max we can extend left is the timeline gap to the
|
||||||
let max_extend = document.find_max_trim_extend_left(
|
// previous clip (beats).
|
||||||
|
let max_extend_beats = document.find_max_trim_extend_left(
|
||||||
layer_id,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.timeline_start,
|
instance.timeline_start,
|
||||||
);
|
);
|
||||||
|
// Audio plays 1 content-second per timeline-second, so the
|
||||||
|
// revealable content equals that gap's wall-clock span.
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let old_timeline_secs = tempo_map.beats_to_seconds(old_timeline);
|
||||||
|
let max_extend_secs = (old_timeline_secs
|
||||||
|
- tempo_map.beats_to_seconds(old_timeline - max_extend_beats))
|
||||||
|
.seconds_to_f64();
|
||||||
|
|
||||||
// Calculate how much we want to extend
|
// Calculate how much we want to extend (content seconds)
|
||||||
let desired_extend = old_trim - new_trim;
|
let desired_extend = old_trim - new_trim;
|
||||||
|
|
||||||
// Clamp to max allowed
|
// Clamp to max allowed
|
||||||
let actual_extend = desired_extend.min(max_extend);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
let clamped_trim_start = old_trim - actual_extend;
|
let clamped_trim_start = old_trim - actual_extend;
|
||||||
let clamped_timeline_start = (old_timeline - actual_extend).max(0.0);
|
// Move the timeline left by the same wall-clock seconds.
|
||||||
|
let clamped_timeline_start = tempo_map
|
||||||
|
.seconds_to_beats(old_timeline_secs - Seconds(actual_extend))
|
||||||
|
.max(Beats::ZERO);
|
||||||
|
|
||||||
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
|
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TrimType::TrimRight => {
|
TrimType::TrimRight => {
|
||||||
let old_trim_end = old.trim_value.unwrap_or(clip_duration);
|
let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
let new_trim_end = new.trim_value.unwrap_or(clip_duration);
|
let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
// If extending to the right (new_trim_end > old_trim_end)
|
// If extending to the right (new_trim_end > old_trim_end)
|
||||||
if should_validate && new_trim_end > old_trim_end {
|
if should_validate && new_trim_end > old_trim_end {
|
||||||
// Calculate current effective duration
|
let tempo_map = document.tempo_map();
|
||||||
let current_effective_duration = old_trim_end - instance.trim_start;
|
// Current effective duration in beats (content seconds
|
||||||
|
// converted to beats at the clip's start).
|
||||||
|
let content_secs = Seconds(old_trim_end - instance.trim_start);
|
||||||
|
let current_effective_duration = tempo_map.seconds_to_beats(
|
||||||
|
tempo_map.beats_to_seconds(instance.timeline_start) + content_secs,
|
||||||
|
) - instance.timeline_start;
|
||||||
|
|
||||||
// Find the maximum we can extend right
|
// Max we can extend right is the timeline gap to the next
|
||||||
let max_extend = document.find_max_trim_extend_right(
|
// clip (beats).
|
||||||
|
let max_extend_beats = document.find_max_trim_extend_right(
|
||||||
layer_id,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.timeline_start,
|
instance.timeline_start,
|
||||||
current_effective_duration,
|
current_effective_duration,
|
||||||
);
|
);
|
||||||
|
// Convert that gap to content seconds at the clip's right edge.
|
||||||
|
let right_edge = instance.timeline_start + current_effective_duration;
|
||||||
|
let max_extend_secs = (tempo_map.beats_to_seconds(right_edge + max_extend_beats)
|
||||||
|
- tempo_map.beats_to_seconds(right_edge))
|
||||||
|
.seconds_to_f64();
|
||||||
|
|
||||||
// Calculate how much we want to extend
|
// Calculate how much we want to extend (content seconds)
|
||||||
let desired_extend = new_trim_end - old_trim_end;
|
let desired_extend = new_trim_end - old_trim_end;
|
||||||
|
|
||||||
// Clamp to max allowed
|
// Clamp to max allowed
|
||||||
let actual_extend = desired_extend.min(max_extend);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
let clamped_trim_end = old_trim_end + actual_extend;
|
let clamped_trim_end = old_trim_end + actual_extend;
|
||||||
|
|
||||||
// Don't exceed clip duration
|
// Don't exceed clip duration
|
||||||
let final_trim_end = clamped_trim_end.min(clip_duration);
|
let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
clamped_new = TrimData::right(Some(final_trim_end));
|
clamped_new = TrimData::right(Some(final_trim_end));
|
||||||
}
|
}
|
||||||
|
|
@ -376,7 +399,7 @@ impl Action for TrimClipInstancesAction {
|
||||||
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
// Instance already has new values after execute()
|
// Instance already has new values after execute()
|
||||||
controller.set_offset(metatrack_id, instance.timeline_start);
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
||||||
controller.set_trim_start(metatrack_id, instance.trim_start);
|
controller.set_trim_start(metatrack_id, instance.trim_start);
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end);
|
controller.set_trim_end(metatrack_id, instance.trim_end);
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +489,7 @@ impl Action for TrimClipInstancesAction {
|
||||||
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
// Instance already has old values after rollback()
|
// Instance already has old values after rollback()
|
||||||
controller.set_offset(metatrack_id, instance.timeline_start);
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
||||||
controller.set_trim_start(metatrack_id, instance.trim_start);
|
controller.set_trim_start(metatrack_id, instance.trim_start);
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end);
|
controller.set_trim_end(metatrack_id, instance.trim_end);
|
||||||
}
|
}
|
||||||
|
|
@ -558,7 +581,7 @@ mod tests {
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
|
|
||||||
let mut clip_instance = ClipInstance::new(clip_id);
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
clip_instance.timeline_start = 0.0;
|
clip_instance.timeline_start = Beats::ZERO;
|
||||||
clip_instance.trim_start = 0.0;
|
clip_instance.trim_start = 0.0;
|
||||||
let instance_id = clip_instance.id;
|
let instance_id = clip_instance.id;
|
||||||
vector_layer.clip_instances.push(clip_instance);
|
vector_layer.clip_instances.push(clip_instance);
|
||||||
|
|
@ -572,8 +595,8 @@ mod tests {
|
||||||
vec![(
|
vec![(
|
||||||
instance_id,
|
instance_id,
|
||||||
TrimType::TrimLeft,
|
TrimType::TrimLeft,
|
||||||
TrimData::left(0.0, 0.0),
|
TrimData::left(0.0, Beats::ZERO),
|
||||||
TrimData::left(2.0, 2.0),
|
TrimData::left(2.0, Beats(2.0)),
|
||||||
)],
|
)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -590,7 +613,7 @@ mod tests {
|
||||||
.find(|ci| ci.id == instance_id)
|
.find(|ci| ci.id == instance_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(instance.trim_start, 2.0);
|
assert_eq!(instance.trim_start, 2.0);
|
||||||
assert_eq!(instance.timeline_start, 2.0);
|
assert_eq!(instance.timeline_start, Beats(2.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rollback
|
// Rollback
|
||||||
|
|
@ -604,7 +627,7 @@ mod tests {
|
||||||
.find(|ci| ci.id == instance_id)
|
.find(|ci| ci.id == instance_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(instance.trim_start, 0.0);
|
assert_eq!(instance.trim_start, 0.0);
|
||||||
assert_eq!(instance.timeline_start, 0.0);
|
assert_eq!(instance.timeline_start, Beats::ZERO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::layer_tree::LayerTree;
|
use crate::layer_tree::LayerTree;
|
||||||
use crate::object::Transform;
|
use crate::object::Transform;
|
||||||
|
use daw_backend::{Beats, Seconds};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -110,7 +111,7 @@ impl VectorClip {
|
||||||
pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
|
pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
|
||||||
let frame_duration = 1.0 / framerate;
|
let frame_duration = 1.0 / framerate;
|
||||||
// Work in beats, convert to seconds at the end.
|
// Work in beats, convert to seconds at the end.
|
||||||
let mut last_beats: Option<f64> = None;
|
let mut last_beats: Option<Beats> = None;
|
||||||
let mut last_secs: Option<f64> = None;
|
let mut last_secs: Option<f64> = None;
|
||||||
|
|
||||||
for layer_node in self.layers.iter() {
|
for layer_node in self.layers.iter() {
|
||||||
|
|
@ -126,18 +127,18 @@ impl VectorClip {
|
||||||
};
|
};
|
||||||
for ci in clip_instances {
|
for ci in clip_instances {
|
||||||
// Compute end position of this clip instance in beats
|
// Compute end position of this clip instance in beats
|
||||||
let end_beats = if let Some(td_beats) = ci.timeline_duration {
|
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
|
||||||
ci.timeline_start + td_beats
|
ci.timeline_start + td_beats
|
||||||
} else if let Some(te) = ci.trim_end {
|
} else if let Some(te) = ci.trim_end {
|
||||||
let secs = (te - ci.trim_start).max(0.0);
|
let secs = (te - ci.trim_start).max(0.0);
|
||||||
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
||||||
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
|
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
|
||||||
let secs = (clip_dur_secs - ci.trim_start).max(0.0);
|
let secs = (clip_dur_secs - ci.trim_start).max(0.0);
|
||||||
ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
last_beats = Some(last_beats.map_or(end_beats, |t: f64| t.max(end_beats)));
|
last_beats = Some(last_beats.map_or(end_beats, |t: Beats| t.max(end_beats)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vector layer keyframes are in seconds
|
// Vector layer keyframes are in seconds
|
||||||
|
|
@ -148,7 +149,7 @@ impl VectorClip {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let from_clips = last_beats.map(|b| tempo_map.transform(b));
|
let from_clips = last_beats.map(|b| tempo_map.beats_to_seconds(b).seconds_to_f64());
|
||||||
let combined = match (from_clips, last_secs) {
|
let combined = match (from_clips, last_secs) {
|
||||||
(Some(a), Some(b)) => Some(a.max(b)),
|
(Some(a), Some(b)) => Some(a.max(b)),
|
||||||
(Some(a), None) => Some(a),
|
(Some(a), None) => Some(a),
|
||||||
|
|
@ -199,7 +200,7 @@ impl VectorClip {
|
||||||
for clip_instance in &vector_layer.clip_instances {
|
for clip_instance in &vector_layer.clip_instances {
|
||||||
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
||||||
// timeline_start is in beats; convert to seconds using document BPM.
|
// timeline_start is in beats; convert to seconds using document BPM.
|
||||||
let start_secs = document.tempo_map().transform(clip_instance.timeline_start);
|
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||||
|
|
||||||
// Look up the nested clip definition
|
// Look up the nested clip definition
|
||||||
|
|
@ -647,12 +648,12 @@ pub struct ClipInstance {
|
||||||
|
|
||||||
/// When this instance starts on the timeline, in **beats**.
|
/// When this instance starts on the timeline, in **beats**.
|
||||||
/// Default: 0.0
|
/// Default: 0.0
|
||||||
pub timeline_start: f64,
|
pub timeline_start: Beats,
|
||||||
|
|
||||||
/// How long this instance appears on the timeline, in **beats**.
|
/// How long this instance appears on the timeline, in **beats**.
|
||||||
/// If set and longer than the trimmed content, the content will loop.
|
/// If set and longer than the trimmed content, the content will loop.
|
||||||
/// Default: None (use trimmed clip duration, no looping)
|
/// Default: None (use trimmed clip duration, no looping)
|
||||||
pub timeline_duration: Option<f64>,
|
pub timeline_duration: Option<Beats>,
|
||||||
|
|
||||||
/// Trim start: offset into the clip's internal content, in **seconds**.
|
/// Trim start: offset into the clip's internal content, in **seconds**.
|
||||||
/// - For audio: byte-offset into the audio file
|
/// - For audio: byte-offset into the audio file
|
||||||
|
|
@ -679,7 +680,7 @@ pub struct ClipInstance {
|
||||||
/// When set, loop iterations are drawn/played before the content start.
|
/// When set, loop iterations are drawn/played before the content start.
|
||||||
/// Default: None (no pre-loop)
|
/// Default: None (no pre-loop)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub loop_before: Option<f64>,
|
pub loop_before: Option<Beats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
|
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
|
||||||
|
|
@ -731,7 +732,7 @@ impl ClipInstance {
|
||||||
transform: Transform::default(),
|
transform: Transform::default(),
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
name: None,
|
name: None,
|
||||||
timeline_start: 0.0,
|
timeline_start: Beats::ZERO,
|
||||||
timeline_duration: None,
|
timeline_duration: None,
|
||||||
trim_start: 0.0,
|
trim_start: 0.0,
|
||||||
trim_end: None,
|
trim_end: None,
|
||||||
|
|
@ -749,7 +750,7 @@ impl ClipInstance {
|
||||||
transform: Transform::default(),
|
transform: Transform::default(),
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
name: None,
|
name: None,
|
||||||
timeline_start: 0.0,
|
timeline_start: Beats::ZERO,
|
||||||
timeline_duration: None,
|
timeline_duration: None,
|
||||||
trim_start: 0.0,
|
trim_start: 0.0,
|
||||||
trim_end: None,
|
trim_end: None,
|
||||||
|
|
@ -784,8 +785,8 @@ impl ClipInstance {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set timeline position
|
/// Set timeline position (beats).
|
||||||
pub fn with_timeline_start(mut self, timeline_start: f64) -> Self {
|
pub fn with_timeline_start(mut self, timeline_start: Beats) -> Self {
|
||||||
self.timeline_start = timeline_start;
|
self.timeline_start = timeline_start;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
@ -810,79 +811,78 @@ impl ClipInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set explicit timeline duration (in beats) by directly setting `timeline_duration`.
|
/// Set explicit timeline duration (in beats) by directly setting `timeline_duration`.
|
||||||
pub fn with_timeline_duration(mut self, duration_beats: f64) -> Self {
|
pub fn with_timeline_duration(mut self, duration_beats: Beats) -> Self {
|
||||||
self.timeline_duration = Some(duration_beats);
|
self.timeline_duration = Some(duration_beats);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content window size in seconds: `trim_end - trim_start`.
|
/// Content window size in seconds: `trim_end - trim_start`.
|
||||||
/// Used for internal looping calculations.
|
/// Used for internal looping calculations.
|
||||||
pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 {
|
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds {
|
||||||
let end = self.trim_end.unwrap_or(clip_duration_secs);
|
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64());
|
||||||
(end - self.trim_start).max(0.0)
|
Seconds((end - self.trim_start).max(0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long this instance appears on the timeline, in **beats**.
|
/// How long this instance appears on the timeline, in **beats**.
|
||||||
///
|
///
|
||||||
/// If `timeline_duration` is set, returns that (enabling content looping).
|
/// If `timeline_duration` is set, returns that (enabling content looping).
|
||||||
/// Otherwise converts the content window from seconds to beats using the tempo map.
|
/// Otherwise converts the content window from seconds to beats using the tempo map.
|
||||||
pub fn effective_duration_beats(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||||
if let Some(td) = self.timeline_duration {
|
if let Some(td) = self.timeline_duration {
|
||||||
return td;
|
return td;
|
||||||
}
|
}
|
||||||
let window_secs = self.content_window_secs(clip_duration_secs);
|
let window = self.content_window_secs(clip_duration_secs);
|
||||||
let start_secs = tempo_map.transform(self.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||||
tempo_map.inverse_transform(start_secs + window_secs) - self.timeline_start
|
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left edge of the clip's visual extent on the timeline, in **beats**.
|
/// Left edge of the clip's visual extent on the timeline, in **beats**.
|
||||||
pub fn effective_start(&self) -> f64 {
|
pub fn effective_start(&self) -> Beats {
|
||||||
self.timeline_start - self.loop_before.unwrap_or(0.0)
|
self.timeline_start - self.loop_before.unwrap_or(Beats::ZERO)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total visual duration (loop_before + effective_duration), in **beats**.
|
/// Total visual duration (loop_before + effective_duration), in **beats**.
|
||||||
pub fn total_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||||
self.loop_before.unwrap_or(0.0) + self.effective_duration_beats(clip_duration_secs, tempo_map)
|
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
||||||
///
|
///
|
||||||
/// Returns `None` if the clip instance is not active at `time_secs`.
|
/// Returns `None` if the clip instance is not active at `time_secs`.
|
||||||
pub fn remap_time_secs(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
|
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||||
let start_secs = tempo_map.transform(self.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||||
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
|
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
|
||||||
let end_secs = tempo_map.transform(self.timeline_start + dur_beats);
|
let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
|
||||||
|
|
||||||
if time_secs < start_secs || time_secs >= end_secs {
|
if time < start_secs || time >= end_secs {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let relative_secs = time_secs - start_secs;
|
let content_time = (time - start_secs) * self.playback_speed;
|
||||||
let content_time = relative_secs * self.playback_speed;
|
|
||||||
let content_window = self.content_window_secs(clip_duration_secs);
|
let content_window = self.content_window_secs(clip_duration_secs);
|
||||||
|
|
||||||
if content_window == 0.0 {
|
if content_window == Seconds::ZERO {
|
||||||
return Some(self.trim_start);
|
return Some(Seconds(self.trim_start));
|
||||||
}
|
}
|
||||||
|
|
||||||
let looped_time = if content_time > content_window {
|
let looped = if content_time > content_window {
|
||||||
content_time % content_window
|
content_time % content_window
|
||||||
} else {
|
} else {
|
||||||
content_time
|
content_time
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(self.trim_start + looped_time)
|
Some(Seconds(self.trim_start) + looped)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias for `remap_time_secs`.
|
/// Alias for `remap_time_secs`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn remap_time(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> {
|
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||||
self.remap_time_secs(time_secs, clip_duration_secs, tempo_map)
|
self.remap_time_secs(time, clip_duration_secs, tempo_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias for `effective_duration_beats`.
|
/// Alias for `effective_duration_beats`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn effective_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 {
|
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||||
self.effective_duration_beats(clip_duration_secs, tempo_map)
|
self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -959,7 +959,7 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(instance.clip_id, clip_id);
|
assert_eq!(instance.clip_id, clip_id);
|
||||||
assert_eq!(instance.opacity, 1.0);
|
assert_eq!(instance.opacity, 1.0);
|
||||||
assert_eq!(instance.timeline_start, 0.0);
|
assert_eq!(instance.timeline_start, Beats::ZERO);
|
||||||
assert_eq!(instance.trim_start, 0.0);
|
assert_eq!(instance.trim_start, 0.0);
|
||||||
assert_eq!(instance.trim_end, None);
|
assert_eq!(instance.trim_end, None);
|
||||||
assert_eq!(instance.playback_speed, 1.0);
|
assert_eq!(instance.playback_speed, 1.0);
|
||||||
|
|
@ -977,7 +977,7 @@ mod tests {
|
||||||
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
||||||
// beats-domain effective duration equals the seconds content window.
|
// beats-domain effective duration equals the seconds content window.
|
||||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||||
assert_eq!(instance.effective_duration(10.0, &tempo_map), 6.0);
|
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -990,7 +990,7 @@ mod tests {
|
||||||
assert_eq!(instance.trim_end, None);
|
assert_eq!(instance.trim_end, None);
|
||||||
// At 60 BPM the tempo map is identity (1 beat == 1 second).
|
// At 60 BPM the tempo map is identity (1 beat == 1 second).
|
||||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||||
assert_eq!(instance.effective_duration(10.0, &tempo_map), 8.0);
|
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
use crate::asset_folder::AssetFolderTree;
|
use crate::asset_folder::AssetFolderTree;
|
||||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
||||||
|
use daw_backend::{Beats, Seconds};
|
||||||
use crate::effect::EffectDefinition;
|
use crate::effect::EffectDefinition;
|
||||||
use crate::layer::{AnyLayer, GroupLayer};
|
use crate::layer::{AnyLayer, GroupLayer};
|
||||||
use crate::script::ScriptDefinition;
|
use crate::script::ScriptDefinition;
|
||||||
|
|
@ -438,20 +439,24 @@ impl Document {
|
||||||
/// Returns the end time of the last clip instance across all layers,
|
/// Returns the end time of the last clip instance across all layers,
|
||||||
/// or the document's duration if no clips are found.
|
/// or the document's duration if no clips are found.
|
||||||
pub fn calculate_timeline_endpoint(&self) -> f64 {
|
pub fn calculate_timeline_endpoint(&self) -> f64 {
|
||||||
|
let tempo_map = self.tempo_map();
|
||||||
|
// Accumulated in **beats** (as f64, to keep the recursive helper's `Fn(_, f64) -> f64`
|
||||||
|
// signature); converted to seconds once at the return.
|
||||||
let mut max_end_time: f64 = 0.0;
|
let mut max_end_time: f64 = 0.0;
|
||||||
|
|
||||||
// Helper function to calculate the end time of a clip instance
|
// End position of a clip instance, in **beats**. Its trimmed content window is in seconds
|
||||||
|
// (scaled by playback speed), so convert that seconds span to beats via the tempo map — the
|
||||||
|
// old code added a seconds duration straight onto the beats start.
|
||||||
let calculate_instance_end = |instance: &ClipInstance, clip_duration: f64| -> f64 {
|
let calculate_instance_end = |instance: &ClipInstance, clip_duration: f64| -> f64 {
|
||||||
let effective_duration = if let Some(timeline_duration) = instance.timeline_duration {
|
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
|
||||||
// Explicit timeline duration set (may include looping)
|
instance.timeline_start + timeline_duration
|
||||||
timeline_duration
|
|
||||||
} else {
|
} else {
|
||||||
// Calculate from trim points
|
|
||||||
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
||||||
let trimmed_duration = trim_end - instance.trim_start;
|
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0);
|
||||||
trimmed_duration / instance.playback_speed // Adjust for playback speed
|
let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
|
||||||
|
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
|
||||||
};
|
};
|
||||||
instance.timeline_start + effective_duration
|
end_beats.beats_to_f64()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Iterate through all layers to find the maximum end time
|
// Iterate through all layers to find the maximum end time
|
||||||
|
|
@ -484,7 +489,7 @@ impl Document {
|
||||||
crate::layer::AnyLayer::Effect(effect_layer) => {
|
crate::layer::AnyLayer::Effect(effect_layer) => {
|
||||||
for instance in &effect_layer.clip_instances {
|
for instance in &effect_layer.clip_instances {
|
||||||
if let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) {
|
if let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) {
|
||||||
let end_time = calculate_instance_end(instance, clip_duration);
|
let end_time = calculate_instance_end(instance, clip_duration.seconds_to_f64());
|
||||||
max_end_time = max_end_time.max(end_time);
|
max_end_time = max_end_time.max(end_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -526,7 +531,7 @@ impl Document {
|
||||||
crate::layer::AnyLayer::Effect(el) => {
|
crate::layer::AnyLayer::Effect(el) => {
|
||||||
for inst in &el.clip_instances {
|
for inst in &el.clip_instances {
|
||||||
if let Some(dur) = doc.get_clip_duration(&inst.clip_id) {
|
if let Some(dur) = doc.get_clip_duration(&inst.clip_id) {
|
||||||
*max_end = max_end.max(calc_end(inst, dur));
|
*max_end = max_end.max(calc_end(inst, dur.seconds_to_f64()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -544,9 +549,10 @@ impl Document {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the maximum end time, or document duration if no clips found
|
// Return the max end (converting the beats accumulator to seconds), or the document
|
||||||
|
// duration (already seconds) if no clips were found.
|
||||||
if max_end_time > 0.0 {
|
if max_end_time > 0.0 {
|
||||||
max_end_time
|
tempo_map.beats_to_seconds(Beats(max_end_time)).seconds_to_f64()
|
||||||
} else {
|
} else {
|
||||||
self.duration
|
self.duration
|
||||||
}
|
}
|
||||||
|
|
@ -890,13 +896,13 @@ impl Document {
|
||||||
/// Searches through all clip libraries to find the clip and return its duration.
|
/// Searches through all clip libraries to find the clip and return its duration.
|
||||||
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
|
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
|
||||||
/// have infinite internal duration.
|
/// have infinite internal duration.
|
||||||
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<f64> {
|
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
|
||||||
if let Some(clip) = self.vector_clips.get(clip_id) {
|
if let Some(clip) = self.vector_clips.get(clip_id) {
|
||||||
if clip.is_group {
|
if clip.is_group {
|
||||||
Some(clip.duration)
|
Some(Seconds(clip.duration))
|
||||||
} else {
|
} else {
|
||||||
let tempo_map = self.tempo_map();
|
let tempo_map = self.tempo_map();
|
||||||
Some(clip.content_duration_with(self.framerate, tempo_map, |id| {
|
Some(Seconds(clip.content_duration_with(self.framerate, tempo_map, |id| {
|
||||||
// Resolve nested clip durations (audio, video, other vector clips)
|
// Resolve nested clip durations (audio, video, other vector clips)
|
||||||
if let Some(vc) = self.vector_clips.get(id) {
|
if let Some(vc) = self.vector_clips.get(id) {
|
||||||
// Avoid deep recursion — use stored duration for nested vector clips
|
// Avoid deep recursion — use stored duration for nested vector clips
|
||||||
|
|
@ -910,23 +916,23 @@ impl Document {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
} else if let Some(clip) = self.video_clips.get(clip_id) {
|
} else if let Some(clip) = self.video_clips.get(clip_id) {
|
||||||
Some(clip.duration)
|
Some(Seconds(clip.duration))
|
||||||
} else if let Some(clip) = self.audio_clips.get(clip_id) {
|
} else if let Some(clip) = self.audio_clips.get(clip_id) {
|
||||||
Some(clip.duration)
|
Some(Seconds(clip.duration))
|
||||||
} else if self.effect_definitions.contains_key(clip_id) {
|
} else if self.effect_definitions.contains_key(clip_id) {
|
||||||
// Effects have infinite internal duration - their timeline length
|
// Effects have infinite internal duration - their timeline length
|
||||||
// is controlled by ClipInstance.trim_end
|
// is controlled by ClipInstance.trim_end
|
||||||
Some(crate::effect::EFFECT_DURATION)
|
Some(Seconds(crate::effect::EFFECT_DURATION))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate the end time of a clip instance on the timeline
|
/// Calculate the end position of a clip instance on the timeline, in **beats**.
|
||||||
pub fn get_clip_instance_end_time(&self, layer_id: &Uuid, instance_id: &Uuid) -> Option<f64> {
|
pub fn get_clip_instance_end_time(&self, layer_id: &Uuid, instance_id: &Uuid) -> Option<Beats> {
|
||||||
let layer = self.get_layer(layer_id)?;
|
let layer = self.get_layer(layer_id)?;
|
||||||
|
|
||||||
// Find the clip instance
|
// Find the clip instance
|
||||||
|
|
@ -942,12 +948,8 @@ impl Document {
|
||||||
|
|
||||||
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
||||||
let clip_duration = self.get_clip_duration(&instance.clip_id)?;
|
let clip_duration = self.get_clip_duration(&instance.clip_id)?;
|
||||||
|
// End position on the timeline, in beats (convert the seconds content window via tempo map).
|
||||||
let trim_start = instance.trim_start;
|
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map()))
|
||||||
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
|
||||||
let effective_duration = trim_end - trim_start;
|
|
||||||
|
|
||||||
Some(instance.timeline_start + effective_duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a time range overlaps with any existing clip on the layer
|
/// Check if a time range overlaps with any existing clip on the layer
|
||||||
|
|
@ -958,8 +960,8 @@ impl Document {
|
||||||
pub fn check_overlap_on_layer(
|
pub fn check_overlap_on_layer(
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
start_time: f64,
|
start_time: Beats,
|
||||||
end_time: f64,
|
end_time: Beats,
|
||||||
exclude_instance_ids: &[Uuid],
|
exclude_instance_ids: &[Uuid],
|
||||||
) -> (bool, Option<Uuid>) {
|
) -> (bool, Option<Uuid>) {
|
||||||
let Some(layer) = self.get_layer(layer_id) else {
|
let Some(layer) = self.get_layer(layer_id) else {
|
||||||
|
|
@ -1012,14 +1014,14 @@ impl Document {
|
||||||
pub fn find_nearest_valid_position(
|
pub fn find_nearest_valid_position(
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
desired_start: f64,
|
desired_start: Beats,
|
||||||
clip_duration: f64,
|
clip_duration: Beats,
|
||||||
exclude_instance_ids: &[Uuid],
|
exclude_instance_ids: &[Uuid],
|
||||||
) -> Option<f64> {
|
) -> Option<Beats> {
|
||||||
let layer = self.get_layer(layer_id)?;
|
let layer = self.get_layer(layer_id)?;
|
||||||
|
|
||||||
// Clamp to timeline start (can't go before 0)
|
// Clamp to timeline start (can't go before 0)
|
||||||
let desired_start = desired_start.max(0.0);
|
let desired_start = desired_start.max(Beats::ZERO);
|
||||||
|
|
||||||
// Vector layers don't need overlap adjustment, but still respect timeline start
|
// Vector layers don't need overlap adjustment, but still respect timeline start
|
||||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||||
|
|
@ -1044,7 +1046,7 @@ impl Document {
|
||||||
AnyLayer::Text(_) => return Some(desired_start), // Text layers don't have own clips
|
AnyLayer::Text(_) => return Some(desired_start), // Text layers don't have own clips
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new();
|
let mut occupied_ranges: Vec<(Beats, Beats, Uuid)> = Vec::new();
|
||||||
for instance in instances {
|
for instance in instances {
|
||||||
if exclude_instance_ids.contains(&instance.id) {
|
if exclude_instance_ids.contains(&instance.id) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1063,7 +1065,7 @@ impl Document {
|
||||||
// Find the clip we're overlapping with and try both sides, pick nearest
|
// Find the clip we're overlapping with and try both sides, pick nearest
|
||||||
for (occupied_start, occupied_end, _) in &occupied_ranges {
|
for (occupied_start, occupied_end, _) in &occupied_ranges {
|
||||||
if desired_start < *occupied_end && *occupied_start < desired_end {
|
if desired_start < *occupied_end && *occupied_start < desired_end {
|
||||||
let mut candidates: Vec<f64> = Vec::new();
|
let mut candidates: Vec<Beats> = Vec::new();
|
||||||
|
|
||||||
// Try snapping to the right (after this clip)
|
// Try snapping to the right (after this clip)
|
||||||
let snap_right = *occupied_end;
|
let snap_right = *occupied_end;
|
||||||
|
|
@ -1079,8 +1081,8 @@ impl Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try snapping to the left (before this clip)
|
// Try snapping to the left (before this clip)
|
||||||
let snap_left = occupied_start - clip_duration;
|
let snap_left = *occupied_start - clip_duration;
|
||||||
if snap_left >= 0.0 {
|
if snap_left >= Beats::ZERO {
|
||||||
let (overlaps_left, _) = self.check_overlap_on_layer(
|
let (overlaps_left, _) = self.check_overlap_on_layer(
|
||||||
layer_id,
|
layer_id,
|
||||||
snap_left,
|
snap_left,
|
||||||
|
|
@ -1095,8 +1097,8 @@ impl Document {
|
||||||
// Pick the candidate closest to desired_start
|
// Pick the candidate closest to desired_start
|
||||||
if !candidates.is_empty() {
|
if !candidates.is_empty() {
|
||||||
candidates.sort_by(|a, b| {
|
candidates.sort_by(|a, b| {
|
||||||
let dist_a = (a - desired_start).abs();
|
let dist_a = (*a - desired_start).abs();
|
||||||
let dist_b = (b - desired_start).abs();
|
let dist_b = (*b - desired_start).abs();
|
||||||
dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal)
|
dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
});
|
});
|
||||||
return Some(candidates[0]);
|
return Some(candidates[0]);
|
||||||
|
|
@ -1106,7 +1108,7 @@ impl Document {
|
||||||
|
|
||||||
// If no gap found, try placing at timeline start
|
// If no gap found, try placing at timeline start
|
||||||
if occupied_ranges.is_empty() || occupied_ranges[0].0 >= clip_duration {
|
if occupied_ranges.is_empty() || occupied_ranges[0].0 >= clip_duration {
|
||||||
return Some(0.0);
|
return Some(Beats::ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No valid position found
|
// No valid position found
|
||||||
|
|
@ -1118,9 +1120,9 @@ impl Document {
|
||||||
pub fn clamp_group_move_offset(
|
pub fn clamp_group_move_offset(
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
group: &[(Uuid, f64, f64)], // (instance_id, timeline_start, effective_duration)
|
group: &[(Uuid, Beats, Beats)], // (instance_id, timeline_start, effective_duration) in beats
|
||||||
desired_offset: f64,
|
desired_offset: Beats,
|
||||||
) -> f64 {
|
) -> Beats {
|
||||||
let Some(layer) = self.get_layer(layer_id) else {
|
let Some(layer) = self.get_layer(layer_id) else {
|
||||||
return desired_offset;
|
return desired_offset;
|
||||||
};
|
};
|
||||||
|
|
@ -1140,8 +1142,8 @@ impl Document {
|
||||||
AnyLayer::Text(_) => &[],
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collect non-group clip ranges
|
// Collect non-group clip ranges (beats)
|
||||||
let mut non_group: Vec<(f64, f64)> = Vec::new();
|
let mut non_group: Vec<(Beats, Beats)> = Vec::new();
|
||||||
for inst in instances {
|
for inst in instances {
|
||||||
if group_ids.contains(&inst.id) {
|
if group_ids.contains(&inst.id) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1163,12 +1165,12 @@ impl Document {
|
||||||
|
|
||||||
// Check against non-group clips
|
// Check against non-group clips
|
||||||
for &(ns, ne) in &non_group {
|
for &(ns, ne) in &non_group {
|
||||||
if clamped < 0.0 {
|
if clamped < Beats::ZERO {
|
||||||
// Moving left: if non-group clip end is between our destination and current start
|
// Moving left: if non-group clip end is between our destination and current start
|
||||||
if ne <= start && ne > start + clamped {
|
if ne <= start && ne > start + clamped {
|
||||||
clamped = clamped.max(ne - start);
|
clamped = clamped.max(ne - start);
|
||||||
}
|
}
|
||||||
} else if clamped > 0.0 {
|
} else if clamped > Beats::ZERO {
|
||||||
// Moving right: if non-group clip start is between our current end and destination
|
// Moving right: if non-group clip start is between our current end and destination
|
||||||
if ns >= end && ns < end + clamped {
|
if ns >= end && ns < end + clamped {
|
||||||
clamped = clamped.min(ns - end);
|
clamped = clamped.min(ns - end);
|
||||||
|
|
@ -1188,8 +1190,8 @@ impl Document {
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
instance_id: &Uuid,
|
instance_id: &Uuid,
|
||||||
current_timeline_start: f64,
|
current_timeline_start: Beats,
|
||||||
) -> f64 {
|
) -> Beats {
|
||||||
let Some(layer) = self.get_layer(layer_id) else {
|
let Some(layer) = self.get_layer(layer_id) else {
|
||||||
return current_timeline_start; // No limit if layer not found
|
return current_timeline_start; // No limit if layer not found
|
||||||
};
|
};
|
||||||
|
|
@ -1200,7 +1202,7 @@ impl Document {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find the nearest clip to the left
|
// Find the nearest clip to the left
|
||||||
let mut nearest_end = 0.0; // Can extend to timeline start by default
|
let mut nearest_end = Beats::ZERO; // Can extend to timeline start by default
|
||||||
|
|
||||||
let instances: &[ClipInstance] = match layer {
|
let instances: &[ClipInstance] = match layer {
|
||||||
AnyLayer::Audio(audio) => &audio.clip_instances,
|
AnyLayer::Audio(audio) => &audio.clip_instances,
|
||||||
|
|
@ -1220,6 +1222,7 @@ impl Document {
|
||||||
// Calculate other clip's extent (accounting for loop_before)
|
// Calculate other clip's extent (accounting for loop_before)
|
||||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
||||||
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
||||||
|
// (clip_duration is Seconds via get_clip_duration; effective_duration converts.)
|
||||||
|
|
||||||
// If this clip is to the left and closer than current nearest
|
// If this clip is to the left and closer than current nearest
|
||||||
if other_end <= current_timeline_start && other_end > nearest_end {
|
if other_end <= current_timeline_start && other_end > nearest_end {
|
||||||
|
|
@ -1239,16 +1242,16 @@ impl Document {
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
instance_id: &Uuid,
|
instance_id: &Uuid,
|
||||||
current_timeline_start: f64,
|
current_timeline_start: Beats,
|
||||||
current_effective_duration: f64,
|
current_effective_duration: Beats,
|
||||||
) -> f64 {
|
) -> Beats {
|
||||||
let Some(layer) = self.get_layer(layer_id) else {
|
let Some(layer) = self.get_layer(layer_id) else {
|
||||||
return f64::MAX; // No limit if layer not found
|
return Beats(f64::MAX); // No limit if layer not found
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only check audio, video, and effect layers
|
// Only check audio, video, and effect layers
|
||||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||||
return f64::MAX; // No limit for vector/group layers
|
return Beats(f64::MAX); // No limit for vector/group layers
|
||||||
}
|
}
|
||||||
|
|
||||||
let instances: &[ClipInstance] = match layer {
|
let instances: &[ClipInstance] = match layer {
|
||||||
|
|
@ -1261,7 +1264,7 @@ impl Document {
|
||||||
AnyLayer::Text(_) => &[],
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut nearest_start = f64::MAX;
|
let mut nearest_start = Beats(f64::MAX);
|
||||||
let current_end = current_timeline_start + current_effective_duration;
|
let current_end = current_timeline_start + current_effective_duration;
|
||||||
|
|
||||||
for other in instances {
|
for other in instances {
|
||||||
|
|
@ -1276,10 +1279,10 @@ impl Document {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if nearest_start == f64::MAX {
|
if nearest_start == Beats(f64::MAX) {
|
||||||
f64::MAX // No clip to the right, can extend freely
|
Beats(f64::MAX) // No clip to the right, can extend freely
|
||||||
} else {
|
} else {
|
||||||
(nearest_start - current_end).max(0.0) // Gap between our end and next clip's start
|
(nearest_start - current_end).max(Beats::ZERO) // Gap between our end and next clip's start
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Find the maximum amount we can extend loop_before to the left without overlapping.
|
/// Find the maximum amount we can extend loop_before to the left without overlapping.
|
||||||
|
|
@ -1289,8 +1292,8 @@ impl Document {
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
instance_id: &Uuid,
|
instance_id: &Uuid,
|
||||||
current_effective_start: f64,
|
current_effective_start: Beats,
|
||||||
) -> f64 {
|
) -> Beats {
|
||||||
let Some(layer) = self.get_layer(layer_id) else {
|
let Some(layer) = self.get_layer(layer_id) else {
|
||||||
return current_effective_start;
|
return current_effective_start;
|
||||||
};
|
};
|
||||||
|
|
@ -1309,7 +1312,7 @@ impl Document {
|
||||||
AnyLayer::Text(_) => &[],
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut nearest_end = 0.0;
|
let mut nearest_end = Beats::ZERO;
|
||||||
|
|
||||||
for other in instances {
|
for other in instances {
|
||||||
if &other.id == instance_id {
|
if &other.id == instance_id {
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,7 @@ impl EffectDefinition {
|
||||||
///
|
///
|
||||||
/// * `timeline_start` - When the effect starts on the timeline (seconds)
|
/// * `timeline_start` - When the effect starts on the timeline (seconds)
|
||||||
/// * `duration` - How long the effect appears on the timeline (seconds)
|
/// * `duration` - How long the effect appears on the timeline (seconds)
|
||||||
pub fn create_instance(&self, timeline_start: f64, duration: f64) -> ClipInstance {
|
pub fn create_instance(&self, timeline_start: daw_backend::Beats, duration: daw_backend::Beats) -> ClipInstance {
|
||||||
ClipInstance::new(self.id)
|
ClipInstance::new(self.id)
|
||||||
.with_timeline_start(timeline_start)
|
.with_timeline_start(timeline_start)
|
||||||
.with_timeline_duration(duration)
|
.with_timeline_duration(duration)
|
||||||
|
|
|
||||||
|
|
@ -148,11 +148,11 @@ impl EffectLayer {
|
||||||
/// `timeline_start` (beats) to seconds for comparison.
|
/// `timeline_start` (beats) to seconds for comparison.
|
||||||
pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> {
|
pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> {
|
||||||
use crate::effect::EFFECT_DURATION;
|
use crate::effect::EFFECT_DURATION;
|
||||||
let time_beats = tempo_map.inverse_transform(time_secs);
|
let time_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(time_secs));
|
||||||
self.clip_instances
|
self.clip_instances
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
let end = e.timeline_start + e.effective_duration(EFFECT_DURATION, tempo_map);
|
let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map);
|
||||||
time_beats >= e.timeline_start && time_beats < end
|
time_beats >= e.timeline_start && time_beats < end
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|
@ -218,7 +218,7 @@ mod tests {
|
||||||
fn test_add_effect() {
|
fn test_add_effect() {
|
||||||
let mut layer = EffectLayer::new("Effects");
|
let mut layer = EffectLayer::new("Effects");
|
||||||
let def = create_test_effect_def();
|
let def = create_test_effect_def();
|
||||||
let effect = def.create_instance(0.0, 10.0);
|
let effect = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let effect_id = effect.id;
|
let effect_id = effect.id;
|
||||||
|
|
||||||
let id = layer.add_clip_instance(effect);
|
let id = layer.add_clip_instance(effect);
|
||||||
|
|
@ -233,11 +233,11 @@ mod tests {
|
||||||
let def = create_test_effect_def();
|
let def = create_test_effect_def();
|
||||||
|
|
||||||
// Effect 1: active from 0 to 5
|
// Effect 1: active from 0 to 5
|
||||||
let effect1 = def.create_instance(0.0, 5.0);
|
let effect1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(5.0));
|
||||||
layer.add_clip_instance(effect1);
|
layer.add_clip_instance(effect1);
|
||||||
|
|
||||||
// Effect 2: active from 3 to 10
|
// Effect 2: active from 3 to 10
|
||||||
let effect2 = def.create_instance(3.0, 7.0); // 3.0 + 7.0 = 10.0 end
|
let effect2 = def.create_instance(daw_backend::Beats(3.0), daw_backend::Beats(7.0)); // 3.0 + 7.0 = 10.0 end
|
||||||
layer.add_clip_instance(effect2);
|
layer.add_clip_instance(effect2);
|
||||||
|
|
||||||
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
||||||
|
|
@ -259,11 +259,11 @@ mod tests {
|
||||||
let mut layer = EffectLayer::new("Effects");
|
let mut layer = EffectLayer::new("Effects");
|
||||||
let def = create_test_effect_def();
|
let def = create_test_effect_def();
|
||||||
|
|
||||||
let effect1 = def.create_instance(0.0, 10.0);
|
let effect1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id1 = effect1.id;
|
let id1 = effect1.id;
|
||||||
layer.add_clip_instance(effect1);
|
layer.add_clip_instance(effect1);
|
||||||
|
|
||||||
let effect2 = def.create_instance(0.0, 10.0);
|
let effect2 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0));
|
||||||
let id2 = effect2.id;
|
let id2 = effect2.id;
|
||||||
layer.add_clip_instance(effect2);
|
layer.add_clip_instance(effect2);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,15 +260,15 @@ pub fn hit_test_clip_instances(
|
||||||
for clip_instance in clip_instances.iter().rev() {
|
for clip_instance in clip_instances.iter().rev() {
|
||||||
// Check time bounds: skip clip instances not active at this time
|
// Check time bounds: skip clip instances not active at this time
|
||||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||||
let timeline_beats = tempo_map.inverse_transform(timeline_time);
|
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
|
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
|
||||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||||
|
|
||||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
|
|
@ -304,14 +304,14 @@ pub fn hit_test_clip_instances_in_rect(
|
||||||
for clip_instance in clip_instances {
|
for clip_instance in clip_instances {
|
||||||
// Check time bounds: skip clip instances not active at this time
|
// Check time bounds: skip clip instances not active at this time
|
||||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||||
let timeline_beats = tempo_map.inverse_transform(timeline_time);
|
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||||
|
|
||||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
use crate::animation::TransformProperty;
|
use crate::animation::TransformProperty;
|
||||||
use crate::clip::{ClipInstance, ImageAsset};
|
use crate::clip::{ClipInstance, ImageAsset};
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
|
use daw_backend::Seconds;
|
||||||
use crate::gpu::BlendMode;
|
use crate::gpu::BlendMode;
|
||||||
use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
|
use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
|
||||||
use kurbo::Affine;
|
use kurbo::Affine;
|
||||||
|
|
@ -567,7 +568,8 @@ pub fn render_layer_isolated(
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
for clip_instance in &video_layer.clip_instances {
|
for clip_instance in &video_layer.clip_instances {
|
||||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
||||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { continue };
|
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue };
|
||||||
|
let clip_time = clip_time.seconds_to_f64();
|
||||||
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue };
|
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue };
|
||||||
|
|
||||||
// Evaluate animated transform properties.
|
// Evaluate animated transform properties.
|
||||||
|
|
@ -975,7 +977,7 @@ pub fn render_single_clip_instance(
|
||||||
.filter(|vc| vc.is_group)
|
.filter(|vc| vc.is_group)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
let frame_duration = 1.0 / document.framerate;
|
let frame_duration = 1.0 / document.framerate;
|
||||||
vector_layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration)
|
vector_layer.group_visibility_end(&clip_instance.id, document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(), frame_duration)
|
||||||
});
|
});
|
||||||
|
|
||||||
render_clip_instance(
|
render_clip_instance(
|
||||||
|
|
@ -1010,18 +1012,18 @@ fn render_clip_instance(
|
||||||
let clip_time = if vector_clip.is_group {
|
let clip_time = if vector_clip.is_group {
|
||||||
// Groups are static — visible from timeline_start to the next keyframe boundary.
|
// Groups are static — visible from timeline_start to the next keyframe boundary.
|
||||||
// timeline_start is in beats; group_end_time is in seconds (render time).
|
// timeline_start is in beats; group_end_time is in seconds (render time).
|
||||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let end = group_end_time.unwrap_or(start_secs);
|
let end = group_end_time.unwrap_or(start_secs);
|
||||||
if time < start_secs || time >= end {
|
if time < start_secs || time >= end {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
|
||||||
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else {
|
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else {
|
||||||
return; // Clip instance not active at this time
|
return; // Clip instance not active at this time
|
||||||
};
|
};
|
||||||
t
|
t.seconds_to_f64()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Evaluate animated transform properties
|
// Evaluate animated transform properties
|
||||||
|
|
@ -1172,9 +1174,10 @@ fn render_video_layer(
|
||||||
|
|
||||||
// Remap timeline time to clip's internal time
|
// Remap timeline time to clip's internal time
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else {
|
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else {
|
||||||
continue; // Clip instance not active at this time
|
continue; // Clip instance not active at this time
|
||||||
};
|
};
|
||||||
|
let clip_time = clip_time.seconds_to_f64();
|
||||||
|
|
||||||
// Get video frame from VideoManager at the output (export/preview) resolution.
|
// Get video frame from VideoManager at the output (export/preview) resolution.
|
||||||
let (target_w, target_h) = video_decode_target(document, base_transform);
|
let (target_w, target_h) = video_decode_target(document, base_transform);
|
||||||
|
|
@ -1568,7 +1571,7 @@ fn render_vector_layer(
|
||||||
.filter(|vc| vc.is_group)
|
.filter(|vc| vc.is_group)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
let frame_duration = 1.0 / document.framerate;
|
let frame_duration = 1.0 / document.framerate;
|
||||||
layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration)
|
layer.group_visibility_end(&clip_instance.id, document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(), frame_duration)
|
||||||
});
|
});
|
||||||
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time, extract.as_deref_mut());
|
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time, extract.as_deref_mut());
|
||||||
}
|
}
|
||||||
|
|
@ -1877,7 +1880,7 @@ fn render_vector_layer_cpu(
|
||||||
.filter(|vc| vc.is_group)
|
.filter(|vc| vc.is_group)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
let frame_duration = 1.0 / document.framerate;
|
let frame_duration = 1.0 / document.framerate;
|
||||||
layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration)
|
layer.group_visibility_end(&clip_instance.id, document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(), frame_duration)
|
||||||
});
|
});
|
||||||
render_clip_instance_cpu(
|
render_clip_instance_cpu(
|
||||||
document, time, clip_instance, layer_opacity, pixmap, base_transform,
|
document, time, clip_instance, layer_opacity, pixmap, base_transform,
|
||||||
|
|
@ -1902,14 +1905,14 @@ fn render_clip_instance_cpu(
|
||||||
|
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let clip_time = if vector_clip.is_group {
|
let clip_time = if vector_clip.is_group {
|
||||||
let start_secs = tempo_map.transform(clip_instance.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let end = group_end_time.unwrap_or(start_secs);
|
let end = group_end_time.unwrap_or(start_secs);
|
||||||
if time < start_secs || time >= end { return; }
|
if time < start_secs || time >= end { return; }
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration);
|
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
|
||||||
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return };
|
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
|
||||||
t
|
t.seconds_to_f64()
|
||||||
};
|
};
|
||||||
|
|
||||||
let transform = &clip_instance.transform;
|
let transform = &clip_instance.transform;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue