Skip to content

Commit ab07670

Browse files
committed
refactor(core): 统一处理负数 PTS
1 parent d534c3c commit ab07670

6 files changed

Lines changed: 163 additions & 87 deletions

File tree

crates/ffmpeg_audio/src/core/frame.rs

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,37 @@ impl<'a> AudioFrame<'a> {
105105
unsafe { (*self.ptr.as_ptr()).sample_rate }
106106
}
107107

108+
/// Converts a time span in microseconds to the corresponding number of samples based on the
109+
/// current frame sample rate
110+
pub(crate) fn calc_samples(&self, micros: i64) -> usize {
111+
let sample_rate = i64::from(self.frame_sample_rate());
112+
113+
if sample_rate > 0 && micros > 0 {
114+
let samples_i64 = ((micros * sample_rate) + 999_999) / 1_000_000;
115+
samples_i64 as usize
116+
} else {
117+
0
118+
}
119+
}
120+
121+
/// Returns the exact physical duration of this frame in microseconds.
122+
pub(crate) fn duration_micros(&self) -> i64 {
123+
let sample_rate = i64::from(self.frame_sample_rate());
124+
if sample_rate > 0 {
125+
(self.samples() as i64 * 1_000_000) / sample_rate
126+
} else {
127+
0
128+
}
129+
}
130+
131+
/// Returns the physical end time of this frame in microseconds.
132+
///
133+
/// Returns None if the frame lacks a valid start PTS.
134+
pub(crate) fn end_micros(&self) -> Option<i64> {
135+
self.pts_micros()
136+
.map(|start| start.saturating_add(self.duration_micros()))
137+
}
138+
108139
/// Returns the PTS in microseconds relative to the stream timeline origin, if available.
109140
pub(crate) fn pts_micros(&self) -> Option<i64> {
110141
let raw_pts = unsafe { (*self.ptr.as_ptr()).pts };
@@ -125,6 +156,12 @@ impl<'a> AudioFrame<'a> {
125156
})
126157
}
127158

159+
/// Returns the precise playback duration of this audio frame.
160+
#[must_use]
161+
pub fn duration(&self) -> Duration {
162+
Duration::from_micros(self.duration_micros().cast_unsigned())
163+
}
164+
128165
/// Returns the Presentation Timestamp (PTS) of this frame relative to the stream timeline
129166
/// origin, if available.
130167
///
@@ -135,23 +172,8 @@ impl<'a> AudioFrame<'a> {
135172
/// - `None` if the underlying frame lacks a valid PTS (`AV_NOPTS_VALUE`).
136173
#[must_use]
137174
pub fn pts(&self) -> Option<Duration> {
138-
let raw_pts = unsafe { (*self.ptr.as_ptr()).pts };
139-
if raw_pts == sys::AV_NOPTS_VALUE {
140-
return None;
141-
}
142-
let relative_pts = raw_pts.saturating_sub(self.timeline_origin_pts);
143-
144-
self.time_base
145-
.calc_duration(relative_pts)
146-
.map(|mut duration| {
147-
let sample_rate = self.frame_sample_rate();
148-
149-
if self.sample_offset > 0 && sample_rate > 0 {
150-
let offset_secs = self.sample_offset as f64 / f64::from(sample_rate);
151-
duration += Duration::from_secs_f64(offset_secs);
152-
}
153-
duration
154-
})
175+
self.pts_micros()
176+
.map(|micros| Duration::from_micros(micros.max(0).cast_unsigned()))
155177
}
156178

157179
/// Zero-copy extraction of raw PCM audio data directly from the underlying FFmpeg AVFrame.
@@ -213,6 +235,8 @@ mod tests {
213235
time::Duration,
214236
};
215237

238+
use ffmpeg_audio_sys::MICROSECONDS_Q;
239+
216240
use super::*;
217241

218242
#[test]
@@ -222,12 +246,7 @@ mod tests {
222246
raw_frame.nb_samples = 1_024;
223247
raw_frame.sample_rate = 48_000;
224248

225-
let time_base = TimeBase::try_new(sys::AVRational {
226-
num: 1,
227-
den: 1_000_000,
228-
})
229-
.unwrap();
230-
249+
let time_base = TimeBase::try_new(MICROSECONDS_Q).unwrap();
231250
let frame = AudioFrame::new(&raw const raw_frame, time_base)
232251
.with_timeline_origin(10_000_000)
233252
.with_offset(48);
@@ -241,4 +260,45 @@ mod tests {
241260
assert_eq!(no_pts_frame.pts_micros(), None);
242261
assert_eq!(no_pts_frame.pts(), None);
243262
}
263+
264+
#[test]
265+
fn test_frame_time_boundary_calculations() {
266+
let mut raw_frame = unsafe { mem::zeroed::<sys::AVFrame>() };
267+
raw_frame.pts = 10_000;
268+
raw_frame.nb_samples = 480;
269+
raw_frame.sample_rate = 48_000;
270+
271+
let time_base = TimeBase::try_new(MICROSECONDS_Q).unwrap();
272+
let frame = AudioFrame::new(&raw const raw_frame, time_base).with_timeline_origin(0);
273+
274+
assert_eq!(frame.duration_micros(), 10_000);
275+
assert_eq!(frame.end_micros(), Some(20_000));
276+
}
277+
278+
#[test]
279+
fn test_calc_samples_for_micros_with_ceiling() {
280+
let mut raw_frame = unsafe { mem::zeroed::<sys::AVFrame>() };
281+
raw_frame.sample_rate = 44_100;
282+
let time_base = TimeBase::try_new(sys::AVRational { num: 1, den: 1 }).unwrap();
283+
let frame = AudioFrame::new(&raw const raw_frame, time_base);
284+
285+
assert_eq!(frame.calc_samples(1_000_000), 44_100);
286+
assert_eq!(frame.calc_samples(1), 1);
287+
assert_eq!(frame.calc_samples(12), 1);
288+
}
289+
290+
#[test]
291+
fn test_frame_fallback_on_invalid_sample_rate() {
292+
let mut raw_frame = unsafe { mem::zeroed::<sys::AVFrame>() };
293+
raw_frame.nb_samples = 1024;
294+
raw_frame.pts = 1000;
295+
raw_frame.sample_rate = 0;
296+
297+
let time_base = TimeBase::try_new(MICROSECONDS_Q).unwrap();
298+
let frame = AudioFrame::new(&raw const raw_frame, time_base);
299+
300+
assert_eq!(frame.duration_micros(), 0);
301+
assert_eq!(frame.end_micros(), Some(1000));
302+
assert_eq!(frame.calc_samples(5_000_000), 0);
303+
}
244304
}

crates/ffmpeg_audio/src/core/time.rs

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
use std::time::Duration;
2-
31
use crate::{
42
AudioError,
53
Result,
@@ -48,31 +46,11 @@ impl TimeBase {
4846
/// The resulting value **can be negative** if the frame represents
4947
/// encoder delay or padding before the physical start of the track (0.0s).
5048
#[must_use]
51-
pub fn calc_micros(self, pts: i64) -> Option<i64> {
49+
pub(crate) fn calc_micros(self, pts: i64) -> Option<i64> {
5250
if pts == sys::AV_NOPTS_VALUE {
5351
return None;
5452
}
5553

5654
unsafe { Some(sys::av_rescale_q(pts, self.0, sys::MICROSECONDS_Q)) }
5755
}
58-
59-
/// Converts a PTS into a `Duration`.
60-
///
61-
/// This method is for high-level business logic, UI progress bars,
62-
/// or playback synchronization where time must be positive.
63-
///
64-
/// # Returns
65-
/// - `Some(Duration)` representing the clamped physical playback time.
66-
/// - `None` if the provided PTS is invalid (`sys::AV_NOPTS_VALUE`).
67-
///
68-
/// # Note
69-
/// If the underlying physical time evaluates to a negative value, it is
70-
/// clamped to `Duration::ZERO`.
71-
#[must_use]
72-
pub fn calc_duration(self, pts: i64) -> Option<Duration> {
73-
self.calc_micros(pts).map(|micros| {
74-
let safe_micros = micros.max(0) as u64;
75-
Duration::from_micros(safe_micros)
76-
})
77-
}
7856
}

crates/ffmpeg_audio/src/decode/demuxer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ impl Demuxer {
135135
if start_time == sys::AV_NOPTS_VALUE {
136136
0
137137
} else {
138-
start_time
138+
start_time.max(0)
139139
}
140140
}
141141
}

crates/ffmpeg_audio/src/decode/engine.rs

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,33 @@ impl DecodeEngine {
149149
loop {
150150
match self.decoder.receive_frame() {
151151
Ok(Some(frame)) => {
152-
let audio_frame = AudioFrame::new(frame, self.time_base)
152+
let mut audio_frame = AudioFrame::new(frame, self.time_base)
153153
.with_timeline_origin(self.timeline_origin_pts);
154-
self.current_pts = audio_frame.pts();
155154

155+
if let (Some(start_us), Some(end_us)) =
156+
(audio_frame.pts_micros(), audio_frame.end_micros())
157+
{
158+
if end_us <= 0 {
159+
#[cfg(feature = "tracing")]
160+
tracing::debug!(
161+
"Dropped preroll frame (start: {start_us} us, end: {end_us} us)"
162+
);
163+
continue;
164+
}
165+
166+
if start_us < 0 && audio_frame.offset() == 0 {
167+
let delta_us = 0 - start_us;
168+
let offset_samples = audio_frame.calc_samples(delta_us);
169+
170+
if offset_samples < audio_frame.samples() {
171+
audio_frame = audio_frame.with_offset(offset_samples);
172+
} else {
173+
continue;
174+
}
175+
}
176+
}
177+
178+
self.current_pts = audio_frame.pts();
156179
self.debug_verify();
157180
return Ok(Some(audio_frame));
158181
}
@@ -209,21 +232,10 @@ impl DecodeEngine {
209232
match self.receive_frame() {
210233
Ok(Some(frame)) => {
211234
if let Some(pts_us) = frame.pts_micros() {
212-
let sample_rate = i64::from(frame.frame_sample_rate());
213-
214-
let duration_us = if sample_rate > 0 {
215-
(frame.samples() as i64 * 1_000_000) / sample_rate
216-
} else {
217-
0
218-
};
219-
220-
if pts_us.saturating_add(duration_us) >= target_us {
235+
if pts_us.saturating_add(frame.duration_micros()) >= target_us {
221236
let delta_us = target_us.saturating_sub(pts_us).max(0);
222-
let offset_samples = if sample_rate > 0 {
223-
((delta_us * sample_rate) + 999_999) / 1_000_000
224-
} else {
225-
0
226-
} as usize;
237+
238+
let offset_samples = frame.calc_samples(delta_us);
227239

228240
if offset_samples >= frame.samples() {
229241
continue;
@@ -268,17 +280,10 @@ impl DecodeEngine {
268280
let frame_ptr = self.decoder.current_frame();
269281
let frame = AudioFrame::new(frame_ptr, self.time_base)
270282
.with_timeline_origin(self.timeline_origin_pts);
271-
let sample_rate = frame.frame_sample_rate();
272-
273-
frame.pts().map_or(Duration::ZERO, |pts| {
274-
if sample_rate > 0 {
275-
let frame_duration_us =
276-
(frame.samples() as u64).saturating_mul(1_000_000) / sample_rate as u64;
277-
pts.saturating_add(Duration::from_micros(frame_duration_us))
278-
} else {
279-
pts
280-
}
281-
})
283+
284+
frame
285+
.pts()
286+
.map_or(Duration::ZERO, |pts| pts.saturating_add(frame.duration()))
282287
}
283288

284289
/// Scans the audio stream to determine its exact total duration.
@@ -325,8 +330,12 @@ impl DecodeEngine {
325330
};
326331
let end_us = self.time_base.calc_micros(end_pts).unwrap_or(start_us);
327332

328-
min_start_us = Some(min_start_us.map_or(start_us, |m| m.min(start_us)));
329-
max_end_us = Some(max_end_us.map_or(end_us, |m| m.max(end_us)));
333+
let safe_start = start_us.max(0);
334+
let safe_end = end_us.max(0);
335+
336+
min_start_us =
337+
Some(min_start_us.map_or(safe_start, |m| m.min(safe_start)));
338+
max_end_us = Some(max_end_us.map_or(safe_end, |m| m.max(safe_end)));
330339
}
331340
},
332341
Ok(None) => break,
@@ -339,19 +348,12 @@ impl DecodeEngine {
339348
ScanMode::Frame => loop {
340349
match self.receive_frame() {
341350
Ok(Some(frame)) => {
342-
let sample_rate = i64::from(frame.frame_sample_rate());
343-
344-
let frame_duration_us = if sample_rate > 0 {
345-
(frame.samples() as i64 * 1_000_000) / sample_rate
346-
} else {
347-
0
348-
};
349-
350351
total_duration_us_fallback =
351-
total_duration_us_fallback.saturating_add(frame_duration_us);
352+
total_duration_us_fallback.saturating_add(frame.duration_micros());
352353

353-
if let Some(start_us) = frame.pts_micros() {
354-
let end_us = start_us.saturating_add(frame_duration_us);
354+
if let (Some(start_us), Some(end_us)) =
355+
(frame.pts_micros(), frame.end_micros())
356+
{
355357
min_start_us = Some(min_start_us.map_or(start_us, |m| m.min(start_us)));
356358
max_end_us = Some(max_end_us.map_or(end_us, |m| m.max(end_us)));
357359
}
94.6 KB
Binary file not shown.

crates/ffmpeg_audio/tests/integration_test.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ mod file_tests {
501501

502502
const AAC_SEEK_PATH: &str = "tests/assets/seek_test.aac";
503503
const MUTATION_AAC_PATH: &str = "tests/assets/format_mutation.aac";
504+
const NEGATIVE_PTS_MKV_PATH: &str = "tests/assets/negative_pts.mkv";
504505

505506
#[test]
506507
fn test_seek_accuracy() {
@@ -610,4 +611,39 @@ mod file_tests {
610611
RawAudioData::Packed(_) => panic!("AAC 应该被解码为 Planar 布局,却得到了 Packed"),
611612
}
612613
}
614+
615+
#[test]
616+
fn test_blocks_negative_pts_from_raw_container() {
617+
// ffmpeg -f lavfi -i "aevalsrc=sin(440*2*PI*t):s=48000:d=1" -c:a pcm_s16le
618+
// -output_ts_offset -0.1 -avoid_negative_ts disabled -y negative_pts.mkv
619+
let file = std::fs::File::open(NEGATIVE_PTS_MKV_PATH).unwrap();
620+
let mut reader = AudioReader::new(file).unwrap();
621+
622+
let mut total_samples = 0;
623+
let mut first_frame_pts = None;
624+
625+
while let Some(frame) = reader.receive_frame().unwrap() {
626+
if first_frame_pts.is_none() {
627+
first_frame_pts = frame.pts();
628+
629+
assert_eq!(
630+
first_frame_pts.unwrap().as_millis(),
631+
0,
632+
"First output frame is not aligned to 0ms"
633+
);
634+
}
635+
total_samples += frame.samples();
636+
}
637+
638+
// 1. The MKV container timebase is 1/1000s (millisecond precision).
639+
// 2. 1024 samples per packet at 48kHz (approx. 21.33ms).
640+
// 3. After millisecond quantization, the PTS of the 5th packet is marked as -15ms.
641+
// 4. Remove the first 4 packets (4096) and the first 15ms of the 5th packet (720 samples),
642+
// totaling 4816 samples removed.
643+
// 5. The final remaining valid sample count is 43184.
644+
assert_eq!(
645+
total_samples, 43_184,
646+
"Should trim exactly 0.1s of negative PTS data. Expected 43184 samples, got {total_samples}"
647+
);
648+
}
613649
}

0 commit comments

Comments
 (0)