Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 252 additions & 24 deletions apps/desktop-gpui/src/editor_clips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,36 +214,134 @@ 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 edges = transition_edges(timeline, segment_index, effective.duration);
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);
ripple_every_track(timeline, edges, 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 edges = transition_edges(timeline, segment_index, old);
timeline
.transitions
.retain(|candidate| candidate.segment_index as usize != segment_index);
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, edges, 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,
}
}

/// 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,
) -> TransitionEdges {
let start = clip_timeline_offsets(timeline)
.get(segment_index)
.copied()
.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 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<T: TrackSegmentOps>(track: &mut [T], edges: TransitionEdges, shift: f64) {
for item in track {
let boundary = edges.boundary_for(item.start(), item.end());
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, 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, end: f64) -> f64 {
if (start - self.incoming).abs() < 1e-6 && end > self.boundary + 1e-6 {
start
} else {
self.boundary
}
}
}

/// `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
Expand Down Expand Up @@ -3984,6 +4082,136 @@ mod tests {
assert_eq!(dropped[0].segment_index, 3);
}

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 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::<Vec<_>>()
};

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 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)]);
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());
}

/// 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`).
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-gpui/src/editor_edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.)];
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop-gpui/src/editor_timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,11 @@ 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: 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`.
Zoom {
Expand Down Expand Up @@ -1717,6 +1722,12 @@ fn clip_rows(
volume: segment.volume(),
recording_clip: segment.recording_clip,
holds: inner_holds,
transition_in: timeline.effective_transition(index).map(|transition| {
(
effective_to_output(holds, previous + transition.duration) - start,
transition.kind == cap_project::ClipTransitionType::FadeThroughBlack,
)
}),
},
}
})
Expand Down Expand Up @@ -2664,6 +2675,26 @@ fn render_empty_track(
))
}

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()
}

/// `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.
Expand Down Expand Up @@ -2763,6 +2794,18 @@ fn render_segment(
}
}

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,
Expand Down
Loading
Loading