//! `VideoFileDecoder::open_audio` — a container opened for its sound, and a //! container that has no picture at all. //! //! Every opener behind `open` asks the container for a video track before it //! looks at an audio one, so a file with only sound in it could not be opened //! for the sound it had. `open_audio` is the second door: the audio stream is //! configured and no picture is negotiated. //! //! The fixtures are built here, in process, by the in-repo encoder: nothing //! in this tree writes an audio-only container (the encoder refuses a clip //! with no picture, and the audio encoder writes Ogg Vorbis), so the //! picture-less case is made by striking the video track out of a clip this //! test just encoded. The surgery is twenty lines of box walking and it //! asserts on its own output, so a demuxer that dislikes the doctored file is //! told apart from a bug in the door. //! //! Runs for real on the two desktop platforms with a decoder behind this //! facade. #![cfg(any(target_os = "windows", target_os = "macos"))] use makepad_video::{ PcmAudioTrackOptions, VideoFileCodec, VideoFileDecoder, VideoFileEncoder, VideoFileEncoderOptions, }; const WIDTH: u32 = 64; const HEIGHT: u32 = 48; const FPS: u32 = 30; const FRAME_COUNT: usize = 30; const AUDIO_RATE: u32 = 48_000; const AUDIO_CHANNELS: u16 = 2; /// One directory per test: they run at once and each clears up after /// itself, so a shared one is a test deleting another's fixture. fn dir(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir() .join(format!("makepad-audio-only-{}-{name}", std::process::id())); std::fs::create_dir_all(&dir).expect("fixture dir"); dir } /// A one-second clip: a flat grey picture and a tone, or a flat grey picture /// alone when `with_audio` is false. fn encode_clip(path: &std::path::Path, with_audio: bool) { let mut encoder = VideoFileEncoder::new( path.to_str().expect("utf8 path"), VideoFileEncoderOptions { codec: VideoFileCodec::H264, width: WIDTH, height: HEIGHT, fps_num: FPS, fps_den: 1, video_bitrate_bps: 2_000_000, audio: with_audio.then_some(PcmAudioTrackOptions { sample_rate: AUDIO_RATE, channels: AUDIO_CHANNELS, aac_bitrate_bps: 128_000, }), keyframe_only: false, ..Default::default() }, ) .expect("file encoder creation"); let frame = vec![128u8; WIDTH as usize * HEIGHT as usize * 3]; for _ in 0..FRAME_COUNT { encoder.push_frame_rgb8(&frame, None).expect("push frame"); } if with_audio { let total = AUDIO_RATE as usize * AUDIO_CHANNELS as usize; let samples: Vec = (0..total) .map(|i| (((i / AUDIO_CHANNELS as usize) % 200) as i16 - 100) * 160) .collect(); encoder.push_audio_i16(&samples).expect("push audio"); } encoder.finish().expect("finish"); } /// Overwrite the type of the container's video track with `free`, the skip /// box every demuxer ignores. Nothing moves and the file keeps its length, so /// every offset in the audio track's sample table stays where it was. fn strike_the_picture(bytes: &mut [u8]) -> bool { fn boxes(bytes: &[u8], mut at: usize, end: usize, mut each: impl FnMut(usize, &[u8; 4], usize, usize)) { while at + 8 <= end { let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; let kind: [u8; 4] = bytes[at + 4..at + 8].try_into().unwrap(); let (body, next) = match size { 0 => (at + 8, end), 1 if at + 16 <= end => { let large = u64::from_be_bytes(bytes[at + 8..at + 16].try_into().unwrap()); (at + 16, (at + large as usize).min(end)) } _ if size >= 8 => (at + 8, (at + size).min(end)), _ => return, }; each(at, &kind, body, next); if next <= at { return; } at = next; } } // moov -> trak -> mdia -> hdlr, whose handler type sits four bytes into // the full box's payload. let mut struck = None; let end = bytes.len(); boxes(bytes, 0, end, |at, kind, body, next| { if kind != b"moov" { return; } boxes(bytes, body, next, |trak_at, kind, body, next| { if kind != b"trak" { return; } boxes(bytes, body, next, |_, kind, body, next| { if kind != b"mdia" { return; } boxes(bytes, body, next, |_, kind, body, _| { if kind == b"hdlr" && body + 12 <= bytes.len() && &bytes[body + 8..body + 12] == b"vide" { struck = Some(trak_at); } }); }); }); let _ = at; }); let Some(at) = struck else { return false }; bytes[at + 4..at + 8].copy_from_slice(b"free"); true } /// The precondition everything else rests on: this encoder and this decoder /// agree about a soundtrack on THIS machine. If it fails, the fixtures are /// the problem and the door below is not on trial. #[test] fn the_encoder_writes_a_soundtrack_this_decoder_can_read_back() { let dir = dir("readback"); let path = dir.join("both.mp4"); encode_clip(&path, true); let mut decoder = VideoFileDecoder::open(path.to_str().unwrap()).expect("open"); assert!(decoder.info().has_audio, "the clip carries a soundtrack"); assert_eq!(decoder.info().audio_sample_rate, AUDIO_RATE); let mut chunks = 0; while let Some(_chunk) = decoder.next_audio().expect("next audio") { chunks += 1; } assert!(chunks > 0, "and the decoder reads it back"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_container_with_no_picture_opens_for_its_sound() { let dir = dir("sound-only"); let source = dir.join("both-for-surgery.mp4"); encode_clip(&source, true); let mut bytes = std::fs::read(&source).expect("read the clip back"); let length = bytes.len(); assert!(strike_the_picture(&mut bytes), "the clip had a picture track to strike"); assert_eq!(bytes.len(), length, "the surgery moves nothing"); let struck = dir.join("sound-only.mp4"); std::fs::write(&struck, &bytes).expect("write the struck clip"); let mut decoder = VideoFileDecoder::open_audio(struck.to_str().unwrap()).expect("open for sound"); { let info = decoder.info(); assert!(info.has_audio, "the sound is there"); assert_eq!(info.audio_sample_rate, AUDIO_RATE); assert_eq!(info.audio_channels, AUDIO_CHANNELS); assert_eq!((info.width, info.height), (0, 0), "no picture was negotiated"); assert!(info.video_codec.is_none(), "and none was named"); } let mut frames = 0usize; let mut chunks = 0usize; while let Some(chunk) = decoder.next_audio().expect("next audio") { assert_eq!(chunk.sample_rate, AUDIO_RATE); frames += chunk.samples.len() / chunk.channels.max(1) as usize; chunks += 1; } assert!(chunks > 0, "the sound came back"); assert!( (40_000..=60_000).contains(&frames), "about a second of it, head and tail padding allowed: {frames}", ); assert!( decoder.next_frame().expect("next frame is not an error").is_none(), "and asking for a picture is end of stream, not a failure", ); let _ = std::fs::remove_dir_all(&dir); } /// The picture door keeps its own refusal. Without this pin the two doors /// could be folded into one and a file with no picture would quietly reach a /// slot player that has nothing to draw. #[test] fn the_picture_opener_still_refuses_a_file_with_no_picture() { let dir = dir("refusal"); let source = dir.join("both-for-refusal.mp4"); encode_clip(&source, true); let mut bytes = std::fs::read(&source).expect("read the clip back"); assert!(strike_the_picture(&mut bytes), "the clip had a picture track to strike"); let struck = dir.join("sound-only-refusal.mp4"); std::fs::write(&struck, &bytes).expect("write the struck clip"); let opened = VideoFileDecoder::open(struck.to_str().unwrap()); match opened { Ok(_) => panic!("the picture door opened a file with no picture"), Err(error) => assert!( format!("{error}").contains("no video stream in file"), "and says why: {error}", ), } let _ = std::fs::remove_dir_all(&dir); } #[test] fn opening_for_sound_ignores_a_picture_that_is_there() { let dir = dir("with-picture"); let path = dir.join("both-for-sound.mp4"); encode_clip(&path, true); let with_picture = VideoFileDecoder::open(path.to_str().unwrap()).expect("open"); assert_eq!(with_picture.info().width, WIDTH, "the picture door sees it"); assert!(with_picture.info().has_audio); drop(with_picture); let mut sound = VideoFileDecoder::open_audio(path.to_str().unwrap()).expect("open for sound"); assert_eq!(sound.info().width, 0, "the sound door does not"); assert!(sound.info().video_codec.is_none()); assert!(sound.info().has_audio); let mut chunks = 0; while let Some(chunk) = sound.next_audio().expect("next audio") { assert_eq!(chunk.sample_rate, AUDIO_RATE); chunks += 1; } assert!(chunks > 0, "and reads the sound of a file that has both"); assert!(sound.next_frame().expect("not an error").is_none()); // An observation rather than a pin: nothing here can make the decoder // re-read its picture type, so this cannot be relied on to catch that // guard going away. assert_eq!(sound.info().width, 0, "still no picture after the sound ran out"); let _ = std::fs::remove_dir_all(&dir); }