From ee49e589e60267b0958d171a694bced0798a1075 Mon Sep 17 00:00:00 2001 From: Jordan Speicher Date: Tue, 30 Jun 2026 21:59:31 -0500 Subject: [PATCH 1/2] Fix cross-segment FFmpeg concat seeking FFmpeg's concat demuxer reported an unknown duration for chained GoPro/DJI segments when the generated manifest only listed file entries. Seeking past the first segment could then fail or stall even when the underlying filesystem, including NFS, supported normal file seeks. Probe each segment duration while building the concat manifest and emit duration entries when all probes succeed. This gives FFmpeg a usable timeline for cross-segment preview scrubbing without adding filesystem-specific path handling. --- crates/reco-io/src/ffmpeg/decoder.rs | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/reco-io/src/ffmpeg/decoder.rs b/crates/reco-io/src/ffmpeg/decoder.rs index 2f04f4ad..e8716306 100644 --- a/crates/reco-io/src/ffmpeg/decoder.rs +++ b/crates/reco-io/src/ffmpeg/decoder.rs @@ -241,6 +241,40 @@ impl Drop for VideoDecoder { } } +fn container_duration_secs(path: &Path) -> Result, DecodeError> { + let ictx = input(path)?; + let duration = ictx.duration(); + if duration > 0 { + Ok(Some(duration as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE))) + } else { + Ok(None) + } +} + +fn concat_seek_durations(paths: &[std::path::PathBuf]) -> Option> { + let mut durations = Vec::with_capacity(paths.len()); + for path in paths { + match container_duration_secs(path) { + Ok(Some(duration)) => durations.push(duration), + Ok(None) => { + log::warn!( + "Concat demuxer: duration unknown for {}; cross-segment seek may be unavailable", + path.display() + ); + return None; + } + Err(e) => { + log::warn!( + "Concat demuxer: failed to probe duration for {} ({e}); cross-segment seek may be unavailable", + path.display() + ); + return None; + } + } + } + Some(durations) +} + impl VideoDecoder { /// Open a video file for decoding. /// @@ -509,12 +543,17 @@ impl VideoDecoder { .suffix(".txt") .tempfile() .map_err(|e| DecodeError::Ffmpeg(format!("concat manifest: {e}")))?; + let durations = concat_seek_durations(paths); writeln!(manifest, "ffconcat version 1.0") .map_err(|e| DecodeError::Ffmpeg(format!("write manifest: {e}")))?; - for p in paths { + for (idx, p) in paths.iter().enumerate() { writeln!(manifest, "file '{}'", p.display()) .map_err(|e| DecodeError::Ffmpeg(format!("write manifest: {e}")))?; + if let Some(durations) = durations.as_ref() { + writeln!(manifest, "duration {:.6}", durations[idx]) + .map_err(|e| DecodeError::Ffmpeg(format!("write manifest: {e}")))?; + } } manifest .flush() From 0fc0bc371c85bf724fdaeee374d64fde56990ed1 Mon Sep 17 00:00:00 2001 From: Jordan Speicher Date: Tue, 30 Jun 2026 22:04:39 -0500 Subject: [PATCH 2/2] Update GUI time display after seeks Manual movement paths updated only the current-frame property after seek or step operations, so the timeline slider moved while the current-time text stayed stale until playback advanced. Route step forward, step backward, relative seek, and debounced slider seek through sync_frame_display so frame count and time text update together after the new frame is rendered. --- crates/reco-gui/src/main.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/reco-gui/src/main.rs b/crates/reco-gui/src/main.rs index 090b6fc8..9288503f 100644 --- a/crates/reco-gui/src/main.rs +++ b/crates/reco-gui/src/main.rs @@ -2801,7 +2801,9 @@ fn main() -> anyhow::Result<()> { let img = s.render_current(); if let (Some(app), Some(img)) = (app_weak.upgrade(), img) { app.set_preview_frame(img); - app.set_current_frame(s.playback.frame_index() as i32); + let fps = s.playback.fps(); + let total = s.playback.total_frames().unwrap_or(0); + sync_frame_display(&app, s.playback.frame_index(), total, fps); } } Ok(false) => {} @@ -2831,7 +2833,9 @@ fn main() -> anyhow::Result<()> { let img = s.render_current(); if let (Some(app), Some(img)) = (app_weak.upgrade(), img) { app.set_preview_frame(img); - app.set_current_frame(s.playback.frame_index() as i32); + let fps = s.playback.fps(); + let total = s.playback.total_frames().unwrap_or(0); + sync_frame_display(&app, s.playback.frame_index(), total, fps); } } Err(e) => log::error!("Step backward error: {e}"), @@ -2948,7 +2952,9 @@ fn main() -> anyhow::Result<()> { let img = s.render_current(); if let (Some(app), Some(img)) = (app_weak.upgrade(), img) { app.set_preview_frame(img); - app.set_current_frame(s.playback.frame_index() as i32); + let fps = s.playback.fps(); + let total = s.playback.total_frames().unwrap_or(0); + sync_frame_display(&app, s.playback.frame_index(), total, fps); } }); @@ -4179,7 +4185,9 @@ fn main() -> anyhow::Result<()> { let img = s.render_current(); if let (Some(app), Some(img)) = (app_weak.upgrade(), img) { app.set_preview_frame(img); - app.set_current_frame(s.playback.frame_index() as i32); + let fps = s.playback.fps(); + let total = s.playback.total_frames().unwrap_or(0); + sync_frame_display(&app, s.playback.frame_index(), total, fps); s.last_render_at = Some(Instant::now()); } }