Repository navigation
feat: keyframes for text and image overlays (engine + GPUI) #2846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ce0d6b7
09ebd74
3782cd2
a06f4de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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., | ||
|
|
@@ -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!( | ||
|
|
@@ -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; | ||
|
|
@@ -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); | ||
|
|
@@ -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) => { | ||
|
|
@@ -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, | ||
| }) | ||
| } | ||
| } | ||
|
|
@@ -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; | ||
| }; | ||
|
|
@@ -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); | ||
| } | ||
| if segment.keyframes.size.is_empty() { | ||
| segment.size = size; | ||
| } else { | ||
| segment.keyframes.set_size(at, size); | ||
|
Comment on lines
+1269
to
+1272
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 AIThis 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 { | ||
|
|
@@ -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, | ||
|
|
@@ -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)) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Deleting a clip does not retime keys on text or images that cross the removed interval. Both still use Prompt To Fix With AIThis 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 { | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keys added by dragging or nudging do not appear on the timeline.
write_overlay_rectcallsproject_changed_live, which does not rebuild the storedTimelineModel; 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