diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 95b37f542..dbf461149 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -82,8 +82,8 @@ jobs: NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "MinhOmega" + git config user.email "49482201+MinhOmega@users.noreply.github.com" # ONE release branch per STABLE version (release/vX.Y.Z), created at rc.1 and # FROZEN until promote publishes the stable tag. Later RCs (rc.2, rc.3, ...) are # cut from this same branch, so they carry the rc.1 snapshot plus any diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 72ffe4766..eb48a0746 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -75,8 +75,8 @@ jobs: # Writes package-lock.json too. A sed over package.json alone left the # lockfile behind on every release up to 1.9.0; see the script header. node .github/scripts/set-release-version.mjs "${STABLE_VERSION}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "MinhOmega" + git config user.email "49482201+MinhOmega@users.noreply.github.com" git add package.json package-lock.json git commit --allow-empty -m "chore(release): bump to ${STABLE_VERSION} [skip ci]" || true git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index 6a07b3562..448981a00 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -366,6 +366,35 @@ pub struct ClipInput { pub has_audio: bool, } +/// Bornes de taille d'export, en pixels. 16 parce qu'en dessous il n'y a plus de place +/// pour un macrobloc ; 8192 parce que c'est `wgpu::Limits::default().max_texture_dimension_2d`, +/// et que `d3d_linux::create_async` ouvre le device avec exactement ces limites — au-delà, +/// `Compositor::new_sized` déclenche une erreur de validation. (D3D11 et Metal montent à +/// 16384, mais le plafond commun est celui du backend le plus bas : le même JSON d'export +/// doit rendre le même fichier sur les trois.) +const EXPORT_PX: std::ops::RangeInclusive = 16..=8192; +/// Bornes de cadence. 240 est déjà au-delà de tout écran que l'app enregistre. +const EXPORT_FPS: std::ops::RangeInclusive = 1..=240; + +/// Taille d'export ramenée à ce que le GPU accepte, et paire. +/// +/// C'est la FRONTIÈRE IPC : `width`/`height`/`fps` arrivent d'un objet JS non validé et +/// allaient droit dans `Compositor::new_sized` (wgpu) puis dans le time base de +/// l'encodeur. `width: 10000` → erreur de validation wgpu → panic du gestionnaire par +/// défaut sur un worker libuv → abort du processus Electron, pas une `Promise` rejetée. +/// Les paliers du renderer bornent ça aujourd'hui, mais c'est ici que ça se borne. +/// +/// Pair : NV12 est 4:2:0, un côté impair n'a pas de plan chroma entier. +fn clamp_export_px(v: u32) -> u32 { + v.clamp(*EXPORT_PX.start(), *EXPORT_PX.end()) & !1 +} + +/// Cadence d'export bornée. `fps: 0` donnait `out_fps = 0` : un time base 1/0 et une +/// fenêtre qui ne produit aucune frame, soit un export vide au message opaque. +fn clamp_export_fps(fps: u32) -> u32 { + fps.clamp(*EXPORT_FPS.start(), *EXPORT_FPS.end()) +} + /// Taille/cadence/codec de sortie voulus par l'app (modale d'export). Tous optionnels : /// absent → comportement historique (1920x1080, fps du 1er clip, h264). `width`/`height` /// sont arrondis au pair le plus proche (exigence NV12 4:2:0) côté `export_multi`. @@ -423,12 +452,12 @@ impl Task for ExportMultiTask { let mut export_params = pipeline::ExportParams::default(); if let Some(p) = &self.params { if let Some(w) = p.width { - export_params.width = w.max(2) & !1; // pair le plus proche (>=2, NV12) + export_params.width = clamp_export_px(w); } if let Some(h) = p.height { - export_params.height = h.max(2) & !1; + export_params.height = clamp_export_px(h); } - export_params.fps = p.fps; + export_params.fps = p.fps.map(clamp_export_fps); if let Some(codec) = &p.codec { export_params.codec = match codec.as_str() { "h264" => pipeline::ExportCodec::H264, @@ -667,9 +696,9 @@ pub fn export_gif( .collect(); let gif_params = params .map(|p| GifExportParams { - width: p.width, - height: p.height, - fps: p.fps, + width: p.width.map(clamp_export_px), + height: p.height.map(clamp_export_px), + fps: p.fps.map(clamp_export_fps), loop_count: p.loop_count, dither: p.dither.unwrap_or(false), }) @@ -746,3 +775,31 @@ pub fn remux_seekable(input_path: String, output_path: String) -> AsyncTask= *EXPORT_PX.start()); + // Et 0 fps donnait un time base 1/0. + assert_eq!(clamp_export_fps(0), 1); + assert_eq!(clamp_export_fps(10_000), *EXPORT_FPS.end()); + + // Tout ce qui sort est pair (NV12 4:2:0) et dans les bornes. + for v in [0, 1, 15, 16, 17, 1919, 1920, 3841, 8191, 8192, 8193, u32::MAX] { + let c = clamp_export_px(v); + assert_eq!(c % 2, 0, "{v} -> {c} impair"); + assert!(EXPORT_PX.contains(&c), "{v} -> {c} hors bornes"); + } + // Une taille déjà légitime n'est pas touchée. + assert_eq!(clamp_export_px(1920), 1920); + assert_eq!(clamp_export_px(1080), 1080); + assert_eq!(clamp_export_fps(30), 30); + } +} diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index ee90c1374..2734fc081 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -241,12 +241,16 @@ unsafe fn decode_clip_audio_inner( source_start_sec: f64, source_end_sec: f64, ) -> Result> { - let mut fmt: *mut AVFormatContext = ptr::null_mut(); + // RAII : `find_stream_info` et tout `?` plus bas fuitaient le contexte ET son + // descripteur de fichier. Un export de 200 clips dont un au codec inconnu perdait + // 200 fd dans le processus principal, qui, lui, ne redémarre pas. + let mut open = crate::ffi::InputGuard::empty(); let cpath = CString::new(path)?; averr( - avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + avformat_open_input(&mut open.0, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), "audio open_input", )?; + let fmt = open.0; averr(avformat_find_stream_info(fmt, ptr::null_mut()), "audio find_stream_info")?; // Énumération de toutes les pistes audio (voir le commentaire de `decode_clip_audio`). let mut audio_stream_count = 0usize; @@ -268,7 +272,6 @@ unsafe fn decode_clip_audio_inner( } let mut dctx = avcodec_alloc_context3(decoder); if dctx.is_null() { - avformat_close_input(&mut fmt); bail!("audio avcodec_alloc_context3"); } if avcodec_parameters_to_context(dctx, codecpar) < 0 @@ -294,7 +297,6 @@ unsafe fn decode_clip_audio_inner( }); } if tracks.is_empty() { - avformat_close_input(&mut fmt); if audio_stream_count > 0 { bail!("audio : {audio_stream_count} piste(s) présentes, aucune décodable"); } @@ -316,8 +318,14 @@ unsafe fn decode_clip_audio_inner( } } - let mut packet = av_packet_alloc(); - let mut frame = av_frame_alloc(); + // Mêmes gardes que le contexte, et le null-check qui manquait : `av_packet_alloc` + // peut rendre NULL, et `av_read_frame` le déréférence sans demander. + let pkt_guard = PacketGuard(av_packet_alloc()); + let frame_guard = FrameGuard(av_frame_alloc()); + let (packet, frame) = (pkt_guard.0, frame_guard.0); + if packet.is_null() || frame.is_null() { + bail!("audio av_packet_alloc/av_frame_alloc"); + } let mut input_eof = false; // Une seule passe de démux alimente tous les décodeurs : chaque paquet est routé vers la @@ -326,13 +334,20 @@ unsafe fn decode_clip_audio_inner( while tracks.iter().any(|t| !t.reached_end && !t.decoder_eof) { if !input_eof { let read = av_read_frame(fmt, packet); - if read == AVERROR_EOF { + // Fin de fichier OU flux illisible : dans les deux cas on garde ce qui a été + // décodé et on vide les décodeurs. Propager l'erreur perdait TOUT l'audio du + // clip — un enregistrement abîmé s'exportait avec l'image et en silence, alors + // que `remux.rs` applique la même règle côté conteneur et que l'image, elle, + // survivait. La règle vit dans `ffi::read_ends_stream`. + if crate::ffi::read_ends_stream(read) { + if read != AVERROR_EOF { + eprintln!("[audio] lecture interrompue ({read}) : on garde le decode"); + } for track in tracks.iter_mut() { avcodec_send_packet(track.dctx, ptr::null()); } input_eof = true; } else { - averr(read, "audio av_read_frame")?; let packet_stream = (*packet).stream_index; if let Some(track) = tracks .iter_mut() @@ -395,10 +410,6 @@ unsafe fn decode_clip_audio_inner( } } - av_frame_free(&mut frame); - av_packet_free(&mut packet); - avformat_close_input(&mut fmt); - let target_samples = (((source_end_sec - source_start_sec).max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64) .round()) as usize; @@ -882,6 +893,17 @@ impl Drop for FilterGraphGuard { } } +/// RAII : libère le paquet de démultiplexage même en sortie précoce sur erreur. +struct PacketGuard(*mut AVPacket); + +impl Drop for PacketGuard { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { av_packet_free(&mut self.0) }; + } + } +} + /// RAII : libère la trame de drain même en sortie précoce sur erreur. struct FrameGuard(*mut AVFrame); diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 32ca76e79..b4361ef29 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -7,8 +7,8 @@ //! compose_frame, readback_direct}`) pour que `live.rs` et `compositor-view-napi` //! (cfg-re-exportes via `crate::compositor`) l'utilisent sans connaitre la //! plateforme. S'y ajoutent, specifiques a ce backend, les trois entrees de la -//! ring de staging (`set_readback_depth`, `readback_submit`, `readback_take`) : -//! seul l'export Linux les utilise, cf. `ReadbackRing`. +//! ring de staging (`set_readback_yuv_depth`, `readback_submit`, +//! `readback_take`) : seul l'export Linux les utilise, cf. `ReadbackRing`. //! //! **Iso-render.** La GEOMETRIE (placement de chaque calque) vient de //! `frame_geometry::plan_frame` -- la MEME fonction que Windows/macOS, au pixel @@ -118,7 +118,8 @@ struct PendingCopy { /// 12,6 ms mesures) et il depasse deja largement la chaine GPU (3,8 a 6,2 ms). /// Une 3e frame n'ajouterait que 8 Mo de memoire mappable et une frame de /// latence de plus. La profondeur reste parametrable parce que la POLITIQUE -/// differe par chemin (cf. `set_readback_depth`), pas pour empiler les buffers. +/// differe par chemin (cf. `set_readback_yuv_depth`), pas pour empiler les +/// buffers. /// /// UN SEUL RT. Le RT n'est pas double-bufferise : la copie de la frame N est /// soumise AVANT les commandes de composition de la frame N+1, sur la meme @@ -365,6 +366,43 @@ pub struct Compositor { seg_failed: RefCell, } +/// Rect englobant de quatre coins deja projetes : `(min_x, min_y, max_x, max_y)`. +/// Trois sites le calculaient a l'identique avant de passer aux coins locaux. +fn corners_bbox(corners: &[(f32, f32); 4]) -> (f32, f32, f32, f32) { + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + (min_x, min_y, max_x, max_y) +} + +/// `LayerCB` d'un fond degrade (mode 5), `c0` -> `c1` dans la direction donnee +/// par l'angle CSS. La bulle et le fond plein rendent le meme degrade, a la +/// boite pres. +fn gradient_cb( + dst: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + angle_deg: f32, + c0: [f32; 4], + c1: [f32; 4], +) -> LayerCB { + // angle CSS -> direction unitaire, meme convention que le fond d'ecran + // (dont la direction se lit en espace SORTIE : le degrade traverse le + // cadre, la bulle n'en montre que sa tranche). + let a = angle_deg.to_radians(); + LayerCB { + dst, + src: c1, + quad_px, + radius_px, + mode: 5.0, + color: c0, + fx: [a.sin(), -a.cos(), 0.0, 0.0], + ..Default::default() + } +} + impl Compositor { pub fn new(gpu: &Gpu) -> Result { Self::new_sized(gpu, OUT_W, OUT_H) @@ -626,9 +664,9 @@ impl Compositor { let (rt, rt_view, accum, accum_view, readback_bpr) = Self::make_targets(&gpu, w, h); let (ann_copy, ann_copy_view, ann_copy_mips) = Self::make_ann_copy(&gpu, w, h); - // Profondeur 1 par defaut = chemin synchrone historique, a l'octet et a - // la latence pres. C'est l'export qui demande explicitement 2 (cf. - // `set_readback_depth`) ; tout autre appelant garde l'ancien contrat. + // Profondeur 1 = chemin synchrone historique, a l'octet et a la latence + // pres, et la ring RGBA n'en sort jamais : seul l'export change de + // profondeur, et il le fait sur la ring YUV (`set_readback_yuv_depth`). let readback = RefCell::new(ReadbackRing { depth: 1, free: vec![Self::make_staging(&gpu, readback_bpr, h)], @@ -1056,10 +1094,7 @@ impl Compositor { opacity: f32, ) -> LayerCB { let (rw, rh) = (self.render_w as f32, self.render_h as f32); - let (min_x, max_x) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let (min_x, min_y, max_x, max_y) = corners_bbox(corners); // La boite doit contenir la penombre entiere, sinon elle se coupe net. let box_w = (max_x - min_x) + 2.0 * spread; let box_h = (max_y - min_y) + 2.0 * spread; @@ -1105,10 +1140,7 @@ impl Compositor { // le rayon, pour qu'il reste constant le long du bord au lieu de s'etirer // avec la perspective. let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; - let (min_x, max_x) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let (min_x, min_y, max_x, max_y) = corners_bbox(&corners); let bbox_w = (max_x - min_x).max(1.0); let bbox_h = (max_y - min_y).max(1.0); // Coins en px LOCAUX a la bbox, pour matcher `i.local` du shader. @@ -1362,20 +1394,7 @@ impl Compositor { Some(SceneBackground::Gradient { angle_deg, stops }) => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); - // angle CSS -> direction unitaire, meme convention que le fond - // d'ecran (dont la direction se lit en espace SORTIE : le degrade - // traverse le cadre, la bulle n'en montre que sa tranche). - let a = angle_deg.to_radians(); - flat(LayerCB { - dst, - src: [c1[0], c1[1], c1[2], c1[3]], - quad_px, - radius_px, - mode: 5.0, - color: c0, - fx: [a.sin(), -a.cos(), 0.0, 0.0], - ..Default::default() - }) + flat(gradient_cb(dst, quad_px, radius_px, *angle_deg, c0, c1)) } Some(SceneBackground::Image { path }) => { // Le cover-fit se mesure sur la BULLE, pas sur la sortie : c'est @@ -1916,16 +1935,7 @@ impl Compositor { Some(SceneBackground::Gradient { angle_deg, stops }) => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); - let a = angle_deg.to_radians(); - let cb = LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - src: [c1[0], c1[1], c1[2], c1[3]], - quad_px: [rw, rh], - mode: 5.0, - color: c0, - fx: [a.sin(), -a.cos(), 0.0, 0.0], - ..Default::default() - }; + let cb = gradient_cb([0.0, 0.0, 1.0, 1.0], [rw, rh], 0.0, angle_deg, c0, c1); ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Gradient(cb))) } Some(SceneBackground::Image { path }) => { @@ -2642,12 +2652,7 @@ impl Compositor { let (px, py) = quad.point_px(fx, fy); (center_px[0] + px, center_px[1] + py) }); - let (min_x, max_x) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let (min_x, min_y, max_x, max_y) = corners_bbox(&corners); // Le quad projete d'un sprite peut etre tres fin de biais : une bbox // d'un pixel de large ferait diverger le warp inverse, d'ou le // plancher a 1 px. @@ -2990,44 +2995,6 @@ impl Compositor { t.create_view(&wgpu::TextureViewDescriptor::default()) } - /// Regle la profondeur de la ring de staging. A appeler AVANT la premiere - /// relecture (elle vide la ring, donc toute frame encore en vol serait - /// perdue -- d'ou le drain explicite plutot qu'un silence). - /// - /// POLITIQUE PAR CHEMIN, et c'est volontaire : - /// - /// - **Export** (`pipeline_linux::run_composited_multi`) : profondeur 2. Il - /// ne veut que du DEBIT, la latence d'une frame ne se voit nulle part - /// puisque la sortie est un fichier. Il draine la ring a la fin, donc - /// aucune frame ne manque au montage. - /// - **Preview live** (`live.rs`) : profondeur 1, inchangee. Une frame de - /// retard y est perceptible -- le canvas afficherait l'avant-derniere - /// frame composee, et surtout la boucle ne relit QUE quand elle a avance - /// (`stepped`) : au repos (fin d'un scrub, pause) la derniere frame - /// resterait coincee dans la ring et le canvas figerait sur la - /// precedente jusqu'au prochain evenement. Le pipeline demanderait donc - /// un drain sur inactivite pour n'etre que neutre visuellement, pour un - /// gain qui n'est pas le goulot mesure ici. On ne l'impose pas. - /// - /// A profondeur 1 le chemin est exactement l'ancien : soumettre, attendre, - /// mapper, depadder. - pub fn set_readback_depth(&self, depth: usize) -> Result<()> { - let depth = depth.max(1); - // Draine d'abord : les frames en vol appartiennent a l'appelant - // precedent, les jeter en silence serait une perte de donnees muette. - while unsafe { self.readback_take()? }.is_some() {} - let mut ring = self.readback.borrow_mut(); - ring.depth = depth; - while ring.free.len() > depth { - ring.free.pop(); - } - while ring.free.len() < depth { - let buf = Self::make_staging(&self.gpu, self.readback_bpr, self.render_h); - ring.free.push(buf); - } - Ok(()) - } - /// Construit (ou reconstruit apres resize) les cibles et pipelines YUV. fn ensure_yuv(&self) -> Result<()> { // I420 par defaut : c'est le seul format que l'encodeur software sait @@ -3393,12 +3360,31 @@ impl Compositor { r.map(|()| true) } - /// Profondeur de la ring YUV. Meme role et memes raisons que - /// `set_readback_depth` pour la ring RGBA. + /// Regle la profondeur de la ring YUV. A appeler AVANT la premiere + /// relecture (elle vide la ring, donc toute frame encore en vol serait + /// perdue -- d'ou le drain explicite plutot qu'un silence). + /// + /// POLITIQUE PAR CHEMIN, et c'est volontaire : + /// + /// - **Export** (`pipeline_linux::run_composited_multi`) : profondeur 2. Il + /// ne veut que du DEBIT, la latence d'une frame ne se voit nulle part + /// puisque la sortie est un fichier. Il draine la ring a la fin, donc + /// aucune frame ne manque au montage. + /// - **Preview live** (`live.rs`) : profondeur 1, inchangee. Une frame de + /// retard y est perceptible -- le canvas afficherait l'avant-derniere + /// frame composee, et surtout la boucle ne relit QUE quand elle a avance + /// (`stepped`) : au repos (fin d'un scrub, pause) la derniere frame + /// resterait coincee dans la ring et le canvas figerait sur la + /// precedente jusqu'au prochain evenement. Le pipeline demanderait donc + /// un drain sur inactivite pour n'etre que neutre visuellement, pour un + /// gain qui n'est pas le goulot mesure ici. On ne l'impose pas. + /// + /// A profondeur 1 le chemin est exactement l'ancien : soumettre, attendre, + /// mapper, depadder. pub fn set_readback_yuv_depth(&self, depth: usize) -> Result<()> { let depth = depth.max(1); - // SAFETY : meme contrat que `set_readback_depth` — le drain ne touche que - // des buffers dont la soumission est terminee. + // SAFETY : le drain ne touche que des buffers dont la soumission est + // terminee. while unsafe { self.readback_take_yuv_with(|_, _, _| Ok(()))? } {} let mut ring = self.readback_yuv.borrow_mut(); ring.depth = depth; diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 6a02db6e1..3564e411e 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -6,17 +6,16 @@ use crate::config::Cfg; // pour le pourquoi. `pub use` sur les constantes : `pipeline_windows.rs`, `live.rs` et // `crates/poc-d3d/src/app.rs` les lisent via `crate::compositor::…`, et ce chemin doit // rester valable. -pub use crate::frame_geometry::{live_params_from_scene, webcam_shape_code, LayerCB, - LiveParams, FIXTURE_FRAMES, HALF_H, HALF_W, OUT_H, OUT_W}; +pub use crate::frame_geometry::{ + live_params_from_scene, webcam_shape_code, LayerCB, LiveParams, FIXTURE_FRAMES, OUT_H, OUT_W, +}; use crate::frame_geometry::{ - cover_crop_uv, cover_uv_rect, cursor_sprite_dst, decode_data_uri, ease_in_out_cubic, lerp, - lerp4, parse_hex, preset_placements, remap_box, screen_source_rect, timeline, CursorPlacement, - FrameParams, Placement, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, - SCREEN_SHADOW_SPREAD_FRAC, SHADOW_TUNING_REF_PX, WEBCAM_SHADOW_OFFSET_FRAC, + cover_crop_uv, cursor_sprite_dst, decode_data_uri, lerp, parse_hex, CursorPlacement, FPS, + SCREEN_SHADOW_OFFSET_FRAC, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC, }; use crate::cursor::CursorTrack; -use crate::scene::{Scene, SceneBackground, SceneCrop, SceneCursorSprite}; +use crate::scene::{Scene, SceneBackground, SceneCursorSprite}; use crate::d3d::Gpu; use crate::ffi::AVFrame; use anyhow::{bail, Result}; @@ -39,21 +38,6 @@ use windows::Win32::Graphics::Dxgi::Common::*; /// (1 774 Mo mesurés en parcourant les 18 wallpapers livrés) en laissant le jeu actif résident. const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024; - - - - - - - - - - - - - - - /// Cadence de l'inférence. Pas 60 : une silhouette ne bouge pas de façon perceptible en /// 16 ms, et c'est le seul levier mesuré qui divise le coût par deux sans toucher au modèle. const SEGMENTATION_HZ: u32 = 30; @@ -115,7 +99,6 @@ pub struct Compositor { ann_copy: ID3D11Texture2D, ann_copy_srv: ID3D11ShaderResourceView, // accumulateur pour le flou de mouvement (supersampling temporel) - accum: ID3D11Texture2D, accum_rtv: ID3D11RenderTargetView, accum_srv: ID3D11ShaderResourceView, blend_add: ID3D11BlendState, @@ -233,17 +216,6 @@ struct ResizeTarget { nv12_rtv_uv: ID3D11RenderTargetView, } - - - - - - - - - - - unsafe fn compile(src: &[u8], entry: &[u8], target: &[u8]) -> Result { let mut code: Option = None; let mut err: Option = None; @@ -599,7 +571,6 @@ impl Compositor { e_srv, ann_copy, ann_copy_srv: ann_copy_srv.unwrap(), - accum, accum_rtv: accum_rtv.unwrap(), accum_srv: accum_srv.unwrap(), blend_add: blend_add.unwrap(), @@ -651,13 +622,6 @@ impl Compositor { self.render_size.get().1 as f32 } - /// Dimensions entières du render target — pour les viewports et les boucles - /// de readback, qui veulent des `u32` et non des `f32`. - #[inline] - fn render_dims(&self) -> (u32, u32) { - self.render_size.get() - } - /// Taille à laquelle ce compositeur rastérise, après l'arrondi au pair de /// `new_sized`. L'appelant la compare à la géométrie qu'il veut produire pour /// savoir s'il doit reconstruire le compositeur (cf. `new_sized`). @@ -788,7 +752,7 @@ impl Compositor { // le rayon effectif du flou changerait avec la résolution de sortie (un // demi de 1080 n'est pas un demi de 2160), et le fond flouté ne serait plus // le même effet d'un format à l'autre. - let (rw_i, rh_i) = self.render_dims(); + let (rw_i, rh_i) = self.render_size(); let (half_w, half_h) = (rw_i / 2, rh_i / 2); let hw = half_w as f32; let hh = half_h as f32; @@ -1368,11 +1332,6 @@ impl Compositor { *self.seg_inbox.lock().unwrap() = None; } - /// Éteint l'effet : la webcam se redessine telle quelle à la frame suivante. - pub fn clear_webcam_mask(&self) { - *self.webcam_mask.borrow_mut() = None; - } - pub fn set_cursor(&self, track: CursorTrack) { *self.cursor.borrow_mut() = Some(track); } @@ -2341,48 +2300,6 @@ impl Compositor { self.ctx.PSSetShaderResources(2, Some(&[None])); } - /// Flou de mouvement (§8) : moyenne de `n` sous-frames aux temps intermédiaires - /// (mêmes textures vidéo, params d'animation à frame+k/n). Résultat laissé dans le RT. - pub unsafe fn compose_frame_mb( - &self, - screen: *const AVFrame, - webcam: *const AVFrame, - frame: u32, - cfg: &Cfg, - ) -> Result<()> { - let n = cfg.mblur_n; - if n <= 1 { - return self.compose_frame(screen, webcam, frame as f32, cfg); - } - // accumulateur à zéro - self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); - let w = 1.0 / n as f32; - for k in 0..n { - let tf = frame as f32 + (k as f32 + 0.5) / n as f32 - 0.5; - self.compose_frame(screen, webcam, tf, cfg)?; // -> self.rt - // accum += rt * (1/n) (blend factor = 1/n, dest = ONE) - self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); - self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); - self.ctx.VSSetShader(&self.vs_fs, None); - self.ctx.PSSetShader(&self.ps_tex, None); - self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); - let vp = D3D11_VIEWPORT { - TopLeftX: 0.0, TopLeftY: 0.0, - Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, - }; - self.ctx.RSSetViewports(Some(&[vp])); - self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); - self.upload_cb(&LayerCB::default()); - self.ctx.Draw(3, 0); - self.ctx.PSSetShaderResources(0, Some(&[None])); - } - // recopie l'accumulateur dans le RT (pour rgb_to_nv12 qui échantillonne rt_srv) - let src: ID3D11Resource = self.accum.cast()?; - let dst: ID3D11Resource = self.rt.cast()?; - self.ctx.CopyResource(&dst, &src); - Ok(()) - } - /// Rend le RT RGBA vers notre texture NV12 puis copie vers la surface `out_tex`/`slice`. pub unsafe fn rgb_to_nv12(&self, out_tex: *mut c_void, slice: u32) -> Result<()> { self.render_nv12(); @@ -2501,10 +2418,9 @@ impl Compositor { /// /// Pourquoi un helper dédié plutôt qu'un open-coding dans `live.rs` : tout le /// pattern GPU→CPU de ce fichier (staging `D3D11_USAGE_STAGING`, `CopyResource`, - /// `Map`/`D3D11_MAP_READ` + copie ligne par ligne qui respecte `RowPitch`) vit déjà - /// dans `dump_nv12`/`dump_raw` — le partager garde la connaissance D3D11 confinée - /// à ce fichier et assure que le live et l'export ne divergent pas sur un détail de - /// copie. La staging est cachée par taille (`live_readback_staging`) — recréée quand + /// `Map`/`D3D11_MAP_READ` + copie ligne par ligne qui respecte `RowPitch`) vit dans + /// ce fichier — le partager garde la connaissance D3D11 confinée à ce fichier et + /// assure que le live et l'export ne divergent pas sur un détail de copie. La staging est cachée par taille (`live_readback_staging`) — recréée quand /// `target_w`/`target_h` changent — pour ne pas payer une allocation par frame. /// /// Pré-requis : `target_w`/`target_h` ≥ 1. Aucun effet sur le pipeline d'export @@ -2581,7 +2497,7 @@ impl Compositor { }; // 3) GPU → CPU : `CopyResource` resize_target → staging, puis `Map` + copie - // ligne par ligne qui respecte `RowPitch` (cf. `dump_nv12`/`dump_raw`). + // ligne par ligne qui respecte `RowPitch` (cf. `readback_direct`). // `ID3D11Texture2D` hérite réellement de `ID3D11Resource` (contrairement au // SRV plus haut) donc ce `.cast()` est valide. let dst: ID3D11Resource = staging.cast()?; @@ -2606,7 +2522,7 @@ impl Compositor { /// Contrairement à `readback_resized` (qui passe par `blit_resized` → un `resize_target` /// incluant une texture NV12 jamais lue par ce chemin RGBA-only, puis une staging séparée), /// on copie directement `rt → staging` : la `staging` du compositeur est DÉJÀ dimensionnée - /// à la résolution de rendu (`new_inner`), exactement le patron de `dump_raw`. Depuis la + /// à la résolution de rendu (`new_inner`). Depuis la /// refonte ratio, le RT est rastérisé à la géométrie de sortie ramenée au panneau — soit /// précisément la taille que la preview veut afficher —, donc le resize de `readback_resized` /// était devenu une copie identité doublée d'une alloc NV12 inutile, du coût pur à chaque @@ -2617,12 +2533,12 @@ impl Compositor { /// canvas côté JS se dimensionne dessus (frame auto-descriptive), donc aucun couplage de /// taille à maintenir des deux côtés. pub unsafe fn readback_direct(&self) -> Result<(u32, u32, Vec)> { - let (rw, rh) = self.render_dims(); + let (rw, rh) = self.render_size(); self.ctx.CopyResource(&self.staging, &self.rt); let mut m = D3D11_MAPPED_SUBRESOURCE::default(); self.ctx.Map(&self.staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; // Copie ligne par ligne qui respecte `RowPitch` (la staging peut être paddée par le - // driver) — même idiome que `dump_raw`/`readback_resized`. + // driver) — même idiome que `readback_resized`. let row = (rw * 4) as usize; let mut out = vec![0u8; row * rh as usize]; for y in 0..rh as usize { @@ -2668,7 +2584,7 @@ impl Compositor { /// backends produisent le MÊME NV12 — sinon l'export CPU dériverait du matériel sur /// un détail de conversion, exactement ce que l'iso doit empêcher. unsafe fn nv12_source(&self, target_w: u32, target_h: u32) -> Result { - let (rw_i, rh_i) = self.render_dims(); + let (rw_i, rh_i) = self.render_size(); if target_w == rw_i && target_h == rh_i { self.render_nv12(); return Ok(self.nv12.clone()); @@ -2798,63 +2714,6 @@ impl Compositor { self.ctx.PSSetShaderResources(0, Some(&[None])); } - /// Debug : dump notre NV12 (Y puis UV entrelacé) en RAW, pour inspecter la conversion. - pub unsafe fn dump_nv12(&self, path: &str) -> Result<()> { - let sd = D3D11_TEXTURE2D_DESC { - Width: OUT_W, - Height: OUT_H, - MipLevels: 1, - ArraySize: 1, - Format: DXGI_FORMAT_NV12, - SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, - Usage: D3D11_USAGE_STAGING, - BindFlags: 0, - CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, - MiscFlags: 0, - }; - let mut stg: Option = None; - self.dev.CreateTexture2D(&sd, None, Some(&mut stg))?; - let stg = stg.unwrap(); - let src: ID3D11Resource = self.nv12.cast()?; - let dstr: ID3D11Resource = stg.cast()?; - self.ctx.CopyResource(&dstr, &src); - let mut m = D3D11_MAPPED_SUBRESOURCE::default(); - self.ctx.Map(&stg, 0, D3D11_MAP_READ, 0, Some(&mut m))?; - let (rw_i, rh_i) = self.render_dims(); - let mut out = Vec::with_capacity((rw_i * rh_i * 3 / 2) as usize); - // plan Y - for y in 0..rh_i as usize { - let row = (m.pData as *const u8).add(y * m.RowPitch as usize); - out.extend_from_slice(std::slice::from_raw_parts(row, rw_i as usize)); - } - // plan UV : commence à RowPitch*Height (offset donné par le pitch), demi-hauteur - let uv_off = m.RowPitch as usize * rh_i as usize; - for y in 0..(rh_i / 2) as usize { - let row = (m.pData as *const u8).add(uv_off + y * m.RowPitch as usize); - out.extend_from_slice(std::slice::from_raw_parts(row, rw_i as usize)); - } - self.ctx.Unmap(&stg, 0); - std::fs::write(path, &out)?; - Ok(()) - } - - /// Recopie le RT en RAM (RGBA tightly-packed) — vérification uniquement. - pub unsafe fn dump_raw(&self, path: &str) -> Result<()> { - self.ctx.CopyResource(&self.staging, &self.rt); - let mut m = D3D11_MAPPED_SUBRESOURCE::default(); - self.ctx.Map(&self.staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; - let (rw_i, rh_i) = self.render_dims(); - let mut out = vec![0u8; (rw_i * rh_i * 4) as usize]; - for y in 0..rh_i as usize { - let src = (m.pData as *const u8).add(y * m.RowPitch as usize); - let dst = out.as_mut_ptr().add(y * rw_i as usize * 4); - std::ptr::copy_nonoverlapping(src, dst, rw_i as usize * 4); - } - self.ctx.Unmap(&self.staging, 0); - std::fs::write(path, &out)?; - Ok(()) - } - /// Blit du RT composité (RGBA) vers un render target externe (backbuffer swapchain), /// mis à l'échelle dans le viewport `(x,y,w,h)` en pixels — sert la preview (§preview). /// Passe de copie `ps_tex` : même échantillonnage que `render_nv12`, sans conversion. diff --git a/crates/compositor/src/d3d_linux.rs b/crates/compositor/src/d3d_linux.rs index 1e1bc5433..754dbc675 100644 --- a/crates/compositor/src/d3d_linux.rs +++ b/crates/compositor/src/d3d_linux.rs @@ -159,6 +159,19 @@ async fn create_async(want: Backend) -> Result { (d, q, false) } }; + // SANS ce gestionnaire, wgpu appelle le sien : il `panic!`. Le compositeur tourne sur + // un worker libuv (export napi) ou sur le thread de `LiveView` -- un panic y devient un + // abort du processus Electron, donc l'enregistrement en cours perdu, pour une erreur de + // validation qui n'aurait du etre qu'un export en echec. Le gestionnaire n'a aucun + // moyen de rendre un `Result` (wgpu l'appelle de maniere asynchrone), donc il trace et + // laisse l'operation suivante echouer normalement. + // + // ponytail: trace seulement. La vraie garde est en amont -- `clamp_export_px` borne la + // taille a la frontiere IPC ; remonter l'erreur jusqu'a l'appelant demanderait un canal + // dans `Gpu`, a ajouter le jour ou une erreur non bornee arrive ici. + device.on_uncaptured_error(Box::new(|e| { + eprintln!("[wgpu] erreur non capturee : {e}"); + })); // Windows loggue son repli (`d3d_windows.rs`), Linux ne loggait rien : un hote // tombe sur lavapipe rendait a quelques fps sans que rien -- ni log, ni rapport // de bug -- ne permette de l'etablir a distance. diff --git a/crates/compositor/src/ffi.rs b/crates/compositor/src/ffi.rs index 0df8963a4..956d2dc4a 100644 --- a/crates/compositor/src/ffi.rs +++ b/crates/compositor/src/ffi.rs @@ -65,8 +65,119 @@ pub fn averr(ret: i32, ctx: &str) -> anyhow::Result<()> { Ok(()) } +/// RAII pour un `AVFormatContext` d'entrée : le ferme quel que soit le chemin de sortie. +/// +/// Un `avformat_open_input` réussi tient un **descripteur de fichier**. Chaque `?` posé +/// entre l'ouverture et la fermeture explicite en fuite donc un, dans le processus +/// principal d'Electron qui vit des heures : ouvrir 200 clips dont un seul est illisible +/// suffisait à consommer 200 fd, et la fuite se manifestait plus tard, ailleurs, en +/// `EMFILE` sur un fichier sans rapport. +/// +/// Même forme que `RemuxGuard` (`remux.rs`), `FrameGuard` et `FilterGraphGuard` +/// (`audio.rs`), mais ici plutôt que dans un module de plateforme : les trois pipelines et +/// le décodage audio démultiplexent tous, et c'est le seul endroit qu'ils partagent. +/// `Drop` ne peut pas faillir, donc l'erreur de fermeture est ignorée — on est déjà en +/// train de rendre une erreur au caller quand ça arrive. +pub struct InputGuard(pub *mut AVFormatContext); + +impl InputGuard { + /// Guard vide, à remplir par `avformat_open_input(&mut guard.0, …)`. + pub fn empty() -> InputGuard { + InputGuard(std::ptr::null_mut()) + } + + /// Cède le contexte à l'appelant, qui en devient responsable. + /// + /// Pour le chemin de succès d'un `open` qui range le contexte dans la structure + /// qu'il rend — sans ça, le guard le fermerait au `return`. + pub fn release(mut self) -> *mut AVFormatContext { + std::mem::replace(&mut self.0, std::ptr::null_mut()) + } +} + +impl Drop for InputGuard { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { avformat_close_input(&mut self.0) }; + } + } +} + +/// RAII pour les ressources de muxage de sortie : contexte, fichier ouvert, paquet. +/// +/// Même problème que `InputGuard`, côté écriture, et une conséquence de plus : `avio_open` +/// tient le fichier de sortie, donc un `?` survenu pendant la marche de timeline (codec +/// absent, dossier disparu, annulation) laissait un MP4 sans `moov` sur le disque **et** +/// son descripteur ouvert — sur Windows, `discard_partial_output` échouait alors à le +/// supprimer (ffmpeg n'ouvre pas en `FILE_SHARE_DELETE`) et l'utilisateur gardait un +/// fichier illisible portant son nom. +/// +/// `pb` n'est pas un champ : il est déjà dans `octx`, et le lire au moment du `Drop` évite +/// la seule chose qui puisse désynchroniser les deux. +/// +/// Linux garde son `Muxer` à lui : il porte en plus l'`AVStream` et l'encodeur AAC parce +/// qu'il est DÉPLACÉ vers le thread d'encodage. Les deux autres pipelines encodent sur +/// place et n'ont besoin que de ces trois pointeurs. +pub struct OutputGuard { + pub octx: *mut AVFormatContext, + pub opkt: *mut AVPacket, +} + +impl OutputGuard { + /// Guard qui possède `octx` dès son `avformat_alloc_output_context2`. + pub fn new(octx: *mut AVFormatContext) -> OutputGuard { + OutputGuard { octx, opkt: std::ptr::null_mut() } + } +} + +impl Drop for OutputGuard { + fn drop(&mut self) { + unsafe { + if !self.opkt.is_null() { + av_packet_free(&mut self.opkt); + } + if !self.octx.is_null() { + let mut pb = sn_fmt_get_pb(self.octx); + if !pb.is_null() { + avio_closep(&mut pb); + sn_fmt_set_pb(self.octx, std::ptr::null_mut()); + } + avformat_free_context(self.octx); + self.octx = std::ptr::null_mut(); + } + } + } +} + +/// Un code de retour d'`av_read_frame` qui met fin à la lecture — EOF **ou** erreur. +/// +/// La règle est la même pour tous les démuxages du crate : ce qui a déjà été lu est bon, +/// donc une erreur en cours de fichier arrête la boucle au lieu de jeter le travail. +/// `remux.rs` l'appliquait depuis toujours (un enregistrement coupé par un crash reste +/// lisible), `audio.rs` non : il ne tolérait qu'`AVERROR_EOF` et propageait le reste, donc +/// le clip s'exportait avec l'image et SANS une seconde de son (`audio_jobs.rs` traduisait +/// l'`Err` en « silence conservé »). La règle vit ici pour qu'elle ne puisse plus diverger +/// entre les deux. +/// +/// Mesuré sur cette machine : un conteneur tronqué (matroska, mov, mp4, wav — et même lu +/// à travers un tube non seekable) rend toujours `AVERROR_EOF`. Les codes qui passent par +/// ici sont donc ceux des données corrompues et des erreurs d'E/S, pas ceux d'une simple +/// coupure — raison pour laquelle aucune fixture ne peut les reproduire. +pub fn read_ends_stream(ret: i32) -> bool { + ret < 0 +} + #[cfg(test)] mod tests { + /// Le défaut : `== AVERROR_EOF` laissait passer les codes d'erreur vers un `?`. + #[test] + fn a_read_error_ends_the_stream_just_like_eof() { + assert!(super::read_ends_stream(super::AVERROR_EOF)); + assert!(super::read_ends_stream(super::AVERROR_INVALIDDATA), "données corrompues"); + assert!(super::read_ends_stream(-5), "EIO"); + assert!(!super::read_ends_stream(0), "un paquet lu n'arrête rien"); + } + /// Les constantes Rust doivent valoir EXACTEMENT ce que les macros ffmpeg valent /// sur cette cible. C'est le test qui aurait attrapé le -11 en dur sur macOS avant /// qu'il ne se manifeste comme « la preview reste noire ». diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 2b31b9e23..aae89ca6c 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -17,13 +17,6 @@ //! Effet de bord immédiat : cette géométrie et ses tests, qui n'avaient jamais été //! exécutés ailleurs que sur Windows, tournent maintenant aussi dans le job macOS. -// Sur macOS, la moitié de ce module est encore sans consommateur : le moteur Metal -// n'a pas de `compose_frame` en couches, donc rien n'appelle encore `screen_source_rect`, -// `cover_uv_rect`, les fractions d'ombre ou `CursorPlacement`. Ce n'est PAS du code mort — -// c'est du code que le port n'a pas encore atteint, et il est exercé par ses tests sur les -// deux plateformes. Le `allow` saute quand le pilotage des couches arrive côté Metal. -#![allow(dead_code)] - use crate::config::Cfg; use crate::scene::{Scene, SceneCrop}; @@ -277,6 +270,11 @@ pub(crate) fn remap_box(base: [f32; 4], cut_ref: [f32; 4], cut: [f32; 4]) -> [f3 /// Retourne `(u0, v0, u1, v1)`. Quand la boîte a déjà le ratio de la source, la coupe /// est la frame entière — donc aucun changement de pixel sur les placements qui étaient /// déjà corrects. +// Windows-only pour l'instant : c'est `compositor_windows` qui pose la coupe +// de la caméra, le port Metal et le port wgpu ne sont pas encore arrivés à ce +// calque. Le `cfg` remplace un `allow(dead_code)` de module qui masquait aussi +// tout le reste. +#[cfg(any(windows, test))] pub(crate) fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f32, f32, f32, f32) { let (cam_w, cam_h) = (visible[0].max(1.0), visible[1].max(1.0)); let (tex_w, tex_h) = (tex[0].max(1.0), tex[1].max(1.0)); @@ -336,8 +334,6 @@ pub(crate) fn cover_uv_rect(uv: [f32; 4], tex: [f32; 2], box_ar: f32) -> [f32; 4 let (cx, cy) = (uv[0] + w_uv * 0.5, uv[1] + h_uv * 0.5); [cx - new_w * 0.5, cy - new_h * 0.5, cx + new_w * 0.5, cy + new_h * 0.5] } -pub const HALF_W: u32 = OUT_W / 2; -pub const HALF_H: u32 = OUT_H / 2; pub const FIXTURE_FRAMES: u32 = 360; pub(crate) const FPS: f32 = 60.0; /// Longueurs de style exprimées en FRACTION du petit côté du cadre, et non en pixels. @@ -782,8 +778,21 @@ pub struct FrameGeometry { /// Metal et D3D (leur `draw_annotations` prend le rect en paramètre). Seul Linux part /// directement de `FrameGeometry`. Passer `s_dst` ici reste donc possible sur deux /// backends sur trois — d'où le nom du paramètre côté appelants, et les tests. +/// +/// La taille est **bornée au cadre de sortie** : `w`/`h` viennent du JSON de scène, non +/// vérifié, et les trois backends en font un `box_px` puis un `vec![0u8; w * h * 4]`. Un +/// `{"w": 100}` (unités relatives, donc 100 × la largeur) rendait 192 000 px à 1080p, soit +/// 83 Go d'allocation ; `1e9` saturait le `as u32` et faisait déborder le produit. Le +/// plafond est ici parce que c'est le seul point par où les trois passent — un `min` dans +/// chaque backend serait trois fois le même oubli en puissance. `min` traite aussi le NaN : +/// `f32::min(NaN, 1.0) == 1.0`. pub fn annotation_dst_in(anchor: [f32; 4], x: f32, y: f32, w: f32, h: f32) -> [f32; 4] { - [anchor[0] + x * anchor[2], anchor[1] + y * anchor[3], w * anchor[2], h * anchor[3]] + [ + anchor[0] + x * anchor[2], + anchor[1] + y * anchor[3], + (w * anchor[2]).min(1.0), + (h * anchor[3]).min(1.0), + ] } impl FrameGeometry { @@ -1623,6 +1632,23 @@ mod tests { } } + /// Une annotation ne peut pas être plus grande que le cadre de sortie. + /// + /// `w`/`h` viennent du JSON de scène : `{"w": 100}` en unités relatives faisait + /// 192 000 px de large à 1080p, donc `vec![0u8; 192_000 * 108_000 * 4]` dans le + /// rastériseur de texte. Le plafond est ici, pas dans les trois backends. + #[test] + fn an_annotation_box_is_clamped_to_the_output_frame() { + let dst = annotation_dst_in([0.0, 0.0, 1.0, 1.0], 0.0, 0.0, 100.0, 100.0); + assert!(dst[2] <= 1.0 && dst[3] <= 1.0, "boîte non bornée : {dst:?}"); + // Un `w` non fini ne doit pas non plus passer en `as u32` chez l'appelant. + let nan = annotation_dst_in([0.0, 0.0, 1.0, 1.0], 0.0, 0.0, f32::NAN, f32::INFINITY); + assert!(nan[2] <= 1.0 && nan[3] <= 1.0, "boîte non finie : {nan:?}"); + // Et une boîte normale n'est pas touchée. + let ok = annotation_dst_in([0.1, 0.2, 0.8, 0.5], 0.0, 0.0, 0.5, 0.5); + assert_eq!([ok[2], ok[3]], [0.4, 0.25]); + } + /// **Le golden iso-render.** /// /// Les deux backends ne peuvent pas tourner sur la même machine, donc « iso avec diff --git a/crates/compositor/src/gif_export.rs b/crates/compositor/src/gif_export.rs index 93950b3fc..371ee4565 100644 --- a/crates/compositor/src/gif_export.rs +++ b/crates/compositor/src/gif_export.rs @@ -211,17 +211,6 @@ fn export_gif_inner( .with_context(|| format!("export_gif: create {}", out_path.display()))?; let mut writer = BufWriter::new(file); - // GIF frame delay in centiseconds (= 1/100 s). `fps` → - // `100 / fps` cs per frame, rounded to the nearest unit the - // GIF spec supports. u16 caps at 65535 — 10.9 minutes per - // frame, plenty. - // - // ponytail: integer centiseconds can't express every fps exactly - // (12 → 8 cs → 12.5 fps). `video_duration_s` below is computed - // from the delays actually written, so the reported duration never - // disagrees with the file. Fractional accumulation if a viewer ever - // cares about the ~4% drift. - let delay_cs: u16 = (100_u32 / fps).max(1) as u16; // Pre-allocate the per-frame index buffer. Reused across // frames so we don't hit the allocator in the hot loop. @@ -301,7 +290,7 @@ fn export_gif_inner( } // Per-frame palette (GIF local palette, written by `write_frame`). - gw.write_frame(&indices, &palette_rgb, delay_cs, fps)?; + gw.write_frame(&indices, &palette_rgb, delay_cs_at(frame_index, fps), fps)?; progress(frame_index + 1); Ok(()) }, @@ -319,15 +308,38 @@ fn export_gif_inner( let wall_s = t0.elapsed().as_secs_f64(); let fps_actual = if wall_s > 0.0 { frames as f64 / wall_s } else { 0.0 }; - // From the delays actually written, not from the requested fps — see the - // `delay_cs` note above. - let video_duration_s = frames as f64 * (delay_cs as f64 / 100.0); + // From the delays actually written, not from the requested fps — the + // cumulative form IS the sum of the per-frame delays, by construction. + let video_duration_s = elapsed_cs(frames, fps) as f64 / 100.0; let file_bytes = std::fs::metadata(out_path).map(|m| m.len()).unwrap_or(0); Ok(GifStats { frames, wall_s, fps: fps_actual, video_duration_s, file_bytes }) } +/// Total delay, in centiseconds, of the first `n` frames at `fps`. +/// +/// The GIF spec measures delays in whole centiseconds, and most fps values do not +/// divide 100: at 30 fps, `100 / 30` truncates to 3 cs, so the file plays at 33.3 fps +/// and a 60 s clip runs out in 54 s. 40 fps was 25 % fast. Keeping the exact cumulative +/// time and taking differences (Bresenham) spreads the remainder over the frames +/// instead of dropping it on every one of them, so the total is right to ±½ cs however +/// many frames there are. +fn elapsed_cs(n: u64, fps: u32) -> u64 { + let fps = fps.max(1) as u64; + // round(n * 100 / fps), in integers. + (n * 200 + fps) / (2 * fps) +} + +/// Delay written in frame `n`'s Graphics Control Extension. +/// +/// `max(1)`: a 0 cs delay means "as fast as the viewer can" in practice, which is not a +/// speed at all. Above 100 fps the spec simply cannot express the cadence — the clamp +/// is the ceiling of the format, not of this code. +fn delay_cs_at(n: u64, fps: u32) -> u16 { + (elapsed_cs(n + 1, fps) - elapsed_cs(n, fps)).max(1).min(u16::MAX as u64) as u16 +} + // ===================================================================== // GIF89a format writer (pure std::io::Write). // ===================================================================== @@ -973,6 +985,33 @@ mod tests { use super::*; use std::io::Cursor; + /// One second of frames must last one second, whatever the fps. + /// + /// `100 / fps` in whole centiseconds made 30 fps play 11 % fast and 40 fps 25 % fast: + /// the walk still emits `fps` frames per source second, so the shortfall is pure + /// speed-up. The per-frame delays are unequal by design — their SUM is the contract. + #[test] + fn a_second_of_frames_lasts_a_second_at_every_fps() { + for fps in [1u32, 10, 12, 24, 25, 30, 40, 50, 60] { + let sum: u64 = (0..fps as u64).map(|i| delay_cs_at(i, fps) as u64).sum(); + assert!( + (sum as i64 - 100).abs() <= 1, + "{fps} fps: une seconde dure {sum} cs au lieu de 100" + ); + // And it stays true over a long clip, which is where truncation showed: + // 60 s at 30 fps used to end at 54 s. + let n = fps as u64 * 60; + let long: u64 = (0..n).map(|i| delay_cs_at(i, fps) as u64).sum(); + assert!( + (long as i64 - 6000).abs() <= 1, + "{fps} fps: 60 s durent {long} cs au lieu de 6000" + ); + // The reported duration is read off the same accumulator, so it cannot + // disagree with the delays actually written. + assert_eq!(elapsed_cs(n, fps), long, "{fps} fps: durée annoncée ≠ écrite"); + } + } + /// The most basic round-trip: write a 2×2 frame and check the /// file is well-formed GIF89a. No decode — we just walk the /// output bytes and confirm the structural shape. diff --git a/crates/compositor/src/linux_decode.rs b/crates/compositor/src/linux_decode.rs index 6add86043..5e13746cb 100644 --- a/crates/compositor/src/linux_decode.rs +++ b/crates/compositor/src/linux_decode.rs @@ -36,10 +36,6 @@ extern "C" { fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; } -/// `SEEK_SET` constant — la position de seek `av_seek_frame` interprète -/// `timestamp` comme un timestamp absolu (AV_TIME_BASE = microsecondes). -const SEEK_SET: i32 = 0; - /// Ce que la boucle d'avance de `decode_at` doit faire de la frame qu'elle vient /// de décoder. #[derive(Debug, Clone, Copy, PartialEq)] @@ -366,7 +362,7 @@ impl SwDecoder { /// jusqu'à la frame DUE à cet instant : la dernière dont le pts est ≤ la /// cible, jamais une frame encore à venir (même invariant que /// `timeline_walk::frame_step`, cf. la boucle plus bas). Le seek est résolu - /// par `av_seek_frame` avec `SEEK_SET | BACKWARD` (cherche le keyframe + /// par `av_seek_frame` avec `AVSEEK_FLAG_BACKWARD` (cherche le keyframe /// précédent le timestamp demandé). Renvoie une `AVFrame` allouée par /// `av_frame_alloc` que le caller doit libérer via `free_frame` — /// ou laisser `vk_frames::VkFrames::present` consommer (qui réécrit @@ -401,7 +397,9 @@ impl SwDecoder { // ~1,78 s, la webcam toutes les ~6,73 s, donc l'écart y est ~4x plus grand. Et // comme `live::Player::step` rattrape la webcam par une boucle monotone vers // l'avant, une fois garée dans le futur elle ne revient jamais — elle fige. - let seek_flags = SEEK_SET | AVSEEK_FLAG_BACKWARD; + // Pas d'autre flag : `av_seek_frame` interprète alors `timestamp` comme + // un timestamp absolu (AV_TIME_BASE = microsecondes). + let seek_flags = AVSEEK_FLAG_BACKWARD; let target_ts_seconds = target_ts / 1_000_000.0; let r = av_seek_frame(self.fmt, -1, target_ts as i64, seek_flags); if r < 0 { diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index f854a0a88..f06652b0c 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -11,8 +11,8 @@ //! - avant : blit du RT vers le backbuffer du swapchain, `Present`. //! - maintenant : `comp.readback_direct()` copie le RT directement vers la staging //! `D3D11_USAGE_STAGING` (déjà dimensionnée à la résolution de rendu), `Map`/ -//! `D3D11_MAP_READ`, copie ligne par ligne qui respecte `RowPitch` (même idiome que -//! `dump_nv12`/`dump_raw`), et stocke le `Vec` dans `Shared::latest_frame` pour +//! `D3D11_MAP_READ`, copie ligne par ligne qui respecte `RowPitch`), et stocke le +//! `Vec` dans `Shared::latest_frame` pour //! le `read_frame` napi. Plus de resize intermédiaire (`blit_resized`) : le RT est //! déjà à la taille voulue, CSS met à l'échelle vers la boîte du panneau côté JS. //! diff --git a/crates/compositor/src/partial_output.rs b/crates/compositor/src/partial_output.rs index 171fc7a46..832061a47 100644 --- a/crates/compositor/src/partial_output.rs +++ b/crates/compositor/src/partial_output.rs @@ -15,10 +15,12 @@ //! façon. Une façade qui enveloppe l'appel les couvre tous sans toucher au //! corps. //! -//! ponytail: seul le fichier est nettoyé ; les contextes ffmpeg alloués dans les -//! `*_inner` fuient toujours sur ces sorties-là (il faudrait une garde RAII par -//! pointeur, comme `FrameGuard`). Un export raté est rare et ne boucle pas — à -//! reprendre si ça devient un mode de marche. +//! Les contextes ffmpeg, eux, sont libérés par leurs gardes RAII +//! (`ffi::OutputGuard`, `ffi::InputGuard`, `pipeline_linux::Muxer`), qui se +//! déclenchent PENDANT le `run` ci-dessous — donc avant la suppression. C'est +//! ce qui la rend possible sur Windows : ffmpeg n'ouvre pas la sortie en +//! `FILE_SHARE_DELETE`, et tant que son descripteur restait ouvert le +//! `remove_file` échouait en silence et le MP4 sans `moov` survivait. use anyhow::Result; use std::path::Path; diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 057e4a2b8..adc283521 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -9,7 +9,7 @@ //! encodeur SOFTWARE (`libopenh264` H264 / `libkvazaar` H265 -- les seuls du //! build LGPL BtbN qui marchent sans device HW, VAAPI/Vulkan-encode = suivi), //! la frame composée est relue en RGBA (ring de staging à 2, cf. -//! `Compositor::set_readback_depth`) puis convertie +//! `Compositor::set_readback_yuv_depth`) puis convertie //! YUV420P par `sws_scale`. La marche de timeline est PARTAGÉE //! (`timeline_walk::walk_composited_timeline`) et le muxer passe par le shim C //! `sn_fmt_set_pb` (comme Windows/macOS). **L'audio AAC n'est pas encore muxé** @@ -524,7 +524,9 @@ struct Muxer { pb: *mut crate::ffi::AVIOContext, ostream: *mut crate::ffi::AVStream, opkt: *mut crate::ffi::AVPacket, - aac: AacEncoder, + /// `None` seulement entre l'`avio_open` et l'ouverture de l'encodeur AAC, le temps + /// que `open_muxer` finisse de garnir le muxer. Un export qui tourne en a toujours un. + aac: Option, } // SAFETY : aucun de ces pointeurs n'a d'affinite de thread. Le muxer est DEPLACE @@ -559,6 +561,61 @@ impl Muxer { } } +/// Ouvre le conteneur de sortie complet : flux video, fichier, flux AAC, header. +/// +/// FONCTION A PART, ET QUI REND UN `Muxer` DEJA CONSTRUIT. En ligne dans +/// `run_composited_multi_inner`, les quarante lignes de reglage vivaient ENTRE +/// `avformat_alloc_output_context2` et la construction du `Muxer` -- donc hors de portee +/// de son `Drop`. Chacun de leurs `?` fuitait le contexte, et ceux d'apres l'`avio_open` +/// fuitaient en plus le fichier ouvert (un `write_header` refuse des que le conteneur ne +/// peut pas porter les deux flux). Ici le muxer est bati des l'alloc et garni ensuite : +/// il n'existe plus d'instant ou une ressource ouverte n'appartient a personne. +unsafe fn open_muxer(out: &str, ectx: *mut crate::ffi::AVCodecContext) -> Result { + let outc = CString::new(out)?; + let mut octx: *mut crate::ffi::AVFormatContext = ptr::null_mut(); + crate::ffi::averr( + crate::ffi::avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + // A partir d'ici tout appartient au muxer, y compris sur les chemins d'erreur. + let mut mux = Muxer { + octx, + pb: ptr::null_mut(), + ostream: ptr::null_mut(), + opkt: ptr::null_mut(), + aac: None, + }; + mux.ostream = crate::ffi::avformat_new_stream(octx, ptr::null()); + if mux.ostream.is_null() { + bail!("avformat_new_stream"); + } + crate::ffi::averr( + crate::ffi::avcodec_parameters_from_context((*mux.ostream).codecpar, ectx), + "params_from_ctx", + )?; + (*mux.ostream).time_base = (*ectx).time_base; + crate::ffi::averr( + crate::ffi::avio_open(&mut mux.pb, outc.as_ptr(), crate::ffi::AVIO_FLAG_WRITE as i32), + "avio_open", + )?; + crate::ffi::sn_fmt_set_pb(octx, mux.pb); + // Le flux AAC doit exister AVANT l'en-tete (le muxer y fige sa table de flux). + // Meme si aucun clip n'a d'audio, on ecrit une piste silencieuse -- parite + // avec Windows/macOS, qui muxent toujours l'AAC. + mux.aac = Some(AacEncoder::open(octx)?); + crate::ffi::averr( + crate::ffi::avformat_write_header(octx, ptr::null_mut()), + "write_header", + )?; + // Jamais verifie : un `av_packet_alloc` a court de memoire rendait NULL et le + // premier `drain` le dereférait. + mux.opkt = crate::ffi::av_packet_alloc(); + if mux.opkt.is_null() { + bail!("av_packet_alloc"); + } + Ok(mux) +} + impl Drop for Muxer { fn drop(&mut self) { unsafe { @@ -970,43 +1027,8 @@ fn run_composited_multi_inner( let mut webcam_decs: HashMap = HashMap::new(); // ---- muxer MP4 (flux video + flux AAC) ---- - let outc = CString::new(out)?; - let mut octx: *mut crate::ffi::AVFormatContext = ptr::null_mut(); - let mut pb: *mut crate::ffi::AVIOContext = ptr::null_mut(); - let ostream; - let opkt; - let audio_encoder; - unsafe { - crate::ffi::averr( - crate::ffi::avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), - "alloc_output_context2", - )?; - ostream = crate::ffi::avformat_new_stream(octx, ptr::null()); - if ostream.is_null() { - bail!("avformat_new_stream"); - } - crate::ffi::averr( - crate::ffi::avcodec_parameters_from_context((*ostream).codecpar, ectx), - "params_from_ctx", - )?; - (*ostream).time_base = (*ectx).time_base; - crate::ffi::averr( - crate::ffi::avio_open(&mut pb, outc.as_ptr(), crate::ffi::AVIO_FLAG_WRITE as i32), - "avio_open", - )?; - crate::ffi::sn_fmt_set_pb(octx, pb); - // Le flux AAC doit exister AVANT l'en-tete (le muxer y fige sa table de flux). - // Meme si aucun clip n'a d'audio, on ecrit une piste silencieuse -- parite - // avec Windows/macOS, qui muxent toujours l'AAC. - audio_encoder = AacEncoder::open(octx)?; - crate::ffi::averr( - crate::ffi::avformat_write_header(octx, ptr::null_mut()), - "write_header", - )?; - opkt = crate::ffi::av_packet_alloc(); - } - // A partir d'ici le muxer est un seul objet, et il part avec l'encodeur. - let mux = Muxer { octx, pb, ostream, opkt, aac: audio_encoder }; + // Un seul objet des le depart, donc un seul proprietaire sur tous les chemins. + let mux = unsafe { open_muxer(out, ectx)? }; // Profondeur 3 : deux frames en vol suffisent a couvrir l'encodeur, la // troisieme absorbe les a-coups de la marche (une fin de clip y decode tout // l'audio du clip d'un coup, cf. `on_clip_end`). @@ -1043,7 +1065,7 @@ fn run_composited_multi_inner( .map(|scene| scene.audio_tracks.clone()) .unwrap_or_default(); // Ring de staging a 2 : l'export ne veut que du debit, une frame de latence - // ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour + // ne se voit pas dans un fichier. Voir `Compositor::set_readback_yuv_depth` pour // la raison pour laquelle la preview, elle, reste a 1. comp.set_readback_yuv_depth(2)?; // pts d'encodage : DECOUPLE de l'index de marche `n`, puisque la frame @@ -1229,7 +1251,7 @@ fn run_composited_multi_inner( let declared_audio: Vec = clips.iter().map(|c| c.has_audio).collect(); let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64); let octx = mux.octx; - mux.aac.encode( + mux.aac.as_mut().expect("encodeur AAC du muxer").encode( &finish_audio( mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks), audio_settings, @@ -1637,6 +1659,72 @@ mod tests { const W: i32 = 320; const H: i32 = 180; + /// Descripteurs ouverts par le processus. + fn fd_count() -> usize { + std::fs::read_dir("/proc/self/fd").expect("/proc/self/fd").count() + } + + /// Contexte d'encodage minimal : `avcodec_parameters_from_context` lit les champs, + /// il n'a pas besoin d'un codec ouvert. Monter un vrai encodeur ferait dépendre le + /// test de la présence de `libopenh264`, qui n'a rien à voir avec ce qu'il mesure. + unsafe fn dummy_video_ctx() -> *mut crate::ffi::AVCodecContext { + let ctx = crate::ffi::avcodec_alloc_context3(ptr::null()); + assert!(!ctx.is_null()); + (*ctx).codec_type = crate::ffi::AVMediaType::AVMEDIA_TYPE_VIDEO; + (*ctx).codec_id = crate::ffi::AVCodecID::AV_CODEC_ID_H264; + (*ctx).width = W; + (*ctx).height = H; + (*ctx).time_base = crate::ffi::AVRational { num: 1, den: 30 }; + ctx + } + + /// Un export qui échoue à l'ouverture du conteneur ne doit rien laisser derrière lui. + /// + /// Les deux échecs sont ceux du terrain : le dossier de destination a disparu (rien + /// n'est ouvert, le contexte fuyait quand même), et le conteneur refuse de porter les + /// deux flux (le fichier EST ouvert — c'est là que partait le descripteur). + #[test] + fn a_muxer_that_fails_to_open_leaks_neither_context_nor_file() { + let dir = std::env::temp_dir().join("capturia-muxer-open"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let missing = dir.join("pas-de-dossier").join("x.mp4"); + let missing = missing.to_str().unwrap().to_string(); + // WAV ne porte qu'un flux : `open_muxer` y ajoute la vidéo PUIS l'AAC, donc + // `write_header` refuse — APRÈS l'`avio_open`, fichier ouvert en main. + let two_streams = dir.join("x.wav"); + let two_streams_s = two_streams.to_str().unwrap().to_string(); + + unsafe { + let ctx = dummy_video_ctx(); + // Un tour à vide : ffmpeg charge ses tables au premier appel. + let _ = open_muxer(&missing, ctx); + let _ = open_muxer(&two_streams_s, ctx); + let _ = std::fs::remove_file(&two_streams); + + let before = fd_count(); + for _ in 0..50 { + assert!(open_muxer(&missing, ctx).is_err(), "dossier absent"); + assert!(open_muxer(&two_streams_s, ctx).is_err(), "deux flux dans un WAV"); + } + let after = fd_count(); + assert!( + after <= before + 2, + "50 ouvertures ratées ont laissé {} fd ouverts ({before} -> {after})", + after as i64 - before as i64 + ); + let mut ctx = ctx; + crate::ffi::avcodec_free_context(&mut ctx); + } + + assert!(!missing.starts_with("/nonexistent"), "chemin de test sous /tmp"); + assert!( + !std::path::Path::new(&missing).exists(), + "un export vers un dossier absent a créé un fichier" + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// Sortie d'un encodage de test : les drapeaux « image clé » paquet par paquet, /// et l'extradata (le SPS, en Annex-B, puisque `try_open` pose /// `AV_CODEC_FLAG_GLOBAL_HEADER`). diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs index d99f29386..82b1ac46d 100644 --- a/crates/compositor/src/pipeline_macos.rs +++ b/crates/compositor/src/pipeline_macos.rs @@ -1,9 +1,9 @@ //! Pipeline ffmpeg côté macOS — VideoToolbox (HW) + libopenh264 (software). //! //! Équivalent macOS de `pipeline_windows.rs` (D3D11VA + h264_amf zero-copy). -//! Exporte la même surface publique : `Stats`, `FrameGuard`, `Decoder`, `VideoEncoder`, -//! `ExportCodec`, `ExportParams`, `ClipSource`, et les points d'entrée `decode_frame_n`, -//! `run_c0`, `run_preview_bench`, `run_composited`, `run_composited_multi`, +//! Exporte la même surface publique : `Stats`, `Decoder`, `VideoEncoder`, +//! `ExportCodec`, `ExportParams`, `ClipSource`, et les points d'entrée `run_c0`, +//! `run_preview_bench`, `run_composited`, `run_composited_multi`, //! `probe_frame_count`. //! //! # Frame seam — adaptation macOS @@ -50,16 +50,6 @@ pub struct Stats { pub video_duration_s: f64, } -/// Garde RAII sur une AVFrame (la libère au Drop). Identique à -/// `pipeline_windows::FrameGuard`. -pub struct FrameGuard(pub *mut crate::ffi::AVFrame); - -impl Drop for FrameGuard { - fn drop(&mut self) { - unsafe { crate::ffi::av_frame_free(&mut self.0) }; - } -} - /// Au-delà de cette distance vers l'avant, `Decoder::seek_to` repart d'une image clé /// plutôt que de dérouler. Identique à `pipeline_windows::SEEK_FORWARD_MAX_SEC` — le /// seuil dépend du GOP des captures, pas du backend de décodage. @@ -127,12 +117,17 @@ impl Decoder { pub fn open_with(path: &str, gpu: &Gpu, intent: DecodeIntent) -> Result { unsafe { - let mut fmt: *mut crate::ffi::AVFormatContext = ptr::null_mut(); + // RAII : tout `?` entre l'ouverture et le `Ok(Decoder { .. })` final -- un + // fichier sans flux vidéo, un codec sans décodeur -- fuitait le contexte ET son + // descripteur de fichier. `release()` cède la propriété au `Decoder`, dont le + // `Drop` ferme comme avant. + let mut open = crate::ffi::InputGuard::empty(); let cpath = CString::new(path)?; crate::ffi::averr( - crate::ffi::avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + crate::ffi::avformat_open_input(&mut open.0, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), "open_input", )?; + let fmt = open.0; crate::ffi::averr( crate::ffi::avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info", @@ -288,7 +283,7 @@ impl Decoder { )?; Ok(Decoder { - fmt, + fmt: open.release(), dctx, hwdev, vidx, @@ -1046,11 +1041,6 @@ unsafe fn nv12_to_yuv420p(_src: *mut crate::ffi::AVFrame, _dst: *mut crate::ffi: // pas exercé (libopenh264 prend NV12, h264_videotoolbox prend VT). } -/// C0 (§9) — stub symétrique à `pipeline_windows::run_c0`. -pub fn decode_frame_n(_path: &str, _gpu: &Gpu, _n: u32) -> Result { - Err(anyhow!("pipeline_macos::decode_frame_n: non implémenté")) -} - pub fn run_c0(_screen: &str, _out: &str, _gpu: &Gpu) -> Result { Err(anyhow!("pipeline_macos::run_c0: non implémenté")) } @@ -1144,6 +1134,12 @@ fn run_composited_multi_inner( "alloc_output_context2", )?; } + // RAII dès l'alloc : tout `?` d'ici à la fin -- la marche de timeline, `enc.send`, + // l'encodage audio, une annulation -- sautait le démontage plus bas et laissait `octx`, + // le paquet et SURTOUT le fichier ouvert par `avio_open`. C'est ce descripteur qui + // faisait échouer la suppression de `partial_output` (ffmpeg n'ouvre pas en + // `FILE_SHARE_DELETE`), donc survivre un MP4 tronqué et sans `moov`. + let mut mux = crate::ffi::OutputGuard::new(octx); let ostream = unsafe { crate::ffi::avformat_new_stream(octx, ptr::null()) }; if ostream.is_null() { bail!("avformat_new_stream"); @@ -1180,7 +1176,11 @@ fn run_composited_multi_inner( let mut audio_jobs: ClipAudioJobs> = ClipAudioJobs::new(clips.len()); let mut clip_frame_counts: Vec = vec![0; clips.len()]; - let mut opkt = unsafe { crate::ffi::av_packet_alloc() }; + mux.opkt = unsafe { crate::ffi::av_packet_alloc() }; + if mux.opkt.is_null() { + bail!("av_packet_alloc"); + } + let opkt = mux.opkt; // La marche de timeline est PARTAGÉE (`timeline_walk`) : c'est elle qui décide quelle // frame source appartient à quelle frame de sortie, en tenant compte des régions de @@ -1281,10 +1281,9 @@ fn run_composited_multi_inner( crate::ffi::av_write_trailer(octx), "write_trailer", )?; - crate::ffi::avio_closep(&mut pb); - crate::ffi::avformat_free_context(octx); - crate::ffi::av_packet_free(&mut opkt); } + // Même ordre qu'avant : le guard ferme le fichier puis libère le contexte et le paquet. + drop(mux); let wall_s = t0.elapsed().as_secs_f64(); drop(_finalize); diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index 801630c1e..b60cbab34 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -27,154 +27,6 @@ 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é @@ -199,129 +51,6 @@ pub struct Stats { pub video_duration_s: f64, } -/// Garde RAII sur une AVFrame (la libère au Drop). -pub struct FrameGuard(pub *mut AVFrame); -impl Drop for FrameGuard { - fn drop(&mut self) { - unsafe { av_frame_free(&mut self.0) }; - } -} - -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 { - unsafe { decode_frame_n_inner(path, gpu, n) } -} - -unsafe fn decode_frame_n_inner(path: &str, gpu: &Gpu, n: u32) -> Result { - let mut fmt: *mut AVFormatContext = ptr::null_mut(); - let cpath = CString::new(path)?; - averr( - avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), - "open_input", - )?; - 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"); - } - let stream = sn_fmt_stream(resources.fmt, vidx); - let codecpar = (*stream).codecpar; - let codec_id = (*codecpar).codec_id; - if !d3d11va_for_codec(codec_id) { - bail!( - "decode_frame_n only supports H.264 D3D11VA (codec_id {})", - codec_id as i32 - ); - } - 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); - - resources.hwdev = attach_d3d11va(resources.dctx, gpu)?; - averr( - avcodec_open2(resources.dctx, dec, ptr::null_mut()), - "avcodec_open2", - )?; - - let pkt = PacketGuard(decode_packet_alloc()); - if pkt.0.is_null() { - bail!("av_packet_alloc"); - } - let frame = FrameGuard(decode_frame_alloc()); - if frame.0.is_null() { - bail!("av_frame_alloc"); - } - #[cfg(test)] - install_decode_frame_lifetime_probes(resources.hwdev, pkt.0, frame.0)?; - let mut got: u32 = 0; - let mut result: *mut AVFrame = ptr::null_mut(); - - 'outer: loop { - let r = av_read_frame(resources.fmt, pkt.0); - if r == AVERROR_EOF { - averr(send_decode_eof(resources.dctx), "send_eof")?; - } else { - averr(r, "read_frame")?; - if (*pkt.0).stream_index != vidx { - av_packet_unref(pkt.0); - continue; - } - averr(avcodec_send_packet(resources.dctx, pkt.0), "send_packet")?; - av_packet_unref(pkt.0); - } - loop { - let r = avcodec_receive_frame(resources.dctx, frame.0); - if r == AVERROR_EAGAIN || r == AVERROR_EOF { - if r == AVERROR_EOF { - break 'outer; - } - break; - } - averr(r, "receive_frame")?; - if got == n { - result = clone_decoded_frame(frame.0); - if result.is_null() { - bail!("av_frame_clone"); - } - break 'outer; - } - got += 1; - } - } - - if result.is_null() { - bail!("frame {n} introuvable"); - } - Ok(FrameGuard(result)) -} - fn averr(ret: i32, ctx: &str) -> Result<()> { if ret < 0 { let mut buf = [0i8; 256]; @@ -389,7 +118,7 @@ unsafe fn attach_d3d11va(dctx: *mut AVCodecContext, gpu: &Gpu) -> Result<*mut AV av_buffer_unref(&mut hwdev); return Err(error); } - let dctx_hwdev = ref_decode_hw_device(hwdev); + let dctx_hwdev = av_buffer_ref(hwdev); if dctx_hwdev.is_null() { av_buffer_unref(&mut hwdev); bail!("av_buffer_ref(hw_device_ctx)"); @@ -861,20 +590,6 @@ impl Decoder { } } - /// Repositionne le flux à la première keyframe (t=0) et vide le codec — pour boucler - /// la playback sans réallouer les décodeurs. La fixture démarre sur un IDR (§11). - pub(crate) unsafe fn rewind(&mut self) -> Result<()> { - // Même règle que `seek_to` : tout repositionnement invalide le peek en attente. - // Il portait sur « la frame d'après l'ancienne position », qui n'a plus de sens - // ici — sans ça le `next()` suivant promouvait une frame décodée avant le rewind, - // avec son ancien `cur_pts`. - self.has_peek = false; - averr(av_seek_frame(self.fmt, self.vidx, 0, AVSEEK_FLAG_BACKWARD), "seek")?; - avcodec_flush_buffers(self.dctx); - self.sent_eof = false; - Ok(()) - } - /// Time_base du flux vidéo (secondes par unité de pts). unsafe fn tb_sec(&self) -> f64 { let tb = (*sn_fmt_stream(self.fmt, self.vidx)).time_base; @@ -1184,6 +899,12 @@ unsafe fn run_c1_inner( avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), "alloc_output_context2", )?; + // RAII dès l'alloc : tout `?` d'ici à la fin -- la marche de timeline, `enc.send`, + // l'encodage audio, une annulation -- sautait le démontage plus bas et laissait `octx`, + // le paquet et SURTOUT le fichier ouvert par `avio_open`. C'est ce descripteur qui + // faisait échouer la suppression de `partial_output` (ffmpeg n'ouvre pas en + // `FILE_SHARE_DELETE`), donc survivre un MP4 tronqué et sans `moov`. + let mut mux = crate::ffi::OutputGuard::new(octx); let ostream = avformat_new_stream(octx, ptr::null()); averr(avcodec_parameters_from_context((*ostream).codecpar, ectx), "params_from_ctx")?; (*ostream).time_base = (*ectx).time_base; @@ -1192,7 +913,11 @@ unsafe fn run_c1_inner( sn_fmt_set_pb(octx, pb); averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; - let opkt = av_packet_alloc(); + mux.opkt = av_packet_alloc(); + if mux.opkt.is_null() { + bail!("av_packet_alloc"); + } + let opkt = mux.opkt; let mut frames: u64 = 0; let t0 = Instant::now(); @@ -1226,13 +951,8 @@ unsafe fn run_c1_inner( let wall_s = t0.elapsed().as_secs_f64(); - av_packet_free(&mut (opkt as *mut _)); - let mut pb2 = sn_fmt_get_pb(octx); - if !pb2.is_null() { - avio_closep(&mut pb2); - sn_fmt_set_pb(octx, ptr::null_mut()); - } - avformat_free_context(octx); + // Même ordre qu'avant : le guard ferme le fichier puis libère le contexte et le paquet. + drop(mux); // `enc` est libéré par son Drop en fin de portée (voir run_multi_inner). av_buffer_unref(&mut enc_frames); av_buffer_unref(&mut enc_hwdev); @@ -1661,6 +1381,12 @@ unsafe fn run_multi_inner( avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), "alloc_output_context2", )?; + // RAII dès l'alloc : tout `?` d'ici à la fin -- la marche de timeline, `enc.send`, + // l'encodage audio, une annulation -- sautait le démontage plus bas et laissait `octx`, + // le paquet et SURTOUT le fichier ouvert par `avio_open`. C'est ce descripteur qui + // faisait échouer la suppression de `partial_output` (ffmpeg n'ouvre pas en + // `FILE_SHARE_DELETE`), donc survivre un MP4 tronqué et sans `moov`. + let mut mux = crate::ffi::OutputGuard::new(octx); let ostream = avformat_new_stream(octx, ptr::null()); if ostream.is_null() { bail!("video avformat_new_stream"); @@ -1675,7 +1401,11 @@ unsafe fn run_multi_inner( sn_fmt_set_pb(octx, pb); averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; - let opkt = av_packet_alloc(); + mux.opkt = av_packet_alloc(); + if mux.opkt.is_null() { + bail!("av_packet_alloc"); + } + let opkt = mux.opkt; let mut clip_frame_counts = vec![0u64; clips.len()]; let mut audio_jobs: ClipAudioJobs> = ClipAudioJobs::new(clips.len()); let t0 = Instant::now(); @@ -1771,14 +1501,9 @@ unsafe fn run_multi_inner( averr(av_write_trailer(octx), "write_trailer")?; let wall_s = t0.elapsed().as_secs_f64(); - // teardown (les décodeurs du cache sont droppés en fin de scope). - av_packet_free(&mut (opkt as *mut _)); - let mut pb2 = sn_fmt_get_pb(octx); - if !pb2.is_null() { - avio_closep(&mut pb2); - sn_fmt_set_pb(octx, ptr::null_mut()); - } - avformat_free_context(octx); + // teardown (les décodeurs du cache sont droppés en fin de scope). Même ordre + // qu'avant : le guard ferme le fichier puis libère le contexte et le paquet. + drop(mux); // `enc` (donc le contexte encodeur) est libéré par son Drop en fin de portée — après // ces unref, ce qui est l'ordre voulu : il garde sa propre référence sur le pool. av_buffer_unref(&mut enc_frames); @@ -1792,39 +1517,6 @@ 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, @@ -1897,13 +1589,6 @@ mod tests { } } - 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}; @@ -2151,173 +1836,6 @@ mod tests { } } - #[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")); @@ -2874,12 +2392,15 @@ unsafe fn drain_encoder( /// si présent (le cas de la fixture MP4), sinon estimé par durée × cadence, sinon fallback. pub fn probe_frame_count(path: &str) -> Result { unsafe { - let mut fmt: *mut AVFormatContext = ptr::null_mut(); + // RAII : `find_stream_info` en erreur laissait le contexte et son descripteur + // ouverts, et la barre de progression repartait sur `FIXTURE_FRAMES`. + let mut open = crate::ffi::InputGuard::empty(); let cpath = CString::new(path)?; averr( - avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + avformat_open_input(&mut open.0, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), "open_input", )?; + let fmt = open.0; 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 n: u64 = 0; @@ -2898,7 +2419,7 @@ pub fn probe_frame_count(path: &str) -> Result { } } } - avformat_close_input(&mut fmt); + drop(open); if n == 0 { n = crate::compositor::FIXTURE_FRAMES as u64; } diff --git a/crates/compositor/src/remux.rs b/crates/compositor/src/remux.rs index e147f8e39..75fc59823 100644 --- a/crates/compositor/src/remux.rs +++ b/crates/compositor/src/remux.rs @@ -203,7 +203,7 @@ pub fn remux_to_seekable_matroska(input: &str, output: &str) -> Result, /// la webcam rétrécit pendant un zoom actif. pub webcam_reactive_zoom: bool, /// User-authored source crop for the camera. Absent keeps the full frame. @@ -381,8 +372,6 @@ pub struct SceneCursor { pub motion_blur: f32, pub click_bounce: f32, pub clip_to_bounds: bool, - /// id du thème (jeu de sprites) — informatif ici : le natif consomme `cursor_sprites`. - pub theme: String, /// Sprites par état de curseur (`"arrow"`, `"text"`, `"pointer"`, `"resize-ew"`, …), chemins /// absolus résolus côté app (compositorViewService, même mécanisme que le wallpaper image). /// Le thème choisi n'y fournit que les états qu'il possède ; l'app complète le reste avec diff --git a/crates/compositor/src/text_linux.rs b/crates/compositor/src/text_linux.rs index 34bac45e1..e6c315258 100644 --- a/crates/compositor/src/text_linux.rs +++ b/crates/compositor/src/text_linux.rs @@ -20,62 +20,7 @@ use cosmic_text::{Attrs, Buffer, FontSystem, Metrics, Shaping, SwashCache}; use crate::d3d::Gpu; -/// Tout ce dont le rendu d'un texte depend. Meme structure et meme `cache_key` -/// que `text_windows::TextSpec` / `text_macos::TextSpec` : la cle est partagee -/// entre plateformes, donc deux specs identiques produisent la meme texture. -#[derive(Clone, PartialEq)] -pub struct TextSpec { - pub content: String, - /// RGBA 0..1 (deja parse depuis la chaine CSS cote appelant). - pub color: [f32; 4], - /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). - pub background: [f32; 4], - pub font_size_px: f32, - pub font_family: String, - pub bold: bool, - pub italic: bool, - pub underline: bool, - /// "left" | "center" | "right". - pub align: String, - /// "top" | "center" | "bottom" -- quelle arete du bloc de texte est epinglee - /// a la boite. "center" est le comportement historique (et celui des - /// annotations, qui reproduisent `alignItems: center` de l'overlay web) ; les - /// sous-titres passent "bottom" ou "top" pour que l'arete ancree ne bouge pas - /// quand le texte gagne une ligne. - pub valign: String, - /// Taille de la boite en px de sortie. - pub box_px: [u32; 2], -} - -impl TextSpec { - /// FNV-1a sur les memes octets, dans le meme ordre, que - /// `text_macos::TextSpec::cache_key` / `text_windows` -- la policy est - /// partagee (cache cross-plateforme coherent). - pub fn cache_key(&self) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - let mut mix = |bytes: &[u8]| { - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x100_0000_01b3); - } - }; - mix(self.content.as_bytes()); - mix(self.font_family.as_bytes()); - mix(&self.font_size_px.to_bits().to_le_bytes()); - for c in self.color.iter().chain(self.background.iter()) { - mix(&c.to_bits().to_le_bytes()); - } - mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); - mix(self.align.as_bytes()); - // Juste apres `align`, memes octets et meme position que sur les deux - // autres backends : deux specs ne differant que par l'alignement vertical - // rendraient sinon les pixels l'une de l'autre depuis le cache. - mix(self.valign.as_bytes()); - mix(&self.box_px[0].to_le_bytes()); - mix(&self.box_px[1].to_le_bytes()); - h - } -} +pub use crate::text_plate::TextSpec; /// Le resultat d'une rasterisation : la texture R8 de couverture + ses dims, /// plus la plaque de fond que le compositeur doit poser dessous. @@ -123,7 +68,7 @@ impl TextRasterizer { /// view. `gpu` fournit le device/queue wgpu (passe au rasterize comme cote /// macOS). Le cache par `cache_key()` est gere par le caller (compositor). pub fn rasterize(&self, gpu: &Gpu, spec: &TextSpec) -> Result { - let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1)); + let (w, h) = crate::text_plate::checked_box_px(spec.box_px)?; let atlas = self.build_atlas(spec)?; Self::upload(gpu, &atlas.pixels, w, h, atlas.plate) @@ -138,7 +83,7 @@ impl TextRasterizer { /// `placement.top` inverse). Tant que ce code vivait derriere un `&Gpu`, il /// etait intestable sans peripherique. Il ne l'est plus. pub fn build_atlas(&self, spec: &TextSpec) -> Result { - let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1)); + let (w, h) = crate::text_plate::checked_box_px(spec.box_px)?; if spec.content.is_empty() { bail!("text_linux::rasterize: texte vide"); } @@ -436,6 +381,17 @@ mod tests { .collect() } + /// Une boite issue du JSON de scene ne doit pas devenir une allocation de + /// plusieurs gigaoctets : `build_atlas` alloue `w * h` octets et le caller + /// `w * h * 4` sur les deux autres backends. + #[test] + fn an_absurd_box_is_refused_before_the_atlas_is_allocated() { + let r = TextRasterizer::new().expect("rasteriseur"); + let mut s = spec("Bonjour", "center"); + s.box_px = [u32::MAX, u32::MAX]; + assert!(r.build_atlas(&s).is_err(), "boite u32::MAX acceptee"); + } + #[test] fn the_plate_hugs_the_text_instead_of_filling_the_box() { // LE bug des sous-titres sous Linux. La plaque etait le quad de la boite diff --git a/crates/compositor/src/text_macos.rs b/crates/compositor/src/text_macos.rs index 5979ab14c..a4f539d8d 100644 --- a/crates/compositor/src/text_macos.rs +++ b/crates/compositor/src/text_macos.rs @@ -35,64 +35,7 @@ use crate::d3d::Gpu; use anyhow::{anyhow, bail, Result}; use std::ffi::c_void; -/// Spécification d'un texte à rastériser. Mêmes champs que `text_windows::TextSpec` -/// — le moteur macOS les consomme via `cache_key` pour déterminer si une re-rastérisation -/// est nécessaire. -#[derive(Clone, PartialEq)] -pub struct TextSpec { - pub content: String, - /// RGBA 0..1 (déjà parsé depuis la chaîne CSS côté appelant). - pub color: [f32; 4], - /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). - pub background: [f32; 4], - pub font_size_px: f32, - pub font_family: String, - pub bold: bool, - pub italic: bool, - pub underline: bool, - /// "left" | "center" | "right". - pub align: String, - /// "top" | "center" | "bottom" — quelle arête du bloc est épinglée à la boîte. - /// "center" est le comportement historique (et celui des annotations) ; les - /// sous-titres passent "bottom" ou "top" pour que l'arête ancrée ne bouge pas - /// quand le texte gagne une ligne. - pub valign: String, - /// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne). - pub box_px: [u32; 2], -} - -impl TextSpec { - /// Clé de cache : couvre exactement les champs dont la variation provoque un - /// changement de pixels. Identique côté Windows/macOS (la policy est partagée). - pub fn cache_key(&self) -> u64 { - // FNV-1a sur les mêmes octets, dans le même ordre, que - // `text_windows::TextSpec::cache_key`. La version précédente appelait - // `Hash::hash(&mut h)` avec un `u64` en guise de `Hasher` — ça ne compile pas, - // et même corrigé, `DefaultHasher` ne donne pas la même clé que Windows. - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - let mut mix = |bytes: &[u8]| { - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x100_0000_01b3); - } - }; - mix(self.content.as_bytes()); - mix(self.font_family.as_bytes()); - mix(&self.font_size_px.to_bits().to_le_bytes()); - for c in self.color.iter().chain(self.background.iter()) { - mix(&c.to_bits().to_le_bytes()); - } - mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); - mix(self.align.as_bytes()); - // Juste après `align`, mêmes octets et même position que sur les deux - // autres backends : deux specs ne différant que par l'alignement vertical - // rendraient sinon les pixels l'une de l'autre depuis le cache. - mix(self.valign.as_bytes()); - mix(&self.box_px[0].to_le_bytes()); - mix(&self.box_px[1].to_le_bytes()); - h - } -} +pub use crate::text_plate::TextSpec; // --------------------------------------------------------------------------- // FFI CoreFoundation / CoreGraphics / CoreText @@ -422,7 +365,8 @@ impl TextRasterizer { /// alors que le `metal::Texture` local était droppé au `return`, soit un /// `id` déjà relâché. pub unsafe fn rasterize(&self, gpu: &Gpu, spec: &TextSpec) -> Result { - let (w, h) = (spec.box_px[0].max(1) as usize, spec.box_px[1].max(1) as usize); + let (w, h) = crate::text_plate::checked_box_px(spec.box_px)?; + let (w, h) = (w as usize, h as usize); if spec.content.is_empty() { bail!("text_macos::rasterize: texte vide"); } diff --git a/crates/compositor/src/text_plate.rs b/crates/compositor/src/text_plate.rs index 0e88ca9a2..ea2299444 100644 --- a/crates/compositor/src/text_plate.rs +++ b/crates/compositor/src/text_plate.rs @@ -1,11 +1,13 @@ -//! Le modèle de boîte de la plaque de fond d'un bloc de texte, partagé par les deux -//! rastériseurs. +//! Ce que les trois rastériseurs de texte partagent : la `TextSpec` qu'ils prennent +//! tous en entrée (et son `cache_key`, une clé CROSS-PLATEFORME), et le modèle de +//! boîte de la plaque de fond d'un bloc de texte. //! -//! `text_windows.rs` (Direct2D) et `text_macos.rs` (CoreText) dessinent la même chose avec -//! deux API qui n'ont rien en commun ; ce qu'elles PEUVENT partager, ce sont les trois -//! nombres qui décident de l'allure du bloc. Ils vivent ici parce que c'est exactement le -//! genre de constante qui dérive en silence quand elle est recopiée : rien dans un rendu -//! Windows ne signale qu'une marge macOS a bougé, et personne ne compare les deux à l'œil. +//! `text_windows.rs` (Direct2D), `text_macos.rs` (CoreText) et `text_linux.rs` +//! (cosmic-text) dessinent la même chose avec des API qui n'ont rien en commun ; ce +//! qu'elles PEUVENT partager, ce sont les trois nombres qui décident de l'allure du +//! bloc. Ils vivent ici parce que c'est exactement le genre de constante qui dérive en +//! silence quand elle est recopiée : rien dans un rendu Windows ne signale qu'une marge +//! macOS a bougé, et personne ne compare les deux à l'œil. //! //! Les valeurs viennent du modèle de boîte de référence de l'app — le `` que //! l'overlay DOM posait derrière le texte et son jumeau canvas @@ -53,6 +55,26 @@ pub fn layout_width(box_w: f32, font_px: f32) -> f32 { (box_w - padding(font_px).0 * 2.0).max(1.0) } +/// Plafond d'un côté de la boîte de texte, en pixels de sortie. +/// +/// 8192 = la limite `max_texture_dimension_2d` par défaut de wgpu/D3D11 : au-delà, la +/// texture serait de toute façon refusée par le device, après l'allocation CPU. +pub const MAX_BOX_PX: u32 = 8192; + +/// `spec.box_px` validé, tel que `w * h * 4` tient dans un `usize` et dans le device. +/// +/// Les trois rastériseurs allouent `w * h * 4` octets d'après une boîte issue du JSON de +/// scène. `annotation_dst_in` borne déjà la boîte au cadre de sortie, mais rien n'oblige +/// un futur appelant à passer par là — et une texture plus grande que le device échoue +/// plus tard et plus mal. La garde est ici plutôt que recopiée trois fois. +pub fn checked_box_px(box_px: [u32; 2]) -> anyhow::Result<(u32, u32)> { + let (w, h) = (box_px[0].max(1), box_px[1].max(1)); + if w > MAX_BOX_PX || h > MAX_BOX_PX { + anyhow::bail!("boîte de texte {w}x{h} px au-delà du plafond {MAX_BOX_PX}"); + } + Ok((w, h)) +} + #[cfg(test)] mod tests { use super::*; @@ -91,4 +113,80 @@ mod tests { // boucler la mise en page au lieu de simplement déborder. assert!(layout_width(4.0, 200.0) >= 1.0); } + + /// Une boîte venue du JSON de scène ne doit jamais devenir un `vec![0u8; w * h * 4]` + /// de plusieurs gigaoctets, ni faire déborder le produit. + #[test] + fn an_absurd_box_is_refused_rather_than_allocated() { + assert!(checked_box_px([u32::MAX, u32::MAX]).is_err()); + assert!(checked_box_px([MAX_BOX_PX + 1, 10]).is_err()); + assert!(checked_box_px([10, MAX_BOX_PX + 1]).is_err()); + assert_eq!(checked_box_px([MAX_BOX_PX, MAX_BOX_PX]).unwrap(), (MAX_BOX_PX, MAX_BOX_PX)); + // Une boîte dégénérée reste ramenée à 1 px, comme avant. + assert_eq!(checked_box_px([0, 0]).unwrap(), (1, 1)); + } +} + +/// Tout ce dont le rendu d'un texte depend, et rien d'autre : deux specs egales +/// donnent la meme texture, donc `cache_key` couvre exactement ces champs. +/// +/// Partage par les trois rastériseurs (`text_windows`, `text_macos`, +/// `text_linux`), qui le re-exportent. Les trois en portaient une copie +/// caractere pour caractere, chacune avec un commentaire demandant aux deux +/// autres de rester synchronisees -- ce que rien ne verifiait. Le `cache_key` +/// est une cle CROSS-PLATEFORME : l'ordre des `mix` fait partie du contrat, pas +/// du detail d'implementation d'un backend. +#[derive(Clone, PartialEq)] +pub struct TextSpec { + pub content: String, + /// RGBA 0..1 (deja parse depuis la chaine CSS cote appelant). + pub color: [f32; 4], + /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). + pub background: [f32; 4], + pub font_size_px: f32, + pub font_family: String, + pub bold: bool, + pub italic: bool, + pub underline: bool, + /// "left" | "center" | "right". + pub align: String, + /// "top" | "center" | "bottom" -- quelle arete du bloc de texte est epinglee + /// a la boite. "center" est le comportement historique (et celui des + /// annotations, qui reproduisent `alignItems: center` de l'overlay web) ; les + /// sous-titres passent "bottom" ou "top" pour que l'arete ancree ne bouge pas + /// quand le texte gagne une ligne. + pub valign: String, + /// Taille de la boite en px de sortie -- la mise en page en depend (retours + /// a la ligne). + pub box_px: [u32; 2], +} + +impl TextSpec { + /// FNV-1a sur les champs. Sert a decider s'il faut re-rasteriser ; + /// volontairement insensible a tout ce qui n'affecte pas les pixels + /// (position, opacite d'animation...). + pub fn cache_key(&self) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |bytes: &[u8]| { + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + }; + mix(self.content.as_bytes()); + mix(self.font_family.as_bytes()); + mix(&self.font_size_px.to_bits().to_le_bytes()); + for c in self.color.iter().chain(self.background.iter()) { + mix(&c.to_bits().to_le_bytes()); + } + mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); + mix(self.align.as_bytes()); + // Juste apres `align`, memes octets et meme position sur les trois + // backends : deux specs ne differant que par l'alignement vertical + // rendraient sinon les pixels l'une de l'autre depuis le cache. + mix(self.valign.as_bytes()); + mix(&self.box_px[0].to_le_bytes()); + mix(&self.box_px[1].to_le_bytes()); + h + } } diff --git a/crates/compositor/src/text_windows.rs b/crates/compositor/src/text_windows.rs index e09f4cdc7..af89e8492 100644 --- a/crates/compositor/src/text_windows.rs +++ b/crates/compositor/src/text_windows.rs @@ -41,59 +41,7 @@ use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_B8G8R8A8_UNORM; use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC; use windows::Win32::Graphics::Dxgi::IDXGISurface; -/// Tout ce dont le rendu d'un texte dépend. Sert aussi de clé de cache : deux specs égales -/// donnent la même texture, donc `cache_key` couvre exactement ces champs. -#[derive(Clone, PartialEq)] -pub struct TextSpec { - pub content: String, - /// RGBA 0..1 (déjà parsé depuis la chaîne CSS côté appelant). - pub color: [f32; 4], - /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). - pub background: [f32; 4], - pub font_size_px: f32, - pub font_family: String, - pub bold: bool, - pub italic: bool, - pub underline: bool, - /// "left" | "center" | "right". - pub align: String, - /// "top" | "center" | "bottom" — quelle arête du bloc est épinglée à la boîte. - /// "center" est le comportement historique (et celui des annotations) ; les - /// sous-titres passent "bottom" ou "top" pour que l'arête ancrée ne bouge pas - /// quand le texte gagne une ligne. - pub valign: String, - /// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne). - pub box_px: [u32; 2], -} - -impl TextSpec { - /// FNV-1a sur les champs. Utilisé pour décider s'il faut re-rastériser ; volontairement - /// insensible à tout ce qui n'affecte pas les pixels (position, opacité d'animation…). - pub fn cache_key(&self) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - let mut mix = |bytes: &[u8]| { - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x100_0000_01b3); - } - }; - mix(self.content.as_bytes()); - mix(self.font_family.as_bytes()); - mix(&self.font_size_px.to_bits().to_le_bytes()); - for c in self.color.iter().chain(self.background.iter()) { - mix(&c.to_bits().to_le_bytes()); - } - mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); - mix(self.align.as_bytes()); - // Juste après `align`, mêmes octets et même position que sur les deux - // autres backends : deux specs ne différant que par l'alignement vertical - // rendraient sinon les pixels l'une de l'autre depuis le cache. - mix(self.valign.as_bytes()); - mix(&self.box_px[0].to_le_bytes()); - mix(&self.box_px[1].to_le_bytes()); - h - } -} +pub use crate::text_plate::TextSpec; /// Chaîne UTF-16 terminée par un zéro, pour les API Win32 qui prennent un `PCWSTR`. fn wide(s: &str) -> Vec { @@ -159,7 +107,7 @@ impl TextRasterizer { dev: &ID3D11Device, spec: &TextSpec, ) -> Result { - let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1)); + let (w, h) = crate::text_plate::checked_box_px(spec.box_px)?; if spec.content.is_empty() { bail!("texte vide"); } diff --git a/crates/compositor/tests/demuxer_fd_leak.rs b/crates/compositor/tests/demuxer_fd_leak.rs new file mode 100644 index 000000000..8c70336e1 --- /dev/null +++ b/crates/compositor/tests/demuxer_fd_leak.rs @@ -0,0 +1,234 @@ +//! Un démultiplexage qui échoue ne doit pas emporter un descripteur de fichier avec lui. +//! +//! `avformat_open_input` réussi tient un fd. Tout `?` posé entre l'ouverture et la +//! fermeture explicite en fuitait un — et le compositeur tourne dans le processus +//! principal d'Electron, qui vit des heures : un export de 200 clips dont quelques-uns +//! sont illisibles finissait en `EMFILE` sur un fichier sans aucun rapport, longtemps +//! après la cause. `ffi::InputGuard` ferme le contexte sur tous les chemins. +//! +//! Linux seulement : la mesure passe par `/proc/self/fd`, qui n'existe pas ailleurs. Le +//! `Drop` testé, lui, est du code partagé par les trois plateformes. +#![cfg(target_os = "linux")] + +use openscreen_compositor::audio::decode_clip_audio; +use openscreen_compositor::ffi::*; +use std::ffi::CString; +use std::ptr; + +/// Descripteurs ouverts par le processus. +fn fd_count() -> usize { + std::fs::read_dir("/proc/self/fd").expect("/proc/self/fd").count() +} + +/// Matroska minimal à une piste VIDÉO, sans audio : `decode_clip_audio` y répond +/// `Ok(None)` après avoir ouvert — et donc après avoir pris un fd. +/// +/// VP8 parce que matroska le range sans jamais regarder la charge utile ; le test est de +/// niveau conteneur, comme `remux_seek_index.rs`. +fn write_video_only_matroska(path: &str) { + let cpath = CString::new(path).unwrap(); + let cfmt = CString::new("matroska").unwrap(); + unsafe { + let mut octx: *mut AVFormatContext = ptr::null_mut(); + assert!( + avformat_alloc_output_context2(&mut octx, ptr::null(), cfmt.as_ptr(), cpath.as_ptr()) + >= 0, + "alloc_output_context2" + ); + let st = avformat_new_stream(octx, ptr::null()); + assert!(!st.is_null(), "avformat_new_stream"); + (*(*st).codecpar).codec_type = AVMediaType::AVMEDIA_TYPE_VIDEO; + (*(*st).codecpar).codec_id = AVCodecID::AV_CODEC_ID_VP8; + (*(*st).codecpar).width = 16; + (*(*st).codecpar).height = 16; + (*st).time_base = AVRational { num: 1, den: 1000 }; + + let mut pb: *mut AVIOContext = ptr::null_mut(); + assert!(avio_open(&mut pb, cpath.as_ptr(), AVIO_FLAG_WRITE as i32) >= 0, "avio_open"); + sn_fmt_set_pb(octx, pb); + assert!(avformat_write_header(octx, ptr::null_mut()) >= 0, "write_header"); + + let payload = vec![0x42u8; 256]; + let pkt = av_packet_alloc(); + for i in 0..10i64 { + assert!(av_new_packet(pkt, payload.len() as i32) >= 0, "av_new_packet"); + ptr::copy_nonoverlapping(payload.as_ptr(), (*pkt).data, payload.len()); + (*pkt).stream_index = 0; + (*pkt).pts = i * 40; + (*pkt).dts = i * 40; + (*pkt).duration = 40; + (*pkt).flags = AV_PKT_FLAG_KEY as i32; + assert!(av_interleaved_write_frame(octx, pkt) >= 0, "write_frame"); + } + let mut pkt = pkt; + av_packet_free(&mut pkt); + assert!(av_write_trailer(octx) >= 0, "write_trailer"); + let mut pb = sn_fmt_get_pb(octx); + avio_closep(&mut pb); + avformat_free_context(octx); + } +} + +/// Matroska à une piste `pcm_s16le` STÉRÉO dont les paquets ne font pas un nombre entier +/// d'échantillons (6 octets pour un `block_align` de 4). +/// +/// `pcm_decode_frame` refuse ça par `AVERROR_INVALIDDATA`, remonté par +/// `avcodec_receive_frame` : c'est un `?` réellement atteignable au milieu de +/// `decode_clip_audio_inner`, donc exactement le chemin qui fuitait. +fn write_misaligned_pcm_matroska(path: &str) { + let cpath = CString::new(path).unwrap(); + let cfmt = CString::new("matroska").unwrap(); + unsafe { + let mut octx: *mut AVFormatContext = ptr::null_mut(); + assert!( + avformat_alloc_output_context2(&mut octx, ptr::null(), cfmt.as_ptr(), cpath.as_ptr()) + >= 0 + ); + let st = avformat_new_stream(octx, ptr::null()); + let par = (*st).codecpar; + (*par).codec_type = AVMediaType::AVMEDIA_TYPE_AUDIO; + (*par).codec_id = AVCodecID::AV_CODEC_ID_PCM_S16LE; + (*par).sample_rate = 48_000; + (*par).format = AVSampleFormat::AV_SAMPLE_FMT_S16 as i32; + (*par).bits_per_coded_sample = 16; + (*par).block_align = 4; + av_channel_layout_default(&mut (*par).ch_layout, 2); + (*st).time_base = AVRational { num: 1, den: 48_000 }; + + let mut pb: *mut AVIOContext = ptr::null_mut(); + assert!(avio_open(&mut pb, cpath.as_ptr(), AVIO_FLAG_WRITE as i32) >= 0); + sn_fmt_set_pb(octx, pb); + assert!(avformat_write_header(octx, ptr::null_mut()) >= 0); + + // 6 octets : 1,5 échantillon stéréo. Le décodeur PCM rejette. + let payload = [1u8, 2, 3, 4, 5, 6]; + let pkt = av_packet_alloc(); + for i in 0..20i64 { + assert!(av_new_packet(pkt, payload.len() as i32) >= 0); + ptr::copy_nonoverlapping(payload.as_ptr(), (*pkt).data, payload.len()); + (*pkt).stream_index = 0; + (*pkt).pts = i; + (*pkt).dts = i; + (*pkt).duration = 1; + (*pkt).flags = AV_PKT_FLAG_KEY as i32; + assert!(av_interleaved_write_frame(octx, pkt) >= 0); + } + let mut pkt = pkt; + av_packet_free(&mut pkt); + assert!(av_write_trailer(octx) >= 0); + let mut pb = sn_fmt_get_pb(octx); + avio_closep(&mut pb); + avformat_free_context(octx); + } +} + +fn fixture(name: &str) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("capturia-fd-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("clip.mkv"); + let s = path.to_str().unwrap().to_string(); + write_video_only_matroska(&s); + (dir, s) +} + +/// Le garde lui-même : 300 ouvertures abandonnées sans fermeture explicite. +/// +/// C'est le défaut à l'état pur — avant, le `?` qui sautait la fermeture laissait +/// exactement ça derrière lui. +#[test] +fn an_abandoned_input_guard_closes_its_file_descriptor() { + let (dir, path) = fixture("guard"); + let cpath = CString::new(path.as_str()).unwrap(); + + // Un tour à vide : ffmpeg charge ses protocoles et ses tables au premier appel, ce + // qui peut ouvrir des fd une bonne fois. La mesure part d'après. + unsafe { + let mut warm = InputGuard::empty(); + avformat_open_input(&mut warm.0, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()); + } + let before = fd_count(); + for _ in 0..300 { + unsafe { + let mut open = InputGuard::empty(); + assert!( + avformat_open_input(&mut open.0, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()) + >= 0, + "open_input" + ); + // Aucune fermeture explicite : c'est `Drop` qui doit s'en charger. + } + } + let after = fd_count(); + assert!( + after <= before + 2, + "300 ouvertures abandonnées ont laissé {} fd ouverts ({before} -> {after})", + after as i64 - before as i64 + ); + + // `release()` cède la propriété : le contexte DOIT survivre, sinon le chemin de + // succès d'un `open` rendrait une structure au pointeur déjà fermé. + unsafe { + let mut open = InputGuard::empty(); + assert!( + avformat_open_input(&mut open.0, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()) >= 0 + ); + let mut fmt = open.release(); + assert!(!fmt.is_null(), "release a rendu NULL"); + // Toujours vivant : on peut encore l'interroger, puis c'est à nous de fermer. + assert!(sn_fmt_nb_streams(fmt) > 0, "contexte fermé trop tôt par le garde"); + avformat_close_input(&mut fmt); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Et le même compte à travers `decode_clip_audio`, qui est le call site qui fuyait. +#[test] +fn decoding_audio_from_two_hundred_clips_does_not_grow_the_fd_table() { + let (dir, path) = fixture("audio"); + + let _ = decode_clip_audio(&path, 0.0, 1.0); + let before = fd_count(); + for _ in 0..200 { + // Pas de piste audio : `Ok(None)`, après ouverture — donc après avoir pris un fd. + let r = decode_clip_audio(&path, 0.0, 1.0).expect("pas d'erreur attendue"); + assert!(r.is_none(), "ce clip n'a pas de piste audio"); + } + let after = fd_count(); + assert!( + after <= before + 2, + "200 décodages ont laissé {} fd ouverts ({before} -> {after})", + after as i64 - before as i64 + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// **Le chemin qui fuyait vraiment** : un `?` au milieu du décodage. +/// +/// Une piste PCM mal alignée fait sortir `avcodec_receive_frame` en `AVERROR_INVALIDDATA`, +/// donc `decode_clip_audio` par `?` — entre l'ouverture et la fermeture explicite. Avant +/// le garde, chacun de ces 200 appels emportait un `AVFormatContext` et son fd. +#[test] +fn a_clip_that_fails_mid_decode_still_closes_its_file() { + let dir = std::env::temp_dir().join("capturia-fd-midfail"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("clip.mkv"); + let path = path.to_str().unwrap().to_string(); + write_misaligned_pcm_matroska(&path); + + let first = decode_clip_audio(&path, 0.0, 1.0); + assert!(first.is_err(), "ce clip doit échouer en cours de décodage : {first:?}"); + + let before = fd_count(); + for _ in 0..200 { + assert!(decode_clip_audio(&path, 0.0, 1.0).is_err()); + } + let after = fd_count(); + assert!( + after <= before + 2, + "200 échecs en cours de décodage ont laissé {} fd ouverts ({before} -> {after})", + after as i64 - before as i64 + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/electron/native/pipewire-capture/src/encoder.rs b/electron/native/pipewire-capture/src/encoder.rs index 2f25b7570..0011c658f 100644 --- a/electron/native/pipewire-capture/src/encoder.rs +++ b/electron/native/pipewire-capture/src/encoder.rs @@ -97,8 +97,18 @@ impl Backend { /// `CAPTURIA_LINUX_ENCODER` without duplicating the list. pub const LADDER: [Backend; 3] = [Backend::Vaapi, Backend::Vulkan, Backend::Software]; -/// Must run before anything creates a Vulkan instance — which, in this process, -/// means before the first `av_hwdevice_ctx_create`. +/// Must run at the TOP OF `main`, before any thread is spawned. +/// +/// It is a `setenv`. glibc's `setenv` may reallocate `environ`, and every other thread +/// in the process — the stdin reader, the portal/zbus thread, the evdev readers, the +/// PipeWire audio thread, and Mesa's own workers — may be inside `getenv` at that +/// instant. That is a use-after-free on `environ`, and it crashes inside whichever C +/// library happened to be reading it. Calling this from `VideoEncoder::open` put the +/// write on the FIRST CAPTURED FRAME, with all of those threads already running; the +/// only safe moment is before the first `thread::spawn`. +/// +/// It must also run before anything creates a Vulkan instance — which, in this process, +/// means before the first `av_hwdevice_ctx_create`. The top of `main` satisfies both. /// /// Mesa gates RADV's video encode queues behind a perf-test flag; without it /// `h264_vulkan` fails with "Device does not support the VK_KHR_video_encode_queue @@ -246,8 +256,8 @@ impl VideoEncoder { forced: Option, mut on_attempt: impl FnMut(Backend, &str), ) -> Result { - prepare_environment(); - + // NOT `prepare_environment()` — see its doc comment. `main` has already run it, + // on a process that was still single-threaded. let candidates: Vec = match forced { Some(backend) => vec![backend], None => LADDER.to_vec(), @@ -1429,5 +1439,26 @@ mod tests { prepare_environment(); assert_eq!(std::env::var("RADV_PERFTEST").unwrap(), value); std::env::remove_var("RADV_PERFTEST"); + + // And opening an encoder must NOT touch the environment: the `setenv` belongs at + // the top of `main`, where the process is still single-threaded. Same test + // function rather than its own, because two tests mutating the same variable + // would race with each other under the default parallel harness. + let _ = VideoEncoder::open( + VideoParams { + width: 320, + height: 240, + src_width: 320, + src_height: 240, + fps: 30, + bitrate: 200_000, + }, + Some(Backend::Software), + |_, _| {}, + ); + assert!( + std::env::var("RADV_PERFTEST").is_err(), + "VideoEncoder::open wrote to the environment with threads live" + ); } } diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 373b2c089..01a4fcd77 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -250,6 +250,12 @@ enum Message { } fn main() { + // FIRST STATEMENT, and it has to stay one: it is a `setenv`, and every thread this + // process later spawns (stdin reader, portal/zbus, evdev readers, PipeWire audio) + // plus Mesa's own workers read the environment concurrently. See + // `encoder::prepare_environment`. + encoder::prepare_environment(); + let debug = std::env::var("CAPTURIA_PIPEWIRE_DEBUG") .map(|value| !matches!(value.as_str(), "" | "0" | "false")) .unwrap_or(false); diff --git a/electron/native/pipewire-capture/src/shim.rs b/electron/native/pipewire-capture/src/shim.rs index 7694c3d72..706a4af29 100644 --- a/electron/native/pipewire-capture/src/shim.rs +++ b/electron/native/pipewire-capture/src/shim.rs @@ -1052,7 +1052,14 @@ fn on_frame_inner(state: &CallbackState, frame: *const RawFrame) -> i32 { let mut planes = Vec::with_capacity(n); for i in 0..n { let fd = frame.plane_fd[i]; - if fd < 0 { + // The producer writes `chunk->offset` as a `uint32_t`; `pw_shim.c` casts it to + // `int32_t`, so anything from 2^31 up arrives NEGATIVE — and + // `dmabuf_import::import` does `offset as isize` straight into the DRM + // descriptor, pointing VAAPI BEFORE the buffer. A zero or negative pitch is + // equally unusable. Declining re-queues the buffer and drops one frame; the + // comment in the C shim claiming "the importer validates the rest" did not + // hold, so the validation is here, where the values first cross into Rust. + if fd < 0 || frame.plane_offset[i] < 0 || frame.plane_stride[i] <= 0 { return 0; } // Dup the plane fd so the descriptor owns a handle independent of the @@ -1203,6 +1210,50 @@ mod tests { use std::sync::mpsc; use std::time::Duration; + /// A producer-written plane offset or pitch goes into a DRM descriptor unchanged. + /// + /// `chunk->offset` is a `uint32_t` in shared memory; the C shim narrows it to + /// `int32_t`, so >= 2^31 arrives negative and the importer would hand VAAPI a plane + /// starting before the buffer. Declined here instead. + #[test] + fn a_plane_with_a_negative_offset_or_a_dead_stride_is_declined() { + let mailbox = std::sync::Arc::new(FrameMailbox::default()); + let state = CallbackState { sink: Box::new(|_| {}), frames: Some(mailbox.clone()) }; + + // SAFETY: `RawFrame` is a `repr(C)` POD whose pointers are valid when null. + let mut frame: RawFrame = unsafe { std::mem::zeroed() }; + frame.is_dmabuf = 1; + frame.buffer_generation = 1; + frame.buffer_handle = 1 as *mut c_void; + frame.n_planes = 1; + frame.width = 1920; + frame.height = 1080; + frame.drm_fourcc = 0x3432_5258; + // fd 0 is a real descriptor in a test binary, so the dup below succeeds and the + // only thing that can decline the frame is the offset/stride check. + frame.plane_fd = [0, -1, -1, -1]; + frame.plane_stride = [7680, 0, 0, 0]; + + frame.plane_offset = [-1, 0, 0, 0]; + assert_eq!(on_frame_inner(&state, &frame), 0, "a negative plane offset must be declined"); + assert!(mailbox.take().is_none(), "the declined frame must not reach the encoder"); + + frame.plane_offset = [0, 0, 0, 0]; + frame.plane_stride = [0, 0, 0, 0]; + assert_eq!(on_frame_inner(&state, &frame), 0, "a zero pitch must be declined"); + assert!(mailbox.take().is_none()); + + frame.plane_stride = [-7680, 0, 0, 0]; + assert_eq!(on_frame_inner(&state, &frame), 0, "a negative pitch must be declined"); + assert!(mailbox.take().is_none()); + + // The guard-rail: a well-formed plane must STILL be taken, or the three + // assertions above would hold for a callback that declines everything. + frame.plane_stride = [7680, 0, 0, 0]; + assert_eq!(on_frame_inner(&state, &frame), 1, "a valid dmabuf plane must be taken"); + assert!(mailbox.take().is_some()); + } + /// A DMA-BUF frame is bounded by our mapping, not by the placeholder sizes /// wlr / niri portals send, which rejected every 1080p frame (#287 follow-up). #[test] diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 99041a02b..2d9e81298 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(wgc-capture src/dpi_awareness.h src/dshow_webcam_capture.cpp src/dshow_webcam_capture.h + src/hresult_log.h src/main.cpp src/mf_encoder.cpp src/mf_encoder.h diff --git a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp index f221a8e73..1453effda 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp @@ -639,11 +639,11 @@ int main() { std::to_string(fromCold.size())); } - // WASAPI delivers no packets while a loopback source is silent; the capture - // layer synthesizes zero frames and pushes them through the SAME callback - // (emitSilenceFrames in wasapi_loopback_capture.cpp). Those frames have to be - // CONSUMED rather than skipped: skipping them would slide the decimation - // phase and shorten the take. Ragged packets, two silent stretches, and a + // A loopback source that is playing silence still delivers packets, flagged + // AUDCLNT_BUFFERFLAGS_SILENT; the capture layer substitutes zeros and pushes + // them through the SAME callback (wasapi_loopback_capture.cpp's captureLoop). + // Those frames have to be CONSUMED rather than skipped: skipping them would + // slide the decimation phase and shorten the take. Ragged packets, two silent stretches, and a // total that is deliberately not a multiple of the factor. { const size_t totalFrames = 7777; @@ -1409,6 +1409,127 @@ int main() { runMixer(true, "-with-mic"); } + // --- The mixer owns the clock, so nobody else may fill a gap ------------ + // + // mixLoop emits a chunk for every chunk of real time whether or not a queue + // has data, which is what keeps a recording that begins in silence from + // starting at timestamp zero. The consequence is that a stalled device + // needs no help: the silence for it is already in the output by the time + // the device comes back. + // + // wasapi_loopback_capture's captureLoop used to help anyway, pushing + // `gapFrames` of zeros on a DATA_DISCONTINUITY. Those frames go to the back + // of a queue that is a stream of samples, not a timeline -- pushSystem does + // not take a timestamp -- so they did not fill the hole, they moved + // everything after it. And the mixer never trims, so the shift is + // permanent: one 500 ms glitch, and the microphone is 500 ms behind the + // picture for the rest of the take. + // + // Measured against the wall clock rather than against a constant, so the + // check does not turn into a test of how promptly this machine schedules. + { + const AudioInputFormat pcm48k = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const size_t toneFrames = 4800; // 100 ms + const size_t stallMs = 500; + const auto tone = [&](size_t frames) { + std::vector buf(frames * pcm48k.blockAlign, 0); + auto* samples = reinterpret_cast(buf.data()); + for (size_t i = 0; i < frames * pcm48k.channels; i += 1) { + samples[i] = 12000; + } + return buf; + }; + + // Plays a stall: tone, `stallMs` of nothing, then `padFrames` of zeros + // (what the capture loop used to insert) followed by the same tone. + // Returns where the second tone landed in the output and where the wall + // clock says it should have landed, both in milliseconds. + const auto runStall = [&](size_t padFrames, double& observedMs, double& expectedMs) { + std::mutex guard; + std::vector collected; + AudioMixer mixer( + pcm48k, pcm48k, pcm48k, true, false, 1.0, + [&](const BYTE* data, DWORD byteCount, int64_t, int64_t) { + std::scoped_lock lock(guard); + collected.insert(collected.end(), data, data + byteCount); + return true; + }); + mixer.start(); + mixer.beginTimeline(); + const auto timelineStart = std::chrono::steady_clock::now(); + + const std::vector burst = tone(toneFrames); + mixer.pushSystem(burst.data(), static_cast(burst.size())); + std::this_thread::sleep_for(std::chrono::milliseconds(stallMs)); + + const std::vector pad(padFrames * pcm48k.blockAlign, 0); + if (padFrames > 0) { + mixer.pushSystem(pad.data(), static_cast(pad.size())); + } + const auto resumed = std::chrono::steady_clock::now(); + mixer.pushSystem(burst.data(), static_cast(burst.size())); + expectedMs = + std::chrono::duration(resumed - timelineStart).count(); + + const size_t needed = + static_cast(expectedMs * 48.0) + padFrames + toneFrames + 4800; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20); + for (;;) { + { + std::scoped_lock lock(guard); + if (collected.size() / pcm48k.blockAlign >= needed) { + break; + } + } + if (std::chrono::steady_clock::now() > deadline) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + mixer.stop(); + + std::vector out; + { + std::scoped_lock lock(guard); + out = collected; + } + // Start of the second run of non-silence: past the first tone, past + // the gap, wherever it ended up. + const auto* samples = reinterpret_cast(out.data()); + const size_t frames = out.size() / pcm48k.blockAlign; + size_t index = 0; + while (index < frames && samples[index * pcm48k.channels] != 0) index += 1; + while (index < frames && samples[index * pcm48k.channels] == 0) index += 1; + observedMs = index >= frames ? -1.0 : static_cast(index) / 48.0; + }; + + double cleanObserved = 0.0; + double cleanExpected = 0.0; + runStall(0, cleanObserved, cleanExpected); + double paddedObserved = 0.0; + double paddedExpected = 0.0; + runStall(24000, paddedObserved, paddedExpected); // 500 ms of gap filler + + char detail[192]{}; + sprintf_s( + detail, "unpadded observed=%.1f ms expected=%.1f ms; padded observed=%.1f ms " + "expected=%.1f ms", + cleanObserved, cleanExpected, paddedObserved, paddedExpected); + std::cout << "GAP_RAW " << detail << std::endl; + + // 40 ms of slack: beginTimeline only wakes mixLoop, which anchors its + // clock on its next pass (up to 20 ms later), and chunks are 10 ms. + expect( + "gap-silence-is-not-double-counted", + cleanObserved >= 0.0 && std::abs(cleanObserved - cleanExpected) < 40.0, detail); + // The other half of the same claim: pushed silence does not fill the + // hole, it displaces everything behind it, by its own length, for good. + expect( + "pushed-gap-silence-displaces-the-rest", + paddedObserved >= 0.0 && std::abs(paddedObserved - (paddedExpected + 500.0)) < 40.0, + detail); + } + HRESULT mfHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); if (FAILED(mfHr) && mfHr != RPC_E_CHANGED_MODE) { skip("mf-startup", "CoInitializeEx failed — no Media Foundation on this host"); diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp index f39e4b065..4a468638b 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp @@ -12,6 +12,8 @@ #include #include +#include "hresult_log.h" + namespace { const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; @@ -29,16 +31,6 @@ ISampleGrabber : public IUnknown { virtual HRESULT STDMETHODCALLTYPE SetCallback(IUnknown* callback, long whichMethodToCallback) = 0; }; -bool succeeded(HRESULT hr, const char* label) { - if (SUCCEEDED(hr)) { - return true; - } - - std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")" - << std::endl; - return false; -} - std::string guidToString(const GUID& guid) { if (guid == MEDIASUBTYPE_RGB32) { return "RGB32"; diff --git a/electron/native/wgc-capture/src/hresult_log.h b/electron/native/wgc-capture/src/hresult_log.h new file mode 100644 index 000000000..3d0a705a1 --- /dev/null +++ b/electron/native/wgc-capture/src/hresult_log.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include + +// Logs a failing HRESULT under `label` and reports whether the call succeeded, +// so a call site reads `if (!succeeded(hr, "What"))`. Four translation units in +// this helper carried their own byte-identical copy. +inline bool succeeded(HRESULT hr, const char* label) { + if (SUCCEEDED(hr)) { + return true; + } + + std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")" + << std::endl; + return false; +} diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index ff341b2f0..0d0c7eb23 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -414,19 +414,30 @@ bool hasVisibleBgraContent(const std::vector& frame) { return maxLuma > 24 || averageLuma > 4; } -bool findBool(const std::string& json, const std::string& key, bool fallback) { +// Offset of the first non-space character after `"key":`, or npos when the key +// or its colon is missing. The four getters below differ only in what they do +// from there. +size_t valueStart(const std::string& json, const std::string& key) { auto pos = json.find("\"" + key + "\""); if (pos == std::string::npos) { - return fallback; + return std::string::npos; } pos = json.find(':', pos); if (pos == std::string::npos) { - return fallback; + return std::string::npos; } pos += 1; while (pos < json.size() && std::isspace(static_cast(json[pos]))) { pos += 1; } + return pos; +} + +bool findBool(const std::string& json, const std::string& key, bool fallback) { + const auto pos = valueStart(json, key); + if (pos == std::string::npos) { + return fallback; + } if (json.compare(pos, 4, "true") == 0) { return true; } @@ -437,18 +448,10 @@ bool findBool(const std::string& json, const std::string& key, bool fallback) { } int64_t findInt64(const std::string& json, const std::string& key, int64_t fallback) { - auto pos = json.find("\"" + key + "\""); - if (pos == std::string::npos) { - return fallback; - } - pos = json.find(':', pos); + const auto pos = valueStart(json, key); if (pos == std::string::npos) { return fallback; } - pos += 1; - while (pos < json.size() && std::isspace(static_cast(json[pos]))) { - pos += 1; - } try { return std::stoll(json.substr(pos)); } catch (...) { @@ -461,18 +464,10 @@ int findInt(const std::string& json, const std::string& key, int fallback) { } double findDouble(const std::string& json, const std::string& key, double fallback) { - auto pos = json.find("\"" + key + "\""); - if (pos == std::string::npos) { - return fallback; - } - pos = json.find(':', pos); + const auto pos = valueStart(json, key); if (pos == std::string::npos) { return fallback; } - pos += 1; - while (pos < json.size() && std::isspace(static_cast(json[pos]))) { - pos += 1; - } try { return std::stod(json.substr(pos)); } catch (...) { @@ -481,18 +476,10 @@ double findDouble(const std::string& json, const std::string& key, double fallba } std::string findString(const std::string& json, const std::string& key) { - auto pos = json.find("\"" + key + "\""); - if (pos == std::string::npos) { - return {}; - } - pos = json.find(':', pos); + auto pos = valueStart(json, key); if (pos == std::string::npos) { return {}; } - pos += 1; - while (pos < json.size() && std::isspace(static_cast(json[pos]))) { - pos += 1; - } if (pos >= json.size() || json[pos] != '"') { return {}; } @@ -721,7 +708,7 @@ int main(int argc, char* argv[]) { return 1; } capturedMonitor = monitor; - if (!session.initialize(monitor, config.fps, config.captureCursor)) { + if (!session.initialize(monitor, config.captureCursor)) { std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl; return 1; } @@ -734,7 +721,7 @@ int main(int argc, char* argv[]) { // A window is captured by whichever display it currently sits on, which // is the adapter that matters for the same reason a monitor's does. capturedMonitor = MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST); - if (!session.initialize(window, config.fps, config.captureCursor)) { + if (!session.initialize(window, config.captureCursor)) { std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl; return 1; } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 70fc203cf..e9779e0e8 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -1,6 +1,7 @@ #include "mf_encoder.h" #include "audio_sample_utils.h" +#include "hresult_log.h" #include #include @@ -16,16 +17,6 @@ namespace { -bool succeeded(HRESULT hr, const char* label) { - if (SUCCEEDED(hr)) { - return true; - } - - std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")" - << std::endl; - return false; -} - // Count how many Media Foundation Transforms are registered for a given // (category, output subtype) pair. Caller does not need the activations // themselves; we just want to know whether at least one is registered so we diff --git a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp index c19c44160..9b8c2168b 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback_capture.cpp @@ -170,6 +170,7 @@ bool WasapiLoopbackCapture::initializeMicrophone(const std::wstring& deviceId, c } bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std::wstring& deviceId, const std::wstring& deviceName) { + endpoint_ = endpoint; HRESULT hr = CoCreateInstance( __uuidof(MMDeviceEnumerator), nullptr, @@ -343,8 +344,6 @@ bool WasapiLoopbackCapture::start(AudioCallback callback) { callback_ = std::move(callback); stopRequested_ = false; writtenFrames_ = 0; - lastDevicePositionEnd_ = 0; - hasLastDevicePosition_ = false; HRESULT hr = audioClient_->Start(); if (!succeeded(hr, "IAudioClient::Start")) { @@ -375,29 +374,55 @@ const std::wstring& WasapiLoopbackCapture::selectedDeviceName() const { return selectedDeviceName_; } +/** + * Reads packets and hands them to the callback. Real packets only. + * + * A gap -- the device position jumping past where the last packet ended, which + * is what AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY reports -- used to be filled + * here with `gapFrames` of zeros. That silence was counted twice. AudioMixer + * runs on steady_clock and emits a chunk for every chunk of real time whether + * or not a queue has data (audio_sample_utils.cpp's mixLoop), so it had already + * emitted zeros for exactly that wall time; the padding then arrived as extra + * frames at the back of the queue and pushed everything after it later by the + * length of the gap. Permanently -- the mixer never trims -- so one 500 ms + * glitch left the microphone 500 ms behind the picture for the rest of the + * take, growing with each further glitch. + * + * The queue is a stream of samples, not a timeline: `timestampHns` is passed + * for the encoder's benefit and the mixer ignores it. So whoever fills the + * silence has to be the one holding the clock, and that is the mixer. + */ void WasapiLoopbackCapture::captureLoop() { - auto emitSilenceFrames = [&](uint64_t frames, int64_t timestampHns) { - constexpr uint64_t MaxSilenceChunkFrames = 4800; - uint64_t remainingFrames = frames; - int64_t currentTimestampHns = timestampHns; - while (remainingFrames > 0 && !stopRequested_) { - const uint64_t chunkFrames = std::min(remainingFrames, MaxSilenceChunkFrames); - const DWORD chunkBytes = static_cast(chunkFrames * inputFormat_.blockAlign); - const int64_t chunkDurationHns = - static_cast((chunkFrames * HnsPerSecond) / inputFormat_.sampleRate); - silenceBuffer_.assign(chunkBytes, 0); - callback_(silenceBuffer_.data(), chunkBytes, currentTimestampHns, chunkDurationHns); - remainingFrames -= chunkFrames; - currentTimestampHns += chunkDurationHns; + // A device that goes away mid-take — a USB microphone unplugged, the + // default endpoint switched, a driver restart — fails every call from here + // on with AUDCLNT_E_DEVICE_INVALIDATED. Breaking out of the loop is the + // right thing to do; what was missing is that nobody was told. The mixer + // keeps emitting wall-clock silence, so the recording runs to its normal + // length and finishes successfully, mute from that second onward, with + // nothing in the helper's output naming the cause. + // + // ponytail: reported, not recovered. Re-resolving the endpoint would need + // an IMMNotificationClient and a re-initialize() this class has no shape + // for; that is where to go if device-swap-mid-recording is ever asked for. + bool reportedDeviceLoss = false; + const auto reportDeviceLoss = [&](HRESULT hr) { + if (reportedDeviceLoss) { + return; } + reportedDeviceLoss = true; + std::cout << R"({"event":"warning","code":"audio-device-lost","endpoint":")" + << (endpoint_ == WasapiCaptureEndpoint::Microphone ? "microphone" : "system") + << R"(","hr":"0x)" << std::hex << hr << std::dec + << R"(","message":"The audio capture device stopped delivering; the rest of )" + R"(this recording has no audio from it"})" + << std::endl; }; while (!stopRequested_) { UINT32 packetFrames = 0; HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames); - if (FAILED(hr)) { - std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" << std::hex - << hr << std::dec << ")" << std::endl; + if (!succeeded(hr, "IAudioCaptureClient::GetNextPacketSize")) { + reportDeviceLoss(hr); break; } @@ -409,21 +434,12 @@ void WasapiLoopbackCapture::captureLoop() { UINT64 qpcPosition = 0; hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, &devicePosition, &qpcPosition); - if (FAILED(hr)) { - std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex - << hr << std::dec << ")" << std::endl; + if (!succeeded(hr, "IAudioCaptureClient::GetBuffer")) { + reportDeviceLoss(hr); break; } (void)qpcPosition; - if (hasLastDevicePosition_ && devicePosition > lastDevicePositionEnd_) { - const uint64_t gapFrames = devicePosition - lastDevicePositionEnd_; - if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0 || gapFrames > framesAvailable) { - const int64_t gapTimestampHns = - static_cast((lastDevicePositionEnd_ * HnsPerSecond) / inputFormat_.sampleRate); - emitSilenceFrames(gapFrames, gapTimestampHns); - } - } const DWORD byteCount = framesAvailable * inputFormat_.blockAlign; const int64_t timestampHns = @@ -442,14 +458,11 @@ void WasapiLoopbackCapture::captureLoop() { } writtenFrames_ += framesAvailable; - lastDevicePositionEnd_ = devicePosition + framesAvailable; - hasLastDevicePosition_ = true; captureClient_->ReleaseBuffer(framesAvailable); hr = captureClient_->GetNextPacketSize(&packetFrames); - if (FAILED(hr)) { - std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" - << std::hex << hr << std::dec << ")" << std::endl; + if (!succeeded(hr, "IAudioCaptureClient::GetNextPacketSize")) { + reportDeviceLoss(hr); packetFrames = 0; break; } diff --git a/electron/native/wgc-capture/src/wasapi_loopback_capture.h b/electron/native/wgc-capture/src/wasapi_loopback_capture.h index 5c5f2b7c0..51600bc84 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback_capture.h +++ b/electron/native/wgc-capture/src/wasapi_loopback_capture.h @@ -48,6 +48,7 @@ class WasapiLoopbackCapture { Microsoft::WRL::ComPtr audioClient_; Microsoft::WRL::ComPtr captureClient_; WAVEFORMATEX* mixFormat_ = nullptr; + WasapiCaptureEndpoint endpoint_ = WasapiCaptureEndpoint::SystemLoopback; AudioInputFormat inputFormat_{}; std::wstring selectedDeviceName_; AudioCallback callback_; @@ -55,6 +56,4 @@ class WasapiLoopbackCapture { std::atomic stopRequested_ = false; std::vector silenceBuffer_; uint64_t writtenFrames_ = 0; - uint64_t lastDevicePositionEnd_ = 0; - bool hasLastDevicePosition_ = false; }; diff --git a/electron/native/wgc-capture/src/webcam_capture.cpp b/electron/native/wgc-capture/src/webcam_capture.cpp index 6377a799b..c75b4dfee 100644 --- a/electron/native/wgc-capture/src/webcam_capture.cpp +++ b/electron/native/wgc-capture/src/webcam_capture.cpp @@ -1,5 +1,7 @@ #include "webcam_capture.h" +#include "hresult_log.h" + #include #include #include @@ -11,16 +13,6 @@ namespace { -bool succeeded(HRESULT hr, const char* label) { - if (SUCCEEDED(hr)) { - return true; - } - - std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")" - << std::endl; - return false; -} - std::wstring readAllocatedString(IMFActivate* activate, REFGUID key) { WCHAR* value = nullptr; UINT32 length = 0; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index 82197f3d5..062c40b7c 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -1,5 +1,7 @@ #include "wgc_session.h" +#include "hresult_log.h" + #include #include #include @@ -21,16 +23,6 @@ extern "C" HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice( namespace { -bool succeeded(HRESULT hr, const char* label) { - if (SUCCEEDED(hr)) { - return true; - } - - std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")" - << std::endl; - return false; -} - int64_t timeSpanToHns(wf::TimeSpan const& value) { return value.count(); } @@ -217,15 +209,8 @@ bool WgcSession::applySessionOptions(bool captureCursor) { return true; } -bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { - fps_ = fps > 0 ? fps : 60; - if (!createD3DDevice()) { - return false; - } - if (!createCaptureItem(monitor)) { - return false; - } - +// Everything both initialize() overloads do once the capture item exists. +bool WgcSession::createSession(bool captureCursor) { framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded( winrtDevice_, wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized, @@ -233,34 +218,15 @@ bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { winrt::Windows::Graphics::SizeInt32{width_, height_}); session_ = framePool_.CreateCaptureSession(item_); - if (!applySessionOptions(captureCursor)) { - return false; - } - - return true; + return applySessionOptions(captureCursor); } -bool WgcSession::initialize(HWND window, int fps, bool captureCursor) { - fps_ = fps > 0 ? fps : 60; - if (!createD3DDevice()) { - return false; - } - if (!createCaptureItem(window)) { - return false; - } - - framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded( - winrtDevice_, - wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized, - 2, - winrt::Windows::Graphics::SizeInt32{width_, height_}); - session_ = framePool_.CreateCaptureSession(item_); - - if (!applySessionOptions(captureCursor)) { - return false; - } +bool WgcSession::initialize(HMONITOR monitor, bool captureCursor) { + return createD3DDevice() && createCaptureItem(monitor) && createSession(captureCursor); +} - return true; +bool WgcSession::initialize(HWND window, bool captureCursor) { + return createD3DDevice() && createCaptureItem(window) && createSession(captureCursor); } bool WgcSession::start() { diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h index 68329e935..193adee67 100644 --- a/electron/native/wgc-capture/src/wgc_session.h +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -52,8 +52,10 @@ class WgcSession { WgcSession(const WgcSession&) = delete; WgcSession& operator=(const WgcSession&) = delete; - bool initialize(HMONITOR monitor, int fps, bool captureCursor); - bool initialize(HWND window, int fps, bool captureCursor); + // No fps parameter: the session has no frame rate of its own -- the caller + // polls tryGetNextFrame() on whatever cadence it wants. + bool initialize(HMONITOR monitor, bool captureCursor); + bool initialize(HWND window, bool captureCursor); bool start(); // Returns the most recently arrived frame's texture and timestamp, or // false if none is available since the last call. The returned pointer @@ -85,6 +87,7 @@ class WgcSession { bool createD3DDevice(); bool createCaptureItem(HMONITOR monitor); bool createCaptureItem(HWND window); + bool createSession(bool captureCursor); bool applySessionOptions(bool captureCursor); void onFrameArrived( winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, @@ -111,7 +114,6 @@ class WgcSession { bool quiesced_ = false; int width_ = 0; int height_ = 0; - int fps_ = 60; bool captureCursor_ = false; bool started_ = false; }; diff --git a/electron/native/whisper-stt/src/main.cpp b/electron/native/whisper-stt/src/main.cpp index f7b213c64..e32840555 100644 --- a/electron/native/whisper-stt/src/main.cpp +++ b/electron/native/whisper-stt/src/main.cpp @@ -35,6 +35,7 @@ // this is a belt-and-braces guarantee against a future bug or parallel invoker). #include "whisper.h" +#include "wav_pcm16.h" #include #include @@ -59,11 +60,6 @@ namespace { -void log(const std::string& msg) { - std::cerr << "[whisper-stt] " << msg << std::endl; - std::cerr.flush(); -} - std::string json_escape(const std::string& s) { std::string out; out.reserve(s.size() + 8); @@ -87,84 +83,6 @@ std::string json_escape(const std::string& s) { return out; } -// Minimal WAV reader: PCM16, any channel count, any sample rate. Fixtures -// (and the renderer's writeSamplesAsWav) are guaranteed PCM16 mono 16 kHz. -// ponytail: v1.9.1 of whisper.cpp dropped `examples/dr_wav.h` in favour of -// miniaudio, but pulling in the full miniaudio.h (4 MB header) just to read -// a 16 kHz mono PCM16 stream would be silly. The format is dead simple; this -// parser is the same one the POC harness shipped. -bool read_wav_pcm16(const std::string& path, std::vector& pcm, - int& sample_rate_out, int& channels_out) { - std::ifstream f(path, std::ios::binary); - if (!f) { log("cannot open " + path); return false; } - - auto read_u32 = [&]()-> uint32_t { - uint32_t v = 0; f.read(reinterpret_cast(&v), 4); return v; - }; - auto read_u16 = [&]()-> uint16_t { - uint16_t v = 0; f.read(reinterpret_cast(&v), 2); return v; - }; - auto read_i16 = [&]()-> int16_t { - int16_t v = 0; f.read(reinterpret_cast(&v), 2); return v; - }; - - char tag[4]; - f.read(tag, 4); - if (f.gcount() != 4 || std::memcmp(tag, "RIFF", 4) != 0) { log("not RIFF"); return false; } - (void)read_u32(); - f.read(tag, 4); - if (std::memcmp(tag, "WAVE", 4) != 0) { log("not WAVE"); return false; } - - uint16_t fmt_format = 0, fmt_channels = 0, fmt_bits = 0; - uint32_t fmt_sample_rate = 0; - bool got_fmt = false; - - while (f) { - char chunk_tag[4]; - f.read(chunk_tag, 4); - if (f.gcount() != 4) break; - const uint32_t chunk_size = read_u32(); - if (std::memcmp(chunk_tag, "fmt ", 4) == 0) { - fmt_format = read_u16(); - fmt_channels = read_u16(); - fmt_sample_rate = read_u32(); - (void)read_u32(); - (void)read_u16(); - fmt_bits = read_u16(); - const uint32_t fmt_extra = chunk_size - 16; - if (fmt_extra) f.seekg(fmt_extra, std::ios::cur); - got_fmt = true; - } else if (std::memcmp(chunk_tag, "data", 4) == 0) { - if (!got_fmt || fmt_format != 1 || fmt_bits != 16) { - log("expected PCM16, got format=" + std::to_string(fmt_format) + - " bits=" + std::to_string(fmt_bits)); - return false; - } - sample_rate_out = static_cast(fmt_sample_rate); - channels_out = fmt_channels; - const size_t frames = chunk_size / 2 / fmt_channels; - pcm.resize(frames); - if (fmt_channels == 1) { - for (size_t i = 0; i < frames; ++i) pcm[i] = static_cast(read_i16()) / 32768.0f; - } else { - std::vector count(frames, 0); - for (size_t ch = 0; ch < fmt_channels; ++ch) { - for (size_t i = 0; i < frames; ++i) { - pcm[i] += static_cast(read_i16()) / 32768.0f; - ++count[i]; - } - } - for (size_t i = 0; i < frames; ++i) pcm[i] /= count[i]; - } - return true; - } else { - f.seekg(chunk_size + (chunk_size & 1), std::ios::cur); - } - } - log("data chunk not found in " + path); - return false; -} - // Write the runtime-detected ggml device name to a stable string the JSON // response can use. Order: enumerate registered devices and pick the first // non-CPU one if any exists (CPU is always last in ggml's priority order). @@ -249,6 +167,13 @@ int main(int argc, char** argv) { if (const char* p = std::getenv("CAPTURIA_WHISPER_PORT")) port = std::atoi(p); } if (const char* p = std::getenv("CAPTURIA_WHISPER_THREADS")) threads = std::atoi(p); + // Both of the above are `atoi`, which answers 0 for "--threads abc" as + // readily as for "--threads 0", and ggml asserts on n_threads < 1 rather + // than correcting it — so a typo in the env var killed the server on the + // first request. Oversubscription is refused for the same reason it is not + // offered: whisper.cpp is compute-bound and more workers than cores only + // costs context switches. + threads = std::clamp(threads, 1, static_cast(std::max(1u, std::thread::hardware_concurrency()))); // Only a fallback, never an override: the app always passes --host 127.0.0.1, // and an env var that can widen a shipped binary's bind address behind an // explicit flag is a hole, not a knob. Anything but loopback is refused diff --git a/electron/native/whisper-stt/src/wav_pcm16.h b/electron/native/whisper-stt/src/wav_pcm16.h new file mode 100644 index 000000000..8c2d0bf21 --- /dev/null +++ b/electron/native/whisper-stt/src/wav_pcm16.h @@ -0,0 +1,127 @@ +#pragma once + +// Minimal WAV reader: PCM16, any channel count, any sample rate. Fixtures +// (and the renderer's writeSamplesAsWav) are guaranteed PCM16 mono 16 kHz. +// ponytail: v1.9.1 of whisper.cpp dropped `examples/dr_wav.h` in favour of +// miniaudio, but pulling in the full miniaudio.h (4 MB header) just to read +// a 16 kHz mono PCM16 stream would be silly. The format is dead simple; this +// parser is the same one the POC harness shipped. +// +// It lives in its own header so it can be compiled without whisper.h, httplib +// and ggml — the header the /inference handler needs, and the reason there was +// no test for the one piece of this server that parses untrusted bytes: +// +// g++ -std=c++20 -O2 -Wall -Wextra -o /tmp/wav_pcm16_test +// electron/native/whisper-stt/src/wav_pcm16_test.cpp && /tmp/wav_pcm16_test + +#include +#include +#include +#include +#include +#include +#include + +inline void log(const std::string& msg) { + std::cerr << "[whisper-stt] " << msg << std::endl; + std::cerr.flush(); +} + +// Every field below is attacker-shaped: the WAV comes in over the wire as a +// multipart upload, so the header says whatever the sender wants. `channels` +// reaching the divisor unchecked was a SIGFPE that killed the server for the +// rest of its life (every later caption call then failed until the Node side +// noticed and respawned it), and `chunk_size` reaching `resize()` unchecked was +// a 2 GB allocation a 44-byte file could ask for. +inline bool read_wav_pcm16(const std::string& path, std::vector& pcm, + int& sample_rate_out, int& channels_out) { + std::ifstream f(path, std::ios::binary); + if (!f) { log("cannot open " + path); return false; } + + f.seekg(0, std::ios::end); + const std::streamoff file_size = f.tellg(); + f.seekg(0, std::ios::beg); + + auto read_u32 = [&]()-> uint32_t { + uint32_t v = 0; f.read(reinterpret_cast(&v), 4); return v; + }; + auto read_u16 = [&]()-> uint16_t { + uint16_t v = 0; f.read(reinterpret_cast(&v), 2); return v; + }; + auto read_i16 = [&]()-> int16_t { + int16_t v = 0; f.read(reinterpret_cast(&v), 2); return v; + }; + + char tag[4]; + f.read(tag, 4); + if (f.gcount() != 4 || std::memcmp(tag, "RIFF", 4) != 0) { log("not RIFF"); return false; } + (void)read_u32(); + f.read(tag, 4); + if (std::memcmp(tag, "WAVE", 4) != 0) { log("not WAVE"); return false; } + + uint16_t fmt_format = 0, fmt_channels = 0, fmt_bits = 0; + uint32_t fmt_sample_rate = 0; + bool got_fmt = false; + + while (f) { + char chunk_tag[4]; + f.read(chunk_tag, 4); + if (f.gcount() != 4) break; + const uint32_t chunk_size = read_u32(); + if (std::memcmp(chunk_tag, "fmt ", 4) == 0) { + // Shorter than the PCM fmt body it is about to be read as, so + // `chunk_size - 16` would wrap and seek the file to nowhere. + if (chunk_size < 16) { log("fmt chunk too short"); return false; } + fmt_format = read_u16(); + fmt_channels = read_u16(); + fmt_sample_rate = read_u32(); + (void)read_u32(); + (void)read_u16(); + fmt_bits = read_u16(); + const uint32_t fmt_extra = chunk_size - 16; + if (fmt_extra) f.seekg(fmt_extra, std::ios::cur); + got_fmt = true; + } else if (std::memcmp(chunk_tag, "data", 4) == 0) { + // 8 is arbitrary but generous: the contract is mono, and no capture + // device this helper is fed from is beyond 7.1. + if (!got_fmt || fmt_format != 1 || fmt_bits != 16 || + fmt_channels == 0 || fmt_channels > 8 || fmt_sample_rate == 0) { + log("expected PCM16, got format=" + std::to_string(fmt_format) + + " bits=" + std::to_string(fmt_bits) + + " channels=" + std::to_string(fmt_channels) + + " rate=" + std::to_string(fmt_sample_rate)); + return false; + } + sample_rate_out = static_cast(fmt_sample_rate); + channels_out = fmt_channels; + // The header's claim is a maximum, not a size: a `data` chunk may + // say 2 GB in a 44-byte file, and `resize()` would believe it. + const std::streamoff here = f.tellg(); + const uint64_t available = + here < 0 || here >= file_size ? 0 : static_cast(file_size - here); + const uint64_t usable = std::min(chunk_size, available); + const size_t frames = static_cast(usable / 2 / fmt_channels); + pcm.assign(frames, 0.0f); + if (fmt_channels == 1) { + for (size_t i = 0; i < frames; ++i) pcm[i] = static_cast(read_i16()) / 32768.0f; + } else { + // PCM is interleaved: every channel of frame 0, then every + // channel of frame 1. Reading channel-major instead walked the + // file once per channel from where the last pass stopped, so a + // stereo file summed its first half onto its second — the frame + // loop has to be the outer one. + for (size_t i = 0; i < frames; ++i) { + for (size_t ch = 0; ch < fmt_channels; ++ch) { + pcm[i] += static_cast(read_i16()) / 32768.0f; + } + pcm[i] /= static_cast(fmt_channels); + } + } + return true; + } else { + f.seekg(chunk_size + (chunk_size & 1), std::ios::cur); + } + } + log("data chunk not found in " + path); + return false; +} diff --git a/electron/native/whisper-stt/src/wav_pcm16_test.cpp b/electron/native/whisper-stt/src/wav_pcm16_test.cpp new file mode 100644 index 000000000..dfecc1442 --- /dev/null +++ b/electron/native/whisper-stt/src/wav_pcm16_test.cpp @@ -0,0 +1,171 @@ +// Tests for the WAV header parser the /inference handler feeds untrusted +// uploads to. Standalone on purpose: nothing here needs whisper.h, ggml or +// httplib, so it builds and runs in a second on any host, including the Linux +// and macOS boxes that cannot build the server itself. +// +// g++ -std=c++20 -O2 -Wall -Wextra -o /tmp/wav_pcm16_test +// electron/native/whisper-stt/src/wav_pcm16_test.cpp && /tmp/wav_pcm16_test +// +// Exit code is the number of failures. + +#include "wav_pcm16.h" + +#include +#include +#include +#include + +namespace { + +int g_ran = 0; +int g_failed = 0; + +void expect(const char* name, bool ok, const std::string& detail = "") { + g_ran += 1; + if (ok) { + std::cout << "PASS " << name << "\n"; + return; + } + g_failed += 1; + std::cout << "FAIL " << name << " " << detail << "\n"; +} + +void put_u32(std::vector& out, uint32_t v) { + for (int i = 0; i < 4; ++i) out.push_back(static_cast((v >> (8 * i)) & 0xff)); +} +void put_u16(std::vector& out, uint16_t v) { + for (int i = 0; i < 2; ++i) out.push_back(static_cast((v >> (8 * i)) & 0xff)); +} +void put_tag(std::vector& out, const char* tag) { + out.insert(out.end(), tag, tag + 4); +} + +// A 44-byte canonical PCM16 header, with every field the caller's to lie about, +// followed by `samples` as-is. `data_size` is what the header CLAIMS, which is +// not necessarily what follows it. +std::vector wav(uint16_t channels, uint32_t sample_rate, uint16_t bits, + uint32_t data_size, const std::vector& samples, + uint32_t fmt_size = 16) { + std::vector out; + put_tag(out, "RIFF"); + put_u32(out, 0); + put_tag(out, "WAVE"); + put_tag(out, "fmt "); + put_u32(out, fmt_size); + put_u16(out, 1); // WAVE_FORMAT_PCM + put_u16(out, channels); + put_u32(out, sample_rate); + put_u32(out, sample_rate * channels * (bits / 8)); + put_u16(out, static_cast(channels * (bits / 8))); + put_u16(out, bits); + put_tag(out, "data"); + put_u32(out, data_size); + for (const int16_t s : samples) put_u16(out, static_cast(s)); + return out; +} + +std::string write_temp(const std::vector& bytes, const char* name) { + const std::string path = (std::filesystem::temp_directory_path() / name).string(); + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f.write(bytes.data(), static_cast(bytes.size())); + f.close(); + return path; +} + +bool read(const std::vector& bytes, const char* name, std::vector& pcm, + int& rate, int& channels) { + const std::string path = write_temp(bytes, name); + pcm.clear(); + rate = -1; + channels = -1; + const bool ok = read_wav_pcm16(path, pcm, rate, channels); + std::filesystem::remove(path); + return ok; +} + +} // namespace + +int main() { + std::vector pcm; + int rate = 0; + int channels = 0; + + // The upload says zero channels. `chunk_size / 2 / channels` divides by it + // — an integer division by zero, i.e. SIGFPE, and the server is a long-lived + // singleton, so it does not come back until the Node side notices. + expect( + "channels-zero-is-rejected-not-divided-by", + !read(wav(0, 16000, 16, 0, {}), "wav-pcm16-ch0.wav", pcm, rate, channels)); + + // 7.1 is the most any capture device here produces; past that the header is + // lying and `frames * channels` reads are not worth attempting. + expect( + "channels-above-eight-is-rejected", + !read(wav(9, 16000, 16, 18, {1, 2, 3, 4, 5, 6, 7, 8, 9}), "wav-pcm16-ch9.wav", + pcm, rate, channels)); + + expect( + "sample-rate-zero-is-rejected", + !read(wav(1, 0, 16, 4, {1, 2}), "wav-pcm16-rate0.wav", pcm, rate, channels)); + + // `chunk_size - 16` on a fmt chunk shorter than 16 wraps to ~4 G and seeks + // the stream off the end of the file. + expect( + "short-fmt-chunk-is-rejected", + !read(wav(1, 16000, 16, 4, {1, 2}, /*fmt_size=*/8), "wav-pcm16-shortfmt.wav", + pcm, rate, channels)); + + // A 44-byte file claiming a 2 GB data chunk. Unbounded, `resize(frames)` + // takes the claim at face value: 1 Gi floats = 4 GB, and the server dies on + // bad_alloc instead of rejecting an 8-byte lie. + { + const bool ok = read( + wav(1, 16000, 16, 0x7fffffffu, {}), "wav-pcm16-overclaim.wav", pcm, rate, channels); + expect( + "frames-are-bounded-by-the-real-file-size", ok && pcm.empty(), + "ok=" + std::to_string(ok) + " frames=" + std::to_string(pcm.size())); + } + { + // Same lie, with four real frames behind it. + const bool ok = read( + wav(1, 16000, 16, 0x7fffffffu, {0, 16384, -16384, 32767}), "wav-pcm16-overclaim4.wav", + pcm, rate, channels); + expect( + "frames-are-bounded-to-the-bytes-present", ok && pcm.size() == 4, + "ok=" + std::to_string(ok) + " frames=" + std::to_string(pcm.size())); + } + + // The shape the renderer actually uploads, so the guards above cannot pass + // by rejecting everything. + { + const bool ok = read( + wav(1, 16000, 16, 8, {0, 16384, -16384, 32767}), "wav-pcm16-mono.wav", pcm, rate, + channels); + const bool values = pcm.size() == 4 && pcm[0] == 0.0f && pcm[1] == 0.5f && + pcm[2] == -0.5f && pcm[3] > 0.99f; + expect( + "mono-16k-still-reads", ok && rate == 16000 && channels == 1 && values, + "ok=" + std::to_string(ok) + " rate=" + std::to_string(rate) + " frames=" + + std::to_string(pcm.size())); + } + + // Interleaved L/R, four frames: the left channel counts up, the right holds + // its negative, so a correct downmix is silence and any other read order is + // loud. (L0 R0 L1 R1 ... — a channel-major read sums the first half of the + // file onto its second and reads 0.5, -0.5, 0.75, -0.75 here.) + { + const bool ok = read( + wav(2, 16000, 16, 16, {8192, -8192, 16384, -16384, 24576, -24576, 32767, -32767}), + "wav-pcm16-stereo.wav", pcm, rate, channels); + bool silent = pcm.size() == 4; + for (const float v : pcm) silent = silent && std::abs(v) < 0.0001f; + expect( + "stereo-is-downmixed-frame-by-frame", ok && channels == 2 && silent, + "ok=" + std::to_string(ok) + " frames=" + std::to_string(pcm.size()) + " first=" + + (pcm.empty() ? "-" : std::to_string(pcm[0]))); + } + + std::cout << (g_failed == 0 ? "OK " : "FAILED ") << (g_ran - g_failed) << "/" << g_ran + << " checks\n"; + return g_failed; +}