From 24785404c57542cff0eb77255da4694079a53d72 Mon Sep 17 00:00:00 2001 From: x1xhlol <185671340+x1xhlol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:54:41 +0000 Subject: [PATCH 1/3] perf(rendering): read recording metadata from container headers --- crates/rendering/src/project_recordings.rs | 308 ++++++++++++++++++++- 1 file changed, 304 insertions(+), 4 deletions(-) diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index 7d7718d5e93..e0fbdd9d248 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -1,12 +1,71 @@ use std::{ cell::RefCell, + ffi::CString, path::{Path, PathBuf}, + ptr, }; use cap_project::{AudioMeta, StudioRecordingMeta, VideoMeta}; +use ffmpeg::Rescale; use serde::Serialize; use specta::Type; +fn open_headers(path: &Path) -> Option { + let path = CString::new(path.to_str()?).ok()?; + let mut context = ptr::null_mut(); + let result = unsafe { + ffmpeg::ffi::avformat_open_input(&mut context, path.as_ptr(), ptr::null(), ptr::null_mut()) + }; + + if result < 0 { + return None; + } + + Some(unsafe { ffmpeg::format::context::Input::wrap(context) }) +} + +fn header_duration_secs(input: &ffmpeg::format::context::Input) -> Option { + let mut min_start = None; + let mut max_end = None; + let mut max_duration = 0_i64; + + for stream in input.streams() { + let time_base = stream.time_base(); + let start_time = stream.start_time(); + let duration = stream.duration(); + + if start_time != ffmpeg::ffi::AV_NOPTS_VALUE { + let start = start_time.rescale(time_base, ffmpeg::rescale::TIME_BASE); + min_start = Some(min_start.map_or(start, |min: i64| min.min(start))); + + if duration != ffmpeg::ffi::AV_NOPTS_VALUE { + let duration = unsafe { + ffmpeg::ffi::av_rescale_q_rnd( + duration, + time_base.into(), + ffmpeg::ffi::AV_TIME_BASE_Q, + ffmpeg::ffi::AVRounding::AV_ROUND_NEAR_INF, + ) + }; + let end = start + duration; + max_end = Some(max_end.map_or(end, |max: i64| max.max(end))); + } + } + + if duration != ffmpeg::ffi::AV_NOPTS_VALUE { + let duration = duration.rescale(time_base, ffmpeg::rescale::TIME_BASE); + max_duration = max_duration.max(duration); + } + } + + let duration = match (min_start, max_end) { + (Some(start), Some(end)) if end - start > max_duration => end - start, + _ => max_duration, + }; + + (duration > 0).then_some(duration as f64 / 1_000_000.0) +} + #[derive(Debug, Clone, Copy, Serialize, Type)] pub struct Video { pub duration: f64, @@ -23,12 +82,43 @@ impl Video { let input = cap_video_decode::ffmpeg::open_fragmented_input(path)?; Self::from_input(input.input(), start_time) } else { + if let Some(video) = + open_headers(path).and_then(|input| Self::from_headers(&input, start_time)) + { + return Ok(video); + } + let input = ffmpeg::format::input(path) .map_err(|error| format!("Failed to open video: {error}"))?; Self::from_input(&input, start_time) } } + fn from_headers(input: &ffmpeg::format::context::Input, start_time: f64) -> Option { + let stream = input.streams().best(ffmpeg::media::Type::Video)?; + let parameters = stream.parameters(); + let parameters = unsafe { &*parameters.as_ptr() }; + + if parameters.width <= 0 || parameters.height <= 0 { + return None; + } + + let rate = stream.avg_frame_rate(); + if rate.numerator() <= 0 || rate.denominator() <= 0 { + return None; + } + + let fps = rate.numerator() as f64 / rate.denominator() as f64; + + Some(Video { + duration: header_duration_secs(input)?, + width: parameters.width as u32, + height: parameters.height as u32, + fps: fps.round() as u32, + start_time, + }) + } + pub fn from_input( input: &ffmpeg::format::context::Input, start_time: f64, @@ -111,11 +201,37 @@ pub struct Audio { impl Audio { pub fn new(path: impl AsRef, start_time: f64) -> Result { - let input = ffmpeg::format::input(path.as_ref()) - .map_err(|e| format!("Failed to open audio: {e}"))?; + let path = path.as_ref(); + if let Some(audio) = + open_headers(path).and_then(|input| Self::from_headers(&input, start_time)) + { + return Ok(audio); + } + + let input = + ffmpeg::format::input(path).map_err(|e| format!("Failed to open audio: {e}"))?; Self::from_input(&input, start_time) } + fn from_headers(input: &ffmpeg::format::context::Input, start_time: f64) -> Option { + let stream = input.streams().best(ffmpeg::media::Type::Audio)?; + let parameters = stream.parameters(); + let parameters = unsafe { &*parameters.as_ptr() }; + let sample_rate = u32::try_from(parameters.sample_rate) + .ok() + .filter(|sample_rate| *sample_rate > 0)?; + let channels = u16::try_from(parameters.ch_layout.nb_channels) + .ok() + .filter(|channels| *channels > 0)?; + + Some(Audio { + duration: header_duration_secs(input)?, + sample_rate, + channels, + start_time, + }) + } + pub fn from_input( input: &ffmpeg::format::context::Input, start_time: f64, @@ -284,9 +400,129 @@ impl SegmentRecordings { #[cfg(test)] mod ordinary_media_access_tests { - use super::ProjectRecordingsMeta; + use super::{Audio, ProjectRecordingsMeta, Video, open_headers}; use cap_project::StudioRecordingMeta; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; + + fn write_video_packets( + encoder: &mut ffmpeg::codec::encoder::video::Encoder, + output: &mut ffmpeg::format::context::Output, + stream_index: usize, + output_time_base: ffmpeg::Rational, + ) { + let mut packet = ffmpeg::Packet::empty(); + while encoder.receive_packet(&mut packet).is_ok() { + packet.set_stream(stream_index); + packet.rescale_ts(encoder.time_base(), output_time_base); + packet.write_interleaved(output).unwrap(); + } + } + + fn write_audio_packets( + encoder: &mut ffmpeg::codec::encoder::audio::Encoder, + output: &mut ffmpeg::format::context::Output, + stream_index: usize, + output_time_base: ffmpeg::Rational, + ) { + let mut packet = ffmpeg::Packet::empty(); + while encoder.receive_packet(&mut packet).is_ok() { + packet.set_stream(stream_index); + packet.rescale_ts(encoder.time_base(), output_time_base); + packet.write_interleaved(output).unwrap(); + } + } + + fn write_test_video(path: &Path, codec_id: ffmpeg::codec::Id) { + ffmpeg::init().unwrap(); + let codec = ffmpeg::encoder::find(codec_id).unwrap_or_else(|| { + panic!("bundled FFmpeg has no encoder for video codec {codec_id:?}") + }); + let mut output = ffmpeg::format::output_as(path, "mp4").unwrap(); + let global_header = output + .format() + .flags() + .contains(ffmpeg::format::Flags::GLOBAL_HEADER); + let (stream_index, mut encoder) = { + let mut stream = output.add_stream(codec).unwrap(); + let stream_index = stream.index(); + stream.set_time_base((1, 30)); + stream.set_rate((30, 1)); + stream.set_avg_frame_rate((30, 1)); + let mut encoder = ffmpeg::codec::context::Context::new_with_codec(codec) + .encoder() + .video() + .unwrap(); + encoder.set_width(64); + encoder.set_height(48); + encoder.set_format(ffmpeg::format::Pixel::YUV420P); + encoder.set_frame_rate(Some((30, 1))); + encoder.set_time_base((1, 30)); + if global_header { + encoder.set_flags(ffmpeg::codec::Flags::GLOBAL_HEADER); + } + stream.set_parameters(&encoder); + let encoder = encoder.open_as(codec).unwrap(); + stream.set_parameters(&encoder); + (stream_index, encoder) + }; + output.write_header().unwrap(); + let output_time_base = output.stream(stream_index).unwrap().time_base(); + + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, 64, 48); + frame.data_mut(0).fill(16); + frame.data_mut(1).fill(128); + frame.data_mut(2).fill(128); + for pts in 0..45 { + frame.set_pts(Some(pts)); + encoder.send_frame(&frame).unwrap(); + write_video_packets(&mut encoder, &mut output, stream_index, output_time_base); + } + + encoder.send_eof().unwrap(); + write_video_packets(&mut encoder, &mut output, stream_index, output_time_base); + output.write_trailer().unwrap(); + } + + fn write_test_audio(path: &Path) { + ffmpeg::init().unwrap(); + let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::PCM_S16LE).unwrap(); + let mut output = ffmpeg::format::output_as(path, "wav").unwrap(); + + let mut encoder = ffmpeg::codec::context::Context::new_with_codec(codec) + .encoder() + .audio() + .unwrap(); + encoder.set_rate(48_000); + encoder.set_channel_layout(ffmpeg::ChannelLayout::STEREO); + encoder.set_format(ffmpeg::format::Sample::I16( + ffmpeg::format::sample::Type::Packed, + )); + encoder.set_time_base((1, 48_000)); + let mut encoder = encoder.open_as(codec).unwrap(); + let stream_index = { + let mut stream = output.add_stream(codec).unwrap(); + let stream_index = stream.index(); + stream.set_time_base((1, 48_000)); + stream.set_parameters(&encoder); + stream_index + }; + output.write_header().unwrap(); + let output_time_base = output.stream(stream_index).unwrap().time_base(); + + let mut frame = ffmpeg::frame::Audio::new( + ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Packed), + 72_000, + ffmpeg::ChannelLayout::STEREO, + ); + frame.set_rate(48_000); + frame.set_pts(Some(0)); + frame.data_mut(0).fill(0); + encoder.send_frame(&frame).unwrap(); + write_audio_packets(&mut encoder, &mut output, stream_index, output_time_base); + encoder.send_eof().unwrap(); + write_audio_packets(&mut encoder, &mut output, stream_index, output_time_base); + output.write_trailer().unwrap(); + } #[test] fn failed_project_refuses_before_opening_invalid_media() { @@ -300,4 +536,68 @@ mod ordinary_media_access_tests { }; assert!(error.contains("requested source failed")); } + + #[test] + fn h264_video_headers_match_full_probe() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("video.mp4"); + write_test_video(&path, ffmpeg::codec::Id::H264); + + let full_input = ffmpeg::format::input(&path).unwrap(); + let expected = Video::from_input(&full_input, 0.25).unwrap(); + let header_input = open_headers(&path).expect("header open should succeed"); + let actual = + Video::from_headers(&header_input, 0.25).expect("video headers should be sufficient"); + + assert_eq!(actual.width, expected.width); + assert_eq!(actual.height, expected.height); + assert_eq!(actual.fps, expected.fps); + assert_eq!(actual.duration, expected.duration); + assert_eq!(actual.start_time, expected.start_time); + } + + #[test] + fn mpeg4_video_falls_back_to_full_probe() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("video.mp4"); + write_test_video(&path, ffmpeg::codec::Id::MPEG4); + + let full_input = ffmpeg::format::input(&path).unwrap(); + let expected = Video::from_input(&full_input, 0.25).unwrap(); + let header_input = open_headers(&path).expect("header open should succeed"); + assert!(Video::from_headers(&header_input, 0.25).is_none()); + + let actual = Video::new(&path, 0.25).unwrap(); + assert_eq!(actual.width, expected.width); + assert_eq!(actual.height, expected.height); + assert_eq!(actual.fps, expected.fps); + assert_eq!(actual.duration, expected.duration); + assert_eq!(actual.start_time, expected.start_time); + } + + #[test] + fn audio_headers_match_full_probe() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("audio.wav"); + write_test_audio(&path); + + let full_input = ffmpeg::format::input(&path).unwrap(); + let expected = Audio::from_input(&full_input, 0.75).unwrap(); + let header_input = open_headers(&path).expect("header open should succeed"); + let actual = + Audio::from_headers(&header_input, 0.75).expect("audio headers should be sufficient"); + + assert_eq!(actual.sample_rate, expected.sample_rate); + assert_eq!(actual.channels, expected.channels); + assert_eq!(actual.duration, expected.duration); + assert_eq!(actual.start_time, expected.start_time); + } + + #[test] + fn nonexistent_video_still_returns_an_error() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing.mp4"); + + assert!(Video::new(path, 0.0).is_err()); + } } From 2b20ddd1030cab86187815201cd9857394fa289d Mon Sep 17 00:00:00 2001 From: x1xhlol <185671340+x1xhlol@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:56:57 +0000 Subject: [PATCH 2/3] perf(rendering): read AVAssetReader decoder dimensions from container headers --- crates/rendering/src/decoder/avassetreader.rs | 3 ++ crates/rendering/src/project_recordings.rs | 42 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/rendering/src/decoder/avassetreader.rs b/crates/rendering/src/decoder/avassetreader.rs index 2be5b47cd3b..f049802ee11 100644 --- a/crates/rendering/src/decoder/avassetreader.rs +++ b/crates/rendering/src/decoder/avassetreader.rs @@ -667,6 +667,9 @@ impl AVAssetReaderDecoder { if let Some((width, height)) = cap_video_decode::scripted::dimensions(&path) { return Ok((width, height, YuvMatrix::default())); } + if let Some(dimensions) = crate::project_recordings::header_video_metadata(&path) { + return Ok(dimensions); + } let input = format::input(&path).map_err(|error| error.to_string())?; let stream = input .streams() diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index e0fbdd9d248..933ed8ad1c9 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -10,7 +10,7 @@ use ffmpeg::Rescale; use serde::Serialize; use specta::Type; -fn open_headers(path: &Path) -> Option { +pub(crate) fn open_headers(path: &Path) -> Option { let path = CString::new(path.to_str()?).ok()?; let mut context = ptr::null_mut(); let result = unsafe { @@ -24,6 +24,29 @@ fn open_headers(path: &Path) -> Option { Some(unsafe { ffmpeg::format::context::Input::wrap(context) }) } +#[cfg(any(target_os = "macos", test))] +pub(crate) fn header_video_metadata(path: &Path) -> Option<(u32, u32, crate::cpu_yuv::YuvMatrix)> { + let input = open_headers(path)?; + let stream = input.streams().best(ffmpeg::media::Type::Video)?; + let parameters = stream.parameters(); + let raw_parameters = unsafe { &*parameters.as_ptr() }; + if raw_parameters.width <= 0 || raw_parameters.height <= 0 { + return None; + } + let video = ffmpeg::codec::Context::from_parameters(parameters) + .and_then(|context| context.decoder().video()) + .ok()?; + if video.width() == 0 || video.height() == 0 { + return None; + } + + Some(( + video.width(), + video.height(), + crate::cpu_yuv::YuvMatrix::from_color_space(video.color_space()), + )) +} + fn header_duration_secs(input: &ffmpeg::format::context::Input) -> Option { let mut min_start = None; let mut max_end = None; @@ -400,7 +423,7 @@ impl SegmentRecordings { #[cfg(test)] mod ordinary_media_access_tests { - use super::{Audio, ProjectRecordingsMeta, Video, open_headers}; + use super::{Audio, ProjectRecordingsMeta, Video, header_video_metadata, open_headers}; use cap_project::StudioRecordingMeta; use std::path::{Path, PathBuf}; @@ -575,6 +598,21 @@ mod ordinary_media_access_tests { assert_eq!(actual.start_time, expected.start_time); } + #[test] + fn header_video_metadata_requires_codecpar_dimensions() { + let directory = tempfile::tempdir().unwrap(); + let h264_path = directory.path().join("h264.mp4"); + let mpeg4_path = directory.path().join("mpeg4.mp4"); + write_test_video(&h264_path, ffmpeg::codec::Id::H264); + write_test_video(&mpeg4_path, ffmpeg::codec::Id::MPEG4); + + assert_eq!( + header_video_metadata(&h264_path).map(|(width, height, _)| (width, height)), + Some((64, 48)) + ); + assert_eq!(header_video_metadata(&mpeg4_path), None); + } + #[test] fn audio_headers_match_full_probe() { let directory = tempfile::tempdir().unwrap(); From b1e82e9d994d79959de5e4230e854d40bd8bb86c Mon Sep 17 00:00:00 2001 From: x1xhlol <185671340+x1xhlol@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:22:54 +0000 Subject: [PATCH 3/3] test(rendering): cover Ogg Opus and fragmented AAC header metadata --- crates/rendering/src/project_recordings.rs | 183 +++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index 933ed8ad1c9..ea9f7d30706 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -547,6 +547,140 @@ mod ordinary_media_access_tests { output.write_trailer().unwrap(); } + fn write_test_opus_ogg(path: &Path) { + ffmpeg::init().unwrap(); + let codec = ffmpeg::encoder::find_by_name("libopus") + .expect("bundled FFmpeg has no libopus encoder"); + let mut output = ffmpeg::format::output(path).unwrap(); + let (stream_index, mut encoder) = { + let mut stream = output.add_stream(codec).unwrap(); + let stream_index = stream.index(); + stream.set_time_base((1, 48_000)); + let mut encoder = ffmpeg::codec::context::Context::new_with_codec(codec) + .encoder() + .audio() + .unwrap(); + encoder.set_bit_rate(128_000); + encoder.set_rate(48_000); + encoder.set_format(ffmpeg::format::Sample::F32( + ffmpeg::format::sample::Type::Packed, + )); + encoder.set_channel_layout(ffmpeg::ChannelLayout::MONO); + encoder.set_time_base((1, 48_000)); + let encoder = encoder.open_as(codec).unwrap(); + stream.set_parameters(&encoder); + (stream_index, encoder) + }; + output.write_header().unwrap(); + let output_time_base = output.stream(stream_index).unwrap().time_base(); + + for frame_index in 0..75 { + let mut frame = ffmpeg::frame::Audio::new( + ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed), + 960, + ffmpeg::ChannelLayout::MONO, + ); + frame.set_rate(48_000); + frame.set_pts(Some(frame_index * 960)); + frame.data_mut(0).fill(0); + encoder.send_frame(&frame).unwrap(); + write_audio_packets(&mut encoder, &mut output, stream_index, output_time_base); + } + + encoder.send_eof().unwrap(); + write_audio_packets(&mut encoder, &mut output, stream_index, output_time_base); + output.write_trailer().unwrap(); + } + + fn write_test_fragmented_aac(path: &Path, frame_count: usize, write_trailer: bool) { + ffmpeg::init().unwrap(); + let codec = + ffmpeg::encoder::find_by_name("aac").expect("bundled FFmpeg has no AAC encoder"); + let mut output = ffmpeg::format::output_as(path, "mp4").unwrap(); + unsafe { + let context = output.as_mut_ptr(); + assert_eq!( + ffmpeg::ffi::av_opt_set( + (*context).priv_data, + c"movflags".as_ptr(), + c"frag_keyframe+empty_moov+default_base_moof+skip_trailer".as_ptr(), + 0, + ), + 0 + ); + assert_eq!( + ffmpeg::ffi::av_opt_set_int( + (*context).priv_data, + c"frag_duration".as_ptr(), + 2_000_000, + 0, + ), + 0 + ); + assert_eq!( + ffmpeg::ffi::av_opt_set_int(context.cast(), c"flush_packets".as_ptr(), 1, 0,), + 0 + ); + } + + let (stream_index, mut encoder) = { + let mut stream = output.add_stream(codec).unwrap(); + let stream_index = stream.index(); + stream.set_time_base((1, 48_000)); + let mut encoder = ffmpeg::codec::context::Context::new_with_codec(codec) + .encoder() + .audio() + .unwrap(); + encoder.set_bit_rate(320_000); + encoder.set_rate(48_000); + encoder.set_format(ffmpeg::format::Sample::F32( + ffmpeg::format::sample::Type::Planar, + )); + encoder.set_channel_layout(ffmpeg::ChannelLayout::STEREO); + encoder.set_time_base((1, 48_000)); + let encoder = encoder.open_as(codec).unwrap(); + stream.set_parameters(&encoder); + (stream_index, encoder) + }; + output.write_header().unwrap(); + let output_time_base = output.stream(stream_index).unwrap().time_base(); + + for frame_index in 0..frame_count { + let mut frame = ffmpeg::frame::Audio::new( + ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar), + 1024, + ffmpeg::ChannelLayout::STEREO, + ); + frame.set_rate(48_000); + frame.set_pts(Some((frame_index * 1024) as i64)); + frame.data_mut(0).fill(0); + frame.data_mut(1).fill(0); + encoder.send_frame(&frame).unwrap(); + write_audio_packets(&mut encoder, &mut output, stream_index, output_time_base); + } + + if write_trailer { + encoder.send_eof().unwrap(); + write_audio_packets(&mut encoder, &mut output, stream_index, output_time_base); + output.write_trailer().unwrap(); + } + } + + fn assert_audio_metadata_eq(actual: Audio, expected: Audio, source: &str) { + assert_eq!( + actual.duration, expected.duration, + "{source} duration differs: actual={actual:?}, expected={expected:?}" + ); + assert_eq!( + actual.sample_rate, expected.sample_rate, + "{source} sample rate differs: actual={actual:?}, expected={expected:?}" + ); + assert_eq!( + actual.channels, expected.channels, + "{source} channel count differs: actual={actual:?}, expected={expected:?}" + ); + } + #[test] fn failed_project_refuses_before_opening_invalid_media() { let meta: StudioRecordingMeta = serde_json::from_str( @@ -631,6 +765,55 @@ mod ordinary_media_access_tests { assert_eq!(actual.start_time, expected.start_time); } + #[test] + fn opus_ogg_headers_match_full_probe() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("audio-input.ogg"); + write_test_opus_ogg(&path); + + let full_input = ffmpeg::format::input(&path).unwrap(); + let expected = Audio::from_input(&full_input, 0.0).unwrap(); + let header_input = open_headers(&path).expect("header open should succeed"); + let from_headers = + Audio::from_headers(&header_input, 0.0).expect("Opus headers should be sufficient"); + assert_audio_metadata_eq(from_headers, expected, "Opus header metadata"); + + let actual = Audio::new(&path, 0.0).unwrap(); + assert_audio_metadata_eq(actual, expected, "Opus Audio::new metadata"); + } + + #[test] + fn fragmented_aac_m4a_headers_match_full_probe() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("audio-input.m4a"); + write_test_fragmented_aac(&path, 72, true); + + let full_input = ffmpeg::format::input(&path).unwrap(); + let expected = Audio::from_input(&full_input, 0.0).unwrap(); + let header_input = open_headers(&path).expect("header open should succeed"); + let from_headers = Audio::from_headers(&header_input, 0.0) + .expect("fragmented AAC M4A headers should be sufficient"); + assert_audio_metadata_eq(from_headers, expected, "fragmented AAC M4A header metadata"); + + let actual = Audio::new(&path, 0.0).unwrap(); + assert_audio_metadata_eq(actual, expected, "fragmented AAC M4A Audio::new metadata"); + } + + #[test] + fn incomplete_fragmented_aac_headers_fall_back_to_full_probe() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("incomplete.m4a"); + write_test_fragmented_aac(&path, 0, false); + + let header_input = open_headers(&path).expect("fragmented MP4 header should open"); + assert!(Audio::from_headers(&header_input, 0.0).is_none()); + + let full_input = ffmpeg::format::input(&path).unwrap(); + let expected = Audio::from_input(&full_input, 0.0).unwrap(); + let actual = Audio::new(&path, 0.0).unwrap(); + assert_audio_metadata_eq(actual, expected, "incomplete fragmented AAC M4A fallback"); + } + #[test] fn nonexistent_video_still_returns_an_error() { let directory = tempfile::tempdir().unwrap();