From 7bf4f2fa467e09356964060e49d5f8dcc9829580 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:51:56 +0000 Subject: [PATCH 1/3] fix(desktop-gpui): add, change and remove clip transitions from the clip menu, and show them on the timeline --- apps/desktop-gpui/src/editor_clips.rs | 200 ++++++++++++++++++++--- apps/desktop-gpui/src/editor_edits.rs | 1 + apps/desktop-gpui/src/editor_timeline.rs | 46 ++++++ apps/desktop-gpui/src/editor_window.rs | 98 +++++++++++ 4 files changed, 321 insertions(+), 24 deletions(-) diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 4d8c095e009..feceb9f02a0 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -214,36 +214,99 @@ pub(crate) fn drop_clip_transition( .retain(|candidate| candidate.segment_index as usize != segment_index); return false; }; - let boundary = clip_timeline_offsets(timeline) - .get(segment_index) - .copied() - .unwrap_or(0.) - + effective.duration; - let boundary = edits::effective_to_output(&timeline.hold_windows(), boundary); + let boundary = transition_boundary(timeline, segment_index, effective.duration); + timeline + .transitions + .retain(|candidate| candidate.segment_index as usize != segment_index); + ripple_every_track(timeline, boundary, effective.duration); + true +} + +/// `setClipTransition` (`ED/context.ts`): puts a `kind` transition on +/// `segment_index`'s leading boundary, `duration` long within the limits of +/// half the shorter clip (`clampTransitionDuration`), and ripples every other +/// track past the boundary by the overlap it gained or gave back. Returns +/// false when the boundary does not exist or the clips are too short for one. +pub(crate) fn set_clip_transition( + timeline: &mut TimelineConfiguration, + segment_index: usize, + kind: cap_project::ClipTransitionType, + duration: f64, +) -> bool { + if segment_index == 0 || segment_index >= timeline.segments.len() { + return false; + } + let maximum = max_transition_duration(timeline, segment_index); + if !maximum.is_finite() || maximum < cap_project::MIN_CLIP_TRANSITION_DURATION { + return false; + } + let duration = duration.clamp(cap_project::MIN_CLIP_TRANSITION_DURATION, maximum); + let old = timeline + .effective_transition(segment_index) + .map_or(0.0, |transition| transition.duration); + let boundary = transition_boundary(timeline, segment_index, old); timeline .transitions .retain(|candidate| candidate.segment_index as usize != segment_index); - ripple_track(&mut timeline.style_segments, boundary, effective.duration); - ripple_track(&mut timeline.image_segments, boundary, effective.duration); - ripple_track(&mut timeline.zoom_segments, boundary, effective.duration); - ripple_track(&mut timeline.scene_segments, boundary, effective.duration); - ripple_track(&mut timeline.mask_segments, boundary, effective.duration); - ripple_track(&mut timeline.text_segments, boundary, effective.duration); - ripple_track(&mut timeline.caption_segments, boundary, effective.duration); - ripple_track( - &mut timeline.camera3d_segments, - boundary, - effective.duration, - ); - ripple_keyboard_track( - &mut timeline.keyboard_segments, - boundary, - effective.duration, - ); - ripple_track(&mut timeline.audio_segments, boundary, effective.duration); + timeline.transitions.push(ClipTransition { + segment_index: segment_index as u32, + kind, + duration, + }); + timeline + .transitions + .sort_by_key(|transition| transition.segment_index); + let shift = old - duration; + if shift != 0.0 { + ripple_every_track(timeline, boundary, shift); + } true } +/// `maxTransitionDuration`: half the shorter of the two clips a boundary joins. +pub(crate) fn max_transition_duration( + timeline: &TimelineConfiguration, + segment_index: usize, +) -> f64 { + match ( + segment_index + .checked_sub(1) + .and_then(|previous| timeline.segments.get(previous)), + timeline.segments.get(segment_index), + ) { + (Some(previous), Some(current)) => previous.duration().min(current.duration()) / 2.0, + _ => 0.0, + } +} + +/// Where the other tracks start to feel a transition on `segment_index`'s +/// boundary that currently overlaps the clips by `overlap`. +fn transition_boundary( + timeline: &TimelineConfiguration, + segment_index: usize, + overlap: f64, +) -> f64 { + let boundary = clip_timeline_offsets(timeline) + .get(segment_index) + .copied() + .unwrap_or(0.) + + overlap; + edits::effective_to_output(&timeline.hold_windows(), boundary) +} + +fn ripple_every_track(timeline: &mut TimelineConfiguration, boundary: f64, shift: f64) { + ripple_track(&mut timeline.style_segments, boundary, shift); + ripple_track(&mut timeline.image_segments, boundary, shift); + ripple_track(&mut timeline.zoom_segments, boundary, shift); + ripple_track(&mut timeline.scene_segments, boundary, shift); + ripple_track(&mut timeline.mask_segments, boundary, shift); + ripple_track(&mut timeline.text_segments, boundary, shift); + ripple_track(&mut timeline.caption_segments, boundary, shift); + ripple_track(&mut timeline.camera3d_segments, boundary, shift); + ripple_keyboard_track(&mut timeline.keyboard_segments, boundary, shift); + ripple_track(&mut timeline.audio_segments, boundary, shift); +} + /// `moveClip` (`ClipsSidebar.tsx:639-690`): reorder `timeline.segments`, /// remapping the transitions that survive and -- for each one that does not -- /// rippling every other track across the boundary the removed overlap used to @@ -3987,6 +4050,95 @@ mod tests { /// The dropped transition's overlap is given back: every later segment on /// the other tracks shifts right by its effective duration /// (`ClipsSidebar.tsx:656-683`). + fn zoom_at(start: f64, end: f64) -> cap_project::ZoomSegment { + cap_project::ZoomSegment { + start, + end, + amount: 1.5, + mode: cap_project::ZoomMode::Auto, + glide_direction: Default::default(), + glide_speed: 0.5, + instant_animation: false, + edge_snap_ratio: 0.25, + } + } + + #[test] + fn adding_a_transition_overlaps_the_clips_and_ripples_the_other_tracks() { + let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 10.0)]); + // A zoom on the second clip, past the boundary at 10s. + config.zoom_segments = vec![zoom_at(15.0, 18.0)]; + + assert!(set_clip_transition( + &mut config, + 1, + cap_project::ClipTransitionType::CrossFade, + 0.5 + )); + let added = config.effective_transition(1).unwrap(); + assert_eq!(added.duration, 0.5); + assert_eq!(added.kind, cap_project::ClipTransitionType::CrossFade); + // The second clip now starts half a second earlier, and so does the + // zoom that sits on it. + assert_eq!(config.zoom_segments[0].start, 14.5); + assert_eq!(config.zoom_segments[0].end, 17.5); + + // Lengthening it ripples by the difference only, and the kind changes. + assert!(set_clip_transition( + &mut config, + 1, + cap_project::ClipTransitionType::FadeThroughBlack, + 1.0 + )); + assert_eq!(config.transitions.len(), 1); + let changed = config.effective_transition(1).unwrap(); + assert_eq!(changed.duration, 1.0); + assert_eq!( + changed.kind, + cap_project::ClipTransitionType::FadeThroughBlack + ); + assert_eq!(config.zoom_segments[0].start, 14.0); + + // Removing it puts everything back where it began. + assert!(drop_clip_transition(&mut config, 1)); + assert!(config.transitions.is_empty()); + assert_eq!(config.zoom_segments[0].start, 15.0); + assert_eq!(config.zoom_segments[0].end, 18.0); + } + + #[test] + fn a_transition_is_held_to_half_the_shorter_clip() { + let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 3.0)]); + assert_eq!(max_transition_duration(&config, 1), 1.5); + assert!(set_clip_transition( + &mut config, + 1, + cap_project::ClipTransitionType::CrossFade, + 5.0 + )); + assert_eq!(config.effective_transition(1).unwrap().duration, 1.5); + assert!(set_clip_transition( + &mut config, + 1, + cap_project::ClipTransitionType::CrossFade, + 0.0 + )); + assert_eq!( + config.effective_transition(1).unwrap().duration, + cap_project::MIN_CLIP_TRANSITION_DURATION + ); + } + + #[test] + fn the_first_clip_and_clips_too_short_take_no_transition() { + let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 0.08)]); + let kind = cap_project::ClipTransitionType::CrossFade; + assert!(!set_clip_transition(&mut config, 0, kind, 0.5)); + assert!(!set_clip_transition(&mut config, 1, kind, 0.5)); + assert!(!set_clip_transition(&mut config, 2, kind, 0.5)); + assert!(config.transitions.is_empty()); + } + #[test] fn a_dropped_transition_ripples_the_other_tracks() { let mut config = timeline(vec![ diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 14bdd11a14a..fff71bfeecb 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -3218,6 +3218,7 @@ mod tests { volume: 1., recording_clip: 0, holds: Arc::from(&[][..]), + transition_in: None, }, }; let clips = [clip(0., 10.), clip(10., 18.)]; diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index ad0f22557c5..03356d76f46 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -1193,6 +1193,10 @@ pub enum SegmentDetail { /// Held (paused) windows inside this clip's on-screen box, in output /// time (`TL/ClipTrack.tsx:658-666`). holds: Arc<[(f64, f64)]>, + /// The transition into this clip from the one before it: its length + /// in seconds, and whether it fades through black rather than + /// crossfading. It overlaps the clip's leading edge. + transition_in: Option<(f64, bool)>, }, /// `TL/ZoomTrack.tsx:343-349`. Zoom { @@ -1717,6 +1721,12 @@ fn clip_rows( volume: segment.volume(), recording_clip: segment.recording_clip, holds: inner_holds, + transition_in: timeline.effective_transition(index).map(|transition| { + ( + transition.duration, + transition.kind == cap_project::ClipTransitionType::FadeThroughBlack, + ) + }), }, } }) @@ -2667,6 +2677,28 @@ fn render_empty_track( /// `SegmentRoot` (`TL/Track.tsx:100-137`): the positioned box, its tinted fill /// and inset ring, the accent bar flush against its left edge, the label at /// whatever tier its visible width allows, and the two trim handles. +/// The overlap a clip transition takes at the clip's leading edge: an accent +/// band, edged on both sides, with a diagonal sheen. +fn render_transition_in(theme: &Theme, overlap_width: f32, clip_width: f32) -> AnyElement { + let accent = Hsla::from(theme.editor.accent); + let band_width = overlap_width.min(clip_width).max(3.); + let hatch = gpui::linear_gradient( + 135., + gpui::linear_color_stop(accent.opacity(0.18), 0.42), + gpui::linear_color_stop(accent.opacity(0.55), 0.5), + ); + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .w(px(band_width)) + .bg(hatch) + .border_x_1() + .border_color(accent.opacity(0.7)) + .into_any_element() +} + #[allow(clippy::too_many_arguments)] fn render_segment( theme: &Theme, @@ -2763,6 +2795,20 @@ fn render_segment( } } + // The transition into the clip, over the stretch where it overlaps the + // clip before (`TL/ClipTrack.tsx`'s transition trigger). + if let SegmentDetail::Clip { + transition_in: Some((seconds, _)), + .. + } = &segment.detail + { + fill = fill.child(render_transition_in( + theme, + (*seconds / secs_per_pixel) as f32, + width, + )); + } + fill = fill.child(render_label( theme, color, diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 855bffba4e2..0e27cc6bc9d 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -1673,6 +1673,11 @@ fn add_track_tile_progress(delta: f32, index: usize) -> f32 { } const CLIP_MENU_WIDTH: f32 = 300.; +/// `DEFAULT_CLIP_TRANSITION_DURATION` (`ED/clip-transitions.ts`). +const DEFAULT_CLIP_TRANSITION_DURATION: f64 = 0.5; +/// The clip menu's transition lengths, longest last; ones longer than half the +/// shorter clip are left out. +const CLIP_TRANSITION_DURATIONS: [f64; 4] = [0.25, 0.5, 1.0, 1.5]; const TEXT_MENU_WIDTH: f32 = 250.; /// Roughly the menu's laid-out height; only decides which side of its anchor /// the card opens on. @@ -8368,6 +8373,32 @@ impl EditorWindow { self.note_edit("clip-hide-cursor", Some(TrackKind::Clip)); } + /// The clip menu's transition chips (`setClipTransition`, + /// `TL/ClipTrack.tsx`): a `kind` transition `duration` long on clip + /// `index`'s leading boundary, or, with `None`, no transition. + fn set_clip_transition( + &mut self, + index: usize, + transition: Option<(cap_project::ClipTransitionType, f64)>, + window: &mut Window, + cx: &mut Context, + ) { + let Some(timeline) = self.project.timeline.as_mut() else { + return; + }; + let changed = match transition { + Some((kind, duration)) => { + crate::editor_clips::set_clip_transition(timeline, index, kind, duration) + } + None => crate::editor_clips::drop_clip_transition(timeline, index), + }; + if !changed { + return; + } + self.project_changed(window, cx); + self.note_edit("clip-transition", Some(TrackKind::Clip)); + } + fn merge_clip( &mut self, index: usize, @@ -8529,6 +8560,10 @@ impl EditorWindow { let merge_next = edits::clip_merge_blocker(&timeline.segments, index, edits::ClipMergeDirection::Next); let can_delete = timeline.segments.len() > 1; + // The transition into this clip from the one before it. + let transition = timeline.effective_transition(index); + let transition_room = crate::editor_clips::max_transition_duration(timeline, index); + let can_transition = transition_room >= cap_project::MIN_CLIP_TRANSITION_DURATION; let rename_input = self .clips .rename_in_progress() @@ -8715,6 +8750,69 @@ impl EditorWindow { )), ), ) + .when(index > 0 && can_transition, |card| { + use cap_project::ClipTransitionType; + let duration = + transition.map_or(DEFAULT_CLIP_TRANSITION_DURATION, |current| current.duration); + let kinds = [ + ("None", None), + ("Crossfade", Some(ClipTransitionType::CrossFade)), + ("Fade", Some(ClipTransitionType::FadeThroughBlack)), + ]; + card.child( + div() + .flex() + .flex_col() + .gap(px(4.)) + .child(clip_menu_section(&theme, "TRANSITION")) + .child(chip_row().children(kinds.into_iter().map(|(label, kind)| { + let selected = transition.map(|current| current.kind) == kind; + chip( + SharedString::from(format!("clip-transition-{label}")), + selected, + label.to_string(), + ) + .on_click(cx.listener( + move |this, _, window, cx| { + this.set_clip_transition( + index, + kind.map(|kind| (kind, duration)), + window, + cx, + ); + }, + )) + }))) + .when_some(transition, |section, current| { + section.child( + chip_row().children( + CLIP_TRANSITION_DURATIONS + .into_iter() + .filter(|seconds| *seconds <= transition_room + 1e-9) + .map(|seconds| { + chip( + SharedString::from(format!( + "clip-transition-{seconds}s" + )), + (current.duration - seconds).abs() < 1e-3, + format!("{seconds}s"), + ) + .on_click( + cx.listener(move |this, _, window, cx| { + this.set_clip_transition( + index, + Some((current.kind, seconds)), + window, + cx, + ); + }), + ) + }), + ), + ) + }), + ) + }) .child(divider()) .child( div() From f87224e2e7bebc1093a9e25ef680ad3f2a5d2170 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:18:36 +0000 Subject: [PATCH 2/3] fix(desktop-gpui): keep effects on a clip's first frame through transition changes and size the band by output time --- apps/desktop-gpui/src/editor_clips.rs | 108 +++++++++++++++++------ apps/desktop-gpui/src/editor_timeline.rs | 19 ++-- apps/desktop-gpui/src/editor_window.rs | 4 +- 3 files changed, 91 insertions(+), 40 deletions(-) diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index feceb9f02a0..ab629df60c5 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -214,11 +214,11 @@ pub(crate) fn drop_clip_transition( .retain(|candidate| candidate.segment_index as usize != segment_index); return false; }; - let boundary = transition_boundary(timeline, segment_index, effective.duration); + let edges = transition_edges(timeline, segment_index, effective.duration); timeline .transitions .retain(|candidate| candidate.segment_index as usize != segment_index); - ripple_every_track(timeline, boundary, effective.duration); + ripple_every_track(timeline, edges, effective.duration); true } @@ -244,7 +244,7 @@ pub(crate) fn set_clip_transition( let old = timeline .effective_transition(segment_index) .map_or(0.0, |transition| transition.duration); - let boundary = transition_boundary(timeline, segment_index, old); + let edges = transition_edges(timeline, segment_index, old); timeline .transitions .retain(|candidate| candidate.segment_index as usize != segment_index); @@ -258,7 +258,7 @@ pub(crate) fn set_clip_transition( .sort_by_key(|transition| transition.segment_index); let shift = old - duration; if shift != 0.0 { - ripple_every_track(timeline, boundary, shift); + ripple_every_track(timeline, edges, shift); } true } @@ -279,32 +279,65 @@ pub(crate) fn max_transition_duration( } } -/// Where the other tracks start to feel a transition on `segment_index`'s -/// boundary that currently overlaps the clips by `overlap`. -fn transition_boundary( +/// The output times of a transition that overlaps `segment_index` with the +/// clip before it by `overlap`: where the incoming clip starts, and where the +/// outgoing one ends (the ripple boundary). +#[derive(Clone, Copy)] +struct TransitionEdges { + incoming: f64, + boundary: f64, +} + +fn transition_edges( timeline: &TimelineConfiguration, segment_index: usize, overlap: f64, -) -> f64 { - let boundary = clip_timeline_offsets(timeline) +) -> TransitionEdges { + let start = clip_timeline_offsets(timeline) .get(segment_index) .copied() - .unwrap_or(0.) - + overlap; - edits::effective_to_output(&timeline.hold_windows(), boundary) + .unwrap_or(0.); + let holds = timeline.hold_windows(); + TransitionEdges { + incoming: edits::effective_to_output(&holds, start), + boundary: edits::effective_to_output(&holds, start + overlap), + } +} + +/// Everything at or past the boundary moves by `shift`, and so does anything +/// that starts with the incoming clip inside the overlap: an effect laid on a +/// clip's first frame stays on it as the overlap grows, shrinks or goes, so +/// adding and then removing a transition puts it back where it was. +fn ripple_every_track(timeline: &mut TimelineConfiguration, edges: TransitionEdges, shift: f64) { + fn ripple(track: &mut [T], edges: TransitionEdges, shift: f64) { + for item in track { + let boundary = edges.boundary_for(item.start()); + ripple_track(std::slice::from_mut(item), boundary, shift); + } + } + ripple(&mut timeline.style_segments, edges, shift); + ripple(&mut timeline.image_segments, edges, shift); + ripple(&mut timeline.zoom_segments, edges, shift); + ripple(&mut timeline.scene_segments, edges, shift); + ripple(&mut timeline.mask_segments, edges, shift); + ripple(&mut timeline.text_segments, edges, shift); + ripple(&mut timeline.caption_segments, edges, shift); + ripple(&mut timeline.camera3d_segments, edges, shift); + for segment in &mut timeline.keyboard_segments { + let boundary = edges.boundary_for(segment.start); + ripple_keyboard_track(std::slice::from_mut(segment), boundary, shift); + } + ripple(&mut timeline.audio_segments, edges, shift); } -fn ripple_every_track(timeline: &mut TimelineConfiguration, boundary: f64, shift: f64) { - ripple_track(&mut timeline.style_segments, boundary, shift); - ripple_track(&mut timeline.image_segments, boundary, shift); - ripple_track(&mut timeline.zoom_segments, boundary, shift); - ripple_track(&mut timeline.scene_segments, boundary, shift); - ripple_track(&mut timeline.mask_segments, boundary, shift); - ripple_track(&mut timeline.text_segments, boundary, shift); - ripple_track(&mut timeline.caption_segments, boundary, shift); - ripple_track(&mut timeline.camera3d_segments, boundary, shift); - ripple_keyboard_track(&mut timeline.keyboard_segments, boundary, shift); - ripple_track(&mut timeline.audio_segments, boundary, shift); +impl TransitionEdges { + fn boundary_for(self, start: f64) -> f64 { + if (start - self.incoming).abs() < 1e-6 { + start + } else { + self.boundary + } + } } /// `moveClip` (`ClipsSidebar.tsx:639-690`): reorder `timeline.segments`, @@ -4047,9 +4080,6 @@ mod tests { assert_eq!(dropped[0].segment_index, 3); } - /// The dropped transition's overlap is given back: every later segment on - /// the other tracks shifts right by its effective duration - /// (`ClipsSidebar.tsx:656-683`). fn zoom_at(start: f64, end: f64) -> cap_project::ZoomSegment { cap_project::ZoomSegment { start, @@ -4106,6 +4136,29 @@ mod tests { assert_eq!(config.zoom_segments[0].end, 18.0); } + #[test] + fn an_effect_on_the_incoming_clips_first_frame_keeps_its_timing() { + let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 10.0)]); + config.zoom_segments = vec![zoom_at(10.0, 12.0), zoom_at(8.0, 9.8)]; + let kind = cap_project::ClipTransitionType::CrossFade; + let zooms = |config: &TimelineConfiguration| { + config + .zoom_segments + .iter() + .map(|zoom| (zoom.start, zoom.end)) + .collect::>() + }; + + assert!(set_clip_transition(&mut config, 1, kind, 0.5)); + assert_eq!(zooms(&config), [(9.5, 11.5), (8.0, 9.8)]); + assert!(set_clip_transition(&mut config, 1, kind, 1.0)); + assert_eq!(zooms(&config), [(9.0, 11.0), (8.0, 9.8)]); + assert!(set_clip_transition(&mut config, 1, kind, 0.25)); + assert_eq!(zooms(&config), [(9.75, 11.75), (8.0, 9.8)]); + assert!(drop_clip_transition(&mut config, 1)); + assert_eq!(zooms(&config), [(10.0, 12.0), (8.0, 9.8)]); + } + #[test] fn a_transition_is_held_to_half_the_shorter_clip() { let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 3.0)]); @@ -4139,6 +4192,9 @@ mod tests { assert!(config.transitions.is_empty()); } + /// The dropped transition's overlap is given back: every later segment on + /// the other tracks shifts right by its effective duration + /// (`ClipsSidebar.tsx:656-683`). #[test] fn a_dropped_transition_ripples_the_other_tracks() { let mut config = timeline(vec![ diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 03356d76f46..a48f3082482 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -1193,9 +1193,10 @@ pub enum SegmentDetail { /// Held (paused) windows inside this clip's on-screen box, in output /// time (`TL/ClipTrack.tsx:658-666`). holds: Arc<[(f64, f64)]>, - /// The transition into this clip from the one before it: its length - /// in seconds, and whether it fades through black rather than - /// crossfading. It overlaps the clip's leading edge. + /// The transition into this clip from the one before it: how long + /// its overlap is on screen, in output seconds (holds inside it + /// stretch it), and whether it fades through black rather than + /// crossfading. transition_in: Option<(f64, bool)>, }, /// `TL/ZoomTrack.tsx:343-349`. @@ -1723,7 +1724,7 @@ fn clip_rows( holds: inner_holds, transition_in: timeline.effective_transition(index).map(|transition| { ( - transition.duration, + effective_to_output(holds, previous + transition.duration) - start, transition.kind == cap_project::ClipTransitionType::FadeThroughBlack, ) }), @@ -2674,11 +2675,6 @@ fn render_empty_track( )) } -/// `SegmentRoot` (`TL/Track.tsx:100-137`): the positioned box, its tinted fill -/// and inset ring, the accent bar flush against its left edge, the label at -/// whatever tier its visible width allows, and the two trim handles. -/// The overlap a clip transition takes at the clip's leading edge: an accent -/// band, edged on both sides, with a diagonal sheen. fn render_transition_in(theme: &Theme, overlap_width: f32, clip_width: f32) -> AnyElement { let accent = Hsla::from(theme.editor.accent); let band_width = overlap_width.min(clip_width).max(3.); @@ -2699,6 +2695,9 @@ fn render_transition_in(theme: &Theme, overlap_width: f32, clip_width: f32) -> A .into_any_element() } +/// `SegmentRoot` (`TL/Track.tsx:100-137`): the positioned box, its tinted fill +/// and inset ring, the accent bar flush against its left edge, the label at +/// whatever tier its visible width allows, and the two trim handles. #[allow(clippy::too_many_arguments)] fn render_segment( theme: &Theme, @@ -2795,8 +2794,6 @@ fn render_segment( } } - // The transition into the clip, over the stretch where it overlaps the - // clip before (`TL/ClipTrack.tsx`'s transition trigger). if let SegmentDetail::Clip { transition_in: Some((seconds, _)), .. diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 0e27cc6bc9d..50a41305bce 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -1675,8 +1675,7 @@ fn add_track_tile_progress(delta: f32, index: usize) -> f32 { const CLIP_MENU_WIDTH: f32 = 300.; /// `DEFAULT_CLIP_TRANSITION_DURATION` (`ED/clip-transitions.ts`). const DEFAULT_CLIP_TRANSITION_DURATION: f64 = 0.5; -/// The clip menu's transition lengths, longest last; ones longer than half the -/// shorter clip are left out. +/// Lengths longer than half the shorter clip are left out of the menu. const CLIP_TRANSITION_DURATIONS: [f64; 4] = [0.25, 0.5, 1.0, 1.5]; const TEXT_MENU_WIDTH: f32 = 250.; /// Roughly the menu's laid-out height; only decides which side of its anchor @@ -8560,7 +8559,6 @@ impl EditorWindow { let merge_next = edits::clip_merge_blocker(&timeline.segments, index, edits::ClipMergeDirection::Next); let can_delete = timeline.segments.len() > 1; - // The transition into this clip from the one before it. let transition = timeline.effective_transition(index); let transition_room = crate::editor_clips::max_transition_duration(timeline, index); let can_transition = transition_room >= cap_project::MIN_CLIP_TRANSITION_DURATION; From f3725d18fb0cfaad09e40131ed10106936da849c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:14:40 +0000 Subject: [PATCH 3/3] fix(desktop-gpui): only carry an effect with the incoming clip when it runs past the overlap --- apps/desktop-gpui/src/editor_clips.rs | 36 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index ab629df60c5..05a3c3d7e90 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -304,14 +304,16 @@ fn transition_edges( } } -/// Everything at or past the boundary moves by `shift`, and so does anything -/// that starts with the incoming clip inside the overlap: an effect laid on a -/// clip's first frame stays on it as the overlap grows, shrinks or goes, so -/// adding and then removing a transition puts it back where it was. +/// Everything at or past the boundary moves by `shift`, and so does an effect +/// that starts with the incoming clip and runs on past the outgoing clip's end: +/// one laid on a clip's first frame stays on it as the overlap grows, shrinks +/// or goes. The project does not record which clip an effect belongs to, so +/// one that starts there but ends inside the overlap is left with the +/// outgoing clip, which it may equally belong to. fn ripple_every_track(timeline: &mut TimelineConfiguration, edges: TransitionEdges, shift: f64) { fn ripple(track: &mut [T], edges: TransitionEdges, shift: f64) { for item in track { - let boundary = edges.boundary_for(item.start()); + let boundary = edges.boundary_for(item.start(), item.end()); ripple_track(std::slice::from_mut(item), boundary, shift); } } @@ -324,15 +326,15 @@ fn ripple_every_track(timeline: &mut TimelineConfiguration, edges: TransitionEdg ripple(&mut timeline.caption_segments, edges, shift); ripple(&mut timeline.camera3d_segments, edges, shift); for segment in &mut timeline.keyboard_segments { - let boundary = edges.boundary_for(segment.start); + let boundary = edges.boundary_for(segment.start, segment.end); ripple_keyboard_track(std::slice::from_mut(segment), boundary, shift); } ripple(&mut timeline.audio_segments, edges, shift); } impl TransitionEdges { - fn boundary_for(self, start: f64) -> f64 { - if (start - self.incoming).abs() < 1e-6 { + fn boundary_for(self, start: f64, end: f64) -> f64 { + if (start - self.incoming).abs() < 1e-6 && end > self.boundary + 1e-6 { start } else { self.boundary @@ -4159,6 +4161,24 @@ mod tests { assert_eq!(zooms(&config), [(10.0, 12.0), (8.0, 9.8)]); } + #[test] + fn an_effect_ending_inside_the_new_overlap_keeps_its_timing() { + let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 10.0)]); + config.zoom_segments = vec![zoom_at(9.0, 9.8)]; + let kind = cap_project::ClipTransitionType::CrossFade; + + assert!(set_clip_transition(&mut config, 1, kind, 1.0)); + assert_eq!( + (config.zoom_segments[0].start, config.zoom_segments[0].end), + (9.0, 9.8) + ); + assert!(drop_clip_transition(&mut config, 1)); + assert_eq!( + (config.zoom_segments[0].start, config.zoom_segments[0].end), + (9.0, 9.8) + ); + } + #[test] fn a_transition_is_held_to_half_the_shorter_clip() { let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 3.0)]);