From bb519742403c5355a1a495e7f20ff0d4da3b0baf Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:04:16 +0000 Subject: [PATCH 1/3] fix(project): preserve fullscreen closing titles --- crates/project/src/configuration.rs | 58 ++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index f49a2d6e795..5e0fb1b158e 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -1949,7 +1949,11 @@ impl TimelineConfiguration { if let Some((hold_start, hold_end)) = active_hold_window(&holds, frame_time) { let effective = hold_start - held_time_before(&holds, hold_start); - let source = match self.get_frame_mapping_unheld(effective)? { + let mapping = self.get_frame_mapping_unheld(effective).or_else(|| { + // A closing title holds the last source instant before the exclusive endpoint. + self.get_frame_mapping_unheld(effective.next_down().max(0.0)) + })?; + let source = match mapping { TimelineFrameMapping::Single { source, .. } | TimelineFrameMapping::Hold { source, .. } => source, TimelineFrameMapping::Transition { incoming, .. } => incoming, @@ -3699,6 +3703,58 @@ mod tests { assert_eq!(segment.recording_clip, 1); } + #[test] + fn fullscreen_outro_maps_every_frame_to_the_last_clip() { + for transitions in [ + Vec::new(), + vec![ClipTransition { + segment_index: 1, + kind: ClipTransitionType::CrossFade, + duration: 1.0, + }], + ] { + let mut timeline = timeline_with_transitions(transitions); + timeline.segments[1].timescale = 2.0; + timeline.text_segments = vec![fullscreen_text(1.0, 3.0)]; + let outro_start = timeline.duration(); + let outro_end = outro_start + 3.0; + timeline + .text_segments + .push(fullscreen_text(outro_start, outro_end)); + let timeline: TimelineConfiguration = + serde_json::from_value(serde_json::to_value(&timeline).unwrap()).unwrap(); + + assert_eq!(timeline.duration(), outro_end); + for frame in 0..(outro_end * 30.0) as u32 { + let time = f64::from(frame) / 30.0; + let mapping = timeline.get_frame_mapping(time); + assert!(mapping.is_some(), "no source at output time {time}"); + if time >= outro_start { + assert!(matches!( + mapping, + Some(TimelineFrameMapping::Hold { source, output_end }) + if source.segment.recording_clip == 1 + && source.source_time > 16.0 - 1e-12 + && source.source_time < 16.0 + && output_end == outro_end + )); + } + } + assert!(timeline.get_frame_mapping(outro_end).is_none()); + } + } + + #[test] + fn fullscreen_title_does_not_create_a_source_for_an_empty_timeline() { + let mut timeline = timeline_with_transitions(Vec::new()); + timeline.text_segments = vec![fullscreen_text(0.0, 3.0)]; + timeline.segments.truncate(1); + timeline.segments[0].end = 0.0; + assert!(timeline.get_frame_mapping(1.0).is_none()); + timeline.segments.clear(); + assert!(timeline.get_frame_mapping(1.0).is_none()); + } + #[test] fn overlay_and_disabled_texts_do_not_hold() { let mut timeline = timeline_with_transitions(Vec::new()); From 7508a79e70fb705d36b5545ba1534464486e6c9a Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:38:35 +0000 Subject: [PATCH 2/3] fix(project): hold the last clip instant for closing titles --- crates/project/src/configuration.rs | 44 +++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 5e0fb1b158e..8624f25903a 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -1949,14 +1949,14 @@ impl TimelineConfiguration { if let Some((hold_start, hold_end)) = active_hold_window(&holds, frame_time) { let effective = hold_start - held_time_before(&holds, hold_start); - let mapping = self.get_frame_mapping_unheld(effective).or_else(|| { - // A closing title holds the last source instant before the exclusive endpoint. - self.get_frame_mapping_unheld(effective.next_down().max(0.0)) - })?; - let source = match mapping { - TimelineFrameMapping::Single { source, .. } - | TimelineFrameMapping::Hold { source, .. } => source, - TimelineFrameMapping::Transition { incoming, .. } => incoming, + let source = match self.get_frame_mapping_unheld(effective) { + Some( + TimelineFrameMapping::Single { source, .. } + | TimelineFrameMapping::Hold { source, .. }, + ) => source, + Some(TimelineFrameMapping::Transition { incoming, .. }) => incoming, + // A closing title starts at the clips' exclusive end, so it holds their last instant. + None => self.last_source()?, }; return Some(TimelineFrameMapping::Hold { source, @@ -2001,6 +2001,19 @@ impl TimelineConfiguration { }) } + fn last_source(&self) -> Option> { + let (segment_index, segment) = self + .segments + .iter() + .enumerate() + .rfind(|(_, segment)| segment.duration() > 0.0)?; + Some(TimelineSource { + source_time: segment.end.next_down(), + segment_index, + segment, + }) + } + fn get_frame_mapping_unheld(&self, frame_time: f64) -> Option> { if self.transitions.is_empty() { return self.get_segment_time_without_transitions(frame_time).map( @@ -3744,6 +3757,21 @@ mod tests { } } + #[test] + fn closing_title_after_an_intro_maps_despite_rounding() { + let mut timeline = timeline_with_transitions(Vec::new()); + timeline.segments.truncate(1); + timeline.segments[0].end = 1.2; + timeline.text_segments = vec![fullscreen_text(0.0, 3.0), fullscreen_text(4.2, 6.2)]; + for time in [4.2, 5.0, 6.1] { + assert!(matches!( + timeline.get_frame_mapping(time), + Some(TimelineFrameMapping::Hold { source, output_end }) + if source.segment_index == 0 && source.source_time < 1.2 && output_end == 6.2 + )); + } + } + #[test] fn fullscreen_title_does_not_create_a_source_for_an_empty_timeline() { let mut timeline = timeline_with_transitions(Vec::new()); From 76607506df2ad2f67751a44cd115b82f04fac519 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:37:16 +0000 Subject: [PATCH 3/3] fix(project): only closing titles at the clips' end take the last clip instant --- crates/project/src/configuration.rs | 20 +++++++++++++++----- crates/verify/tests/edits/mod.rs | 20 ++++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 8624f25903a..66381de2c74 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -1872,6 +1872,10 @@ pub struct CaptionTrackSegment { pub font_size_override: Option, } +/// How far a hold's start may sit from the clips' end, after float rounding, +/// and still count as a closing title. +const CLOSING_HOLD_TOLERANCE: f64 = 1e-9; + impl TimelineConfiguration { pub fn effective_transition(&self, segment_index: usize) -> Option { if segment_index == 0 || segment_index >= self.segments.len() { @@ -1956,7 +1960,10 @@ impl TimelineConfiguration { ) => source, Some(TimelineFrameMapping::Transition { incoming, .. }) => incoming, // A closing title starts at the clips' exclusive end, so it holds their last instant. - None => self.last_source()?, + None if (effective - self.clips_duration()).abs() <= CLOSING_HOLD_TOLERANCE => { + self.last_source()? + } + None => return None, }; return Some(TimelineFrameMapping::Hold { source, @@ -2125,8 +2132,13 @@ impl TimelineConfiguration { } pub fn duration(&self) -> f64 { + self.clips_duration() + self.held_duration() + } + + /// Output seconds covered by the clips, without fullscreen text holds. + fn clips_duration(&self) -> f64 { let segment_duration: f64 = self.segments.iter().map(TimelineSegment::duration).sum(); - let segment_duration = if self.transitions.is_empty() { + if self.transitions.is_empty() { segment_duration } else { segment_duration @@ -2134,9 +2146,7 @@ impl TimelineConfiguration { .filter_map(|segment_index| self.effective_transition(segment_index)) .map(|transition| transition.duration) .sum::() - }; - - segment_duration + self.held_duration() + } } /// Gapless-time windows covered by clips that hide the cursor, with diff --git a/crates/verify/tests/edits/mod.rs b/crates/verify/tests/edits/mod.rs index 53898427ab1..5f4f6448c95 100644 --- a/crates/verify/tests/edits/mod.rs +++ b/crates/verify/tests/edits/mod.rs @@ -18,7 +18,8 @@ //! - enabled fullscreen text segments are holds: the recording clock //! pauses for their output-time window (overlapping or touching windows //! merge), showing the frame at the hold start; the timeline grows by the -//! held time. +//! held time. A hold starting at the clips' end (a closing title) shows +//! the last clip's final frame; a hold past it shows nothing. #![allow(dead_code)] @@ -592,7 +593,22 @@ impl TimeModel { pub fn map(&self, o: f64) -> Option { if let Some(&(a, b)) = self.holds.iter().find(|&&(a, b)| o >= a && o < b) { - let source = self.base_map(a - self.held_before(a))?.shown(); + let e = a - self.held_before(a); + let source = match self.base_map(e) { + Some(mapping) => mapping.shown(), + None if (e - self.base_duration()).abs() <= 1e-9 => { + let (segment, s) = self + .segments + .iter() + .enumerate() + .rfind(|(_, s)| s.duration > 0.0)?; + Source { + segment, + time: s.end, + } + } + None => return None, + }; return Some(Mapping::Hold { source, output_end: b,