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
3 changes: 3 additions & 0 deletions apps/desktop-gpui/assets/icons/diamond.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop-gpui/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons":
"prev.svg",
"rectangle-horizontal.svg",
"redo.svg",
"diamond.svg",
"restart.svg",
"scissors.svg",
"square.svg",
Expand Down
76 changes: 60 additions & 16 deletions apps/desktop-gpui/src/editor_canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ impl EditorWindow {
for (index, segment) in timeline.image_segments.iter().enumerate() {
if exclude == CanvasSelection::Image(index)
|| !segment.is_active_at(t)
|| segment.opacity <= 0.
|| segment.opacity_at(t) <= 0.
{
continue;
}
Expand All @@ -624,8 +624,12 @@ impl EditorWindow {
if !(t >= segment.start && t < segment.end && segment.enabled) {
continue;
}
let center = segment.center;
let size = segment.size;
let (center, size, _) = segment.keyframes.sample(
segment.center,
segment.size,
f64::from(segment.opacity),
t - segment.start,
);
rects.push(NormRect {
x: center.x - size.x / 2.,
y: center.y - size.y / 2.,
Expand Down Expand Up @@ -981,6 +985,14 @@ impl EditorWindow {
self.snap_guides.clear();
self.canvas_overlay_rect = None;
if drag.moved {
// Live drag writes skip the timeline rebuild, and a drag over an
// animated overlay can add keys whose marks the model carries.
if matches!(
drag.element,
CanvasSelection::Text(_) | CanvasSelection::Image(_)
) {
self.rebuild_timeline();
}
self.schedule_save(window, cx);
match drag.element {
CanvasSelection::Display => tracing::info!(
Expand Down Expand Up @@ -1034,7 +1046,8 @@ impl EditorWindow {
.as_ref()
.and_then(|timeline| timeline.image_segments.get(index))
.is_some_and(|segment| {
segment.is_active_at(self.preview_or_playhead()) && segment.opacity > 0.
let now = self.preview_or_playhead();
segment.is_active_at(now) && segment.opacity_at(now) > 0.
})
{
return false;
Expand Down Expand Up @@ -1078,6 +1091,7 @@ impl EditorWindow {
CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => {
self.canvas_overlay_rect = Some(optimistic);
self.write_overlay_rect(selected, optimistic, cx);
self.rebuild_timeline();
}
}
self.schedule_save(window, cx);
Expand Down Expand Up @@ -1169,11 +1183,17 @@ impl EditorWindow {
return Some(rect);
}
let segment = self.project.timeline.as_ref()?.image_segments.get(index)?;
let (center, size, _) = segment.keyframes.sample(
segment.center,
segment.size,
f64::from(segment.opacity),
self.preview_or_playhead() - segment.start,
);
Some(NormRect {
x: segment.center.x - segment.size.x / 2.,
y: segment.center.y - segment.size.y / 2.,
w: segment.size.x,
h: segment.size.y,
x: center.x - size.x / 2.,
y: center.y - size.y / 2.,
w: size.x,
h: size.y,
})
}
CanvasSelection::Mask(index) => {
Expand All @@ -1199,11 +1219,17 @@ impl EditorWindow {
return Some(rect);
}
let segment = self.project.timeline.as_ref()?.text_segments.get(index)?;
let (center, size, _) = segment.keyframes.sample(
segment.center,
segment.size,
f64::from(segment.opacity),
self.preview_or_playhead() - segment.start,
);
Some(NormRect {
x: segment.center.x - segment.size.x / 2.,
y: segment.center.y - segment.size.y / 2.,
w: segment.size.x,
h: segment.size.y,
x: center.x - size.x / 2.,
y: center.y - size.y / 2.,
w: size.x,
h: size.y,
})
}
}
Expand All @@ -1223,6 +1249,7 @@ impl EditorWindow {
x: rect.w.max(0.01),
y: rect.h.max(0.01),
};
let now = self.preview_or_playhead();
let Some(timeline) = self.project.timeline.as_mut() else {
return;
};
Expand All @@ -1231,8 +1258,19 @@ impl EditorWindow {
let Some(segment) = timeline.image_segments.get_mut(index) else {
return;
};
segment.center = center;
segment.size = size;
// An animated property edits the key under the playhead
// (adding one there); a still one moves the overlay itself.
let at = (now - segment.start).clamp(0., segment.end - segment.start);
if segment.keyframes.position.is_empty() {
segment.center = center;
} else {
segment.keyframes.set_position(at, center);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New keys lack timeline marks

Keys added by dragging or nudging do not appear on the timeline. write_overlay_rect calls project_changed_live, which does not rebuild the stored TimelineModel; ending the drag only saves. The new marks read key times copied into that model, so they remain stale until another edit rebuilds it. Refresh those times when the canvas edit finishes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/editor_canvas.rs
Line: 1258

Comment:
**New keys lack timeline marks**

Keys added by dragging or nudging do not appear on the timeline. `write_overlay_rect` calls `project_changed_live`, which does not rebuild the stored `TimelineModel`; ending the drag only saves. The new marks read key times copied into that model, so they remain stale until another edit rebuilds it. Refresh those times when the canvas edit finishes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
if segment.keyframes.size.is_empty() {
segment.size = size;
} else {
segment.keyframes.set_size(at, size);
Comment on lines +1269 to +1272

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Moving changes the size animation

Moving or nudging an image inserts a size key even when its size did not change. With size keys at 0 and 10 seconds, moving the image at 5 seconds inserts a third size key. Because each span eases separately, this changes the size animation before and after the move. Write a size key only when the gesture changes size.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/editor_canvas.rs
Line: 1260-1263

Comment:
**Moving changes the size animation**

Moving or nudging an image inserts a size key even when its size did not change. With size keys at 0 and 10 seconds, moving the image at 5 seconds inserts a third size key. Because each span eases separately, this changes the size animation before and after the move. Write a size key only when the gesture changes size.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
}
CanvasSelection::Mask(index) => {
let Some(segment) = timeline.mask_segments.get_mut(index) else {
Expand All @@ -1247,7 +1285,13 @@ impl EditorWindow {
let Some(segment) = timeline.text_segments.get_mut(index) else {
return;
};
segment.center = center;
// Text keys position only; its box follows the font size.
let at = (now - segment.start).clamp(0., segment.end - segment.start);
if segment.keyframes.position.is_empty() {
segment.center = center;
} else {
segment.keyframes.set_position(at, center);
}
segment.size = size;
}
_ => return,
Expand Down Expand Up @@ -1388,7 +1432,7 @@ impl EditorWindow {
.filter(|(_, segment)| {
segment.track == track.track
&& segment.is_active_at(time)
&& segment.opacity > 0.
&& segment.opacity_at(time) > 0.
})
{
if let Some(rect) = self.element_rect(CanvasSelection::Image(index)) {
Expand Down
90 changes: 88 additions & 2 deletions apps/desktop-gpui/src/editor_edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,9 +650,39 @@ impl TrackSegmentOps for MaskSegment {
self.track
}
}
impl_track_segment!(TextSegment, lane: track);
/// Text and image keyframes are measured from the segment's start, so moving
/// the start (a start trim, or the tail half of a split) shifts them by the
/// same amount and the animation stays put in output time. Off-segment keys
/// still anchor interpolation at the new edge, as with masks.
macro_rules! impl_keyed_overlay_segment {
($type:ty) => {
impl TrackSegmentOps for $type {
fn start(&self) -> f64 {
self.start
}
fn end(&self) -> f64 {
self.end
}
fn set_start(&mut self, value: f64) {
self.start = value;
}
fn set_end(&mut self, value: f64) {
self.end = value;
}
fn trim_start_to(&mut self, value: f64) {
self.keyframes.shift(self.start - value);
self.start = value;
}
fn lane(&self) -> u32 {
self.track
}
}
};
}

impl_keyed_overlay_segment!(TextSegment);
impl_track_segment!(cap_project::StyleSegment, lane: track);
impl_track_segment!(cap_project::ImageSegment, lane: track);
impl_keyed_overlay_segment!(cap_project::ImageSegment);
Comment on lines +683 to +685

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deleted clips leave keys behind

Deleting a clip does not retime keys on text or images that cross the removed interval. Both still use ripple_delete_track, which changes only their bounds. For an overlay spanning 10–20 seconds, deleting 12–14 seconds shortens it to 10–18, but a key originally at 18 stays there instead of moving to 16. Add key-aware ripple deletion for both overlay types.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/editor_edits.rs
Line: 683-685

Comment:
**Deleted clips leave keys behind**

Deleting a clip does not retime keys on text or images that cross the removed interval. Both still use `ripple_delete_track`, which changes only their bounds. For an overlay spanning 10–20 seconds, deleting 12–14 seconds shortens it to 10–18, but a key originally at 18 stays there instead of moving to 16. Add key-aware ripple deletion for both overlay types.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


impl TrackSegmentOps for CaptionTrackSegment {
fn start(&self) -> f64 {
Expand Down Expand Up @@ -1697,6 +1727,7 @@ pub fn place_segment_at_time<T: TrackSegmentOps>(

pub fn default_text_segment(start: f64, end: f64, track: u32) -> TextSegment {
TextSegment {
keyframes: Default::default(),
start,
end,
track,
Expand Down Expand Up @@ -2893,6 +2924,61 @@ mod tests {

// -- Selection ----------------------------------------------------------

fn keyed_image() -> cap_project::ImageSegment {
let mut image = cap_project::ImageSegment {
start: 10.0,
end: 20.0,
..Default::default()
};
image
.keyframes
.set_position(2.0, cap_project::XY::new(0.2, 0.5));
image
.keyframes
.set_position(8.0, cap_project::XY::new(0.8, 0.5));
image.keyframes.set_opacity(5.0, 0.5);
image
}

#[test]
fn splitting_a_keyed_overlay_keeps_the_animation_in_place() {
let image = keyed_image();
let before = image
.keyframes
.sample(image.center, image.size, 1.0, 16.0 - 10.0);
let tail = image.split_tail(4.0);
assert_eq!(tail.start, 14.0);
let after = tail
.keyframes
.sample(tail.center, tail.size, 1.0, 16.0 - tail.start);
assert_eq!(before.0, after.0, "same position at 0:16");
assert_eq!(tail.keyframes.times(), vec![-2.0, 1.0, 4.0]);

let mut text: TextSegment = serde_json::from_value(serde_json::json!({
"start": 10.0, "end": 20.0, "content": "Hi"
}))
.unwrap();
text.keyframes
.set_position(6.0, cap_project::XY::new(0.3, 0.3));
let tail = text.split_tail(4.0);
assert_eq!(tail.keyframes.times(), vec![2.0]);
}

#[test]
fn trimming_a_keyed_overlays_start_keeps_keys_in_output_time() {
let mut image = keyed_image();
image.trim_start_to(13.0);
assert_eq!(image.start, 13.0);
assert_eq!(image.keyframes.times(), vec![-1.0, 2.0, 5.0]);
let mut image = keyed_image();
image.trim_start_to(8.0);
assert_eq!(image.keyframes.times(), vec![4.0, 7.0, 10.0]);
// The end handle leaves keys alone.
let mut image = keyed_image();
image.set_end(15.0);
assert_eq!(image.keyframes.times(), vec![2.0, 5.0, 8.0]);
}

#[test]
fn a_plain_click_selects_one_segment() {
let selection = click_selection(None, TrackKind::Zoom, 3, false, false);
Expand Down
Loading
Loading