From c1a5415a65c96bc4067539d12764c16c0d1f605b Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:16:01 +0000 Subject: [PATCH] fix(project): keep legacy captions at the timeline end --- crates/project/src/caption_timing.rs | 77 ++++++++++++++++++++++++++-- crates/verify/expectations.toml | 7 --- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/crates/project/src/caption_timing.rs b/crates/project/src/caption_timing.rs index aa7661afb46..63700fc2abd 100644 --- a/crates/project/src/caption_timing.rs +++ b/crates/project/src/caption_timing.rs @@ -369,7 +369,12 @@ pub fn synchronize_captions(project: &mut ProjectConfiguration, recording_durati if !captions.source_timed { let holds = timeline.hold_windows(); let mappings = build_source_to_edited_mappings(timeline, recording_durations); - let to_source = |output: f64| -> Option { + let output_end = timeline.duration() as f32; + let to_source = |output: f32| -> Option { + if output == output_end { + return mappings.last().map(|mapping| mapping.source_end as f32); + } + let output = f64::from(output); let effective = output - holds .iter() @@ -388,16 +393,16 @@ pub fn synchronize_captions(project: &mut ProjectConfiguration, recording_durati .segments .iter() .filter_map(|caption| { - let start = to_source(f64::from(caption.start))?; - let end = to_source(f64::from(caption.end))?; + let start = to_source(caption.start)?; + let end = to_source(caption.end)?; let words = caption .words .iter() .filter_map(|word| { Some(CaptionWord { text: word.text.clone(), - start: to_source(f64::from(word.start))?, - end: to_source(f64::from(word.end))?, + start: to_source(word.start)?, + end: to_source(word.end)?, }) }) .collect(); @@ -466,6 +471,68 @@ mod tests { assert_eq!(track[0].end, 10.0); } + #[test] + fn legacy_caption_migration_preserves_rounded_timeline_end() { + let durations = [41.0 / 15.0, 82.0 / 15.0]; + for (timescale, hold_duration) in [ + (1.0, 0.0), + (2.0, 0.0), + (0.5, 0.0), + (1.0, 0.1), + (1.0, 10000.1), + ] { + let mut timeline: TimelineConfiguration = serde_json::from_value(serde_json::json!({ + "segments": [ + {"recordingSegment": 0, "start": 0.0, "end": durations[0], "timescale": timescale}, + {"recordingSegment": 1, "start": 0.0, "end": durations[1], "timescale": timescale} + ], + "transitions": [{"segmentIndex": 1, "type": "cross-fade", "duration": 0.2}], + "zoomSegments": [] + })) + .unwrap(); + if hold_duration > 0.0 { + timeline.text_segments.push( + serde_json::from_value(serde_json::json!({ + "start": 1.0, "end": 1.0 + hold_duration, "layout": "fullscreen" + })) + .unwrap(), + ); + } + let end = timeline.duration() as f32; + let mut project = project(false, end - 0.5, end); + project.timeline = Some(timeline); + + synchronize_captions(&mut project, &durations); + + let captions = project.captions.as_ref().unwrap(); + assert_eq!(captions.segments.len(), 1, "{timescale}, {hold_duration}"); + let source_end = durations.iter().sum::() as f32; + assert_eq!(captions.segments[0].end, source_end); + assert_eq!(captions.segments[0].words[0].end, source_end); + let track = &project.timeline.as_ref().unwrap().caption_segments; + assert_eq!(track.len(), 1); + assert!((track[0].end - f64::from(end)).abs() <= f64::from(end * f32::EPSILON)); + let saved = serde_json::to_string(&project).unwrap(); + synchronize_captions(&mut project, &durations); + assert_eq!(serde_json::to_string(&project).unwrap(), saved); + } + } + + #[test] + fn legacy_caption_migration_rejects_times_beyond_rounded_timeline_end() { + let mut project = project(false, 8.0, 9.0_f32.next_up()); + synchronize_captions(&mut project, &[10.0]); + assert!(project.captions.as_ref().unwrap().segments.is_empty()); + assert!( + project + .timeline + .as_ref() + .unwrap() + .caption_segments + .is_empty() + ); + } + #[test] fn stale_caption_tracks_are_replaced_and_styles_preserved() { let mut project = project(true, 8.0, 9.0); diff --git a/crates/verify/expectations.toml b/crates/verify/expectations.toml index b6b97fa360a..511cd9bea32 100644 --- a/crates/verify/expectations.toml +++ b/crates/verify/expectations.toml @@ -172,13 +172,6 @@ bound = { count = 1, missing = 30 } reason = "NEW: same as editing/mapping/duration-covered, end to end: a 1 s fullscreen text at 5.4 s and the clip trimmed to 3.2 s give a 4.2 s timeline, but the export stops at the first unmapped frame: 96 frames instead of 126, and the text card is never rendered" link = "" -[[xfail]] -case = "captions/legacy-migration/ends-at-timeline-end" -class = "caption-migration" -bound = { count = 1 } -reason = "NEW (minor): loading a legacy (output-timed) project drops a caption that ends exactly at the timeline end: synchronize_captions maps each end through source_start + (end - start) / timescale, which rounds just below the timeline end after a clip transition (clips 41/15 s and 82/15 s, 0.2 s transition: 7.9999999 < 8.0), so the end does not map and the whole caption is discarded" -link = "" - [[xfail]] case = "playback/basic/cuts-speeds-reorder/realtime30-from25/avf" class = "stale"