diff --git a/crates/compositor/src/cpu_frames_windows.rs b/crates/compositor/src/cpu_frames_windows.rs index c7a31930..790f23a7 100644 --- a/crates/compositor/src/cpu_frames_windows.rs +++ b/crates/compositor/src/cpu_frames_windows.rs @@ -100,6 +100,11 @@ impl CpuFrames { } self.upload(w, h)?; + // `Decoder::seek_to` and `decode_forward_to` inspect the presentation frame returned by + // `present`, so it must carry the decoded frame's timing just like the macOS/Linux CPU + // paths. Leaving the allocation defaults here makes every non-H.264 frame look untimed. + (*self.present).pts = (*src).pts; + (*self.present).best_effort_timestamp = (*src).best_effort_timestamp; Ok(self.present) } diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index f51e9e65..ae413b12 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -26,6 +26,154 @@ use std::ptr; use std::time::Instant; use windows::core::Interface; +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DecodeFrameTestFault { + AfterAllocations, + PacketAllocNull, + FrameAllocNull, + CloneNull, + EofSendError, + AttachBufferRefNull, +} + +#[cfg(test)] +thread_local! { + static DECODE_FRAME_TEST_FAULT: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + static DECODE_FRAME_TEST_PACKET_RELEASED: std::cell::RefCell< + Option> + > = const { std::cell::RefCell::new(None) }; + static DECODE_FRAME_TEST_FRAME_RELEASED: std::cell::RefCell< + Option> + > = const { std::cell::RefCell::new(None) }; + static DECODE_FRAME_TEST_HWDEV_OBSERVER: std::cell::Cell<*mut AVBufferRef> = + const { std::cell::Cell::new(ptr::null_mut()) }; +} + +#[cfg(test)] +unsafe extern "C" fn observe_test_buffer_release(opaque: *mut c_void, data: *mut u8) { + let released = Box::from_raw(opaque as *mut std::sync::Arc); + released.store(true, std::sync::atomic::Ordering::SeqCst); + drop(Box::from_raw(data)); +} + +#[cfg(test)] +unsafe fn install_decode_frame_lifetime_probes( + hwdev: *mut AVBufferRef, + pkt: *mut AVPacket, + frame: *mut AVFrame, +) -> Result<()> { + let should_fail = DECODE_FRAME_TEST_FAULT + .with(|fault| fault.get() == Some(DecodeFrameTestFault::AfterAllocations)); + if !should_fail { + return Ok(()); + } + + let packet_released = DECODE_FRAME_TEST_PACKET_RELEASED.with(|signal| { + signal + .borrow() + .as_ref() + .expect("packet release signal") + .clone() + }); + let frame_released = DECODE_FRAME_TEST_FRAME_RELEASED.with(|signal| { + signal + .borrow() + .as_ref() + .expect("frame release signal") + .clone() + }); + let packet_data = Box::into_raw(Box::new(0u8)); + let packet_opaque = Box::into_raw(Box::new(packet_released)); + let packet_buf = av_buffer_create( + packet_data, + 1, + Some(observe_test_buffer_release), + packet_opaque as *mut c_void, + 0, + ); + if packet_buf.is_null() { + drop(Box::from_raw(packet_data)); + drop(Box::from_raw(packet_opaque)); + bail!("test av_buffer_create(packet)"); + } + (*pkt).buf = packet_buf; + (*pkt).data = packet_data; + (*pkt).size = 1; + + let frame_data = Box::into_raw(Box::new(0u8)); + let frame_opaque = Box::into_raw(Box::new(frame_released)); + let frame_buf = av_buffer_create( + frame_data, + 1, + Some(observe_test_buffer_release), + frame_opaque as *mut c_void, + 0, + ); + if frame_buf.is_null() { + drop(Box::from_raw(frame_data)); + drop(Box::from_raw(frame_opaque)); + bail!("test av_buffer_create(frame)"); + } + (*frame).buf[0] = frame_buf; + (*frame).data[0] = frame_data; + + let observer = av_buffer_ref(hwdev); + if observer.is_null() { + bail!("test av_buffer_ref(hwdev)"); + } + DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.set(observer)); + bail!("injected failure after decode allocations") +} + +#[cfg(test)] +fn decode_frame_test_fault_is(expected: DecodeFrameTestFault) -> bool { + DECODE_FRAME_TEST_FAULT.with(|fault| fault.get() == Some(expected)) +} + +unsafe fn decode_packet_alloc() -> *mut AVPacket { + #[cfg(test)] + if decode_frame_test_fault_is(DecodeFrameTestFault::PacketAllocNull) { + return ptr::null_mut(); + } + av_packet_alloc() +} + +unsafe fn decode_frame_alloc() -> *mut AVFrame { + #[cfg(test)] + if decode_frame_test_fault_is(DecodeFrameTestFault::FrameAllocNull) { + return ptr::null_mut(); + } + av_frame_alloc() +} + +unsafe fn clone_decoded_frame(frame: *const AVFrame) -> *mut AVFrame { + #[cfg(test)] + if decode_frame_test_fault_is(DecodeFrameTestFault::CloneNull) { + return ptr::null_mut(); + } + av_frame_clone(frame) +} + +unsafe fn send_decode_eof(dctx: *mut AVCodecContext) -> i32 { + #[cfg(test)] + if decode_frame_test_fault_is(DecodeFrameTestFault::EofSendError) { + return AVERROR_INVALIDDATA; + } + avcodec_send_packet(dctx, ptr::null()) +} + +unsafe fn ref_decode_hw_device(hwdev: *const AVBufferRef) -> *mut AVBufferRef { + #[cfg(test)] + if decode_frame_test_fault_is(DecodeFrameTestFault::AttachBufferRefNull) { + let observer = av_buffer_ref(hwdev); + DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.set(observer)); + return ptr::null_mut(); + } + av_buffer_ref(hwdev) +} + // Macros libav non générées par bindgen (function-like). Valeurs Windows/MSVC. // `AVERROR(EAGAIN)` dépend de la plateforme (cf. `crate::ffi`) ; ce fichier est // Windows-only, mais garder une troisième copie de la valeur est ce qui a laissé @@ -58,6 +206,13 @@ impl Drop for FrameGuard { } } +struct PacketGuard(*mut AVPacket); +impl Drop for PacketGuard { + fn drop(&mut self) { + unsafe { av_packet_free(&mut self.0) }; + } +} + /// Décode la n-ième frame d'une source sur NOTRE device (textures échantillonnables). /// Sert le harnais de composition (S3+), hors mesure. Retourne une frame indépendante. pub fn decode_frame_n(path: &str, gpu: &Gpu, n: u32) -> Result { @@ -71,49 +226,77 @@ unsafe fn decode_frame_n_inner(path: &str, gpu: &Gpu, n: u32) -> Result Result bool { + codec_id == AVCodecID::AV_CODEC_ID_H264 +} + +unsafe fn require_decoder_id( + codec_id: AVCodecID::Type, +) -> Result<(*const AVCodec, *mut AVCodecContext)> { + let dec = avcodec_find_decoder(codec_id); + if dec.is_null() { + bail!("no decoder for codec_id {}", codec_id as i32); + } + let dctx = avcodec_alloc_context3(dec); + if dctx.is_null() { + bail!("avcodec_alloc_context3"); + } + Ok((dec, dctx)) +} + +unsafe fn require_decoder( + codecpar: *mut AVCodecParameters, +) -> Result<(*const AVCodec, *mut AVCodecContext)> { + if codecpar.is_null() { + bail!("codecpar null"); + } + require_decoder_id((*codecpar).codec_id) +} + +unsafe fn attach_d3d11va(dctx: *mut AVCodecContext, gpu: &Gpu) -> Result<*mut AVBufferRef> { + let mut hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + if hwdev.is_null() { + bail!("av_hwdevice_ctx_alloc"); + } + let hwdc = (*hwdev).data as *mut AVHWDeviceContext; + let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; + let dev_clone = gpu.device.clone(); + (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; + std::mem::forget(dev_clone); + if let Err(error) = averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init") { + av_buffer_unref(&mut hwdev); + return Err(error); + } + let dctx_hwdev = ref_decode_hw_device(hwdev); + if dctx_hwdev.is_null() { + av_buffer_unref(&mut hwdev); + bail!("av_buffer_ref(hw_device_ctx)"); + } + (*dctx).hw_device_ctx = dctx_hwdev; + (*dctx).get_format = Some(get_hw_format); + Ok(hwdev) +} + // D3D11_TEXTURE2D_DESC.BindFlags (valeurs SDK) const D3D11_BIND_SHADER_RESOURCE: u32 = 0x8; const D3D11_BIND_DECODER: u32 = 0x200; @@ -230,10 +465,18 @@ unsafe fn run_c0_inner(screen: &str, out: &str, gpu: &Gpu) -> Result { avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), "avformat_open_input", )?; - averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + let mut resources = DecoderOpenResources { + fmt, + dctx: ptr::null_mut(), + hwdev: ptr::null_mut(), + }; + averr( + avformat_find_stream_info(resources.fmt, ptr::null_mut()), + "find_stream_info", + )?; let vidx = av_find_best_stream( - fmt, + resources.fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, @@ -243,33 +486,30 @@ unsafe fn run_c0_inner(screen: &str, out: &str, gpu: &Gpu) -> Result { if vidx < 0 { bail!("aucun flux vidéo"); } - let stream = sn_fmt_stream(fmt, vidx); + let stream = sn_fmt_stream(resources.fmt, vidx); let codecpar = (*stream).codecpar; - // ---- décodeur D3D11VA sur NOTRE device ---- - let dec = avcodec_find_decoder((*codecpar).codec_id); - if dec.is_null() { - bail!("décodeur introuvable"); - } - let dctx = avcodec_alloc_context3(dec); - averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; - allow_d3d11va_h264_baseline(dctx); - - let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); - if hwdev.is_null() { - bail!("av_hwdevice_ctx_alloc"); + // ---- décodeur D3D11VA sur NOTRE device (H.264 only; see d3d11va_for_codec) ---- + if !d3d11va_for_codec((*codecpar).codec_id) { + bail!( + "C0 D3D11VA only supports H.264 (codec_id {})", + (*codecpar).codec_id as i32 + ); } - let hwdc = (*hwdev).data as *mut AVHWDeviceContext; - let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; - // AddRef : ffmpeg Release ce device au teardown. On garde un +1 en fuyant un clone. - let dev_clone = gpu.device.clone(); - (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; - std::mem::forget(dev_clone); - averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; + let (dec, dctx) = require_decoder(codecpar)?; + resources.dctx = dctx; + averr( + avcodec_parameters_to_context(resources.dctx, codecpar), + "params_to_ctx", + )?; + allow_d3d11va_h264_baseline(resources.dctx); - (*dctx).hw_device_ctx = av_buffer_ref(hwdev); - (*dctx).get_format = Some(get_hw_format); - averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2(dec)")?; + resources.hwdev = attach_d3d11va(resources.dctx, gpu)?; + averr( + avcodec_open2(resources.dctx, dec, ptr::null_mut()), + "avcodec_open2(dec)", + )?; + let (mut fmt, dctx, hwdev) = resources.into_raw(); // ---- encodeur (ouvert paresseusement à la 1re frame : il lui faut ses dims + hw_frames_ctx) ---- let mut enc: Option = None; @@ -494,6 +734,37 @@ pub(crate) struct Decoder { has_peek: bool, } +/// Owns the FFmpeg resources allocated while `Decoder::open` is still fallible. +/// Once a complete `Decoder` exists, `into_raw` transfers the same three pointers +/// to it and disarms this guard so exactly one Drop path remains responsible. +struct DecoderOpenResources { + fmt: *mut AVFormatContext, + dctx: *mut AVCodecContext, + hwdev: *mut AVBufferRef, +} + +impl DecoderOpenResources { + unsafe fn cleanup(&mut self) { + avcodec_free_context(&mut self.dctx); + av_buffer_unref(&mut self.hwdev); + avformat_close_input(&mut self.fmt); + } + + unsafe fn into_raw(mut self) -> (*mut AVFormatContext, *mut AVCodecContext, *mut AVBufferRef) { + let resources = (self.fmt, self.dctx, self.hwdev); + self.fmt = ptr::null_mut(); + self.dctx = ptr::null_mut(); + self.hwdev = ptr::null_mut(); + resources + } +} + +impl Drop for DecoderOpenResources { + fn drop(&mut self) { + unsafe { self.cleanup() }; + } +} + // SAFETY: `Decoder` only owns FFI pointers into FFmpeg's own heap-allocated state, which // has no OS thread affinity — safe to create on one thread and hand off to another as long // as it's touched from a single thread at a time (never concurrently), which is exactly the @@ -516,47 +787,56 @@ impl Decoder { avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), "open_input", )?; - averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; - let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0); + let mut resources = DecoderOpenResources { + fmt, + dctx: ptr::null_mut(), + hwdev: ptr::null_mut(), + }; + averr( + avformat_find_stream_info(resources.fmt, ptr::null_mut()), + "find_stream_info", + )?; + let vidx = av_find_best_stream( + resources.fmt, + AVMediaType::AVMEDIA_TYPE_VIDEO, + -1, + -1, + ptr::null_mut(), + 0, + ); if vidx < 0 { bail!("aucun flux vidéo dans {path}"); } - let stream = sn_fmt_stream(fmt, vidx); + let stream = sn_fmt_stream(resources.fmt, vidx); let codecpar = (*stream).codecpar; - let dec = avcodec_find_decoder((*codecpar).codec_id); - let dctx = avcodec_alloc_context3(dec); - averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; - allow_d3d11va_h264_baseline(dctx); - - // Backend CPU : on n'attache AUCUN hw_device_ctx et on ne force pas `get_format`, - // donc libavcodec choisit son décodeur logiciel et sort en mémoire système. Passer - // le device WARP à D3D11VA ne marcherait pas de toute façon — WARP n'expose pas - // d'`ID3D11VideoDevice` (`tests/warp_device_cannot_decode.rs`). - let cpu = if gpu.backend == Backend::Cpu { - // `threads = 0` : libavcodec prend le nombre de cœurs. C'est le seul réglage - // qui compte vraiment ici — sans lui le décodage logiciel est mono-thread et - // le benchmark mesurerait surtout ça. - (*dctx).thread_count = 0; - Some(CpuFrames::new(gpu)?) - } else { - None - }; + let codec_id = (*codecpar).codec_id; + let (dec, dctx) = require_decoder(codecpar)?; + resources.dctx = dctx; + averr( + avcodec_parameters_to_context(resources.dctx, codecpar), + "params_to_ctx", + )?; + allow_d3d11va_h264_baseline(resources.dctx); - let hwdev = if cpu.is_some() { - ptr::null_mut() + // Hardware D3D11VA is the H.264 capture path. WARP has no video decoder + // (`tests/warp_device_cannot_decode.rs`). AV1/VP9 (legacy WebMs, #554) + // take the same software CpuFrames axis as Backend::Cpu. + let want_hw = gpu.backend != Backend::Cpu && d3d11va_for_codec(codec_id); + let cpu = if want_hw { + None } else { - let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); - let hwdc = (*hwdev).data as *mut AVHWDeviceContext; - let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; - let dev_clone = gpu.device.clone(); - (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; - std::mem::forget(dev_clone); - averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; - (*dctx).hw_device_ctx = av_buffer_ref(hwdev); - (*dctx).get_format = Some(get_hw_format); - hwdev + (*resources.dctx).thread_count = 0; + Some(CpuFrames::new(gpu)?) }; - averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2")?; + + if want_hw { + resources.hwdev = attach_d3d11va(resources.dctx, gpu)?; + } + averr( + avcodec_open2(resources.dctx, dec, ptr::null_mut()), + "avcodec_open2", + )?; + let (fmt, dctx, hwdev) = resources.into_raw(); Ok(Decoder { fmt, @@ -581,9 +861,18 @@ impl Decoder { /// `next`, donc les deux doivent rendre la même chose. Le temps (`cur_time_sec`), lui, /// continue de se lire sur la vraie frame décodée. pub(crate) fn cur_frame(&self) -> *mut AVFrame { - match &self.cpu { + let frame = match &self.cpu { Some(cpu) => cpu.current(), None => self.frame, + }; + // `AVFrame*` identifies the reusable container, not whether it currently contains a + // presentable frame. It stays allocated before the first decode and can be unreffed by + // a seek that runs to EOF. Returning that non-null shell let Player's webcam hold path + // feed a null D3D11 texture to the compositor on replay after #554's AV1 clip. + if frame.is_null() || unsafe { (*frame).data[0].is_null() } { + ptr::null_mut() + } else { + frame } } @@ -1513,6 +1802,141 @@ unsafe fn run_multi_inner( mod tests { use super::*; + struct DecodeFrameFaultReset; + + impl Drop for DecodeFrameFaultReset { + fn drop(&mut self) { + DECODE_FRAME_TEST_FAULT.with(|fault| fault.set(None)); + DECODE_FRAME_TEST_PACKET_RELEASED.with(|signal| *signal.borrow_mut() = None); + DECODE_FRAME_TEST_FRAME_RELEASED.with(|signal| *signal.borrow_mut() = None); + DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| unsafe { + let mut observer = slot.replace(ptr::null_mut()); + av_buffer_unref(&mut observer); + }); + } + } + + fn install_decode_frame_fault( + fault: DecodeFrameTestFault, + packet_released: std::sync::Arc, + frame_released: std::sync::Arc, + ) -> DecodeFrameFaultReset { + DECODE_FRAME_TEST_FAULT.with(|slot| slot.set(Some(fault))); + DECODE_FRAME_TEST_PACKET_RELEASED.with(|slot| *slot.borrow_mut() = Some(packet_released)); + DECODE_FRAME_TEST_FRAME_RELEASED.with(|slot| *slot.borrow_mut() = Some(frame_released)); + DecodeFrameFaultReset + } + + fn install_simple_decode_frame_fault(fault: DecodeFrameTestFault) -> DecodeFrameFaultReset { + install_decode_frame_fault( + fault, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + } + + #[derive(Debug, PartialEq, Eq)] + enum HardwarePrerequisite { + Available, + Unsupported, + } + + const DXGI_ERROR_UNSUPPORTED_CODE: i32 = 0x887A_0004u32 as i32; + + fn classify_hardware_prerequisite( + result: windows::core::Result<()>, + ) -> windows::core::Result { + match result { + Ok(()) => Ok(HardwarePrerequisite::Available), + Err(error) if error.code().0 == DXGI_ERROR_UNSUPPORTED_CODE => { + Ok(HardwarePrerequisite::Unsupported) + } + Err(error) => Err(error), + } + } + + fn raw_hardware_prerequisite() -> windows::core::Result<()> { + use windows::Win32::Foundation::{E_UNEXPECTED, HMODULE}; + use windows::Win32::Graphics::Direct3D::{ + D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL, D3D_FEATURE_LEVEL_11_1, + }; + use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION, + }; + + let levels = [D3D_FEATURE_LEVEL_11_1]; + let mut device: Option = None; + let mut context: Option = None; + let mut got = D3D_FEATURE_LEVEL::default(); + unsafe { + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_HARDWARE, + HMODULE::default(), + D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT, + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + Some(&mut got), + Some(&mut context), + )?; + } + if device.is_none() || context.is_none() || got != D3D_FEATURE_LEVEL_11_1 { + return Err(windows::core::Error::from(E_UNEXPECTED)); + } + Ok(()) + } + + fn strict_hardware_gpu(test_name: &str) -> Option { + match classify_hardware_prerequisite(raw_hardware_prerequisite()) { + Ok(HardwarePrerequisite::Unsupported) => { + println!( + "NOT_EXECUTED:{test_name}:raw D3D11 preflight returned DXGI_ERROR_UNSUPPORTED (0x887A0004)" + ); + None + } + Ok(HardwarePrerequisite::Available) => Some( + Gpu::create(false) + .unwrap_or_else(|error| panic!("{test_name}: strict Gpu::create failed after successful raw hardware preflight: {error:#}")), + ), + Err(error) => panic!( + "{test_name}: raw D3D11 hardware preflight failed with non-skippable HRESULT {:#010X}: {error}", + error.code().0 as u32 + ), + } + } + + fn decode_frame_error(path: &std::path::Path, gpu: &Gpu, n: u32) -> anyhow::Error { + match decode_frame_n(path.to_str().expect("utf8 path"), gpu, n) { + Ok(_) => panic!("decode_frame_n unexpectedly succeeded"), + Err(error) => error, + } + } + + #[test] + fn hardware_prerequisite_classification() { + use windows::Win32::Foundation::{E_INVALIDARG, E_OUTOFMEMORY, E_UNEXPECTED}; + + assert_eq!( + classify_hardware_prerequisite(Ok(())).expect("success classification"), + HardwarePrerequisite::Available + ); + let unsupported = + windows::core::Error::from(windows::core::HRESULT(DXGI_ERROR_UNSUPPORTED_CODE)); + assert_eq!( + classify_hardware_prerequisite(Err(unsupported)) + .expect("DXGI_ERROR_UNSUPPORTED classification"), + HardwarePrerequisite::Unsupported + ); + for hresult in [E_INVALIDARG, E_OUTOFMEMORY, E_UNEXPECTED] { + let error = windows::core::Error::from(hresult); + let returned = classify_hardware_prerequisite(Err(error)) + .expect_err("ordinary failures must remain failures"); + assert_eq!(returned.code(), hresult); + } + } + /// L'ordre EST le contrat : tous les candidats zéro-copie d'abord, ceux qui exigent la /// mémoire système ensuite (`*_qsv` et `*_mf` sont matériels eux aussi — ce qui les /// distingue est le format d'entrée, pas le silicium). Un candidat système remonté @@ -1606,6 +2030,831 @@ mod tests { } } } + + #[test] + fn d3d11va_is_h264_only() { + assert!(d3d11va_for_codec(AVCodecID::AV_CODEC_ID_H264)); + assert!(!d3d11va_for_codec(AVCodecID::AV_CODEC_ID_AV1)); + assert!(!d3d11va_for_codec(AVCodecID::AV_CODEC_ID_VP9)); + } + + #[test] + fn require_decoder_rejects_none() { + let err = unsafe { require_decoder_id(AVCodecID::AV_CODEC_ID_NONE) } + .expect_err("NONE must not allocate a context"); + let msg = format!("{err:#}"); + assert!(msg.contains("codec_id"), "{msg}"); + } + + fn select_ffmpeg_exe( + crate_dir: &std::path::Path, + configured_dir: Option, + ) -> Option { + let mut candidates = Vec::new(); + if let Some(dir) = configured_dir { + candidates.push(dir.join("bin").join("ffmpeg.exe")); + } + candidates + .push(crate_dir.join("../thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared/bin/ffmpeg.exe")); + candidates.into_iter().find(|p| p.is_file()) + } + + fn ffmpeg_exe() -> std::path::PathBuf { + let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + select_ffmpeg_exe( + &crate_dir, + std::env::var_os("FFMPEG_DIR").map(std::path::PathBuf::from), + ) + .unwrap_or_else(|| panic!("ffmpeg.exe not found next to FFMPEG_DIR / crates/thirdparty")) + } + + fn ffprobe_exe() -> std::path::PathBuf { + let ffprobe = ffmpeg_exe().with_file_name("ffprobe.exe"); + assert!( + ffprobe.is_file(), + "ffprobe.exe not found next to ffmpeg.exe: {ffprobe:?}" + ); + ffprobe + } + + fn encode_color(codec_args: &[&str], filename: &str) -> std::path::PathBuf { + encode_color_for_duration(codec_args, filename, "0.4") + } + + fn encode_color_for_duration( + codec_args: &[&str], + filename: &str, + duration_sec: &str, + ) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("openscreen-554-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let out = dir.join(filename); + let ff = ffmpeg_exe(); + let mut cmd = std::process::Command::new(&ff); + let input = format!("color=c=red:s=64x64:d={duration_sec}"); + cmd.args(["-y", "-f", "lavfi", "-i", input.as_str()]); + cmd.args(codec_args); + cmd.arg(&out); + let output = cmd.output().unwrap_or_else(|e| panic!("spawn {ff:?}: {e}")); + assert!( + output.status.success() && out.is_file(), + "ffmpeg {cmd:?} failed status={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + out + } + + unsafe fn allocated_decoder_open_resources(filename: &str) -> DecoderOpenResources { + let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], filename); + let cpath = CString::new(path.to_string_lossy().as_bytes()).expect("fixture path CString"); + let mut fmt = ptr::null_mut(); + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "test open_input", + ) + .expect("open fixture"); + averr( + avformat_find_stream_info(fmt, ptr::null_mut()), + "test find_stream_info", + ) + .expect("find fixture streams"); + let vidx = av_find_best_stream( + fmt, + AVMediaType::AVMEDIA_TYPE_VIDEO, + -1, + -1, + ptr::null_mut(), + 0, + ); + assert!(vidx >= 0, "fixture video stream"); + let codecpar = (*sn_fmt_stream(fmt, vidx)).codecpar; + let (_, dctx) = require_decoder(codecpar).expect("fixture decoder context"); + let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + assert!(!hwdev.is_null(), "test hardware-device context"); + DecoderOpenResources { fmt, dctx, hwdev } + } + + #[test] + fn decoder_open_resources_cleanup_nulls_every_owned_pointer() { + unsafe { + let mut resources = + allocated_decoder_open_resources("decoder-open-resources-cleanup.mp4"); + resources.cleanup(); + assert!(resources.dctx.is_null()); + assert!(resources.hwdev.is_null()); + assert!(resources.fmt.is_null()); + } + } + + #[test] + fn decoder_open_resources_release_transfers_every_pointer_once() { + unsafe { + let resources = allocated_decoder_open_resources("decoder-open-resources-release.mp4"); + let (mut fmt, mut dctx, mut hwdev) = resources.into_raw(); + assert!(!dctx.is_null()); + assert!(!hwdev.is_null()); + assert!(!fmt.is_null()); + avcodec_free_context(&mut dctx); + av_buffer_unref(&mut hwdev); + avformat_close_input(&mut fmt); + } + } + + #[test] + fn decode_frame_n_failure_paths_release_resources() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let Some(gpu) = strict_hardware_gpu("decode_frame_n_failure_paths_release_resources") + else { + return; + }; + let path = encode_color( + &["-c:v", "libopenh264", "-b:v", "200k"], + "decode-frame-n-lifetime.mp4", + ); + let packet_released = Arc::new(AtomicBool::new(false)); + let frame_released = Arc::new(AtomicBool::new(false)); + let _fault = install_decode_frame_fault( + DecodeFrameTestFault::AfterAllocations, + Arc::clone(&packet_released), + Arc::clone(&frame_released), + ); + + let error = decode_frame_error(&path, &gpu, 0); + assert!( + format!("{error:#}").contains("injected failure after decode allocations"), + "unexpected injected error: {error:#}" + ); + let mut observer = + DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.replace(ptr::null_mut())); + assert!( + !observer.is_null(), + "hardware-device observer was not installed" + ); + let hwdev_ref_count = unsafe { av_buffer_get_ref_count(observer) }; + let input_handle_released = std::fs::remove_file(&path).is_ok(); + let packet_was_released = packet_released.load(Ordering::SeqCst); + let frame_was_released = frame_released.load(Ordering::SeqCst); + unsafe { av_buffer_unref(&mut observer) }; + + println!( + "RELEASE_OBSERVATION:fmt_handle={input_handle_released}:hwdev_refs={hwdev_ref_count}:packet_callback={packet_was_released}:frame_callback={frame_was_released}" + ); + assert!( + input_handle_released + && hwdev_ref_count == 1 + && packet_was_released + && frame_was_released, + "UNRELEASED_RESOURCE fmt_handle={input_handle_released} hwdev_refs={hwdev_ref_count} packet_callback={packet_was_released} frame_callback={frame_was_released}" + ); + + let unsupported_path = encode_color( + &["-c:v", "libaom-av1", "-cpu-used", "8"], + "decode-frame-n-unsupported.webm", + ); + let unsupported_error = decode_frame_error(&unsupported_path, &gpu, 0); + let unsupported_message = format!("{unsupported_error:#}"); + assert!( + unsupported_message + .contains(&format!("codec_id {}", AVCodecID::AV_CODEC_ID_AV1 as i32)), + "unsupported codec id was not preserved before format teardown: {unsupported_message}" + ); + std::fs::remove_file(&unsupported_path).expect("unsupported input handle released"); + + for (fault, filename, expected) in [ + ( + DecodeFrameTestFault::PacketAllocNull, + "decode-frame-n-packet-null.mp4", + "av_packet_alloc", + ), + ( + DecodeFrameTestFault::FrameAllocNull, + "decode-frame-n-frame-null.mp4", + "av_frame_alloc", + ), + ( + DecodeFrameTestFault::CloneNull, + "decode-frame-n-clone-null.mp4", + "av_frame_clone", + ), + ( + DecodeFrameTestFault::EofSendError, + "decode-frame-n-eof-send.mp4", + "send_eof", + ), + ] { + let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], filename); + let frame_number = if fault == DecodeFrameTestFault::EofSendError { + u32::MAX + } else { + 0 + }; + let error = { + let _fault = install_simple_decode_frame_fault(fault); + decode_frame_error(&path, &gpu, frame_number) + }; + let message = format!("{error:#}"); + assert!(message.contains(expected), "{fault:?}: {message}"); + std::fs::remove_file(&path) + .unwrap_or_else(|error| panic!("{fault:?}: input handle leaked: {error}")); + } + + let attach_path = encode_color( + &["-c:v", "libopenh264", "-b:v", "200k"], + "decode-frame-n-attach-ref-null.mp4", + ); + let attach_fault = + install_simple_decode_frame_fault(DecodeFrameTestFault::AttachBufferRefNull); + let attach_error = decode_frame_error(&attach_path, &gpu, 0); + assert!( + format!("{attach_error:#}").contains("av_buffer_ref(hw_device_ctx)"), + "unexpected attach error: {attach_error:#}" + ); + let mut attach_observer = + DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.replace(ptr::null_mut())); + assert!( + !attach_observer.is_null(), + "attach observer was not installed" + ); + let attach_refs = unsafe { av_buffer_get_ref_count(attach_observer) }; + unsafe { av_buffer_unref(&mut attach_observer) }; + drop(attach_fault); + std::fs::remove_file(&attach_path).expect("attach-ref-null input handle released"); + assert_eq!( + attach_refs, 1, + "UNRELEASED_RESOURCE attach_d3d11va local hwdev refs={attach_refs}" + ); + println!("FAILURE_PATH_ASSERTIONS_COMPLETED"); + } + + #[test] + fn decode_frame_n_returned_frame_keeps_its_buffers() { + let Some(gpu) = strict_hardware_gpu("decode_frame_n_returned_frame_keeps_its_buffers") + else { + return; + }; + let path = encode_color( + &["-c:v", "libopenh264", "-b:v", "200k"], + "decode-frame-n-returned-frame.mp4", + ); + let frame = decode_frame_n(path.to_str().expect("utf8 path"), &gpu, 0) + .unwrap_or_else(|error| panic!("decode first H.264 frame: {error:#}")); + assert!(!frame.0.is_null(), "returned frame pointer"); + let source_buffer = unsafe { (*frame.0).buf[0] }; + assert!(!source_buffer.is_null(), "returned frame buffer reference"); + let mut observer = unsafe { av_buffer_ref(source_buffer) }; + assert!( + !observer.is_null(), + "observer reference for returned frame buffer" + ); + let refs_with_frame = unsafe { av_buffer_get_ref_count(observer) }; + drop(frame); + let refs_after_frame_drop = unsafe { av_buffer_get_ref_count(observer) }; + println!( + "RETURNED_FRAME_REFS:with_frame={refs_with_frame}:after_frame_drop={refs_after_frame_drop}" + ); + assert_eq!( + refs_after_frame_drop + 1, + refs_with_frame, + "FrameGuard must own one independent AVBuffer reference" + ); + assert!( + refs_after_frame_drop >= 1, + "observer reference must remain valid" + ); + unsafe { av_buffer_unref(&mut observer) }; + std::fs::remove_file(path).expect("returned-frame input handle released"); + } + + #[test] + fn ffmpeg_dir_selects_the_configured_executable_over_the_builtin() { + let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let built_in = + crate_dir.join("../thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared/bin/ffmpeg.exe"); + assert!( + built_in.is_file(), + "built-in control is missing: {built_in:?}" + ); + + let configured_dir = std::env::temp_dir().join(format!( + "openscreen-554-ffmpeg-override-{}", + std::process::id() + )); + let configured = configured_dir.join("bin").join("ffmpeg.exe"); + std::fs::create_dir_all(configured.parent().expect("configured ffmpeg parent")) + .expect("create configured ffmpeg directory"); + std::fs::File::create(&configured).expect("create configured ffmpeg executable"); + + let selected = select_ffmpeg_exe(&crate_dir, Some(configured_dir.clone())) + .expect("select configured ffmpeg executable"); + assert_eq!( + selected, configured, + "an explicit FFMPEG_DIR must override the built-in test fixture executable" + ); + std::fs::remove_dir_all(configured_dir).expect("remove configured ffmpeg directory"); + } + + #[derive(Clone, Copy, Debug)] + struct EbmlElement { + id: u64, + start: usize, + size_offset: usize, + size_width: usize, + data_start: usize, + data_end: usize, + unknown_size: bool, + } + + fn vint_width(first: u8, at: usize) -> usize { + let width = first.leading_zeros() as usize + 1; + assert!(width <= 8, "invalid EBML vint at offset {at:#x}"); + width + } + + fn read_ebml_id(bytes: &[u8], at: usize) -> (u64, usize) { + let first = *bytes + .get(at) + .unwrap_or_else(|| panic!("missing EBML id at {at:#x}")); + let width = vint_width(first, at); + assert!(width <= 4, "EBML id is wider than four bytes at {at:#x}"); + let end = at.checked_add(width).expect("EBML id offset overflow"); + let encoded = bytes + .get(at..end) + .unwrap_or_else(|| panic!("truncated EBML id at {at:#x}")); + let id = encoded + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)); + (id, width) + } + + fn read_ebml_size(bytes: &[u8], at: usize) -> (Option, usize) { + let first = *bytes + .get(at) + .unwrap_or_else(|| panic!("missing EBML size at {at:#x}")); + let width = vint_width(first, at); + let end = at.checked_add(width).expect("EBML size offset overflow"); + let encoded = bytes + .get(at..end) + .unwrap_or_else(|| panic!("truncated EBML size at {at:#x}")); + let value_mask = if width == 8 { 0 } else { 0xffu8 >> width }; + let value = encoded[1..] + .iter() + .fold(u64::from(first & value_mask), |value, byte| { + (value << 8) | u64::from(*byte) + }); + let unknown_value = (1u64 << (7 * width)) - 1; + if value == unknown_value { + (None, width) + } else { + let value = usize::try_from(value).expect("EBML size does not fit usize"); + (Some(value), width) + } + } + + fn ebml_element_at(bytes: &[u8], start: usize, parent_end: usize) -> EbmlElement { + let (id, id_width) = read_ebml_id(bytes, start); + let size_offset = start + .checked_add(id_width) + .expect("EBML size offset overflow"); + let (size, size_width) = read_ebml_size(bytes, size_offset); + let data_start = size_offset + .checked_add(size_width) + .expect("EBML payload offset overflow"); + assert!( + data_start <= parent_end, + "EBML header exceeds parent at {start:#x}" + ); + let data_end = match size { + Some(size) => data_start + .checked_add(size) + .expect("EBML payload offset overflow"), + None => parent_end, + }; + assert!( + data_end <= parent_end, + "EBML element {id:#x} exceeds its parent" + ); + EbmlElement { + id, + start, + size_offset, + size_width, + data_start, + data_end, + unknown_size: size.is_none(), + } + } + + fn ebml_children(bytes: &[u8], start: usize, end: usize) -> Vec { + let mut children = Vec::new(); + let mut at = start; + while at < end { + let child = ebml_element_at(bytes, at, end); + assert!( + child.data_end > at, + "empty EBML element cannot advance at {at:#x}" + ); + children.push(child); + at = child.data_end; + if child.unknown_size { + assert_eq!( + at, end, + "unknown-sized child must consume the parent remainder" + ); + } + } + assert_eq!(at, end, "EBML children did not exactly fill their parent"); + children + } + + fn exactly_one(children: &[EbmlElement], id: u64, label: &str) -> EbmlElement { + let found: Vec<_> = children + .iter() + .copied() + .filter(|child| child.id == id) + .collect(); + assert_eq!( + found.len(), + 1, + "expected exactly one {label}, found {}", + found.len() + ); + found[0] + } + + /// Turn the pinned ffmpeg's valid AV1 WebM into the three malformed-but-decodable + /// characteristics from #554. This is deliberately a structural EBML edit: a raw byte + /// search could hit an AV1 payload byte and produce a fixture that only looked relevant. + fn make_legacy_av1_fixture(filename: &str) -> std::path::PathBuf { + const SEGMENT_ID: u64 = 0x1853_8067; + const TRACKS_ID: u64 = 0x1654_ae6b; + const TRACK_ENTRY_ID: u64 = 0xae; + const CODEC_ID_ID: u64 = 0x86; + const DEFAULT_DURATION_ID: u64 = 0x23e383; + const CODEC_PRIVATE_ID: u64 = 0x63a2; + const CLUSTER_ID: u64 = 0x1f43_b675; + + // One frame is intentional: after DefaultDuration is removed, there is no second + // timestamp from which avformat_find_stream_info can infer a replacement frame rate. + let path = encode_color_for_duration( + &[ + "-c:v", + "libaom-av1", + "-cpu-used", + "8", + "-usage", + "realtime", + "-b:v", + "50k", + "-output_ts_offset", + "0.4", + ], + filename, + "0.04", + ); + let mut bytes = std::fs::read(&path).expect("read generated AV1 WebM"); + let original = bytes.clone(); + let top_level = ebml_children(&bytes, 0, bytes.len()); + let segment = exactly_one(&top_level, SEGMENT_ID, "Segment"); + assert!( + !segment.unknown_size, + "generated Segment must have a finite size" + ); + let segment_children = ebml_children(&bytes, segment.data_start, segment.data_end); + let tracks = exactly_one(&segment_children, TRACKS_ID, "Tracks"); + let cluster = exactly_one(&segment_children, CLUSTER_ID, "Cluster"); + assert!( + !cluster.unknown_size, + "generated Cluster must start with a finite size" + ); + + let track_entries: Vec<_> = ebml_children(&bytes, tracks.data_start, tracks.data_end) + .into_iter() + .filter(|child| child.id == TRACK_ENTRY_ID) + .collect(); + let av1_tracks: Vec<_> = track_entries + .iter() + .copied() + .filter(|entry| { + let children = ebml_children(&bytes, entry.data_start, entry.data_end); + children.iter().any(|child| { + child.id == CODEC_ID_ID && &bytes[child.data_start..child.data_end] == b"V_AV1" + }) + }) + .collect(); + assert_eq!(av1_tracks.len(), 1, "expected exactly one V_AV1 TrackEntry"); + let track_children = + ebml_children(&bytes, av1_tracks[0].data_start, av1_tracks[0].data_end); + let codec_private = exactly_one(&track_children, CODEC_PRIVATE_ID, "AV1 CodecPrivate"); + let default_duration = exactly_one(&track_children, DEFAULT_DURATION_ID, "DefaultDuration"); + + assert!( + codec_private.data_start < codec_private.data_end, + "empty AV1 CodecPrivate" + ); + assert_eq!( + bytes[codec_private.data_start], 0x81, + "pinned encoder's AV1CodecConfigurationRecord layout changed" + ); + bytes[codec_private.data_start] = 0xff; + + let default_duration_len = default_duration.data_end - default_duration.start; + assert!( + (3..=128).contains(&default_duration_len), + "DefaultDuration cannot be replaced by a one-byte-size Void" + ); + let void_payload_len = default_duration_len - 2; + assert!( + void_payload_len <= 126, + "one-byte Void size would become unknown" + ); + bytes[default_duration.start] = 0xec; + bytes[default_duration.start + 1] = 0x80 | void_payload_len as u8; + bytes[default_duration.start + 2..default_duration.data_end].fill(0); + + assert!( + (1..=8).contains(&cluster.size_width), + "invalid Cluster size width {}", + cluster.size_width + ); + bytes[cluster.size_offset] = 0xff >> (cluster.size_width - 1); + bytes[cluster.size_offset + 1..cluster.data_start].fill(0xff); + + assert_eq!( + bytes.len(), + original.len(), + "fixture patch must preserve file length" + ); + let allowed = [ + codec_private.data_start..codec_private.data_start + 1, + default_duration.start..default_duration.data_end, + cluster.size_offset..cluster.data_start, + ]; + let changed: Vec<_> = original + .iter() + .zip(&bytes) + .enumerate() + .filter_map(|(index, (before, after))| (before != after).then_some(index)) + .collect(); + assert!(!changed.is_empty(), "fixture patch changed no bytes"); + assert!( + changed + .iter() + .all(|index| allowed.iter().any(|range| range.contains(index))), + "fixture patch changed bytes outside the three intended EBML fields: {changed:?}" + ); + for range in &allowed { + assert!( + changed.iter().any(|index| range.contains(index)), + "fixture patch did not change intended range {range:?}" + ); + } + std::fs::write(&path, &bytes).expect("write structurally patched AV1 WebM"); + + let ffprobe = ffprobe_exe(); + let output = std::process::Command::new(&ffprobe) + .args([ + "-v", + "warning", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,avg_frame_rate", + "-of", + "default=noprint_wrappers=1", + ]) + .arg(&path) + .output() + .unwrap_or_else(|e| panic!("spawn {ffprobe:?}: {e}")); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "ffprobe failed status={} stdout={stdout} stderr={stderr}", + output.status + ); + assert!( + stdout.contains("codec_name=av1"), + "ffprobe stdout: {stdout}" + ); + assert!( + stdout.contains("avg_frame_rate=0/0"), + "ffprobe stdout: {stdout}" + ); + assert!( + stderr.contains("Unknown version 127 of AV1CodecConfigurationRecord"), + "ffprobe stderr: {stderr}" + ); + assert!( + stderr + .to_ascii_lowercase() + .contains("unknown-sized element"), + "ffprobe stderr: {stderr}" + ); + + if let Some(out) = std::env::var_os("OPENSCREEN_554_FIXTURE_OUT") { + let out = std::path::PathBuf::from(out); + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent).expect("create fixture output directory"); + } + std::fs::copy(&path, &out).expect("copy verified issue 554 fixture"); + println!("ISSUE554_FIXTURE={}", out.display()); + } + path + } + + unsafe fn first_decoded_frame(dec: &mut Decoder) -> *mut AVFrame { + let frame = dec + .next() + .unwrap_or_else(|e| panic!("Decoder::next: {e:#}")); + assert!(!frame.is_null(), "expected a decoded frame, got null (EOF)"); + assert!( + (*frame).width > 0 && (*frame).height > 0, + "decoded frame has no pixels ({}x{})", + (*frame).width, + (*frame).height + ); + frame + } + + #[test] + fn av1_webm_opens_on_software_path() { + let Some(gpu) = strict_hardware_gpu("av1_webm_opens_on_software_path") else { + return; + }; + assert_eq!(gpu.backend, Backend::Hardware); + let path = make_legacy_av1_fixture("tiny-legacy.webm"); + let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) } + .unwrap_or_else(|e| panic!("AV1 Decoder::open: {e:#}")); + assert!( + dec.cpu.is_some(), + "AV1 on Hardware must use CpuFrames, not D3D11VA" + ); + let target_sec = 0.4; + let frame = unsafe { dec.seek_to(target_sec) } + .unwrap_or_else(|e| panic!("legacy AV1 nonzero seek: {e:#}")); + assert!(!frame.is_null(), "legacy AV1 nonzero seek reached EOF"); + assert_eq!(unsafe { (*frame).width }, 64); + assert_eq!(unsafe { (*frame).height }, 64); + let pts = unsafe { (*frame).best_effort_timestamp }; + assert_ne!(pts, i64::MIN, "legacy AV1 presentation timestamp"); + let time_base = unsafe { dec.tb_sec() }; + let observed_sec = pts as f64 * time_base; + assert!( + observed_sec >= target_sec - time_base * 0.5, + "legacy AV1 seek landed before its nonzero source target: target={target_sec:.3}s observed={observed_sec:.3}s" + ); + println!("CORE_ASSERTIONS_COMPLETED:av1_webm_opens_on_software_path"); + } + + #[test] + fn av1_software_seek_preserves_the_nonzero_target_timestamp() { + let Some(gpu) = + strict_hardware_gpu("av1_software_seek_preserves_the_nonzero_target_timestamp") + else { + return; + }; + assert_eq!(gpu.backend, Backend::Hardware); + let path = encode_color_for_duration( + &[ + "-c:v", + "libaom-av1", + "-cpu-used", + "8", + "-usage", + "realtime", + "-g", + "100", + ], + "nonzero-seek-av1.webm", + "1.2", + ); + let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) } + .unwrap_or_else(|e| panic!("AV1 Decoder::open: {e:#}")); + assert!( + dec.cpu.is_some(), + "AV1 must use the software presentation path" + ); + + let target_sec = 0.72; + let frame = unsafe { dec.seek_to(target_sec) }.expect("seek to nonzero AV1 source time"); + assert!(!frame.is_null(), "nonzero AV1 seek reached EOF"); + let pts = unsafe { (*frame).best_effort_timestamp }; + assert_ne!( + pts, + i64::MIN, + "software presentation frame must retain the decoded timestamp" + ); + let time_base = unsafe { dec.tb_sec() }; + let observed_sec = pts as f64 * time_base; + assert!( + observed_sec >= target_sec - time_base * 0.5, + "seek accepted a pre-target frame: target={target_sec:.3}s observed={observed_sec:.3}s" + ); + println!( + "CORE_ASSERTIONS_COMPLETED:av1_software_seek_preserves_the_nonzero_target_timestamp" + ); + } + + #[test] + fn h264_opens_on_d3d11va() { + let Some(gpu) = strict_hardware_gpu("h264_opens_on_d3d11va") else { + return; + }; + assert_eq!(gpu.backend, Backend::Hardware); + let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], "tiny.mp4"); + let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) } + .unwrap_or_else(|e| panic!("H.264 Decoder::open: {e:#}")); + assert!( + dec.cpu.is_none(), + "H.264 on Hardware must keep D3D11VA" + ); + unsafe { first_decoded_frame(&mut dec) }; + println!("CORE_ASSERTIONS_COMPLETED:h264_opens_on_d3d11va"); + } + + #[test] + fn current_frame_requires_pixels_and_recovers_after_eof_seek() { + let Some(gpu) = + strict_hardware_gpu("current_frame_requires_pixels_and_recovers_after_eof_seek") + else { + return; + }; + assert_eq!(gpu.backend, Backend::Hardware); + let path = encode_color( + &["-c:v", "libopenh264", "-b:v", "200k"], + "current-frame.mp4", + ); + let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) } + .unwrap_or_else(|e| panic!("H.264 Decoder::open: {e:#}")); + + assert!( + dec.cur_frame().is_null(), + "allocated AVFrame without pixels is not current" + ); + unsafe { first_decoded_frame(&mut dec) }; + assert!( + !dec.cur_frame().is_null(), + "decoded frame must be presentable" + ); + + let unavailable = unsafe { dec.seek_to(10.0) }.expect("seek beyond EOF must not error"); + assert!( + unavailable.is_null(), + "seek beyond EOF must report no target frame" + ); + assert!( + dec.cur_frame().is_null(), + "unreffed AVFrame shell after EOF must not be exposed to the compositor" + ); + + let recovered = unsafe { dec.seek_to(0.0) }.expect("seek back to start"); + assert!( + !recovered.is_null(), + "seek back to start must decode a frame" + ); + assert!( + !dec.cur_frame().is_null(), + "recovered frame must be presentable" + ); + println!( + "CORE_ASSERTIONS_COMPLETED:current_frame_requires_pixels_and_recovers_after_eof_seek" + ); + } + + /// Playhead crossing clips is `Decoder::open` of the next source on the + /// same `Gpu` (#554). H.264 must stay on D3D11VA after an AV1 software + /// decoder has been opened and dropped. + #[test] + fn switching_h264_then_av1_then_h264_stays_alive() { + let Some(gpu) = strict_hardware_gpu("switching_h264_then_av1_then_h264_stays_alive") else { + return; + }; + assert_eq!(gpu.backend, Backend::Hardware); + let h264_path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], "switch-h264.mp4"); + let av1_path = make_legacy_av1_fixture("switch-legacy-av1.webm"); + let h264 = h264_path.to_str().expect("utf8"); + let av1 = av1_path.to_str().expect("utf8"); + + unsafe { + let mut a = Decoder::open(h264, &gpu).unwrap_or_else(|e| panic!("H.264 open: {e:#}")); + assert!(a.cpu.is_none(), "first clip must stay D3D11VA"); + first_decoded_frame(&mut a); + drop(a); + + let mut b = Decoder::open(av1, &gpu).unwrap_or_else(|e| panic!("AV1 open after H.264: {e:#}")); + assert!(b.cpu.is_some(), "AV1 clip must use CpuFrames"); + first_decoded_frame(&mut b); + drop(b); + + let mut c = Decoder::open(h264, &gpu).unwrap_or_else(|e| panic!("H.264 reopen: {e:#}")); + assert!(c.cpu.is_none(), "H.264 after AV1 must keep D3D11VA"); + first_decoded_frame(&mut c); + } + println!("CORE_ASSERTIONS_COMPLETED:switching_h264_then_av1_then_h264_stays_alive"); + } } unsafe fn drain_encoder( diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 6bb70ee3..b8738728 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -549,5 +549,8 @@ The mask comes from the native compositor (ONNX Runtime + the vendored selfie-se | 2026-08-22 | installed `v1.10.0-rc.3` — CI-built NSIS artifact from build run 32582966489 (`openscreen-windows`), App menu → About reports `1.10.0-rc.3`, native payload complete and uniformly stamped (19 files in `resources/electron/native/bin/win32-x64`, all `17:59:10`, so helper + compositor addon + av\* DLLs are one matched CI set) | Windows 11 26200, 1920×1080 @ 100% | **Pass — 2 minor defects** | **Pause works, and the measurement that says so is the wall clock.** `createdAt` 20:25:52.208 against a file finalised at 20:30:56.754 is 304.55 s elapsed for a **286.333 s** file — **18.21 s shorter, exactly the paused interval**, so capture was genuinely suspended. The HUD timer froze at `03:58` across two reads 7 s apart with the indicator amber, and resume was clean (`04:01` → `04:08` over 7 s, no time lost). An earlier draft of this row called this a blocking defect, on the strength of comparing the file duration against a timer read *before* the stop click; with tool round-trips of ~20 s that comparison is worthless, and the packet count offered as corroboration proves nothing either — a file is continuous 60 fps whether or not capture was ever suspended. Written down because the wrong version of this measurement is easy to repeat: compare against wall-clock elapsed, never against the last timer you happened to screenshot. **Capture is otherwise sound, on two takes.** 15.8 s: fragmented (`ftyp uuid pdin moov` then 16 `moof`/`mdat`, `mvex` present), `mfra` on the clean stop, 1920×1080 @ 60/1, 948 packets = 15.8 × 60, `ffmpeg -v error -f null -` exit 0, both sidecars written. 286.3 s: 287 `moof`, `mfra` present, 17,180 packets, decodes clean, `.cursor.json` 1.3 MB. No pacing drift and no dropped frames over 4 min 46. **Export passes and honours its settings**: 720p/30 requested from a 1080p60 source gave 1280×720, `avg_frame_rate` 85900/2863 = 30.004, 8590 packets matching the frame count the progress UI itself reported, duration 286.333 s identical to source, decodes clean, 124.5 MB, written to the path chosen in the native save dialog and reported back as "Saved to …". Composition verified by extracting a frame and reading it at full resolution (not from a preview screenshot): gradient background, content inset as a rounded card with a drop shadow, content aspect ≈1.76 against the 16:9 target, synthetic cursor drawn. Note the exporter adds a silent **AAC 48 kHz stereo** track even though no audio source was enabled. **Retracted: "the HUD language menu ignores `Escape`".** It does not — the maintainer confirms the key works by hand. **Claude Desktop swallows `Escape` before it reaches the app under test**, so a synthesised press proves nothing about the app, and `GetForegroundWindow()` returning the HUD does not rescue the inference: the key never left the driver. The companion observation (an outside click on the HUD's own drag handle did not dismiss the menu) is withdrawn with it, since the HUD's own chrome is not "outside" the popover in any meaningful sense. What *is* established is that the blur path shipped in this RC works: `54e12706 fix(hud): dismiss the HUD popovers when the window loses focus` dismissed the menu on a click to the desktop. **Rule for anyone driving keyboard checks from computer-use: `Escape` is unusable as evidence, and any negative keyboard result needs a by-hand confirmation before it goes in this table.** **Behaviour vs doc**: the record button is not disabled without a source — it opens the source selector. No recording starts, so the check's intent holds, but AGENTS.md still describes a disabled button with a "Please select a source to record" tooltip, and that is why no tooltip appears. **Passed**: single launch window, no startup crash; HUD visible under `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`; tray layout toggles horizontal↔vertical both ways; HUD drag follows the pointer without drift and stays at the drop point; language menu opens with its locale list; minimize hides the HUD without quitting (6 processes still alive); relaunching routes through the single-instance lock, restores the window and mints no duplicate; source selector opens, selecting a card enables Share, and the HUD label becomes the picked source (`Tout l'écran`); record → stop opens the editor with the asset, a timeline clip and a rendered preview; About reports the RC version. **Local transcription works, on GPU** — an earlier draft of this row reported it broken, which was wrong. Relaunching with stdout/stderr captured and importing a 15 s asset that carries an audio track settles it: `[whisper-stt] boot: model=…\whisper-ggml\ggml-small-q8_0.bin host=127.0.0.1 port=64720 threads=16`, `ggml_vulkan: 0 = NVIDIA GeForce RTX 4070 Ti`, `model loaded; backend=whispercpp-vulkan`, then `[stt] done on whispercpp-vulkan: 1 chunk(s), 15.0s audio in 0.1s (0.01 rtf, 106.8x real-time)`. The pane switched to "1 caption lines, derived live from the transcript". **The real (minor) defect is the error message**: on an asset with *no audio track* the captions pane says **"Failed to fetch"**, which reads as a network failure and sent this run hunting a broken STT server that was never involved — the pipeline simply has no audio to extract. It should say so. **Second minor find, from the same stderr**: `listProjects` cannot read three saved projects — one `ZodError` (`transcript.segments[0].endSec must be greater than or equal to startSec`, repeated across `segments`, `words` and `transcripts[0]`) and two `SyntaxError: Unexpected non-whitespace character after JSON`, i.e. truncated or double-written project files. They are skipped silently in the UI. **Caption anchoring — the rc.2→rc.3 delta — is present but its rendering was not measured.** The Position section carries exactly the model those commits describe: `Bottom`/`Top`, the note "Long captions grow upward — the bottom edge stays put", `Distance from bottom` defaulting to **1.5 %**, and Left/Center/Right. What could not be checked is where a caption actually lands, because the only transcript obtainable here came from a 300 Hz sine and yielded one line that never surfaced at any scrubbed position. **Closed out of band: the maintainer ran the caption sections by hand on a real spoken-audio recording and reports them correct**, which is the coverage this automated run could not supply and the last gap standing between this RC and a promote. Also confirmed from stderr: `[content-protection] OFF for the HUD window (OPENSCREEN_DISABLE_CONTENT_PROTECTION=1)`, so the flag does log its effect, and with the flag unset the HUD is correctly invisible to screenshots. **The consequence matters more than the cause: the eight caption anchoring/margin/inset cherry-picks that are the entire delta from rc.2 to rc.3 are NOT covered by this run.** **Not run**: restart and cancel actions; audio capture of any kind; webcam PiP; GIF; DPI scaling; HUD/notes exclusion from captured video with content protection ON (the whole session ran with it off, and the exported frame confirms the HUD *is* captured when it is off); regions, modifiers, timeline navigation, clip operations, persistence; macOS and Linux. **Environment limits that shaped this run, worth knowing before the next one.** `parsecd.exe` runs **elevated** and holds an invisible always-foreground window (`ParsecMinFrameRate16`); the moment OpenScreen loses focus every computer-use click is refused, and because the process is elevated UIPI makes granting Parsec useless — **tray-icon refocus could therefore not be tested at all**. Relaunching the app (single-instance raises it) is the way back. Dragging the HUD only works while every intermediate pointer position stays inside the HUD's own 904×698 mostly-transparent window; as soon as one lands on the desktop, the tier-"click" shell gate refuses the drag mid-gesture and leaves the button down — release it explicitly. Finally, the Microsoft Store package (`EtienneLescot.OpenScreen`, 1.9.6) **shadows the NSIS install in `request_access`**: every grant resolved to the Store bundle and the RC window stayed masked in screenshots while reporting success, until the Store package was removed. Screenshots do **not** interrupt a recording — that hypothesis was raised and disproved by running a 90 s capture with none taken and then taking one mid-capture with the helper surviving. | | 2026-08-23 | installed `v1.10.0-rc.3` (Developer ID, unmodified) run with `OPENSCREEN_SCK_CAPTURE_EXE` pointed at a helper built from this branch | macOS 26.6.2 (25G83), M1, 1728×1117 @ 2× | Pass — fixes a blocker | **Window capture section only.** Before: selecting any window in the source picker kills the helper the instant `start()` builds its filter — `Assertion failed: (did_initialize), function CGS_REQUIRE_INIT, file CGInitialization.c, line 44`, SIGABRT, `-[SCContentFilter initWithDesktopIndependentWindow:]` → `SLSGetDisplaysWithRect`. 6/6 attempts on the shipped rc.3, no file, no error surfaced in the UI (the HUD returns to idle as if nothing happened). Display capture is unaffected and always worked, which is why this went unnoticed: the two paths diverge at `makeCaptureTarget`, and only the window branch resolves a rect through SkyLight. After: record → 25.2s → stop → **editor opened on the take**, `recording-1787475175449.mp4` 12,559,123 bytes / 25.18s / 2674×1684, the MP4 and both sidecars written (`.cursor.json`, `.session.json`), one project minted, zero crash reports. Helper-level A/B on an identical request JSON isolates the change: shipped signed helper → assertion, no file; this branch's helper → `recording-started`/`recording-stopped`, 4.49s / 1336×840 decodable MP4. NOT covered: webcam PiP, microphone, system audio (all off for these runs), export, GIF, AI/transcript sections, Windows, Linux. Not covered by unit tests either — `Package.swift` scopes the Swift test target to what runs without a screen, a display server or a TCC grant, and this crash needs all three. | | 2026-09-03 | dev build, worktree `github-issue-385-38d731` @ `437e4bd2` (main). Natives rebuilt from this tree: `wgc-capture.exe` and `compositor_view.node` both dated 2026-09-03, verified by string probe (`[segmentation]` HIT / control `OPENSCREEN_EXPORT_ENCODER` HIT; helper control `encoder-selection` HIT). ONNX Runtime 1.27.1 staged. | Windows 11 26200, 1920×1080 @ 100% | Partial — no defect, one validation unresolved | Post-1.10.0 slice. **Passed:** hardware H.264 is the Windows default (`videoEncoderRuntime: "hardware"` on the plain path); recording AAC valid at 48 kHz; camera background Original/Cutout/Blur/Custom all render in preview **and** in the exported frames; the control is correctly hidden when `onnxruntime.dll` is removed and returns when it is restored; caption anchor presets Top/Left move the band and relabel their sliders, and the exported plate's left edge measured x=192/1920 = 10.00% against `insetX: 10`; crop field down-arrow steps one source pixel (100 → 99.9479) and the preview box carries the source aspect; a zoom lying entirely inside a trim fires in the preview with the playhead parked on it (zoom 19.956–22.927 inside trim 19.931–23.596, checked in the saved project, not by eye); export progress reached 100% with a 1.5× speed region present and reported a real frame total; exported audio and video durations agree within one frame at the export's **30 fps** — 41.200 s audio vs 41.167 s video, a 33 ms gap that is exactly 1/30; `editor-window.json` persists bounds and a hand-planted zero-size/non-boolean state is rejected on relaunch; Regenerate-as lists all 101 whisper language entries; regions and settings survive a restart. **Skipped:** Ctrl/Shift+scroll zoom and pan — the computer-use `scroll` action does not carry a modifier, so the fix that moved the wheel listener to the whole pane could not be exercised either over the ruler or over the lanes; tray context menu (Save Diagnostics, Update Settings) — the desktop shell is granted at tier `click`, which blocks right-click; Update Settings in general — dev build, `app.isPackaged` is false so the submenu is correctly absent; the illegal-AAC-rate snap end-to-end — no 96 kHz device on this machine, covered instead by the helper's own `audio_sample_utils_test` MF probes, which passed at build time. **Note, not a defect:** Escape does not reach the app through this driver (it failed to close the Edit Clip dialog too), so any Escape-based check here is untestable rather than failing — same conclusion as the rc.3 retraction. **Unresolved — do not read this row as clearing it:** the exported duration ran 0.48 s (≈13 frames at 30 fps) under the trim/speed arithmetic — 1236 frames against a predicted 1249, from source 46.300 s minus a 3.665 s trim minus 0.990 s saved by a 2.971 s span at 1.5×. Ruled out: the source being shorter than its container claims (`-count_frames` gives exactly 2778 = 46.300 s × 60 fps). Not reproduced under controlled conditions, and not attributable to any post-1.10.0 change — the trim/speed length arithmetic predates them — so it is logged as an open question rather than a defect against this slice. The silence check in the same run is also weaker than it looks: that recording was ambient-mic screen capture, i.e. mostly silence, so it could not have distinguished an `atempo` hole from the source's own quiet. Both are why the two checks above now demand controlled audio and frame-level arithmetic. | +| 2026-09-04 | signed follow-up candidate `256ec082` on `feat/fix-av1-webm-clip-switch`, packaged from a raw-Git-blob build tree (`Openscreen.exe` `32469490…3E6E`, `compositor_view.node` `090B606C…AA3A`) | Windows 11 Pro Insider Preview 26340, 1707×1067 logical | Partial — no defect | **AV1 clip-switch section only, driven by real computer-use against the packaged app.** Three-clip synthetic project: H.264 blue 0–1.50 s, malformed AV1 1.50–1.54 s (`AV1CodecConfigurationRecord` version 127, `avg_frame_rate=0/0`, unknown-sized EBML, and a **nonzero source start of 0.400 s**), H.264 green from 1.54 s. Real pointer control throughout — hover then click on the click-through HUD's Open Studio, ruler clicks and the previous-clip control to seek, the play control to run. **Seeking into the malformed AV1 clip renders that clip's own frame rather than a neighbour's**: the preview turned red at `0:01.5`, against blue at `0:01.2` and green at `0:02.3`. Read those colours as the three fixtures' own identities — blue `(0,0,255)`, AV1 `(254,0,0)`, green `(0,255,1)` at source 0.400 s, measured from the files, **not sampled off the screen**: no screenshot from this session was retained, so the on-screen colours are a qualitative reading and the identification rests on the clip-switch log below. Playback from 1.2 s crossed both boundaries and reached the third clip (green at `0:03.4`) before stopping at the end of the timeline. The app's own stderr binds that traversal to the fixtures in order (`h264-before-blue.mp4` → `legacy-av1-v127-zero-rate.webm` → `h264-after-green.mp4`) and shows the malformed traits being exercised live — 38 × `Unknown version 127 of AV1CodecConfigurationRecord found!` plus `Unknown-sized element at 0x3cd inside parent with finite size` — with no panic, fatal or abort line, the process still logging and still accepting UI input at the end of the pass, and closing Studio from the UI ending the app. (Liveness was polled during the run, but those polls were not persisted; what is checkable afterwards is the log and the fact that every later click landed.) **The AV1 clip is 40 ms, narrower than the 0.1 s playhead readout**, so the AV1 frame is identified by clip-boundary navigation and by the logged clip switch, not by the readout alone. **The fixtures are synthetic**, not a reporter-supplied file. **No Playwright ran in this pass** — the earlier Playwright run against the same package is supplemental and is not manual evidence. **Exit code not captured**: the waiter script threw before recording it, so "normal exit" rests on the UI close, the process disappearing and the absence of any Windows error event for the app. **Not run**: capture/HUD recording flow, webcam, microphone, system audio, export, GIF, transcription, editor operations beyond seek and play, macOS and Linux. **Environment note for the next run**: `request_access` resolves only Start-Menu-registered applications and refreshes that index on a delay, so an unpacked build needs a temporary shortcut *and* a retry before it can be driven at all; and re-fronting through that shortcut launches a **duplicate instance on the default profile** instead of raising the isolated one. | +| 2026-09-04 | three packaged arms differing in exactly one file: hashing all 181 packaged files shows `resources/electron/native/bin/win32-x64/compositor_view.node` is the only path that differs. PR base `9f64ae6d` built for this run as `CDA2618F…164B`; an older pre-fix addon from an unrelated branch build (`18B44DFB…0CDA`, 2026-09-02); this branch's `090B606C…AA3A`. Shared `Openscreen.exe` `32469490…3E6E` and project `B915113B…3E93` | Windows 11 Pro Insider Preview 26340.9233 | Pass — reproduces and clears the synthetic repro of issue 554 | **Scripted, not a manual OS-input pass**, and **each arm ran once**. All three arms were driven by the same Playwright script against the real packaged Electron process, so this row is a controlled A/B and does not replace the computer-use row above. **The base build crashes where this branch's build does not.** Playing H.264 → malformed AV1 → H.264, the PR-base arm terminated with exit code `3221225477` (`0xC0000005`, access violation) without reaching the third clip. The older pre-fix addon advanced its playhead to 1.907 s but still showed the first clip's blue `[0,15,255,255]` at 1.915 s, then terminated with the same `0xC0000005` before a screenshot could be taken. This branch's arm crossed at 1.908 s, rendered the third clip's green `[0,216,0,255]` (retained as a screenshot), and closed with exit code `0`. All three arms logged `Unknown version 127 of AV1CodecConfigurationRecord` and reached the AV1 clip's cursor sidecar, each recording `loaded=FAIL`; those `[live]` lines are native-compositor output, so the addon was live in every arm. Limits: the media is the synthetic fixture matching the reported AV1 configuration, not the reporter's unavailable recording, so this clears the reproduction rather than the reporter's file; interaction was script-injected rather than real OS input; the host is build 26340, not the reporter's 26200; n=1 per arm against a memory-corruption crash; and the post-playback canvas probe reads a different, smaller canvas element than the pre-playback one, so only the green frame has a screenshot behind it. | | | | | | | | | | | | | +| 2026-09-05 | Windows single-frame decoder ownership follow-up; source SHA-256 `FAF87561...87117`, bundled addon `94bc16d7...43a2c`, app.asar `8063a377...96f04` | Windows 11 build 26340; packaged Electron 41.2.1 | Scoped pass: nonzero AV1 seek and cross-clip playback | Fresh package, unique profile, no addon override; all 15 packaged native files hash-match staging (the existing packaging filter excludes standalone `ffmpeg.exe`). Native keyboard opened Studio after HUD pointer targeting failed; Studio seek/play/close used native input. AV1 at timeline 1.500 s/source 0.400 s rendered red `[255,24,0,255]` with a retained screenshot. During native playback from the first H.264 clip, read-only CDP samples observed blue followed by third-clip green `[0,216,0,255]` at 1.571 s; native logs confirm the malformed AV1 and following H.264 sources. PID 35472 exited 0 through window Close. An earlier sandboxed launch failed during Chromium GPU startup and is excluded from this pass. Limits: synthetic media, two native playback attempts in one successful session, no repeated stability or reporter-file claim; capture/webcam/tray/export not run. Exact-endpoint 4.540 s and one subsequent seek showed first-clip blue after native free-run wrapped: unchanged clip-identity synchronization paths, outside this resource/preflight follow-up; endpoint correctness is not claimed. Historical A/B rows remain separate. |