From ce0d6b7cf13b3fd4979964162e7d3d87bb4ebfcc Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Wed, 21 Oct 2026 18:31:22 +0300 Subject: [PATCH 1/3] Keyframes for text and image overlays (engine + GPUI editor) Text and image segments gain optional keyframes for position, size and opacity (text: position and opacity), in seconds from the segment start, eased between keys. The renderer samples them per frame. In the GPUI editor the text and image panels get a Keyframes section: add a key at the playhead, jump to or remove keys. Once a property is keyed, dragging on the canvas or moving the opacity slider edits the key under the playhead. Keys show as marks on the timeline boxes. Co-Authored-By: Claude Opus 5.5 --- apps/desktop-gpui/src/editor_canvas.rs | 60 ++++- apps/desktop-gpui/src/editor_edits.rs | 1 + apps/desktop-gpui/src/editor_panels.rs | 213 +++++++++++++++- apps/desktop-gpui/src/editor_timeline.rs | 27 +++ crates/project/src/configuration.rs | 295 +++++++++++++++++++++++ crates/rendering/src/layers/image.rs | 21 +- crates/rendering/src/takeover.rs | 1 + crates/rendering/src/text.rs | 17 +- 8 files changed, 605 insertions(+), 30 deletions(-) diff --git a/apps/desktop-gpui/src/editor_canvas.rs b/apps/desktop-gpui/src/editor_canvas.rs index 14618f7bea7..96cdfa5ca73 100644 --- a/apps/desktop-gpui/src/editor_canvas.rs +++ b/apps/desktop-gpui/src/editor_canvas.rs @@ -626,8 +626,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., @@ -1171,11 +1175,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) => { @@ -1201,11 +1211,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, }) } } @@ -1225,6 +1241,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; }; @@ -1233,8 +1250,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); + } } CanvasSelection::Mask(index) => { let Some(segment) = timeline.mask_segments.get_mut(index) else { @@ -1249,7 +1277,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, diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index f2df27fdd60..8ad3a9d2d2e 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -1630,6 +1630,7 @@ pub fn place_segment_at_time( pub fn default_text_segment(start: f64, end: f64, track: u32) -> TextSegment { TextSegment { + keyframes: Default::default(), start, end, track, diff --git a/apps/desktop-gpui/src/editor_panels.rs b/apps/desktop-gpui/src/editor_panels.rs index 4ad482e3035..a3a63307457 100644 --- a/apps/desktop-gpui/src/editor_panels.rs +++ b/apps/desktop-gpui/src/editor_panels.rs @@ -3125,6 +3125,13 @@ impl EditorWindow { return 0.; }; match slider { + PanelSlider::Image(ImageProperty::Opacity) => { + let now = self.preview_or_playhead(); + timeline + .image_segments + .get(index) + .map_or(0., |segment| (segment.opacity_at(now) * 100.) as f32) + } PanelSlider::Image(property) => timeline .image_segments .get(index) @@ -3175,7 +3182,9 @@ impl EditorWindow { PanelSlider::TextFontSize => segment.font_size, PanelSlider::TextLineHeight => segment.line_height, PanelSlider::TextLetterSpacing => segment.letter_spacing, - PanelSlider::TextOpacity => segment.opacity, + PanelSlider::TextOpacity => { + segment.opacity_at(self.preview_or_playhead()) as f32 + } PanelSlider::TextShadow => segment.shadow, PanelSlider::TextStroke => segment.stroke_width, PanelSlider::TextGlow => segment.glow, @@ -3269,6 +3278,15 @@ impl EditorWindow { cx: &mut Context, ) { match slider { + PanelSlider::Image(ImageProperty::Opacity) => { + let now = self.preview_or_playhead(); + self.edit_image_segment("image-transform", index, window, cx, move |segment| { + if value.is_finite() { + segment.set_opacity_at(now, f64::from(value.clamp(0., 100.) / 100.)); + } + true + }) + } PanelSlider::Image(property) => { self.edit_image_segment("image-transform", index, window, cx, move |segment| { property.write(segment, value); @@ -3312,6 +3330,7 @@ impl EditorWindow { | PanelSlider::TextShadow | PanelSlider::TextStroke | PanelSlider::TextGlow => { + let now = self.preview_or_playhead(); self.edit_text_segment("text-slider", index, window, cx, move |segment| { match slider { PanelSlider::TextLayoutTransition => { @@ -3337,7 +3356,9 @@ impl EditorWindow { PanelSlider::TextLetterSpacing => { segment.letter_spacing = value.clamp(-2., 20.) } - PanelSlider::TextOpacity => segment.opacity = value.clamp(0., 1.), + PanelSlider::TextOpacity => { + segment.set_opacity_at(now, f64::from(value.clamp(0., 1.))) + } PanelSlider::TextStroke => segment.stroke_width = value.clamp(0., 12.), PanelSlider::TextGlow => segment.glow = value.clamp(0., 1.), _ => segment.shadow = value.clamp(0., 1.), @@ -5511,9 +5532,180 @@ impl EditorWindow { })), ), ) + .child(self.render_overlay_keyframes(KeyframeTarget::Text(index), cx)) .into_any_element() } + /// The Keyframes section of the text and image panels: add a key at the + /// playhead, then any move, resize or opacity change at another time adds + /// or edits a key there. Each key is a chip: click to jump, × to remove. + fn render_overlay_keyframes( + &self, + target: KeyframeTarget, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let now = self.preview_or_playhead(); + let Some((start, end, times)) = self.timeline().and_then(|timeline| match target { + KeyframeTarget::Text(index) => timeline + .text_segments + .get(index) + .map(|s| (s.start, s.end, s.keyframes.times())), + KeyframeTarget::Image(index) => timeline + .image_segments + .get(index) + .map(|s| (s.start, s.end, s.keyframes.times())), + }) else { + return div().into_any_element(); + }; + let inside = now >= start && now <= end; + let key = match target { + KeyframeTarget::Text(index) => format!("text-{index}"), + KeyframeTarget::Image(index) => format!("image-{index}"), + }; + let buttons = div() + .flex() + .flex_row() + .gap(px(6.)) + .child( + ui::Button::plain( + &theme, + SharedString::from(format!("keyframe-add-{key}")), + ui::ButtonVariant::Gray, + ui::ButtonSize::Sm, + ) + .label("◆ Add keyframe") + .disabled(!inside) + .on_click(cx.listener(move |this, _, window, cx| { + let now = this.preview_or_playhead(); + this.keyframe_action(target, KeyframeAction::Add(now), window, cx); + })), + ) + .children((!times.is_empty()).then(|| { + ui::Button::plain( + &theme, + SharedString::from(format!("keyframe-clear-{key}")), + ui::ButtonVariant::Gray, + ui::ButtonSize::Sm, + ) + .label("Clear") + .on_click(cx.listener(move |this, _, window, cx| { + this.keyframe_action(target, KeyframeAction::Clear, window, cx); + })) + })); + let body: AnyElement = if times.is_empty() { + div() + .text_size(px(12.)) + .text_color(Hsla::from(theme.editor.text_3)) + .child( + "Add a keyframe, move the playhead, then drag the overlay or change its \ + opacity. It animates smoothly between keyframes.", + ) + .into_any_element() + } else { + div() + .flex() + .flex_row() + .flex_wrap() + .gap(px(4.)) + .children(times.into_iter().enumerate().map(|(n, time)| { + let current = (start + time - now).abs() <= 1. / 60.; + div() + .flex() + .flex_row() + .items_center() + .gap(px(4.)) + .px(px(6.)) + .h(px(22.)) + .rounded(px(6.)) + .text_size(px(11.)) + .bg(Hsla::from(if current { + theme.editor.accent + } else { + theme.editor.ctl + })) + .text_color(Hsla::from(if current { + theme.editor.card + } else { + theme.editor.text_1 + })) + .child( + div() + .id(SharedString::from(format!("keyframe-{key}-{n}"))) + .cursor_pointer() + .child(SharedString::from(format!( + "◆ {}:{:04.1}", + (time / 60.).floor() as u32, + time % 60. + ))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.seek_to_time(start + time, cx); + cx.notify(); + })), + ) + .child( + div() + .id(SharedString::from(format!("keyframe-remove-{key}-{n}"))) + .cursor_pointer() + .opacity(0.6) + .hover(|x| x.opacity(1.)) + .child("×") + .on_click(cx.listener(move |this, _, window, cx| { + this.keyframe_action( + target, + KeyframeAction::Remove(time), + window, + cx, + ); + })), + ) + .into_any_element() + })) + .into_any_element() + }; + ui::Field::section(&theme, "Keyframes") + .child( + div() + .flex() + .flex_col() + .gap(px(8.)) + .child(buttons) + .child(body), + ) + .into_any_element() + } + + fn keyframe_action( + &mut self, + target: KeyframeTarget, + action: KeyframeAction, + window: &mut Window, + cx: &mut Context, + ) { + match target { + KeyframeTarget::Text(index) => { + self.edit_text_segment("text-keyframe", index, window, cx, move |segment| { + match action { + KeyframeAction::Add(time) => segment.add_keyframe_at(time), + KeyframeAction::Remove(time) => segment.keyframes.remove_at(time), + KeyframeAction::Clear => segment.keyframes = Default::default(), + } + true + }) + } + KeyframeTarget::Image(index) => { + self.edit_image_segment("image-keyframe", index, window, cx, move |segment| { + match action { + KeyframeAction::Add(time) => segment.add_keyframe_at(time), + KeyframeAction::Remove(time) => segment.keyframes.remove_at(time), + KeyframeAction::Clear => segment.keyframes = Default::default(), + } + true + }) + } + } + } + /// `CaptionSegmentConfig` (`:4231-4341`). fn render_caption_panel(&self, index: usize, cx: &mut Context) -> AnyElement { let theme = self.theme; @@ -8679,6 +8871,22 @@ pub enum ImageProperty { Rounding, } +/// Which overlay a Keyframes section edits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyframeTarget { + Text(usize), + Image(usize), +} + +#[derive(Debug, Clone, Copy)] +enum KeyframeAction { + /// Key the overlay's current look at this output time. + Add(f64), + /// Remove the keys at this segment-relative time. + Remove(f64), + Clear, +} + impl ImageProperty { fn label(self) -> &'static str { match self { @@ -8853,6 +9061,7 @@ impl EditorWindow { ), ); } + panel = panel.child(self.render_overlay_keyframes(KeyframeTarget::Image(index), cx)); panel.into_any_element() } diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 43702143ce5..11e18b0dd63 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -127,6 +127,8 @@ const SEGMENT_PADDING_RIGHT: f32 = 10.; /// handles still own the outer pixels. pub const CAMERA3D_POSE_DOT: f32 = 6.; pub const CAMERA3D_POSE_DOT_INSET: f32 = 5.; +/// Side of the keyframe marks drawn on text and image boxes. +const KEYFRAME_MARK: f32 = 4.; /// The waveform's ceiling inside a row, so it never climbs into the labels. const WAVEFORM_MAX_HEIGHT: f32 = 18.; @@ -1073,6 +1075,8 @@ pub enum SegmentDetail { Image { name: SharedString, enabled: bool, + /// Keyframe times, seconds from the segment start. + keyframes: Arc<[f64]>, }, /// `TL/ClipTrack.tsx`. `start`/`end` above are the **output-time** box; /// these carry the recording-domain numbers the label reads. @@ -1116,6 +1120,8 @@ pub enum SegmentDetail { bold: bool, fullscreen: bool, enabled: bool, + /// Keyframe times, seconds from the segment start. + keyframes: Arc<[f64]>, }, /// `TL/MaskTrack.tsx:349-350`. Mask { @@ -1308,6 +1314,7 @@ impl TimelineModel { bold: segment.font_weight >= 600., fullscreen: matches!(segment.layout, TextLayout::Fullscreen), enabled: segment.enabled, + keyframes: segment.keyframes.times().into(), }, }) .collect(); @@ -1407,6 +1414,7 @@ impl TimelineModel { detail: SegmentDetail::Image { name: segment.name.clone().into(), enabled: segment.enabled, + keyframes: segment.keyframes.times().into(), }, }) .collect(); @@ -2705,6 +2713,25 @@ fn render_segment( } } + // Overlay keyframes: small diamonds along the bottom of the box. + if let SegmentDetail::Text { keyframes, .. } | SegmentDetail::Image { keyframes, .. } = + &segment.detail + { + let duration = (segment.end - segment.start).max(0.0001); + for time in keyframes.iter() { + let x = ((time / duration).clamp(0., 1.) as f32) * width; + fill = fill.child( + div() + .absolute() + .bottom(px(2.)) + .left(px(x - KEYFRAME_MARK / 2.)) + .size(px(KEYFRAME_MARK)) + .rounded(px(1.)) + .bg(gpui::white().opacity(0.9)), + ); + } + } + // The selected 3D shot's two pose dots: the end the panel is editing, and // the other one a click away. They sit `CAMERA3D_POSE_DOT_INSET` inside // the box so the resize handles keep the edges themselves. diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 9a2aa35ebf7..aa30fc1fc76 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -1213,6 +1213,139 @@ pub enum TextLayout { SplitRight, } +/// Animation of a text or image overlay. Times are seconds from the +/// segment start; values hold flat before the first and after the last key. +#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct OverlayKeyframes { + #[serde(default)] + pub position: Vec, + #[serde(default)] + pub size: Vec, + #[serde(default)] + pub opacity: Vec, +} + +/// Keys closer together than this (seconds) are the same keyframe. +pub const OVERLAY_KEYFRAME_MATCH_SECS: f64 = 1.0 / 120.0; + +impl OverlayKeyframes { + pub fn is_empty(&self) -> bool { + self.position.is_empty() && self.size.is_empty() && self.opacity.is_empty() + } + + /// Distinct key times across every property, ascending. + pub fn times(&self) -> Vec { + let mut times: Vec = self + .position + .iter() + .chain(&self.size) + .map(|k| k.time) + .chain(self.opacity.iter().map(|k| k.time)) + .filter(|t| t.is_finite()) + .collect(); + times.sort_by(f64::total_cmp); + times.dedup_by(|b, a| (*b - *a).abs() <= OVERLAY_KEYFRAME_MATCH_SECS); + times + } + + pub fn set_position(&mut self, time: f64, value: XY) { + upsert_vector_key(&mut self.position, time, value); + } + + pub fn set_size(&mut self, time: f64, value: XY) { + upsert_vector_key(&mut self.size, time, value); + } + + pub fn set_opacity(&mut self, time: f64, value: f64) { + let keys = &mut self.opacity; + match keys + .iter_mut() + .find(|k| (k.time - time).abs() <= OVERLAY_KEYFRAME_MATCH_SECS) + { + Some(key) => key.value = value, + None => { + keys.push(MaskScalarKeyframe { time, value }); + keys.sort_by(|a, b| a.time.total_cmp(&b.time)); + } + } + } + + /// Removes every property's key at `time`. + pub fn remove_at(&mut self, time: f64) { + let keep = |t: f64| (t - time).abs() > OVERLAY_KEYFRAME_MATCH_SECS; + self.position.retain(|k| keep(k.time)); + self.size.retain(|k| keep(k.time)); + self.opacity.retain(|k| keep(k.time)); + } + + /// Center, size and opacity at `relative_time`, falling back to the base + /// values for any property without keys. + pub fn sample( + &self, + center: XY, + size: XY, + opacity: f64, + relative_time: f64, + ) -> (XY, XY, f64) { + ( + sample_vector_keys(&self.position, relative_time).unwrap_or(center), + sample_vector_keys(&self.size, relative_time).unwrap_or(size), + sample_scalar_keys(&self.opacity, relative_time).unwrap_or(opacity), + ) + } +} + +fn keyframe_span(keys: &[T], time: f64, key_time: impl Fn(&T) -> f64) -> Option<(&T, &T, f64)> { + let mut sorted: Vec<&T> = keys.iter().filter(|k| key_time(k).is_finite()).collect(); + sorted.sort_by(|a, b| key_time(a).total_cmp(&key_time(b))); + let first = *sorted.first()?; + if time <= key_time(first) { + return Some((first, first, 0.0)); + } + for pair in sorted.windows(2) { + let (prev, next) = (pair[0], pair[1]); + if time <= key_time(next) { + let span = (key_time(next) - key_time(prev)).max(1e-6); + // Ease in and out between keys so motion starts and stops softly + let t = ((time - key_time(prev)) / span).clamp(0.0, 1.0); + return Some((prev, next, t * t * (3.0 - 2.0 * t))); + } + } + let last = *sorted.last()?; + Some((last, last, 0.0)) +} + +fn upsert_vector_key(keys: &mut Vec, time: f64, value: XY) { + match keys + .iter_mut() + .find(|k| (k.time - time).abs() <= OVERLAY_KEYFRAME_MATCH_SECS) + { + Some(key) => { + key.x = value.x; + key.y = value.y; + } + None => { + keys.push(MaskVectorKeyframe { + time, + x: value.x, + y: value.y, + }); + keys.sort_by(|a, b| a.time.total_cmp(&b.time)); + } + } +} + +fn sample_vector_keys(keys: &[MaskVectorKeyframe], time: f64) -> Option> { + let (a, b, t) = keyframe_span(keys, time, |k| k.time)?; + Some(XY::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)) +} + +fn sample_scalar_keys(keys: &[MaskScalarKeyframe], time: f64) -> Option { + let (a, b, t) = keyframe_span(keys, time, |k| k.time)?; + Some(a.value + (b.value - a.value) * t) +} + #[derive(Type, Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct TextSegment { @@ -1285,9 +1418,54 @@ pub struct TextSegment { /// segment edges when `layout` is not `Overlay`. #[serde(default = "TextSegment::default_layout_transition")] pub layout_transition: f64, + #[serde(default)] + pub keyframes: OverlayKeyframes, } impl TextSegment { + /// Seconds into the segment for `time`, clamped to its length: the + /// timebase of [`OverlayKeyframes`]. + pub fn key_time(&self, time: f64) -> f64 { + (time - self.start).clamp(0., (self.end - self.start).max(0.)) + } + + /// Opacity at output `time`, keyframes applied. + pub fn opacity_at(&self, time: f64) -> f64 { + self.keyframes + .sample( + self.center, + self.size, + f64::from(self.opacity), + time - self.start, + ) + .2 + } + + /// Sets the opacity at `time`: the key there when opacity is animated, + /// otherwise the segment's own value. + pub fn set_opacity_at(&mut self, time: f64, value: f64) { + if self.keyframes.opacity.is_empty() { + self.opacity = value as f32; + } else { + let at = self.key_time(time); + self.keyframes.set_opacity(at, value); + } + } + + /// Records the current position and opacity as a keyframe at `time`. + /// Text size is not keyed: its box follows the font size. + pub fn add_keyframe_at(&mut self, time: f64) { + let (center, _, opacity) = self.keyframes.sample( + self.center, + self.size, + f64::from(self.opacity), + time - self.start, + ); + let at = self.key_time(time); + self.keyframes.set_position(at, center); + self.keyframes.set_opacity(at, opacity); + } + fn default_enabled() -> bool { true } @@ -1751,6 +1929,8 @@ pub struct ImageSegment { pub flip_x: bool, pub flip_y: bool, pub lock_aspect: bool, + #[serde(default)] + pub keyframes: OverlayKeyframes, } impl Default for ImageSegment { @@ -1770,6 +1950,7 @@ impl Default for ImageSegment { flip_x: false, flip_y: false, lock_aspect: true, + keyframes: OverlayKeyframes::default(), } } } @@ -1778,6 +1959,49 @@ impl ImageSegment { pub fn is_active_at(&self, time: f64) -> bool { self.enabled && is_timeline_interval_active(self.start, self.end, time) } + + /// Seconds into the segment for `time`, clamped to its length: the + /// timebase of [`OverlayKeyframes`]. + pub fn key_time(&self, time: f64) -> f64 { + (time - self.start).clamp(0., (self.end - self.start).max(0.)) + } + + /// Opacity at output `time`, keyframes applied. + pub fn opacity_at(&self, time: f64) -> f64 { + self.keyframes + .sample( + self.center, + self.size, + f64::from(self.opacity), + time - self.start, + ) + .2 + } + + /// Sets the opacity at `time`: the key there when opacity is animated, + /// otherwise the segment's own value. + pub fn set_opacity_at(&mut self, time: f64, value: f64) { + if self.keyframes.opacity.is_empty() { + self.opacity = value as f32; + } else { + let at = self.key_time(time); + self.keyframes.set_opacity(at, value); + } + } + + /// Records the current position, size and opacity as a keyframe at `time`. + pub fn add_keyframe_at(&mut self, time: f64) { + let (center, size, opacity) = self.keyframes.sample( + self.center, + self.size, + f64::from(self.opacity), + time - self.start, + ); + let at = self.key_time(time); + self.keyframes.set_position(at, center); + self.keyframes.set_size(at, size); + self.keyframes.set_opacity(at, opacity); + } } fn is_timeline_interval_active(start: f64, end: f64, time: f64) -> bool { @@ -3116,6 +3340,74 @@ mod notch_tests { #[cfg(test)] mod tests { use super::*; + + #[test] + fn overlay_keyframes_ease_between_keys_and_hold_outside() { + let keys = OverlayKeyframes { + position: vec![ + MaskVectorKeyframe { + time: 1.0, + x: 0.2, + y: 0.5, + }, + MaskVectorKeyframe { + time: 3.0, + x: 0.8, + y: 0.5, + }, + ], + size: vec![], + opacity: vec![ + MaskScalarKeyframe { + time: 0.0, + value: 0.0, + }, + MaskScalarKeyframe { + time: 2.0, + value: 1.0, + }, + ], + }; + let base = XY::new(0.5, 0.5); + let sample = |t| keys.sample(base, XY::new(0.3, 0.1), 0.7, t); + assert_eq!(sample(0.0).0.x, 0.2); + assert!((sample(2.0).0.x - 0.5).abs() < 1e-9); + assert_eq!(sample(9.0).0.x, 0.8); + assert_eq!(sample(2.0).1, XY::new(0.3, 0.1)); + assert_eq!(sample(1.0).2, 0.5); + assert!(OverlayKeyframes::default().is_empty()); + } + + #[test] + fn image_opacity_edits_the_key_under_the_playhead_once_animated() { + let mut image = ImageSegment { + start: 10.0, + end: 20.0, + ..Default::default() + }; + image.set_opacity_at(12.0, 0.5); + assert_eq!(image.opacity, 0.5); + image.add_keyframe_at(10.0); + image.set_opacity_at(14.0, 0.0); + assert_eq!(image.opacity, 0.5, "the base stays once keyed"); + assert_eq!(image.keyframes.times(), vec![0.0, 4.0]); + assert!((image.opacity_at(12.0) - 0.25).abs() < 1e-9); + assert_eq!(image.opacity_at(30.0), 0.0); + } + + #[test] + fn overlay_keyframe_edits_upsert_and_remove_by_time() { + let mut keys = OverlayKeyframes::default(); + keys.set_position(2.0, XY::new(0.1, 0.1)); + keys.set_position(0.0, XY::new(0.0, 0.0)); + keys.set_position(2.001, XY::new(0.3, 0.3)); + keys.set_opacity(1.0, 0.5); + assert_eq!(keys.position.len(), 2); + assert_eq!(keys.position[1].x, 0.3); + assert_eq!(keys.times(), vec![0.0, 1.0, 2.0]); + keys.remove_at(2.0); + assert_eq!(keys.times(), vec![0.0, 1.0]); + } use serde_json::Value; #[test] @@ -3701,6 +3993,7 @@ mod tests { fn fullscreen_text(start: f64, end: f64) -> TextSegment { TextSegment { + keyframes: Default::default(), start, end, track: 0, @@ -4547,6 +4840,7 @@ mod tests { scene_segments: Vec::new(), mask_segments: Vec::new(), text_segments: vec![TextSegment { + keyframes: Default::default(), start: 0.0, end: 1.0, track: 0, @@ -4656,6 +4950,7 @@ mod tests { scene_segments: Vec::new(), mask_segments: Vec::new(), text_segments: vec![TextSegment { + keyframes: Default::default(), start: 0.0, end: 1.0, track: 0, diff --git a/crates/rendering/src/layers/image.rs b/crates/rendering/src/layers/image.rs index 1fa6f2204a6..dd63a68d9ba 100644 --- a/crates/rendering/src/layers/image.rs +++ b/crates/rendering/src/layers/image.rs @@ -115,20 +115,27 @@ fn visible_images( .iter() .enumerate() .filter_map(|(index, segment)| { + let (center, size, opacity) = segment.keyframes.sample( + segment.center, + segment.size, + f64::from(segment.opacity), + time - segment.start, + ); + let opacity = opacity as f32; if !segment.is_active_at(time) || segment.path.is_empty() - || !segment.opacity.is_finite() - || segment.opacity <= 0.0 + || !opacity.is_finite() + || opacity <= 0.0 || !segment.rotation.is_finite() || !segment.rounding.is_finite() { return None; } let center_size = [ - (segment.center.x * f64::from(output_size.0)) as f32, - (segment.center.y * f64::from(output_size.1)) as f32, - (segment.size.x * f64::from(output_size.0)) as f32, - (segment.size.y * f64::from(output_size.1)) as f32, + (center.x * f64::from(output_size.0)) as f32, + (center.y * f64::from(output_size.1)) as f32, + (size.x * f64::from(output_size.0)) as f32, + (size.y * f64::from(output_size.1)) as f32, ]; if center_size .iter() @@ -156,7 +163,7 @@ fn visible_images( rotation_opacity_radius: [ cos, sin, - segment.opacity.clamp(0.0, 1.0), + opacity.clamp(0.0, 1.0), segment.rounding.clamp(0.0, 100.0) * 0.005 * center_size[2].min(center_size[3]), diff --git a/crates/rendering/src/takeover.rs b/crates/rendering/src/takeover.rs index 43721a85144..09556d6312b 100644 --- a/crates/rendering/src/takeover.rs +++ b/crates/rendering/src/takeover.rs @@ -155,6 +155,7 @@ mod tests { fn segment(layout: TextLayout, start: f64, end: f64) -> TextSegment { TextSegment { + keyframes: Default::default(), start, end, track: 0, diff --git a/crates/rendering/src/text.rs b/crates/rendering/src/text.rs index 9f72eca35bb..f03ed0bf008 100644 --- a/crates/rendering/src/text.rs +++ b/crates/rendering/src/text.rs @@ -329,14 +329,14 @@ pub fn prepare_texts( continue; } - let center = XY::new( - segment.center.x.clamp(0.0, 1.0), - segment.center.y.clamp(0.0, 1.0), - ); - let size = XY::new( - segment.size.x.clamp(0.01, 2.0), - segment.size.y.clamp(0.01, 2.0), + let (center, size, keyed_opacity) = segment.keyframes.sample( + segment.center, + segment.size, + f64::from(segment.opacity), + frame_time - segment.start, ); + let center = XY::new(center.x.clamp(0.0, 1.0), center.y.clamp(0.0, 1.0)); + let size = XY::new(size.x.clamp(0.01, 2.0), size.y.clamp(0.01, 2.0)); let width = (size.x * output_size.x as f64).max(1.0) as f32; let height = (size.y * output_size.y as f64).max(1.0) as f32; @@ -364,7 +364,7 @@ pub fn prepare_texts( slide_px, ); - let opacity = segment.opacity.clamp(0.0, 1.0) * enter.alpha * exit.alpha; + let opacity = (keyed_opacity as f32).clamp(0.0, 1.0) * enter.alpha * exit.alpha; if opacity <= 0.0 { continue; } @@ -429,6 +429,7 @@ mod tests { fn segment(animation_in: TextAnimation, animation_out: TextAnimation) -> TextSegment { TextSegment { + keyframes: Default::default(), start: 0.0, end: 10.0, track: 0, From 09ebd7405f8d283cbbb6d88aaf534d1f29bde509 Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Wed, 21 Oct 2026 18:57:36 +0300 Subject: [PATCH 2/3] Include empty overlay keyframes in the image serde-defaults test Co-Authored-By: Claude Opus 5.5 --- crates/project/src/configuration.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index aa30fc1fc76..35c37d861cf 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -3554,7 +3554,8 @@ mod tests { "path": "", "name": "Image", "center": { "x": 0.5, "y": 0.5 }, "size": { "x": 0.3, "y": 0.3 }, "opacity": 1.0, "rotation": 0.0, "rounding": 0.0, "flipX": false, - "flipY": false, "lockAspect": true + "flipY": false, "lockAspect": true, + "keyframes": { "position": [], "size": [], "opacity": [] } }) ); assert_eq!( From a06f4de1237561d339374d5814a7e92773a36c1c Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Tue, 27 Oct 2026 21:13:37 +0200 Subject: [PATCH 3/3] Scan keyframes without sorting, mark only keys inside the segment, refresh marks after canvas edits Co-Authored-By: Claude Opus 5.5 --- apps/desktop-gpui/src/editor_canvas.rs | 9 ++++++ apps/desktop-gpui/src/editor_panels.rs | 7 +---- apps/desktop-gpui/src/editor_timeline.rs | 11 +++++--- apps/desktop-gpui/src/editor_window.rs | 2 +- crates/project/src/configuration.rs | 35 +++++++++++------------- 5 files changed, 34 insertions(+), 30 deletions(-) diff --git a/apps/desktop-gpui/src/editor_canvas.rs b/apps/desktop-gpui/src/editor_canvas.rs index 57c9bdb51a2..293ae3f9085 100644 --- a/apps/desktop-gpui/src/editor_canvas.rs +++ b/apps/desktop-gpui/src/editor_canvas.rs @@ -985,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!( @@ -1083,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); diff --git a/apps/desktop-gpui/src/editor_panels.rs b/apps/desktop-gpui/src/editor_panels.rs index 2d321ed3983..d62b208d1f3 100644 --- a/apps/desktop-gpui/src/editor_panels.rs +++ b/apps/desktop-gpui/src/editor_panels.rs @@ -5684,9 +5684,6 @@ impl EditorWindow { .into_any_element() } - /// The Keyframes section of the text and image panels: add a key at the - /// playhead, then any move, resize or opacity change at another time adds - /// or edits a key there. Each key is a chip: click to jump, × to remove. fn render_overlay_keyframes( &self, target: KeyframeTarget, @@ -9216,7 +9213,6 @@ pub enum ImageProperty { Rounding, } -/// Which overlay a Keyframes section edits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KeyframeTarget { Text(usize), @@ -9225,9 +9221,8 @@ enum KeyframeTarget { #[derive(Debug, Clone, Copy)] enum KeyframeAction { - /// Key the overlay's current look at this output time. Add(f64), - /// Remove the keys at this segment-relative time. + /// Segment-relative time. Remove(f64), Clear, } diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 3690e58888f..ebb36ebe9d7 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -127,7 +127,6 @@ const SEGMENT_PADDING_RIGHT: f32 = 10.; /// handles still own the outer pixels. pub const CAMERA3D_POSE_DOT: f32 = 6.; pub const CAMERA3D_POSE_DOT_INSET: f32 = 5.; -/// Side of the keyframe marks drawn on text and image boxes. const KEYFRAME_MARK: f32 = 4.; /// The waveform's ceiling inside a row, so it never climbs into the labels. const WAVEFORM_MAX_HEIGHT: f32 = 18.; @@ -2800,13 +2799,17 @@ fn render_segment( } } - // Overlay keyframes: small diamonds along the bottom of the box. if let SegmentDetail::Text { keyframes, .. } | SegmentDetail::Image { keyframes, .. } = &segment.detail { let duration = (segment.end - segment.start).max(0.0001); - for time in keyframes.iter() { - let x = ((time / duration).clamp(0., 1.) as f32) * width; + // Trims and splits keep keys outside the segment so the animation + // holds; those play no part in what is shown, so they get no mark. + for time in keyframes + .iter() + .filter(|time| (0.0..=duration).contains(*time)) + { + let x = ((time / duration) as f32) * width; fill = fill.child( div() .absolute() diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index ecbc4b4254b..ffb4bb4022d 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -2275,7 +2275,7 @@ impl EditorWindow { /// Re-derive the drawn model from the live config. Runs after every edit; /// the waveforms arrive separately and later, so whatever has landed is /// carried across. - fn rebuild_timeline(&mut self) { + pub(crate) fn rebuild_timeline(&mut self) { self.dismiss_transient_menus(); let mic = std::mem::take(&mut self.timeline.mic_waveforms); let system = std::mem::take(&mut self.timeline.system_waveforms); diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 9e6651c641e..7cf0669e5b5 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -1286,7 +1286,6 @@ impl OverlayKeyframes { } } - /// Removes every property's key at `time`. pub fn remove_at(&mut self, time: f64) { let keep = |t: f64| (t - time).abs() > OVERLAY_KEYFRAME_MATCH_SECS; self.position.retain(|k| keep(k.time)); @@ -1311,24 +1310,26 @@ impl OverlayKeyframes { } } +// Sampled for every property on every frame, so this scans the keys rather +// than sorting a copy. Ties resolve as a stable sort by time would. fn keyframe_span(keys: &[T], time: f64, key_time: impl Fn(&T) -> f64) -> Option<(&T, &T, f64)> { - let mut sorted: Vec<&T> = keys.iter().filter(|k| key_time(k).is_finite()).collect(); - sorted.sort_by(|a, b| key_time(a).total_cmp(&key_time(b))); - let first = *sorted.first()?; + let finite = || keys.iter().filter(|k| key_time(k).is_finite()); + let by_time = |a: &&T, b: &&T| key_time(a).total_cmp(&key_time(b)); + let first = finite().min_by(by_time)?; if time <= key_time(first) { return Some((first, first, 0.0)); } - for pair in sorted.windows(2) { - let (prev, next) = (pair[0], pair[1]); - if time <= key_time(next) { - let span = (key_time(next) - key_time(prev)).max(1e-6); - // Ease in and out between keys so motion starts and stops softly - let t = ((time - key_time(prev)) / span).clamp(0.0, 1.0); - return Some((prev, next, t * t * (3.0 - 2.0 * t))); - } - } - let last = *sorted.last()?; - Some((last, last, 0.0)) + let Some(next) = finite().filter(|k| time <= key_time(k)).min_by(by_time) else { + let last = finite().max_by(by_time)?; + return Some((last, last, 0.0)); + }; + let prev = finite() + .filter(|k| key_time(k) < key_time(next)) + .max_by(by_time)?; + let span = (key_time(next) - key_time(prev)).max(1e-6); + // Ease in and out between keys so motion starts and stops softly + let t = ((time - key_time(prev)) / span).clamp(0.0, 1.0); + Some((prev, next, t * t * (3.0 - 2.0 * t))) } fn upsert_vector_key(keys: &mut Vec, time: f64, value: XY) { @@ -1446,7 +1447,6 @@ impl TextSegment { (time - self.start).clamp(0., (self.end - self.start).max(0.)) } - /// Opacity at output `time`, keyframes applied. pub fn opacity_at(&self, time: f64) -> f64 { self.keyframes .sample( @@ -1469,7 +1469,6 @@ impl TextSegment { } } - /// Records the current position and opacity as a keyframe at `time`. /// Text size is not keyed: its box follows the font size. pub fn add_keyframe_at(&mut self, time: f64) { let (center, _, opacity) = self.keyframes.sample( @@ -1985,7 +1984,6 @@ impl ImageSegment { (time - self.start).clamp(0., (self.end - self.start).max(0.)) } - /// Opacity at output `time`, keyframes applied. pub fn opacity_at(&self, time: f64) -> f64 { self.keyframes .sample( @@ -2008,7 +2006,6 @@ impl ImageSegment { } } - /// Records the current position, size and opacity as a keyframe at `time`. pub fn add_keyframe_at(&mut self, time: f64) { let (center, size, opacity) = self.keyframes.sample( self.center,