diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index 5e5037c896c..07982ee9843 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -196,7 +196,15 @@ pub(crate) async fn apply_video_start_gate( offset_ns = offset_ns as i64, "Trimmed leading audio samples for encoder-pair alignment" ); - VideoStartGateAction::UseFrame(AudioFrame::new(trimmed, frame.timestamp)) + // Advance the timestamp to the first *committed* sample so that + // first_timestamp (and therefore mic_start_time in metadata) reflects + // the actual capture time after the trim, not the pre-trim buffer start. + // Without this, the editor's mic_offset = display_start - mic_start equals + // trim_duration and causes it to skip that same duration a second time. + let trim_duration = Duration::from_nanos( + trim_samples as u64 * 1_000_000_000 / sample_rate as u64, + ); + VideoStartGateAction::UseFrame(AudioFrame::new(trimmed, frame.timestamp + trim_duration)) } None => { warn!( @@ -4706,6 +4714,21 @@ mod tests { let expected_trim = ns_to_sample_count(video_start_ns, info.sample_rate) as usize; assert_eq!(new_frame.inner.samples(), 2048 - expected_trim); + // Timestamp must be advanced by the trim so that mic_start_time in + // metadata reflects the first committed sample, preventing the editor + // from double-counting the same gap as a skip offset. + let trim_duration = Duration::from_nanos( + expected_trim as u64 * 1_000_000_000 / info.sample_rate as u64, + ); + let expected_ts = Timestamp::Instant(start + trim_duration); + assert_eq!( + new_frame + .timestamp + .signed_duration_since_secs(master_clock.timestamps()), + expected_ts + .signed_duration_since_secs(master_clock.timestamps()), + "gate UseFrame timestamp must be advanced past the trimmed samples" + ); } other => panic!("expected UseFrame when audio leads, got {other:?}"), }