From f7479b101b97cac8f80c6fef38c7e42693259aef Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:03:37 +0900 Subject: [PATCH 1/5] fix(timestamp): move skipped video frames back onto source time --- crates/timestamp/src/master_clock.rs | 237 +++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/crates/timestamp/src/master_clock.rs b/crates/timestamp/src/master_clock.rs index a203d4d08f..747375d105 100644 --- a/crates/timestamp/src/master_clock.rs +++ b/crates/timestamp/src/master_clock.rs @@ -170,6 +170,10 @@ pub struct SourceClockState { timing_adjust: i64, next_expected_ns: Option, last_cadence_output_ns: Option, + detect_skipped_frames: bool, + pending_lag_ns: Option, + skip_confirmed: bool, + skip_count: u64, snap_count: u64, hard_reset_count: u64, resync_count: u64, @@ -185,6 +189,10 @@ impl SourceClockState { timing_adjust: 0, next_expected_ns: None, last_cadence_output_ns: None, + detect_skipped_frames: false, + pending_lag_ns: None, + skip_confirmed: false, + skip_count: 0, snap_count: 0, hard_reset_count: 0, resync_count: 0, @@ -193,6 +201,16 @@ impl SourceClockState { } } + /// Frames that arrive a whole cadence slot late because the source skipped + /// frames are moved onto their real timestamp once the lag persists into the + /// next frame, instead of being snapped back a slot for the rest of the + /// recording. A single late frame, or a backlog that drains, is still + /// smoothed onto the cadence. + pub fn detecting_skipped_frames(mut self) -> Self { + self.detect_skipped_frames = true; + self + } + pub fn name(&self) -> &'static str { self.name } @@ -209,6 +227,14 @@ impl SourceClockState { self.next_expected_ns } + pub fn skip_count(&self) -> u64 { + self.skip_count + } + + pub fn lag_pending(&self) -> bool { + self.pending_lag_ns.is_some() + } + pub fn snap_count(&self) -> u64 { self.snap_count } @@ -278,6 +304,8 @@ impl SourceClockState { let mut ts_ns = raw_ns; let duration_ns = frame_duration_ns.min(i64::MAX as u64) as i64; + let pending_lag_ns = self.pending_lag_ns.take(); + let skip_confirmed = std::mem::take(&mut self.skip_confirmed); if !self.timing_set { self.timing_adjust = now_ns.saturating_sub(raw_ns); @@ -291,6 +319,17 @@ impl SourceClockState { self.hard_reset_count = self.hard_reset_count.saturating_add(1); self.resync_count = self.resync_count.saturating_add(1); outcome = SourceClockOutcome::HardReset; + } else if smooth + && diff < TS_SMOOTHING_THRESHOLD_NS + && self.skipped_frames( + raw_ns - expected, + duration_ns, + pending_lag_ns, + skip_confirmed, + ) + { + self.skip_confirmed = true; + self.skip_count = self.skip_count.saturating_add(1); } else if smooth && diff < TS_SMOOTHING_THRESHOLD_NS { // Snap jitter onto the expected cadence, but cap how far the // ladder may lead the source clock. Each snap re-anchors @@ -338,11 +377,33 @@ impl SourceClockState { } } + fn skipped_frames( + &mut self, + lag_ns: i64, + duration_ns: i64, + pending_lag_ns: Option, + skip_confirmed: bool, + ) -> bool { + let half_frame_ns = duration_ns / 2; + if !self.detect_skipped_frames || half_frame_ns == 0 || lag_ns < half_frame_ns { + return false; + } + if skip_confirmed + || pending_lag_ns.is_some_and(|previous| lag_ns >= previous - half_frame_ns) + { + return true; + } + self.pending_lag_ns = Some(lag_ns); + false + } + pub fn reset(&mut self) { self.timing_set = false; self.timing_adjust = 0; self.next_expected_ns = None; self.last_cadence_output_ns = None; + self.pending_lag_ns = None; + self.skip_confirmed = false; } } @@ -898,4 +959,180 @@ mod tests { ); assert_eq!(second.master_ns - first.master_ns, 47_000_000); } + + const FRAME_60_NS: u64 = 16_666_666; + + fn frame_raw_ns(frame: u64) -> i64 { + (frame * 1_000_000_000 / 60) as i64 + } + + fn replay(state: &mut SourceClockState, raws: &[i64], frame_ns: u64) -> Vec { + raws.iter() + .map(|&raw| { + state + .remap_ns(raw, raw + 20_000_000, frame_ns, true) + .master_ns + }) + .collect() + } + + fn skip_tracking() -> SourceClockState { + SourceClockState::new("video").detecting_skipped_frames() + } + + fn frames_with_skips(total: u64, skips: &[(u64, u64)]) -> Vec { + (0..total) + .filter(|frame| { + !skips + .iter() + .any(|&(start, count)| *frame >= start && *frame < start + count) + }) + .map(frame_raw_ns) + .collect() + } + + #[test] + fn skipped_frames_at_start_return_to_source_time() { + for dropped in [1, 2, 3, 4] { + let raws = frames_with_skips(240, &[(1, dropped)]); + let legacy = replay(&mut SourceClockState::new("legacy"), &raws, FRAME_60_NS); + let output = replay(&mut skip_tracking(), &raws, FRAME_60_NS); + + let lag = + (raws[239 - dropped as usize] as u64).abs_diff(legacy[239 - dropped as usize]); + assert!( + lag.abs_diff(dropped * FRAME_60_NS) < 1_000_000, + "legacy smoothing keeps {dropped} skipped frames as lag, got {lag}" + ); + for (index, (&raw, &mapped)) in raws.iter().zip(&output).enumerate().skip(2) { + assert!( + (raw as u64).abs_diff(mapped) < 1_000, + "dropped={dropped} frame {index}: {mapped} vs source {raw}" + ); + } + } + } + + #[test] + fn skipped_frames_beyond_smoothing_threshold_match_legacy() { + for dropped in [5, 8] { + let raws = frames_with_skips(240, &[(1, dropped)]); + let legacy = replay(&mut SourceClockState::new("legacy"), &raws, FRAME_60_NS); + assert_eq!(replay(&mut skip_tracking(), &raws, FRAME_60_NS), legacy); + } + } + + #[test] + fn first_frame_after_skip_is_pending_until_lag_persists() { + let raws = frames_with_skips(10, &[(3, 2)]); + let mut state = skip_tracking(); + let mut pending = Vec::new(); + let mut output = Vec::new(); + for raw in raws.iter().copied() { + output.push(state.remap_ns(raw, raw, FRAME_60_NS, true).master_ns); + pending.push(state.lag_pending()); + } + assert_eq!( + pending, + [false, false, false, true, false, false, false, false] + ); + assert_eq!(output[3], output[2] + FRAME_60_NS); + assert_eq!(output[4], raws[4] as u64); + assert_eq!(state.skip_count(), 1); + } + + #[test] + fn repeated_mid_recording_skips_never_accumulate_lag() { + let raws = frames_with_skips(600, &[(100, 1), (200, 3), (300, 2), (301 + 50, 4)]); + let output = replay(&mut skip_tracking(), &raws, FRAME_60_NS); + let last = raws.len() - 1; + assert!((raws[last] as u64).abs_diff(output[last]) < 1_000); + for pair in output.windows(2) { + assert!(pair[1] > pair[0]); + } + } + + #[test] + fn skip_tracking_is_identical_to_legacy_without_skips() { + for nominal in [24.0, 30.0, 60.0] { + let frame_ns = (1_000_000_000.0 / nominal) as u64; + for seed in 0..20 { + let mut rng = TestRng(0x5EED + seed); + let raws: Vec = (0..600) + .map(|frame| { + let jitter = (rng.next_f64() * 2.0 - 1.0) * 0.24 * frame_ns as f64; + (frame as f64 * 1_000_000_000.0 / nominal + jitter).max(0.0) as i64 + }) + .collect(); + assert_eq!( + replay(&mut skip_tracking(), &raws, frame_ns), + replay(&mut SourceClockState::new("legacy"), &raws, frame_ns), + "nominal={nominal} seed={seed}" + ); + } + } + } + + #[test] + fn single_late_frame_is_still_smoothed() { + let mut raws: Vec = (0..120).map(frame_raw_ns).collect(); + raws[40] += 12_000_000; + raws[80] += 60_000_000; + let output = replay(&mut skip_tracking(), &raws, FRAME_60_NS); + assert_eq!( + output, + replay(&mut SourceClockState::new("legacy"), &raws, FRAME_60_NS) + ); + assert_eq!(output[40], output[39] + FRAME_60_NS); + } + + #[test] + fn delivery_backlog_draining_is_still_smoothed() { + for stall_ms in [20, 40, 60] { + let stall_ns = stall_ms * 1_000_000; + let resume = frame_raw_ns(30) + stall_ns; + let raws: Vec = (0..120) + .map(|frame| { + let raw = frame_raw_ns(frame); + if frame >= 30 { + raw.max(resume + (frame as i64 - 30) * 100_000) + } else { + raw + } + }) + .collect(); + let output = replay(&mut skip_tracking(), &raws, FRAME_60_NS); + assert_eq!( + output, + replay(&mut SourceClockState::new("legacy"), &raws, FRAME_60_NS), + "stall={stall_ms}ms" + ); + } + } + + #[test] + fn slow_source_tracks_source_time_after_first_skip() { + let raws: Vec = (0..60).map(|frame| frame * 66_666_666).collect(); + let output = replay(&mut skip_tracking(), &raws, 33_333_333); + for (index, (&raw, &mapped)) in raws.iter().zip(&output).enumerate().skip(2) { + assert_eq!(mapped, raw as u64, "frame {index}"); + } + } + + #[test] + fn skip_tracking_keeps_fast_source_bounds() { + for nominal in [24.0, 30.0, 60.0] { + let frame_ns = (1_000_000_000.0 / nominal) as u64; + for actual in [60.0, 90.0, 120.0, 240.0, 1000.0] { + let raws: Vec = (0..((1.5 * actual) as i64)) + .map(|frame| (frame as f64 * 1_000_000_000.0 / actual) as i64) + .collect(); + assert_eq!( + replay(&mut skip_tracking(), &raws, frame_ns), + replay(&mut SourceClockState::new("legacy"), &raws, frame_ns), + "nominal={nominal} actual={actual}" + ); + } + } + } } From 11d2491102ae7221bbb53046866e93375d147ad0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:03:37 +0900 Subject: [PATCH 2/5] fix(recording): keep startup frame drops from shifting video ahead of audio --- crates/recording/src/output_pipeline/core.rs | 145 ++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index c4f5991b85..f5f79b7c1d 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -1146,6 +1146,7 @@ struct VideoDriftTracker { #[cfg(target_os = "linux")] capture_timeline: CaptureInstantTimeline, anchor: Option<(f64, f64)>, + anchor_deferred: bool, capped_frame_count: u64, clamp_warning_logged: bool, } @@ -1156,11 +1157,19 @@ impl VideoDriftTracker { #[cfg(target_os = "linux")] capture_timeline: CaptureInstantTimeline::default(), anchor: None, + anchor_deferred: false, capped_frame_count: 0, clamp_warning_logged: false, } } + // The anchor freezes its frame's source time into every later timestamp, so + // it must not land on a frame the source clock still holds a slot behind a + // skip; the next frame settles onto the real source time. + fn defer_anchor(&mut self, deferred: bool) { + self.anchor_deferred = deferred; + } + // Post-warmup video PTS are pinned to the wall clock, but anchored at the // warmup boundary's *source content time* rather than rebased onto the // absolute wall clock. The capture pipeline's startup latency makes the @@ -1185,7 +1194,9 @@ impl VideoDriftTracker { let wall_clock_secs = wall_clock_elapsed.as_secs_f64(); let max_allowed_secs = wall_clock_secs + VIDEO_WALL_CLOCK_TOLERANCE_SECS; - if self.anchor.is_none() && (wall_clock_secs < 2.0 || camera_secs < 2.0) { + if self.anchor.is_none() + && (self.anchor_deferred || wall_clock_secs < 2.0 || camera_secs < 2.0) + { let result_secs = camera_secs.min(max_allowed_secs); if result_secs < camera_secs { self.capped_frame_count += 1; @@ -2944,7 +2955,7 @@ fn spawn_video_encoder, TVideo: V let mut frame_count = 0u64; let mut anomaly_tracker = TimestampAnomalyTracker::new("video"); let mut drift_tracker = VideoDriftTracker::new(); - let mut source_clock = SourceClockState::new("video"); + let mut source_clock = SourceClockState::new("video").detecting_skipped_frames(); let mut dropped_during_pause: u64 = 0; let mut last_frame = None; let mut first_frame_offset = None; @@ -3083,6 +3094,7 @@ fn spawn_video_encoder, TVideo: V "Timeline resync detected (anomaly collapsed jump); wall-clock anchor covers the gap" ); } + drift_tracker.defer_anchor(source_clock.lag_pending()); let duration = calculate_video_timestamp( &mut drift_tracker, timestamp, @@ -3210,6 +3222,7 @@ fn spawn_video_encoder, TVideo: V } let resynced = anomaly_tracker.take_resync_flag(); + drift_tracker.defer_anchor(source_clock.lag_pending()); let duration = calculate_video_timestamp( &mut drift_tracker, timestamp, @@ -4585,6 +4598,134 @@ pub trait VideoMuxer: Muxer { mod tests { use super::*; + #[cfg(not(target_os = "linux"))] + mod video_skipped_frames { + use super::*; + + const FRAME_NS: u64 = 16_666_666; + const LATENCY: Duration = Duration::from_millis(30); + + fn source_frames(total: u64, skips: &[(u64, u64)]) -> Vec { + (0..total) + .filter(|frame| { + !skips + .iter() + .any(|&(start, count)| *frame >= start && *frame < start + count) + }) + .map(|frame| Duration::from_nanos(frame * 1_000_000_000 / 60)) + .collect() + } + + fn mux_chain(frames: &[Duration], mut source: SourceClockState) -> Vec { + let timestamps = Timestamps::now(); + let master = MasterClock::new(timestamps, 48_000); + let mut anomaly = TimestampAnomalyTracker::new("video"); + let mut drift = VideoDriftTracker::new(); + frames + .iter() + .map(|&elapsed| { + let timestamp = Timestamp::Instant(timestamps.instant() + elapsed); + let remap = remap_video_timestamp(&mut source, &master, timestamp, FRAME_NS); + let wall = elapsed + LATENCY; + let raw_duration = anomaly + .process_timestamp( + Timestamp::Instant(timestamps.instant() + remap.duration()), + timestamps, + wall, + ) + .unwrap(); + drift.defer_anchor(source.lag_pending()); + calculate_video_timestamp( + &mut drift, + timestamp, + raw_duration, + wall, + anomaly.take_resync_flag() + || matches!(remap.outcome, SourceClockOutcome::HardReset), + ) + }) + .collect() + } + + fn settled_offset_ms(frames: &[Duration], output: &[Duration]) -> f64 { + let index = frames + .iter() + .position(|frame| *frame >= Duration::from_secs(5)) + .unwrap(); + (output[index].as_secs_f64() - frames[index].as_secs_f64()) * 1000.0 + } + + fn video_source() -> SourceClockState { + SourceClockState::new("video").detecting_skipped_frames() + } + + #[test] + fn startup_skips_keep_video_on_source_time_after_anchor() { + let reference = source_frames(420, &[]); + let reference_offset = + settled_offset_ms(&reference, &mux_chain(&reference, video_source())); + for dropped in [1, 2, 4, 5, 8] { + let frames = source_frames(420, &[(1, dropped)]); + let offset = settled_offset_ms(&frames, &mux_chain(&frames, video_source())); + assert!( + (offset - reference_offset).abs() < 0.5, + "dropped={dropped}: settled offset {offset:.3}ms vs {reference_offset:.3}ms" + ); + } + } + + #[test] + fn startup_skips_without_detection_shift_video_early() { + let reference = source_frames(420, &[]); + let reference_offset = settled_offset_ms( + &reference, + &mux_chain(&reference, SourceClockState::new("legacy")), + ); + for dropped in [1, 2, 4] { + let frames = source_frames(420, &[(1, dropped)]); + let offset = settled_offset_ms( + &frames, + &mux_chain(&frames, SourceClockState::new("legacy")), + ); + let shift = reference_offset - offset; + assert!( + (shift - dropped as f64 * FRAME_NS as f64 / 1e6).abs() < 0.5, + "dropped={dropped}: shift {shift:.3}ms" + ); + } + } + + #[test] + fn skip_on_anchor_frame_defers_anchor_to_source_time() { + let reference = source_frames(420, &[]); + let reference_offset = + settled_offset_ms(&reference, &mux_chain(&reference, video_source())); + for start in 118..124 { + for dropped in [1, 2, 4] { + let frames = source_frames(420, &[(start, dropped)]); + let output = mux_chain(&frames, video_source()); + let offset = settled_offset_ms(&frames, &output); + assert!( + (offset - reference_offset).abs() < 0.5, + "skip {start}+{dropped}: settled offset {offset:.3}ms vs {reference_offset:.3}ms" + ); + for pair in output.windows(2) { + assert!(pair[1] > pair[0]); + } + } + } + } + + #[test] + fn timeline_without_skips_matches_legacy_smoothing() { + let frames = source_frames(420, &[]); + assert_eq!( + mux_chain(&frames, video_source()), + mux_chain(&frames, SourceClockState::new("legacy")) + ); + } + } + #[cfg(target_os = "linux")] mod video_cadence { use super::*; From e91f15e05a3754f8f3ba42b2b20d2efb0ca1e0cd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:48:41 +0900 Subject: [PATCH 3/5] fix(timestamp): keep draining callback-stamped backlogs on the cadence --- crates/timestamp/src/master_clock.rs | 72 ++++++++++++++++++---------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/crates/timestamp/src/master_clock.rs b/crates/timestamp/src/master_clock.rs index 747375d105..7072196afe 100644 --- a/crates/timestamp/src/master_clock.rs +++ b/crates/timestamp/src/master_clock.rs @@ -202,10 +202,11 @@ impl SourceClockState { } /// Frames that arrive a whole cadence slot late because the source skipped - /// frames are moved onto their real timestamp once the lag persists into the - /// next frame, instead of being snapped back a slot for the rest of the - /// recording. A single late frame, or a backlog that drains, is still - /// smoothed onto the cadence. + /// frames are moved onto their real timestamp once the next frame shows the + /// source kept its cadence (the lag did not shrink), instead of being + /// snapped back a slot for the rest of the recording. A single late frame, + /// or a backlog that drains, is still smoothed onto the cadence: sources + /// stamped on callback arrival (Windows cameras) report a stall that way. pub fn detecting_skipped_frames(mut self) -> Self { self.detect_skipped_frames = true; self @@ -389,7 +390,7 @@ impl SourceClockState { return false; } if skip_confirmed - || pending_lag_ns.is_some_and(|previous| lag_ns >= previous - half_frame_ns) + || pending_lag_ns.is_some_and(|previous| lag_ns >= previous - duration_ns / 8) { return true; } @@ -1088,28 +1089,51 @@ mod tests { #[test] fn delivery_backlog_draining_is_still_smoothed() { - for stall_ms in [20, 40, 60] { - let stall_ns = stall_ms * 1_000_000; - let resume = frame_raw_ns(30) + stall_ns; - let raws: Vec = (0..120) - .map(|frame| { - let raw = frame_raw_ns(frame); - if frame >= 30 { - raw.max(resume + (frame as i64 - 30) * 100_000) - } else { - raw - } - }) - .collect(); - let output = replay(&mut skip_tracking(), &raws, FRAME_60_NS); - assert_eq!( - output, - replay(&mut SourceClockState::new("legacy"), &raws, FRAME_60_NS), - "stall={stall_ms}ms" - ); + for (fps, spacings_us) in [ + (60, [100, 5_000, 10_000, 14_000]), + (30, [100, 10_000, 20_000, 28_000]), + ] { + let frame_ns = 1_000_000_000 / fps; + for stall_ms in [20, 40, 60] { + for spacing_us in spacings_us { + let resume = 30 * frame_ns as i64 + stall_ms * 1_000_000; + let raws: Vec = (0..(4 * fps as i64)) + .map(|frame| { + let raw = frame * frame_ns as i64; + if frame >= 30 { + raw.max(resume + (frame - 30) * spacing_us * 1_000) + } else { + raw + } + }) + .collect(); + let mut state = skip_tracking(); + let output = replay(&mut state, &raws, frame_ns); + assert_eq!( + output, + replay(&mut SourceClockState::new("legacy"), &raws, frame_ns), + "fps={fps} stall={stall_ms}ms spacing={spacing_us}us" + ); + assert_eq!(state.skip_count(), 0); + } + } } } + #[test] + fn skipped_frames_with_timestamp_jitter_return_to_source_time() { + let mut rng = TestRng(0xD12); + let raws: Vec = frames_with_skips(240, &[(1, 2), (150, 3)]) + .into_iter() + .map(|raw| raw + ((rng.next_f64() * 2.0 - 1.0) * 500_000.0) as i64) + .collect(); + let mut state = skip_tracking(); + let output = replay(&mut state, &raws, FRAME_60_NS); + assert_eq!(state.skip_count(), 2); + let last = raws.len() - 1; + assert!((raws[last] as u64).abs_diff(output[last]) < 1_000_000); + } + #[test] fn slow_source_tracks_source_time_after_first_skip() { let raws: Vec = (0..60).map(|frame| frame * 66_666_666).collect(); From 962f99f1a6d2ac229c2a04539e66dfd0e6f67201 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:01:46 +0900 Subject: [PATCH 4/5] fix(recording): detect skipped video frames only on macOS capture timestamps --- crates/recording/src/output_pipeline/core.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index f5f79b7c1d..0a43594779 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -2955,7 +2955,10 @@ fn spawn_video_encoder, TVideo: V let mut frame_count = 0u64; let mut anomaly_tracker = TimestampAnomalyTracker::new("video"); let mut drift_tracker = VideoDriftTracker::new(); - let mut source_clock = SourceClockState::new("video").detecting_skipped_frames(); + let source_clock = SourceClockState::new("video"); + #[cfg(target_os = "macos")] + let source_clock = source_clock.detecting_skipped_frames(); + let mut source_clock = source_clock; let mut dropped_during_pause: u64 = 0; let mut last_frame = None; let mut first_frame_offset = None; From 31be1d2cbdd346f248caea29dbdf8120c8939744 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:01:46 +0900 Subject: [PATCH 5/5] docs(timestamp): note skipped-frame detection needs capture-time stamps --- crates/timestamp/src/master_clock.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/timestamp/src/master_clock.rs b/crates/timestamp/src/master_clock.rs index 7072196afe..239a315913 100644 --- a/crates/timestamp/src/master_clock.rs +++ b/crates/timestamp/src/master_clock.rs @@ -205,8 +205,10 @@ impl SourceClockState { /// frames are moved onto their real timestamp once the next frame shows the /// source kept its cadence (the lag did not shrink), instead of being /// snapped back a slot for the rest of the recording. A single late frame, - /// or a backlog that drains, is still smoothed onto the cadence: sources - /// stamped on callback arrival (Windows cameras) report a stall that way. + /// or a backlog that drains, is still smoothed onto the cadence. Only for + /// sources stamped at capture time: on sources stamped on callback arrival + /// (Windows cameras), scheduling jitter can persist for a frame and read as + /// a skip. pub fn detecting_skipped_frames(mut self) -> Self { self.detect_skipped_frames = true; self