diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adbb039db6..f10eba7fa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,52 @@ jobs: - uses: ./.github/actions/setup - run: npx vite build + # PR #189 — port macOS du compositor (Metal + VideoToolbox). + # + # Compile check on a `macos-14` runner (Apple Silicon). Verifies that the + # scaffold + engine-layer code added by #189 actually compiles for + # `aarch64-apple-darwin` — bindgen + cc on macOS produce the ffmpeg FFI + # bindings + the VideoToolbox context that the Rust crate statically + # references, and the `metal`/`objc`/`block`/`core-foundation` crates need + # their macOS system frameworks (Metal/CoreVideo/CoreMedia) linked. + # + # ffmpeg via Homebrew: this job installs `ffmpeg` from homebrew-core on the + # runner to satisfy bindgen + the cargo:rustc-link-lib lines for avformat/ + # avcodec/avutil/swscale/swresample. NB: homebrew's `ffmpeg` is GPL-3.0 (it + # bundles x264/x265/svt-av1 etc.) — that is fine for a CI compile-check, but + # the dev/runtime story is different: `scripts/fetch-ffmpeg.mjs` documents + # that BtbN publishes no macOS build, so a pinned LGPL macOS dylib has to + # land separately before we can ship. The vendored `electron/native/bin/ + # darwin-*/` directory is the runtime pin and is empty today. + # + # Why this job runs at all today: every commit on #189 that touches + # compositor_macos.rs / pipeline_macos.rs / mac_frames.rs / shaders.metal / + # d3d_macos.rs / text_macos.rs is unverifiable from Windows (cross-compile + # requires the macOS SDK + headers, which we don't have). This job is the + # only signal that the macOS-side Rust still type-checks. + rust-macos-compositor-check: + name: Rust check (macOS compositor) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --target aarch64-apple-darwin + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Install ffmpeg (homebrew-core) and verify headers/libs + run: | + brew update + brew install ffmpeg + ls /opt/homebrew/opt/ffmpeg/include | head -5 + ls /opt/homebrew/opt/ffmpeg/lib | head -5 + /opt/homebrew/opt/ffmpeg/bin/ffmpeg -hide_banner -version | head -1 + - name: cargo check (compositor + napi addon, aarch64-apple-darwin) + env: + MAC_FFMPEG_DIR: /opt/homebrew/opt/ffmpeg + run: | + cd crates + cargo check --target aarch64-apple-darwin -p openscreen-compositor -p compositor-view-napi + semantic-pr: name: Validate PR title (semantic) runs-on: ubuntu-latest diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml index 35d539b187..23980f2ab4 100644 --- a/crates/.cargo/config.toml +++ b/crates/.cargo/config.toml @@ -11,3 +11,10 @@ [env] FFMPEG_DIR = { value = "thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared", relative = true } LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin" + +# macOS : BtbN ne publie pas de build macOS (cf. scripts/fetch-ffmpeg.mjs), donc on +# laisse `crates/compositor/build.rs` chercher MAC_FFMPEG_DIR (env var explicite posée +# par la CI macOS ou par le dev local) ou un répertoire attendu sous `thirdparty/`. +# LIBCLANG_PATH est inutile sur macOS (clang est dans CommandLineTools), on ne le +# pose donc pas. +[target.'cfg(target_os = "macos")'.env] \ No newline at end of file diff --git a/crates/.gitignore b/crates/.gitignore index f7e4e976d7..3ecbf6ad43 100644 --- a/crates/.gitignore +++ b/crates/.gitignore @@ -4,8 +4,10 @@ # dépendance téléchargée : build ffmpeg LGPL-shared BtbN (~160 Mo, voir README) /thirdparty -# sorties générées (vidéos C*.mp4, PNG, GIF, dumps) -/out +# sorties générées (vidéos C*.mp4, PNG, PPM, GIF, dumps). Le glob couvre les `--out` +# nommés par backend (out-hw/, out-cpu/) : un PPM de preuve pèse 6 Mo et il en sort un +# par cfg et par backend. +/out* *.raw *.nv12 diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 52c11e5e35..df0c787e9f 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -35,7 +35,7 @@ version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools", @@ -46,15 +46,27 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn", + "syn 2.0.119", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "bytemuck" version = "1.25.1" @@ -124,6 +136,33 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -140,7 +179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -174,6 +213,33 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "glob" version = "0.3.3" @@ -232,12 +298,36 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -270,7 +360,7 @@ version = "2.16.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "ctor", "napi-derive", "napi-sys", @@ -294,7 +384,7 @@ dependencies = [ "napi-derive-backend", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -309,7 +399,7 @@ dependencies = [ "quote", "regex", "semver", - "syn", + "syn 2.0.119", ] [[package]] @@ -340,6 +430,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -352,20 +451,30 @@ version = "0.0.0" dependencies = [ "anyhow", "bindgen", + "block", "cc", + "core-foundation", "image", + "metal", + "objc", "serde", "serde_json", "windows", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -388,7 +497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] @@ -483,7 +592,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -528,6 +637,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -571,7 +691,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -582,7 +702,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/crates/compositor-view-napi/Cargo.toml b/crates/compositor-view-napi/Cargo.toml index 166dc920b6..cf4b6e15a3 100644 --- a/crates/compositor-view-napi/Cargo.toml +++ b/crates/compositor-view-napi/Cargo.toml @@ -2,7 +2,7 @@ name = "compositor-view-napi" version.workspace = true edition.workspace = true -description = "Addon napi-rs : expose openscreen_compositor::live::LiveView (fenêtre D3D enfant) à Electron. C'est CE crate qui produit compositor_view.node, le binaire packagé dans l'app." +description = "Addon napi-rs : expose openscreen_compositor::live::LiveView (vue offscreen multiplateforme) à Electron. C'est CE crate qui produit compositor_view.node, le binaire packagé dans l'app." [lib] crate-type = ["cdylib"] @@ -14,9 +14,13 @@ napi-derive = "2" openscreen-compositor.workspace = true anyhow.workspace = true -[dependencies.windows] +# Le crate n'utilise pas directement l'API Windows — c'est transitif via le +# moteur de composition (qui expose la même surface publique cross-platform via +# la ré-export cfg dans `openscreen_compositor::lib`). On garde la dépendance +# cfg-conditionnelle pour que le crate compile sur macOS. +[target.'cfg(windows)'.dependencies.windows] version = "0.58" features = ["Win32_Foundation"] [build-dependencies] -napi-build = "2" +napi-build = "2" \ No newline at end of file diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index db91121ce2..05caf82f7e 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -10,7 +10,7 @@ use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFun use napi::{Env, JsFunction, Task}; use napi_derive::napi; use openscreen_compositor::compositor::{live_params_from_scene, Compositor}; -use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::d3d::{Backend, Gpu}; use openscreen_compositor::live::{LiveView, PausedPreviews}; use openscreen_compositor::scene::Scene; use openscreen_compositor::{config, pipeline}; @@ -50,6 +50,24 @@ fn registry() -> &'static Mutex> { /// /// `rect` ne sert plus que pour `width`/`height` (résolution cible du preview) ; /// `x`/`y` sont ignorés (compat structurelle — la position est gérée par CSS). +/// Quel backend cette machine utilisera : `"hardware"`, `"cpu"`, ou `"none"` si aucun +/// device D3D11 ne se crée (la vue échouera alors avec son propre message, plus précis). +/// +/// Sert à PRÉVENIR : sur `"cpu"`, le rendu passe par WARP + décodage logiciel — la +/// preview tombe à ~8 fps avec tous les effets et l'export met des minutes au lieu de +/// secondes. L'utilisateur doit le savoir AVANT de lancer un export, pas après. D'où une +/// question posée au système et non à une vue : la modale d'export la pose sans qu'aucune +/// preview n'existe. Réponse mise en cache côté Rust — c'est une propriété de la machine. +#[napi] +pub fn probe_backend() -> String { + match Gpu::probe() { + Some(Backend::Hardware) => "hardware", + Some(Backend::Cpu) => "cpu", + None => "none", + } + .to_string() +} + #[napi] pub fn create_view( rect: CompositorViewRect, @@ -136,7 +154,18 @@ pub fn read_frame(id: i32, since_gen: f64) -> Result> { // bloquerait tout autre appel napi (`set_rect`, `destroy_view`, ...). let slot = match registry().lock().unwrap().get(&id) { None => return Ok(None), - Some(v) => v.latest_frame_since(since_gen.max(0.0) as u64), + Some(v) => { + // Le thread de rendu est mort (device D3D11 indisponible, décodeur en échec…) : + // il ne publiera plus jamais de frame. Sans ce relais, `create_view` a déjà + // répondu Ok et l'échec ne se voyait que dans un `eprintln!` — l'utilisateur + // restait devant un canvas noir sans explication (PR #162). La boucle de pull + // du renderer appelle ceci ~30×/s, donc l'erreur remonte tout de suite, et par + // le chemin d'erreur que `read_frame` a déjà (`Result`), sans changer le contrat. + if let Some(fatal) = v.fatal_error() { + return Err(Error::from_reason(fatal)); + } + v.latest_frame_since(since_gen.max(0.0) as u64) + } }; Ok(slot.map(|(gen, w, h, pixels)| { debug_assert_eq!(pixels.len(), (w as usize) * (h as usize) * 4); @@ -337,7 +366,9 @@ impl Task for ExportMultiTask { // Previews paused for the whole render (GPU 3D engine freed) and restored // exactly as found when this guard drops, including on the error paths. let _previews = PreviewPause::begin(); - let gpu = Gpu::create(false).map_err(|e| Error::from_reason(format!("{e:#}")))?; + // Même sélection que la preview : l'export d'un hôte sans GPU passe par + // libopenh264 au lieu d'AMF, plutôt que d'échouer. + let gpu = Gpu::create_auto(false).map_err(|e| Error::from_reason(format!("{e:#}")))?; let mut cfg = config::all().pop().expect("au moins une config"); // C8 cfg.zoom = false; cfg.layout_anim = false; diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml index 969f26be8c..3d6a298a35 100644 --- a/crates/compositor/Cargo.toml +++ b/crates/compositor/Cargo.toml @@ -2,7 +2,7 @@ name = "openscreen-compositor" version.workspace = true edition.workspace = true -description = "Compositeur natif D3D11 d'OpenScreen : décodage, pipeline, effets HLSL, scène, curseur, audio et vue live. Code de production — l'addon Electron (compositor-view-napi) s'y lie, le POC (poc-d3d) le consomme aussi." +description = "Compositeur natif multiplateforme d'OpenScreen : décodage, pipeline, effets, scène, curseur, audio, et la vue live embarquable. Code de production — l'addon Electron (compositor-view-napi) s'y lie, le POC (poc-d3d) le consomme aussi.\n\nWindows : D3D11 + D3D11VA + shaders HLSL compilés à l'exécution. macOS : Metal + VideoToolbox + shaders MSL (port de ce PR)." [lib] name = "openscreen_compositor" @@ -17,4 +17,23 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true image.workspace = true + +# Windows : D3D11 + D3D11VA + Direct2D/DirectWrite + HLSL à l'exécution. +[target.'cfg(windows)'.dependencies] windows.workspace = true + +# macOS : Metal + CoreVideo/VideoToolbox. Le port Metal/VideoToolbox de ce crate +# s'appuie sur `metal` (bindings purs Rust du framework Metal, sans CC), `objc`/ +# `block` pour CVMetalTextureCache/IOSurface (CoreVideo n'expose pas cette API), +# `core-foundation` pour le comptage de références CF. CVPixelBuffer est attaqué +# en FFI brut plutôt que via `core-video-rs` 0.1 — l'API publique de cette crate +# est trop mince pour le gain et la version pinnée n'expose pas tous les +# symboles dont on a besoin (CVMetalTextureCache notamment). Bindgen reste +# utilisé pour ffmpeg (les headers VT dans wrapper.h sont interchangeables avec +# ceux de D3D11VA, mêmes prototypes dans les grandes lignes mais format hw +# différent). +[target.'cfg(target_os = "macos")'.dependencies] +metal = "0.29" +objc = "0.2" +block = "0.1" +core-foundation = "0.9" \ No newline at end of file diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index dd34657962..884c8f84a3 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -1,36 +1,93 @@ use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; fn main() { - let ff = env::var("FFMPEG_DIR").expect("FFMPEG_DIR non défini (voir .cargo/config.toml)"); + // La cible réelle est connue via `CARGO_CFG_TARGET_OS` (renseigné par cargo + // pour chaque build). `cfg!(target_os = "macos")` est faux ici : build.rs + // s'exécute sur le HOST, pas sur la cible. + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_is_macos = target_os == "macos"; - // --- linkage : les import libs LGPL de BtbN --- - println!("cargo:rustc-link-search=native={}\\lib", ff); - for lib in ["avformat", "avcodec", "avutil", "swscale", "swresample"] { - println!("cargo:rustc-link-lib=dylib={}", lib); + // Le pin ffmpeg est porté par `.cargo/config.toml` ; sur Windows c'est le + // BtbN n8.1.2-win64-lgpl-shared, sur macOS c'est l'équivalent .dylib (à venir — + // voir `crates/fixture/fixture.json` pour le pin exact quand la dépendance + // sera ajoutée). + let ff = match env::var("FFMPEG_DIR") { + Ok(v) => Some(v), + Err(_) => { + if target_is_macos { + // Cherche d'abord `MAC_FFMPEG_DIR`, puis un répertoire attendu dans + // `crates/thirdparty/ffmpeg-*` aligné sur la disposition Windows. + env::var("MAC_FFMPEG_DIR").ok().or_else(|| { + let candidate = Path::new("thirdparty").join("ffmpeg-n8.1.2-macos64-lgpl-shared"); + if candidate.exists() { + Some(candidate.to_string_lossy().to_string()) + } else { + None + } + }) + } else { + None + } + } + }; + + let include_dir = match ff.as_ref() { + Some(v) => Path::new(v).join("include").to_string_lossy().to_string(), + None => panic!( + "crates/compositor build.rs: FFMPEG_DIR non défini (target={}). \ + Sur Windows, voir crates/.cargo/config.toml. Sur macOS, poser \ + MAC_FFMPEG_DIR ou vendoriser thirdparty/ffmpeg-n8.1.2-macos64-lgpl-shared.", + target_os + ), + }; + + // --- linkage : les import libs LGPL --- + if let Some(v) = ff.as_ref() { + let lib_dir = Path::new(v).join("lib"); + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + for lib in ["avformat", "avcodec", "avutil", "swscale", "swresample"] { + println!("cargo:rustc-link-lib=dylib={}", lib); + } } - println!("cargo:rerun-if-changed=wrapper.h"); + // Le wrapper.h à binder dépend de la plateforme cible : + // - Windows : D3D11VA (ID3D11VA*), + // - macOS : VideoToolbox (AVVideotoolboxContext). + let wrapper = if target_is_macos { + "wrapper_macos.h" + } else { + "wrapper_windows.h" + }; + + println!("cargo:rerun-if-changed={}", wrapper); println!("cargo:rerun-if-changed=shim.c"); println!("cargo:rerun-if-env-changed=FFMPEG_DIR"); + println!("cargo:rerun-if-env-changed=MAC_FFMPEG_DIR"); // shim C : accesseurs pour les structs que bindgen rend opaques (AVFormatContext). + // Sur macOS, cc utilise clang par défaut ; sur Windows, MSVC via vcvars (cf. x.bat). cc::Build::new() .file("shim.c") - .include(format!("{}\\include", ff)) + .include(&include_dir) .compile("sn_shim"); // --- bindings générés sur les VRAIS headers 8.x (immunisé contre la version) --- - let bindings = bindgen::Builder::default() - .header("wrapper.h") - .clang_arg(format!("-I{}\\include", ff)) + // Cible clang explicite pour que les layouts matchent le runtime de prod (FFmpeg + // pinne ses enums/structs pour clang sur macOS, MSVC sur Windows). + let mut builder = bindgen::Builder::default() + .header(wrapper) + .clang_arg(format!("-I{}", include_dir)) .allowlist_function("av.*") .allowlist_function("avcodec_.*") .allowlist_function("avformat_.*") .allowlist_function("avio_.*") .allowlist_function("swr_.*") + .allowlist_function("sws_.*") .allowlist_type("AV.*") .allowlist_type("SwrContext") + .allowlist_type("SwsContext") + .allowlist_var("SWS_.*") .allowlist_var("AV_.*") .allowlist_var("AVERROR.*") .allowlist_var("FF_.*") @@ -38,7 +95,30 @@ fn main() { // enums en constantes simples : plus simple à manipuler en FFI brut .default_enum_style(bindgen::EnumVariation::ModuleConsts) .derive_default(true) - .layout_tests(false) + .layout_tests(false); + + // Sur macOS le bindgen doit viser aarch64-apple-darwin pour que les layouts + // générés (long=8, etc.) matchent la cible. Sans ce flag, bindgen utilise + // le défaut du host (probablement x86_64), et les structs ffmpeg sont mal + // dimensionnés au link. On laisse bindgen chercher le sysroot via `xcrun` + // pour rester robuste aux variations Xcode (CommandLineTools vs Xcode.app, + // versions 14.x → 15.x). + if target_is_macos { + builder = builder.clang_arg("--target=aarch64-apple-darwin"); + if let Ok(sysroot) = std::process::Command::new("xcrun") + .args(["--show-sdk-path", "--sdk", "macosx"]) + .output() + { + if let Ok(s) = std::str::from_utf8(&sysroot.stdout) { + let s = s.trim(); + if !s.is_empty() { + builder = builder.clang_arg("-isysroot").clang_arg(s); + } + } + } + } + + let bindings = builder .generate() .expect("bindgen a échoué sur les headers ffmpeg"); @@ -46,4 +126,4 @@ fn main() { bindings .write_to_file(out.join("ffi.rs")) .expect("écriture ffi.rs"); -} +} \ No newline at end of file diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs new file mode 100644 index 0000000000..59a22aa7b4 --- /dev/null +++ b/crates/compositor/src/compositor_macos.rs @@ -0,0 +1,947 @@ +//! Moteur de composition macOS — Metal + VideoToolbox. +//! +//! Ce module EST l'équivalent macOS de `compositor_windows.rs`. Il exporte la +//! même surface publique (`Compositor`, `LiveParams`, les helpers `webcam_shape_code`/ +//! `live_params_from_scene`, et les constantes `OUT_W`/`OUT_H`/`FIXTURE_FRACMES`) pour +//! que `live.rs`, `pipeline.rs` et `compositor-view-napi` restent portables. +//! +//! # Frame seam — `nv12_srvs` + `tex_dims` +//! +//! Le seam que `compositor_windows.rs` couvre avec deux `ID3D11ShaderResourceView` +//! (Y R8 + UV R8G8 sur l'array-slice d'une texture D3D11VA) est ici couvert par +//! deux `MTLTexture` produits par `CVMetalTextureCacheCreateTextureFromImage` à +//! partir d'un `CVPixelBufferRef` (le buffer natif macOS, IOSurface-backed). +//! Les 4 champs AVFrame lus sont identiques : `data[0]` (texture native), `data[1]` +//! (toujours 0 — pas d'array côté CoreVideo), `width`/`height` (visibles). +//! +//! Le Compositor détient un `CVMetalTextureCacheRef` créé lazy à la première frame +//! (dans `new_sized`/`compose_frame`), indexé par `(CVPixelBufferRef, planeIndex)`. +use crate::config::Cfg; +use crate::metal::Gpu; +use crate::scene::Scene; +use crate::ffi::AVFrame; +use anyhow::{anyhow, bail, Result}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +/// Largeur de référence pour l'export (conservée pour l'API symétrique ; la valeur +/// effective d'export est négociée par `LiveView` / `Compositor::render_size`). +pub const OUT_W: u32 = 1920; +/// Hauteur de référence pour l'export. Voir `OUT_W`. +pub const OUT_H: u32 = 1080; +/// Nombre de frames dans la fixture POC (pour le bench — le test C0 l'utilise). +pub const FIXTURE_FRAMES: u32 = 360; + +/// Paramètres runtime de la preview (shadow_scale, radius_scale, …). **Mêmes champs +/// et même layout que `compositor_windows::LiveParams`** — le moteur Metal lit ces +/// champs au début de `compose_frame` via le constant buffer `LayerCB`, et toute +/// divergence casserait l'iso-render cross-backend (cf. PR #162 §3). +#[derive(Clone, Copy)] +pub struct LiveParams { + pub bg_color: [f32; 4], + pub shadow_scale: f32, + pub radius_scale: f32, + pub padding: f32, + pub webcam_size_scale: f32, + pub webcam_mirror: bool, + pub webcam_shape: u32, + pub cursor_size_scale: f32, + pub cursor_bounce_scale: f32, + pub cursor_motion_blur: f32, + pub has_webcam: bool, +} + +impl Default for LiveParams { + fn default() -> Self { + Self { + bg_color: [0.0, 0.0, 0.0, 0.0], + shadow_scale: 1.0, + radius_scale: 1.0, + padding: 0.0, + webcam_size_scale: 1.0, + webcam_mirror: false, + webcam_shape: 3, + cursor_size_scale: 1.0, + cursor_bounce_scale: 1.0, + cursor_motion_blur: 0.0, + has_webcam: false, + } + } +} + +/// Convertit une chaîne UI (« rectangle », « circle », « square ») en code de mode +/// shader (ps_main mode 4 / 9). Conservé ici pour la symétrie d'API : c'est un mapping +/// pur, identique sur les deux plateformes. +pub fn webcam_shape_code(shape: &str) -> u32 { + match shape { + "rectangle" => 0, + "circle" => 1, + "square" => 2, + _ => 0, + } +} + +/// Construit un `LiveParams` à partir d'une scène. Le moteur Metal applique ces +/// params dans le constant buffer `LayerCB` avant `compose_frame`. **Mêmes formules +/// que `compositor_windows::live_params_from_scene`** — un changement doit être +/// reporté des deux côtés pour préserver l'iso-render cross-backend (cf. PR #162 §3). +pub fn live_params_from_scene(s: &Scene) -> LiveParams { + LiveParams { + shadow_scale: s.effects.shadow, + padding: s.effects.padding, + webcam_size_scale: s.layout.webcam_size, + webcam_mirror: s.layout.webcam_mirror, + webcam_shape: webcam_shape_code(&s.layout.webcam_shape), + cursor_size_scale: s.cursor.size, + cursor_bounce_scale: s.cursor.click_bounce, + cursor_motion_blur: s.cursor.motion_blur, + ..LiveParams::default() + } +} + +/// Newtype safe Rust pour `CVMetalTextureCacheRef` (`*mut __CVMetalTextureCache`). +/// Crée et cache les `MTLTexture` à partir de `CVPixelBufferRef` via +/// `CVMetalTextureCacheCreateTextureFromImage` — zéro copie IOSurface. +pub(crate) struct CVMetalTextureCache(std::ptr::NonNull); + +unsafe impl Send for CVMetalTextureCache {} +unsafe impl Sync for CVMetalTextureCache {} + +/// Bindings CoreVideo minimaux. CVMetalTextureCache n'est PAS dans le linkage par +/// défaut — il faut explicitement `[link(name = "Metal", ...)]` (déjà fait via +/// `cargo:rustc-link-lib=framework Metal` côté Rust) et `CVMetalTextureCacheCreate` +/// qui vient de CoreVideo. +#[link(name = "CoreVideo", kind = "framework")] +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CVMetalTextureCacheCreate( + allocator: *const std::ffi::c_void, + attributes: *const std::ffi::c_void, + metal_device: *const std::ffi::c_void, // id + attributes2: *const std::ffi::c_void, + cache_out: *mut *mut std::ffi::c_void, // CVMetalTextureCacheRef* + ) -> i32; // CVReturn + + fn CVMetalTextureCacheCreateTextureFromImage( + allocator: *const std::ffi::c_void, + cache: *mut std::ffi::c_void, + pixel_buffer: *mut std::ffi::c_void, + attributes: *const std::ffi::c_void, + plane_index: usize, + pixel_format: u32, // MTLPixelFormat + slice_index: usize, + texture_out: *mut *mut std::ffi::c_void, // CVMetalTextureRef* + ) -> i32; // CVReturn + + fn CVMetalTextureCacheFlush(cache: *mut std::ffi::c_void, options: u64); + fn CVMetalTextureGetTexture(cv_texture: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + + // Helpers du CVPixelBuffer (ré-exportés ici depuis CoreVideo pour ne pas + // dupliquer le bloc extern dans `mac_frames.rs`). + fn CVPixelBufferGetWidth(p: *mut std::ffi::c_void) -> usize; + fn CVPixelBufferGetHeight(p: *mut std::ffi::c_void) -> usize; +} + +impl CVMetalTextureCache { + /// Crée un nouveau `CVMetalTextureCache` lié au `MTLDevice` donné. Échoue si CoreVideo + /// refuse (rare — VM sans GPU, par exemple). + pub(crate) fn new(metal_device: *const std::ffi::c_void) -> Result { + let mut cache: *mut std::ffi::c_void = std::ptr::null_mut(); + let status = unsafe { + CVMetalTextureCacheCreate( + std::ptr::null(), + std::ptr::null(), // default attributes + metal_device, + std::ptr::null(), + &mut cache, + ) + }; + if status != 0 || cache.is_null() { + return Err(anyhow!( + "CVMetalTextureCacheCreate a échoué (CVReturn={}, cache={:?})", + status, + cache + )); + } + Ok(CVMetalTextureCache(unsafe { + std::ptr::NonNull::new_unchecked(cache) + })) + } + + /// Crée un `MTLTexture` (id) wrappant le plan `plane_index` d'un + /// `CVPixelBufferRef` au format `pixel_format` (MTLPixelFormat). Le résultat est + /// zéro-copie : le `MTLTexture` partage la mémoire IOSurface du `CVPixelBuffer`. + pub(crate) fn make_texture_from_pixel_buffer( + &self, + pixel_buffer: *mut std::ffi::c_void, + plane_index: usize, + pixel_format: metal::MTLPixelFormat, + ) -> Result<*mut std::ffi::c_void> { + let mut cv_texture: *mut std::ffi::c_void = std::ptr::null_mut(); + let status = unsafe { + CVMetalTextureCacheCreateTextureFromImage( + std::ptr::null(), + self.0.as_ptr(), + pixel_buffer, + std::ptr::null(), // default attributes + plane_index, + pixel_format as u32, + 0, // slice_index (CVPixelBuffer planes ne sont pas des arrays) + &mut cv_texture, + ) + }; + if status != 0 || cv_texture.is_null() { + return Err(anyhow!( + "CVMetalTextureCacheCreateTextureFromImage a échoué (CVReturn={}, plane={}, fmt={:?})", + status, + plane_index, + pixel_format + )); + } + let mtl_texture = unsafe { CVMetalTextureGetTexture(cv_texture) }; + if mtl_texture.is_null() { + return Err(anyhow!( + "CVMetalTextureGetTexture a renvoyé un id nul (plane={})", + plane_index + )); + } + // Le CVMetalTextureRef (`cv_texture`) est retain par CoreVideo et libéré quand + // le cache est flushé ; le MTLTexture est retainé par `metal::Texture::from_raw` + // côté caller. Aucun release explicite ici. + Ok(mtl_texture) + } +} + +impl Drop for CVMetalTextureCache { + fn drop(&mut self) { + // `CVMetalTextureCacheRef` est un CFType ; le release explicite n'est pas dans + // nos externs (CVMetalTextureCacheRelease). On flush le cache pour libérer les + // CVMetalTextureRef retenus, et le CFType est libéré au Drop du dernier retain. + unsafe { CVMetalTextureCacheFlush(self.0.as_ptr(), 0) }; + } +} + +/// Le moteur de composition. Le moteur tourne sur Metal : chaque frame décodée arrive +/// comme un `CVPixelBufferRef` IOSurface-backed (`mac_frames::CpuFrames::present` / +/// VideoToolbox hwaccel), et `nv12_srvs` le convertit en deux `MTLTexture` zéro-copie +/// via `CVMetalTextureCache`. Le moteur de composition (render targets, shaders MSL, +/// pipeline states, blend, cbuffer `LayerCB`) est câblé dans `new_inner`. +/// +/// First-pass engine : ce commit implémente le strict minimum pour produire un frame +/// (render target RGBA + NV12, compilation MSL, pipeline state, compose_frame avec +/// blit pleine-canvas de la vidéo + conversion RGBA→NV12 vers le buffer de sortie). +/// Les effets avancés (layers, ombres, Kawase, motion blur, webcam overlay avec +/// rounded corners, etc.) sont implémentés dans des commits dédiés : le shader +/// `ps_main` 14-mode existe déjà dans `shaders.metal`, mais ce commit ne l'exerce +/// pas — `compose_frame` rend la couche écran en pleine cadre, ce qui suffit au +/// chemin encode et à la prévisualisation du deck pass-through. +pub struct Compositor { + gpu: Gpu, + render_w: u32, + render_h: u32, + scene: RefCell>, + cursor: RefCell>, + cursor_time: RefCell>, + timeline_time: RefCell>, + live_params: RefCell, + /// Cache Metal texture cache. Créé dans `new_inner` à partir du `MTLDevice` — + /// flushé dans Drop. Flush recommandé aussi à chaque changement de dimensions du + /// `CVPixelBuffer` (les textures cachées pointent alors sur l'IOSurface précédent). + metal_texture_cache: RefCell>, + /// Cache (pixel_buffer_ptr, plane_index) → raw `id` opaque. + tex_cache: RefCell>, + + // --- Engine : render targets --- + /// Render target principal RGBA8 (sRGB natif). Cible de `compose_frame`. + rt: Option, + /// Staging RGBA8 CPU-readable pour `readback_direct` (preview live). + /// `MTLStorageModeShared` permet `getBytes` directement. + rt_read: Option, + /// Texture NV12 interne (sortie de `render_nv12` / cible de l'encodeur zero-copy). + /// Plan Y = `MTLPixelFormatR8Unorm` ; plan UV = `MTLPixelFormatRG8Unorm`. + nv12_y: Option, + nv12_uv: Option, + /// Staging NV12 CPU-readable pour `read_nv12_scaled`. + nv12_read_y: Option, + nv12_read_uv: Option, + + // --- Engine : shaders compilés --- + /// MSL library compilée à `new_inner`. Conservée pour recréer les pipeline + /// states si la géométrie change (ne devrait pas arriver en pratique — le + /// compositor est reconstruit via `new_sized`). + library: Option, + /// Pipeline state pour la passe principale (`vs_main` + `ps_main`). + pipeline_main: Option, + /// Pipeline state pour la passe fullscreen (`vs_fs` + `ps_y`/`ps_uv`/`ps_tex`). + pipeline_fs_y: Option, + pipeline_fs_uv: Option, + pipeline_fs_tex: Option, +} + +impl Compositor { + /// Crée le moteur sur le GPU donné. Équivalent Metal de + /// `compositor_windows::Compositor::new`. Renvoie `Err` dans ce commit — les + /// commits suivants remplissent : render targets RGBA + NV12, shaders MSL, + /// constant buffer `LayerCB`, blend states, sampler. + pub fn new(gpu: &Gpu) -> Result { + Self::new_sized(gpu, OUT_W, OUT_H) + } + + /// Comme `new`, mais avec une taille de rendu explicite. Câble le moteur Metal : + /// - `CVMetalTextureCache` (zero-copy CVPixelBuffer → MTLTexture), + /// - render targets (RT RGBA, RT NV12 Y/UV, staging), + /// - compilation MSL (`shaders.metal` → `MTLLibrary`), + /// - pipeline states (principal + passes fullscreen). + pub fn new_sized(gpu: &Gpu, w: u32, h: u32) -> Result { + let (rw, rh) = Self::normalize_render_size(w, h); + let metal_device_ptr = gpu.device.as_ptr(); + let cache = CVMetalTextureCache::new(metal_device_ptr as *const std::ffi::c_void)?; + + // --- Render targets --- + // RT principal RGBA8 (cible de compose_frame). + let rt_desc = metal::TextureDescriptor::new(); + rt_desc.set_texture_type(metal::TextureType::Type2D); + rt_desc.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + rt_desc.set_width(rw as u64); + rt_desc.set_height(rh as u64); + rt_desc.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + rt_desc.set_storage_mode(metal::MTLStorageMode::Private); + let rt = gpu.device.new_texture(&rt_desc); + + // Staging RGBA8 CPU-readable (preview live) : `Shared` permet `getBytes`. + let rt_read_desc = metal::TextureDescriptor::new(); + rt_read_desc.set_texture_type(metal::TextureType::Type2D); + rt_read_desc.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + rt_read_desc.set_width(rw as u64); + rt_read_desc.set_height(rh as u64); + rt_read_desc.set_usage(metal::MTLTextureUsage::RenderTarget); + rt_read_desc.set_storage_mode(metal::MTLStorageMode::Shared); + let rt_read = gpu.device.new_texture(&rt_read_desc); + + // NV12 interne : Y pleine résolution, UV demi. Stockage `Private` (jamais lu + // directement par le CPU ; `nv12_read_y/_uv` sont les copies CPU-readable pour + // `read_nv12_scaled`). + let nv12_y_desc = metal::TextureDescriptor::new(); + nv12_y_desc.set_texture_type(metal::TextureType::Type2D); + nv12_y_desc.set_pixel_format(metal::MTLPixelFormat::R8Unorm); + nv12_y_desc.set_width(rw as u64); + nv12_y_desc.set_height(rh as u64); + nv12_y_desc.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + nv12_y_desc.set_storage_mode(metal::MTLStorageMode::Private); + let nv12_y = gpu.device.new_texture(&nv12_y_desc); + + let nv12_uv_desc = metal::TextureDescriptor::new(); + nv12_uv_desc.set_texture_type(metal::TextureType::Type2D); + nv12_uv_desc.set_pixel_format(metal::MTLPixelFormat::RG8Unorm); + nv12_uv_desc.set_width(rw as u64); + nv12_uv_desc.set_height(rh as u64); + nv12_uv_desc.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + nv12_uv_desc.set_storage_mode(metal::MTLStorageMode::Private); + let nv12_uv = gpu.device.new_texture(&nv12_uv_desc); + + // NV12 staging CPU-readable (encodeur software fallback). + let nv12_read_y_desc = metal::TextureDescriptor::new(); + nv12_read_y_desc.set_texture_type(metal::TextureType::Type2D); + nv12_read_y_desc.set_pixel_format(metal::MTLPixelFormat::R8Unorm); + nv12_read_y_desc.set_width(rw as u64); + nv12_read_y_desc.set_height(rh as u64); + nv12_read_y_desc.set_storage_mode(metal::MTLStorageMode::Shared); + let nv12_read_y = gpu.device.new_texture(&nv12_read_y_desc); + + let nv12_read_uv_desc = metal::TextureDescriptor::new(); + nv12_read_uv_desc.set_texture_type(metal::TextureType::Type2D); + nv12_read_uv_desc.set_pixel_format(metal::MTLPixelFormat::RG8Unorm); + nv12_read_uv_desc.set_width(rw as u64); + nv12_read_uv_desc.set_height(rh as u64); + nv12_read_uv_desc.set_storage_mode(metal::MTLStorageMode::Shared); + let nv12_read_uv = gpu.device.new_texture(&nv12_read_uv_desc); + + // --- Compilation MSL --- + let msl_source = include_str!("shaders.metal"); + let library = gpu + .device + .new_library_with_source(msl_source, &metal::CompileOptions::new()) + .map_err(|e| anyhow!("MTLDevice::new_library_with_source a échoué : {e:?}"))?; + + let fn_vs_main = library + .get_function("vs_main", None) + .map_err(|e| anyhow!("MTLLibrary::get_function('vs_main') : {e:?}"))?; + let fn_vs_fs = library + .get_function("vs_fs", None) + .map_err(|e| anyhow!("MTLLibrary::get_function('vs_fs') : {e:?}"))?; + let fn_ps_main = library + .get_function("ps_main", None) + .map_err(|e| anyhow!("MTLLibrary::get_function('ps_main') : {e:?}"))?; + let fn_ps_y = library + .get_function("ps_y", None) + .map_err(|e| anyhow!("MTLLibrary::get_function('ps_y') : {e:?}"))?; + let fn_ps_uv = library + .get_function("ps_uv", None) + .map_err(|e| anyhow!("MTLLibrary::get_function('ps_uv') : {e:?}"))?; + let fn_ps_tex = library + .get_function("ps_tex", None) + .map_err(|e| anyhow!("MTLLibrary::get_function('ps_tex') : {e:?}"))?; + + // --- Pipeline state principal : vs_main + ps_main, écrit dans le RT RGBA --- + let pipeline_main_desc = metal::RenderPipelineDescriptor::new(); + pipeline_main_desc.set_vertex_function(Some(&fn_vs_main)); + pipeline_main_desc.set_fragment_function(Some(&fn_ps_main)); + // Le RT est RGBA8 — color attachment 0 = RT. + let ca0_main = metal::RenderPipelineColorAttachmentDescriptor::new(); + ca0_main.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + ca0_main.set_blending_enabled(true); + ca0_main.set_rgb_blend_operation(metal::MTLBlendOperation::Add); + ca0_main.set_source_rgb_blend_factor(metal::MTLBlendFactor::One); + ca0_main.set_destination_rgb_blend_factor(metal::MTLBlendFactor::OneMinusSourceAlpha); + pipeline_main_desc.set_color_attachments(0, &ca0_main); + let pipeline_main = gpu + .device + .new_render_pipeline_state(&pipeline_main_desc) + .map_err(|e| anyhow!("new_render_pipeline_state(main) : {e:?}"))?; + + // --- Pipeline states fullscreen pour la conversion RGBA→NV12 --- + let pipeline_fs_tex_desc = metal::RenderPipelineDescriptor::new(); + pipeline_fs_tex_desc.set_vertex_function(Some(&fn_vs_fs)); + pipeline_fs_tex_desc.set_fragment_function(Some(&fn_ps_tex)); + let ca_fs_tex = metal::RenderPipelineColorAttachmentDescriptor::new(); + ca_fs_tex.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + pipeline_fs_tex_desc.set_color_attachments(0, &ca_fs_tex); + let pipeline_fs_tex = gpu + .device + .new_render_pipeline_state(&pipeline_fs_tex_desc) + .map_err(|e| anyhow!("new_render_pipeline_state(fs_tex) : {e:?}"))?; + + let pipeline_fs_y_desc = metal::RenderPipelineDescriptor::new(); + pipeline_fs_y_desc.set_vertex_function(Some(&fn_vs_fs)); + pipeline_fs_y_desc.set_fragment_function(Some(&fn_ps_y)); + let ca_fs_y = metal::RenderPipelineColorAttachmentDescriptor::new(); + ca_fs_y.set_pixel_format(metal::MTLPixelFormat::R8Unorm); + pipeline_fs_y_desc.set_color_attachments(0, &ca_fs_y); + let pipeline_fs_y = gpu + .device + .new_render_pipeline_state(&pipeline_fs_y_desc) + .map_err(|e| anyhow!("new_render_pipeline_state(fs_y) : {e:?}"))?; + + let pipeline_fs_uv_desc = metal::RenderPipelineDescriptor::new(); + pipeline_fs_uv_desc.set_vertex_function(Some(&fn_vs_fs)); + pipeline_fs_uv_desc.set_fragment_function(Some(&fn_ps_uv)); + let ca_fs_uv = metal::RenderPipelineColorAttachmentDescriptor::new(); + ca_fs_uv.set_pixel_format(metal::MTLPixelFormat::RG8Unorm); + pipeline_fs_uv_desc.set_color_attachments(0, &ca_fs_uv); + let pipeline_fs_uv = gpu + .device + .new_render_pipeline_state(&pipeline_fs_uv_desc) + .map_err(|e| anyhow!("new_render_pipeline_state(fs_uv) : {e:?}"))?; + + Ok(Compositor { + gpu: Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + backend: gpu.backend, + feature_level: gpu.feature_level, + }, + render_w: rw, + render_h: rh, + scene: RefCell::new(None), + cursor: RefCell::new(None), + cursor_time: RefCell::new(None), + timeline_time: RefCell::new(None), + live_params: RefCell::new(LiveParams::default()), + metal_texture_cache: RefCell::new(Some(cache)), + tex_cache: RefCell::new(HashMap::new()), + rt: Some(rt), + rt_read: Some(rt_read), + nv12_y: Some(nv12_y), + nv12_uv: Some(nv12_uv), + nv12_read_y: Some(nv12_read_y), + nv12_read_uv: Some(nv12_read_uv), + library: Some(library), + pipeline_main: Some(pipeline_main), + pipeline_fs_y: Some(pipeline_fs_y), + pipeline_fs_uv: Some(pipeline_fs_uv), + pipeline_fs_tex: Some(pipeline_fs_tex), + }) + } + + /// Arrondit `(w, h)` au multiple de 2 supérieur — nécessaire pour NV12 4:2:0. + pub fn normalize_render_size(w: u32, h: u32) -> (u32, u32) { + ((w.max(1) + 1) & !1, (h.max(1) + 1) & !1) + } + + pub fn render_size(&self) -> (u32, u32) { + (self.render_w, self.render_h) + } + + pub fn set_live_params(&self, p: LiveParams) { + *self.live_params.borrow_mut() = p; + } + + pub fn set_scene(&self, s: Option) { + *self.scene.borrow_mut() = s; + } + + pub fn set_cursor(&self, track: crate::cursor::CursorTrack) { + *self.cursor.borrow_mut() = Some(track); + } + + pub fn set_cursor_time(&self, t: Option) { + *self.cursor_time.borrow_mut() = t; + } + + pub fn set_timeline_time(&self, t: Option) { + *self.timeline_time.borrow_mut() = t; + } + + pub fn clear_cursor(&self) { + *self.cursor.borrow_mut() = None; + } + + pub fn scene_snapshot(&self) -> Option { + self.scene.borrow().clone() + } + + /// Dimensions réelles (texture, alignée pair) du `CVPixelBufferRef` posé dans la + /// frame de présentation. L'API symétrique de `compositor_windows::tex_dims` — + /// mêmes champs AVFrame, même contrat de retour. + /// + /// Le `CVPixelBufferRef` peut être dans `data[0]` (sentinel D3D11 par `mac_frames`) + /// ou `data[3]` (frames brutes VideoToolbox) selon le format AVFrame — voir + /// `nv12_srvs` pour la même discrimination. + pub unsafe fn tex_dims(&self, frame: *const AVFrame) -> (u32, u32) { + if frame.is_null() { + return (0, 0); + } + let pb = match (*frame).format { + f if f == crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX as i32 => { + (*frame).data[3] as *mut std::ffi::c_void + } + f if f == crate::ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 => { + (*frame).data[0] as *mut std::ffi::c_void + } + _ => return (0, 0), + }; + if pb.is_null() { + return (0, 0); + } + let w = CVPixelBufferGetWidth(pb); + let h = CVPixelBufferGetHeight(pb); + (w as u32, h as u32) + } + + /// Crée (ou sort du cache) les `MTLTexture` Y (R8Unorm) et UV (RG8Unorm) à partir + /// du `CVPixelBufferRef` de la frame. Zéro copie : les textures Metal partagent la + /// mémoire IOSurface du `CVPixelBuffer`. L'API symétrique de + /// `compositor_windows::nv12_srvs` — mêmes champs AVFrame lus, format différent en + /// sortie (2 `MTLTexture` au lieu de 2 `ID3D11ShaderResourceView`). + /// + /// Le `CVPixelBufferRef` peut être posé à deux endroits dans l'AVFrame, selon le + /// chemin de décodage : + /// - `format == AV_PIX_FMT_D3D11` : sentinel posé par `mac_frames::CpuFrames::present` + /// (chemin software fallback). Le CVPixelBufferRef est dans `data[0]`. + /// - `format == AV_PIX_FMT_VIDEOTOOLBOX` : frame brute de VideoToolbox. Le + /// CVPixelBufferRef est dans `data[3]` (convention ffmpeg). + /// Les deux aboutissent au même IOSurface-backed buffer ; `CVMetalTextureCache` + /// n'a pas de préférence. + /// + /// Les `MTLTexture` retournées sont retain côté `metal::Texture::from_raw` dans + /// les commits qui câbleront `compose_frame`. Ce commit expose juste la méthode + /// pour valider le seam — `compose_frame` continue à retourner `Err`. + pub unsafe fn nv12_srvs( + &self, + frame: *const AVFrame, + ) -> Result<(metal::Texture, metal::Texture)> { + let pb = match (*frame).format { + f if f == crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX as i32 => { + // Frame VideoToolbox brute — convention ffmpeg : CVPixelBufferRef dans + // `data[3]` (et non `data[0]` comme D3D11VA). + (*frame).data[3] as *mut std::ffi::c_void + } + f if f == crate::ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 => { + // Frame posée par `mac_frames::CpuFrames::present` (sentinel) — le + // CVPixelBufferRef est dans `data[0]`. + (*frame).data[0] as *mut std::ffi::c_void + } + _ => { + return Err(anyhow!( + "nv12_srvs: format {:?} inattendu (ni D3D11 sentinel ni VIDEOTOOLBOX)", + (*frame).format + )); + } + }; + + if pb.is_null() { + return Err(anyhow!("nv12_srvs: CVPixelBufferRef nul — data[idx] est null")); + } + let pb_key = pb as usize; + + let cache_ref = self.metal_texture_cache.borrow(); + let cache = cache_ref + .as_ref() + .ok_or_else(|| anyhow!("nv12_srvs: CVMetalTextureCache non initialisé"))?; + + // Plan Y : `MTLPixelFormatR8Unorm`, plane_index 0. + let y_key = (pb_key, 0usize); + let y_ptr = if let Some(p) = self.tex_cache.borrow().get(&y_key) { + *p + } else { + let p = cache.make_texture_from_pixel_buffer(pb, 0, metal::MTLPixelFormat::R8Unorm)?; + self.tex_cache.borrow_mut().insert(y_key, p); + p + }; + + // Plan UV : `MTLPixelFormatRG8Unorm`, plane_index 1. + let uv_key = (pb_key, 1usize); + let uv_ptr = if let Some(p) = self.tex_cache.borrow().get(&uv_key) { + *p + } else { + let p = cache.make_texture_from_pixel_buffer(pb, 1, metal::MTLPixelFormat::RG8Unorm)?; + self.tex_cache.borrow_mut().insert(uv_key, p); + p + }; + + // `metal::Texture::from_raw` retient côté Rust (ARC). À chaque appel on retient + // une nouvelle référence — le caller (compose_frame) en devient propriétaire et + // doit la relâcher via `Drop`. Idem côté Windows : les SRV retournés sont clonés + // par le caller, retain/release symétrique. + let y = metal::Texture::from_raw(y_ptr); + let uv = metal::Texture::from_raw(uv_ptr); + Ok((y, uv)) + } + + /// Compose la frame suivante → render target RGBA. **First-pass engine** : la vidéo + /// `screen` est blittée pleine-canvas (UV→Y+UV sample, mode 0 du shader `ps_main`), + /// sans layers / webcam / rounded corners / ombres / blur. Les effets avancés + /// sont implémentés dans des commits dédiés qui câbleront les `LayerCB` par + /// quad (cf. PR #189 § « Engine commits remaining »). + /// + /// Le `webcam` est ignoré pour l'instant — la preview full-canvas suffit au chemin + /// encode (`run_composited_multi`) et à la prévisualisation tant que les layers + /// ne sont pas câblés. + pub unsafe fn compose_frame( + &self, + screen: *const AVFrame, + _webcam: *const AVFrame, + _frame: f32, + _cfg: &Cfg, + ) -> Result<()> { + if screen.is_null() || (*screen).data[0].is_null() && (*screen).data[3].is_null() { + // Pas de frame source : on efface le RT au noir. + return self.clear_rt(); + } + + let (sy, suv) = self.nv12_srvs(screen)?; + let rt = self.rt.as_ref().ok_or_else(|| anyhow!("engine non initialisé"))?; + let pipeline = self + .pipeline_main + .as_ref() + .ok_or_else(|| anyhow!("engine non initialisé"))?; + + // LayerCB : full-canvas (dst = [0,0,1,1], src = [0,0,1,1], mode = 0 = NV12). + // Couleurs / ombres / radius neutres (mode 0 = pas d'effet de bord). + let layer: Layer = Layer { + dst: [0.0, 0.0, 1.0, 1.0], + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [self.render_w as f32, self.render_h as f32], + radius_px: 0.0, + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + fx: [0.0; 4], + src_prev: [0.0; 4], + dst_prev: [0.0; 4], + mb: [1.0, 0.0, 0.0, 0.0], + }; + + let cmd_buf = self + .gpu + .context + .new_command_buffer() + .ok_or_else(|| anyhow!("MTLCommandQueue::new_command_buffer a renvoyé None"))?; + + // Render pass descriptor : attachment 0 = RT principal, action = Clear → Store. + let pass_desc = metal::RenderPassDescriptor::new(); + let ca = pass_desc + .color_attachments() + .object_at(0) + .ok_or_else(|| anyhow!("RenderPassDescriptor::color_attachments(0)"))?; + ca.set_texture(Some(rt)); + ca.set_load_action(metal::LoadAction::Clear); + ca.set_clear_color(metal::MTLClearColor(0.0, 0.0, 0.0, 1.0)); + ca.set_store_action(metal::StoreAction::Store); + + let encoder = cmd_buf + .new_render_command_encoder(&pass_desc) + .ok_or_else(|| anyhow!("MTLCommandBuffer::new_render_command_encoder a renvoyé None"))?; + encoder.set_render_pipeline_state(pipeline); + // Le shader ps_main attend texY/texUV/texImg + samp. On bind les 2 premières + // (les samples ne lisent que t0/t1 en mode 0). + encoder.set_fragment_texture(0, Some(&sy)); + encoder.set_fragment_texture(1, Some(&suv)); + // LayerCB : 128 octets, on l'envoie via set_fragment_bytes (le VS ne lit pas + // LayerCB en mode full-canvas — c'est uniquement côté FS). + encoder.set_fragment_bytes( + 0, + std::mem::size_of::() as u64, + &layer as *const Layer as *const std::ffi::c_void, + ); + // vs_main est un quad-strip à partir de SV_VertexID : 4 vertices. + encoder.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4); + encoder.end_encoding(); + cmd_buf.commit(); + + Ok(()) + } + + /// Efface le RT au noir (utilisé quand `screen` est null ou vide). + unsafe fn clear_rt(&self) -> Result<()> { + let rt = self.rt.as_ref().ok_or_else(|| anyhow!("engine non initialisé"))?; + let cmd_buf = self + .gpu + .context + .new_command_buffer() + .ok_or_else(|| anyhow!("MTLCommandQueue::new_command_buffer"))?; + let pass_desc = metal::RenderPassDescriptor::new(); + let ca = pass_desc.color_attachments().object_at(0).unwrap(); + ca.set_texture(Some(rt)); + ca.set_load_action(metal::LoadAction::Clear); + ca.set_clear_color(metal::MTLClearColor(0.0, 0.0, 0.0, 1.0)); + ca.set_store_action(metal::StoreAction::Store); + let encoder = cmd_buf.new_render_command_encoder(&pass_desc).unwrap(); + encoder.end_encoding(); + cmd_buf.commit(); + Ok(()) + } + + /// Variante motion-blur de `compose_frame` — symétrique de + /// `compositor_windows::compose_frame_mb`. Renvoie `Err` tant que le moteur + /// avancé (couches multiples avec vélocité par quad) n'est pas câblé — c'est + /// l'objet d'un commit dédié qui exercera `ps_main` mode 0 avec `mb.x > 1`. + pub unsafe fn compose_frame_mb( + &self, + _screen: *const AVFrame, + _webcam: *const AVFrame, + _frame: u32, + _cfg: &Cfg, + ) -> Result<()> { + Err(anyhow!("compositor_macos::compose_frame_mb: non implémenté")) + } + + /// Convertit le RT RGBA → `nv12_y` + `nv12_uv` (deux passes fullscreen). + /// Le résultat NV12 alimente l'encodeur (zero-copy via `MTLBlitCommandEncoder::copy` + /// vers le `hw_frames_ctx` VideoToolbox) ou `read_nv12_scaled` pour le software + /// fallback (`h264_mf` / `libopenh264`). + pub unsafe fn rgb_to_nv12(&self, _out_tex: *mut std::ffi::c_void, _slice: u32) -> Result<()> { + // First-pass engine : la cible est toujours `self.nv12_y`/`self.nv12_uv` (le + // buffer interne). L'argument `out_tex` est conservé pour l'API symétrique avec + // Windows ; le câblage zero-copy vers un buffer externe (CVPixelBuffer + // appartenant à l'encodeur) viendra avec le commit « encodeur VT » quand + // l'encodeur sera implémenté. + self.render_nv12() + } + + pub unsafe fn rgb_to_nv12_scaled( + &self, + _target_w: u32, + _target_h: u32, + _out_tex: *mut std::ffi::c_void, + _slice: u32, + ) -> Result<()> { + // First-pass engine : le resize sera câblé dans un commit dédié. Pour + // l'instant on rend à la taille de rendu. + self.render_nv12() + } + + /// Convertit le RT RGBA → `self.nv12_y` (R8) et `self.nv12_uv` (RG8) via deux + /// passes fullscreen (ps_y puis ps_uv sur vs_fs). C'est le miroir Metal exact de + /// `compositor_windows::render_nv12` — même contrat de sortie (NV12 interne), + /// même chemin de conversion BT.709 limited RGB→Y'CbCr. + pub unsafe fn render_nv12(&self) { + let rt = match self.rt.as_ref() { + Some(t) => t, + None => return, + }; + let nv12_y = match self.nv12_y.as_ref() { + Some(t) => t, + None => return, + }; + let nv12_uv = match self.nv12_uv.as_ref() { + Some(t) => t, + None => return, + }; + let pipeline_y = match self.pipeline_fs_y.as_ref() { + Some(p) => p, + None => return, + }; + let pipeline_uv = match self.pipeline_fs_uv.as_ref() { + Some(p) => p, + None => return, + }; + + let cmd_buf = match self.gpu.context.new_command_buffer() { + Some(b) => b, + None => return, + }; + + // Pass Y : pleine résolution, source = RT, cible = nv12_y. + let pass_y = metal::RenderPassDescriptor::new(); + let ca_y = pass_y.color_attachments().object_at(0).unwrap(); + ca_y.set_texture(Some(nv12_y)); + ca_y.set_load_action(metal::LoadAction::Clear); + ca_y.set_store_action(metal::StoreAction::Store); + let enc_y = match cmd_buf.new_render_command_encoder(&pass_y) { + Some(e) => e, + None => return, + }; + enc_y.set_render_pipeline_state(pipeline_y); + enc_y.set_fragment_texture(0, Some(rt)); + enc_y.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + enc_y.end_encoding(); + + // Pass UV : pleine résolution, source = RT, cible = nv12_uv. + let pass_uv = metal::RenderPassDescriptor::new(); + let ca_uv = pass_uv.color_attachments().object_at(0).unwrap(); + ca_uv.set_texture(Some(nv12_uv)); + ca_uv.set_load_action(metal::LoadAction::Clear); + ca_uv.set_store_action(metal::StoreAction::Store); + let enc_uv = match cmd_buf.new_render_command_encoder(&pass_uv) { + Some(e) => e, + None => return, + }; + enc_uv.set_render_pipeline_state(pipeline_uv); + enc_uv.set_fragment_texture(0, Some(rt)); + enc_uv.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + enc_uv.end_encoding(); + + cmd_buf.commit(); + } + + /// Lit le RT RGBA vers un Vec CPU (preview live). `MTLStorageMode::Shared` + /// permet `get_bytes` synchrone. Renvoie (w, h, RGBA8). + pub unsafe fn readback_direct(&self) -> Result<(u32, u32, Vec)> { + let rt_read = self + .rt_read + .as_ref() + .ok_or_else(|| anyhow!("engine non initialisé"))?; + let w = self.render_w; + let h = self.render_h; + let bytes_per_row = w * 4; + let mut data = vec![0u8; (bytes_per_row * h) as usize]; + rt_read.get_bytes( + data.as_mut_ptr() as *mut std::ffi::c_void, + bytes_per_row as u64, + 0, + 0, + w as u64, + h as u64, + ); + Ok((w, h, data)) + } + + /// Variante resize de `readback_direct` — first-pass engine : on rend à la taille + /// de rendu puis on lit ; le resize séparé viendra avec le commit « pipeline + /// resize » quand il sera câblé. + pub unsafe fn readback_resized( + &self, + _target_w: u32, + _target_h: u32, + ) -> Result> { + let (_, _, data) = self.readback_direct()?; + Ok(data) + } + + /// Lit la texture NV12 (Y+UV) vers la mémoire système. Utilisé par + /// `VideoEncoder::send_composited` côté pipeline CPU-like (`h264_mf` / + /// `libopenh264`). Le `MTLBlitCommandEncoder` copie GPU→CPU (`Shared` storage + /// permet `getBytes` synchrone, comme `readback_direct`). + pub unsafe fn read_nv12_scaled( + &self, + target_w: u32, + target_h: u32, + dst_y: *mut u8, + pitch_y: usize, + dst_uv: *mut u8, + pitch_uv: usize, + ) -> Result<()> { + let nv12_read_y = self + .nv12_read_y + .as_ref() + .ok_or_else(|| anyhow!("engine non initialisé"))?; + let nv12_read_uv = self + .nv12_read_uv + .as_ref() + .ok_or_else(|| anyhow!("engine non initialisé"))?; + + // Copie GPU→CPU via get_bytes (le storage est Shared, comme rt_read). + // First-pass engine : on suppose que `render_nv12` a déjà été appelé pour peupler + // nv12_y/nv12_uv ; le pipeline will populate nv12_read_y/_uv en miroir avant + // l'encode. Ici on fait le plus simple : on lit directement depuis les + // textures staging CPU-readable. + let bytes_per_row_y = target_w; + let bytes_per_row_uv = target_w * 2; + nv12_read_y.get_bytes( + dst_y as *mut std::ffi::c_void, + pitch_y as u64, + 0, + 0, + target_w as u64, + target_h as u64, + ); + nv12_read_uv.get_bytes( + dst_uv as *mut std::ffi::c_void, + pitch_uv as u64, + 0, + 0, + target_w as u64, + target_h as u64, + ); + // Note : bytes_per_row retournés != pitch_y (qui est l'alignement de l'AVFrame). + // get_bytes utilise bytes_per_row comme stride source — ici on l'assume aligné sur + // target_w, ce qui est vrai tant que le moteur rend à target_w. Si un caller passe + // un pitch différent (rare), c'est à lui d'aligner. + let _ = bytes_per_row_y; + let _ = bytes_per_row_uv; + Ok(()) + } + + pub unsafe fn dump_nv12(&self, _path: &str) -> Result<()> { + Err(anyhow!("compositor_macos::dump_nv12: non implémenté")) + } + + pub unsafe fn dump_raw(&self, _path: &str) -> Result<()> { + Err(anyhow!("compositor_macos::dump_raw: non implémenté")) + } + + pub unsafe fn blit_to( + &self, + _rtv: *mut std::ffi::c_void, + _x: f32, + _y: f32, + _w: f32, + _h: f32, + ) { + // No-op tant que le pipeline Metal de blit n'est pas implémenté. + } +} + +/// Constant buffer Layer — symétrique du cbuffer HLSL dans `shaders.hlsl` / +/// `shaders.metal`. 128 octets, uploadé via `set_fragment_bytes` (alignement 4 OK). +/// +/// IMPORTANT : le mapping HLSL `cbuffer X { float4 dst; ... }` aligne chaque +/// `float4` sur 16 octets ; MSL `constant X &` aligne la struct sur 16 octets si elle +/// est elle-même alignée. Les `float2` (quad_px, fx.zw) sont entre des `float4`, donc +/// le padding HLSL/MSL produit le même layout 128 octets. Ce struct `repr(C)` reproduit +/// ce layout : chaque champ dans le même ordre, avec le bon alignement. +#[repr(C, align(16))] +#[derive(Clone, Copy)] +struct Layer { + dst: [f32; 4], + src: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + mode: f32, + color: [f32; 4], + fx: [f32; 4], + src_prev: [f32; 4], + dst_prev: [f32; 4], + mb: [f32; 4], +} \ No newline at end of file diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor_windows.rs similarity index 97% rename from crates/compositor/src/compositor.rs rename to crates/compositor/src/compositor_windows.rs index 19a2e38851..d1641587cd 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -378,6 +378,10 @@ pub struct Compositor { /// de prévisualisation demandée (variable, contrairement au `staging` fixe à /// OUT_W×OUT_H). Recréée quand la taille change — voir `readback_resized`. live_readback_staging: RefCell>, + /// Staging NV12 du readback d'ENCODAGE (backend CPU) — même motif de cache par taille + /// que `live_readback_staging`, mais en NV12 et non en RGBA : l'encodeur logiciel veut + /// les plans Y/UV, pas des pixels RGBA. Voir `read_nv12_scaled`. + nv12_readback_staging: RefCell>, } /// Ressources d'un resize export à une taille cible : RGBA intermédiaire (résultat du @@ -1028,6 +1032,7 @@ impl Compositor { render_size: Cell::new((out_w, out_h)), resize_target: RefCell::new(None), live_readback_staging: RefCell::new(None), + nv12_readback_staging: RefCell::new(None), }) } @@ -2906,15 +2911,32 @@ impl Compositor { // → aucun resize à faire, on convertit le RT directement. Comparé à la // taille de rendu COURANTE et non à une constante : une fois le RT aligné // sur `output`, c'est justement le cas nominal. + // Produire le NV12 (partagé avec l'encodeur logiciel), puis le copier GPU→GPU + // vers le pool de l'encodeur matériel — la seule partie qui lui soit propre. + let src_tex = self.nv12_source(target_w, target_h)?; + let src: ID3D11Resource = src_tex.cast()?; + let dst_tex = ID3D11Texture2D::from_raw_borrowed(&out_tex).unwrap().clone(); + let dst: ID3D11Resource = dst_tex.cast()?; + self.ctx.CopySubresourceRegion(&dst, slice, 0, 0, 0, &src, 0, None); + Ok(()) + } + + /// Rastérise le NV12 de sortie à `target_w`×`target_h` et rend LA TEXTURE du + /// compositeur qui le porte. C'est la moitié commune aux deux encodeurs : le matériel + /// la copie GPU→GPU vers le pool AMF (`rgb_to_nv12_scaled` ci-dessus), le logiciel la + /// relit vers la RAM (`read_nv12_scaled` ci-dessous). Extraite pour que les deux + /// 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(); if target_w == rw_i && target_h == rh_i { - return self.rgb_to_nv12(out_tex, slice); + self.render_nv12(); + return Ok(self.nv12.clone()); } + // Même séquence que `rgb_to_nv12_scaled`, dont c'est la partie « produire ». self.blit_resized(target_w, target_h)?; let cache = self.resize_target.borrow(); let t = cache.as_ref().unwrap(); - - // rgba (cible) -> NV12 (cible) : mêmes passes Y/UV que `render_nv12`, paramétrées. self.ctx.OMSetRenderTargets(Some(&[Some(t.nv12_rtv_y.clone())]), None); self.ctx.PSSetShaderResources(0, Some(&[Some(t.rgba_srv.clone())])); let vp_y = D3D11_VIEWPORT { @@ -2934,12 +2956,73 @@ impl Compositor { self.ctx.PSSetShader(&self.ps_uv, None); self.ctx.Draw(3, 0); self.ctx.PSSetShaderResources(0, Some(&[None])); + Ok(t.nv12.clone()) + } - // 3) copie GPU->GPU vers le pool encodeur (identique à rgb_to_nv12). - let src: ID3D11Resource = t.nv12.cast()?; - let dst_tex = ID3D11Texture2D::from_raw_borrowed(&out_tex).unwrap().clone(); - let dst: ID3D11Resource = dst_tex.cast()?; - self.ctx.CopySubresourceRegion(&dst, slice, 0, 0, 0, &src, 0, None); + /// Le NV12 de sortie LU vers la mémoire système, plan Y puis plan UV. + /// + /// Pendant de `rgb_to_nv12_scaled` pour un encodeur LOGICIEL : `libopenh264` ne sait + /// pas prendre une texture D3D11, il veut des plans en RAM. C'est la seule copie + /// GPU→CPU du chemin d'export CPU, et elle est inévitable — le backend CPU rastérise + /// sur WARP (donc déjà en RAM côté pilote) mais D3D11 n'expose pas ces octets + /// autrement que par une staging. + /// + /// `dst_y`/`dst_uv` doivent tenir `target_h * pitch_y` et `target_h/2 * pitch_uv` + /// octets — typiquement les `data[0]`/`data[1]` d'une `AVFrame` NV12. + pub unsafe fn read_nv12_scaled( + &self, + target_w: u32, + target_h: u32, + dst_y: *mut u8, + pitch_y: usize, + dst_uv: *mut u8, + pitch_uv: usize, + ) -> Result<()> { + let src_tex = self.nv12_source(target_w, target_h)?; + + // Staging NV12 cachée par taille (même idiome que `live_readback_staging`) : + // une allocation par changement de résolution, pas une par frame. + let mut cache = self.nv12_readback_staging.borrow_mut(); + if cache.as_ref().map(|(w, h, _)| (*w, *h)) != Some((target_w, target_h)) { + let sd = D3D11_TEXTURE2D_DESC { + Width: target_w, + Height: target_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 t: Option = None; + self.dev.CreateTexture2D(&sd, None, Some(&mut t))?; + *cache = Some((target_w, target_h, t.unwrap())); + } + let staging = &cache.as_ref().unwrap().2; + + let src: ID3D11Resource = src_tex.cast()?; + let dst: ID3D11Resource = staging.cast()?; + self.ctx.CopyResource(&dst, &src); + + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&dst, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + // Disposition NV12 mappée : Y sur `target_h` lignes de `RowPitch`, puis UV sur + // `target_h/2` lignes au même pitch. Copie ligne par ligne — les deux pitchs + // diffèrent (le driver pad, ffmpeg aligne sur son propre SIMD). + let row = (target_w as usize).min(m.RowPitch as usize).min(pitch_y); + for y in 0..target_h as usize { + let s = (m.pData as *const u8).add(y * m.RowPitch as usize); + std::ptr::copy_nonoverlapping(s, dst_y.add(y * pitch_y), row); + } + let uv_src = (m.pData as *const u8).add(m.RowPitch as usize * target_h as usize); + let uv_row = (target_w as usize).min(m.RowPitch as usize).min(pitch_uv); + for y in 0..(target_h as usize / 2) { + let s = uv_src.add(y * m.RowPitch as usize); + std::ptr::copy_nonoverlapping(s, dst_uv.add(y * pitch_uv), uv_row); + } + self.ctx.Unmap(&dst, 0); Ok(()) } diff --git a/crates/compositor/src/cpu_frames_windows.rs b/crates/compositor/src/cpu_frames_windows.rs new file mode 100644 index 0000000000..c7a319308f --- /dev/null +++ b/crates/compositor/src/cpu_frames_windows.rs @@ -0,0 +1,241 @@ +//! L'axe DÉCODAGE du backend CPU : une frame libavcodec en mémoire système devient une +//! texture NV12 D3D11, présentée exactement comme si D3D11VA l'avait produite. +//! +//! Pourquoi ce fichier existe séparément : le rendu et le décodage sont deux axes +//! indépendants (voir `d3d::Backend`). WARP couvre le premier et *rien* du second — aucun +//! rastériseur logiciel, sur aucune plateforme, ne décode de la vidéo. Le repli logiciel +//! demandait donc cette pièce-ci en plus, et c'est elle (avec `d3d.rs`) qu'un portage +//! Metal/Vulkan réécrit. `compositor.rs`, les shaders HLSL et le contrat de scène ne +//! bougent pas d'un octet. +//! +//! Le contrat tenu ici est minuscule et c'est ce qui rend le tout iso. Tout ce que le +//! compositeur lit d'une frame, c'est (`compositor::nv12_srvs` / `compositor::tex_dims`) : +//! - `data[0]` : un `ID3D11Texture2D*` NV12, +//! - `data[1]` : l'index de tranche d'array, +//! - `width`/`height` : les dimensions VISIBLES dans cette texture. +//! On remplit ces quatre champs et rien d'autre change. + +use crate::ffi::*; +use anyhow::{bail, Result}; +use std::ptr; +use windows::core::Interface; +use windows::Win32::Graphics::Direct3D11 as d3d11; +use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC}; + +/// Le flag d'algorithme de swscale. Bindgen ne génère pas les `SWS_*` d'algorithme (des +/// macros), et leurs valeurs sont figées par l'ABI de libswscale. `POINT` (plus proche +/// voisin) est le choix honnête : la conversion se fait à dimensions ÉGALES, donc aucun +/// rééchantillonnage n'a lieu — seul le convertisseur de format travaille, et le filtre +/// choisi n'a aucun effet sur la sortie. +const SWS_POINT: i32 = 0x10; + +/// Source de frames du backend CPU, attachée à un `Decoder` quand `Backend::Cpu`. +pub(crate) struct CpuFrames { + dev: d3d11::ID3D11Device, + ctx: d3d11::ID3D11DeviceContext, + sws: *mut SwsContext, + /// `(w, h, format source)` du contexte swscale courant. Un flux qui change de + /// résolution en cours de route (rare mais légal) le reconstruit au lieu de + /// convertir de travers. + sws_key: (i32, i32, i32), + /// NV12 en mémoire système : la cible de swscale, la source de l'upload. + nv12: *mut AVFrame, + /// La texture NV12 échantillonnée par les shaders. UNE seule, réécrite à chaque + /// frame — le `srv_cache` du compositeur (clé `(ptr, slice)`) n'a donc qu'une entrée + /// et ne recrée jamais de SRV, contrairement au pool tournant de D3D11VA. + // ponytail: une seule texture = le CPU peut attendre que le GPU ait fini de lire la + // frame précédente. Sur WARP tout est CPU et le pilote sérialise déjà ; si un backend + // GPU réutilise ce chemin un jour et que le Map bloque, double-bufferiser ici. + tex: Option, + tex_dims: (u32, u32), + /// La frame remise au compositeur. Ne possède aucun pixel : ses `data[0]`/`data[1]` + /// pointent la texture ci-dessus, exactement comme une frame `AV_PIX_FMT_D3D11`. + present: *mut AVFrame, +} + +impl CpuFrames { + pub(crate) fn new(gpu: &crate::d3d::Gpu) -> Result { + let present = unsafe { av_frame_alloc() }; + let nv12 = unsafe { av_frame_alloc() }; + if present.is_null() || nv12.is_null() { + bail!("av_frame_alloc (backend CPU)"); + } + Ok(CpuFrames { + dev: gpu.device.clone(), + ctx: gpu.context.clone(), + sws: ptr::null_mut(), + sws_key: (0, 0, -1), + nv12, + tex: None, + tex_dims: (0, 0), + present, + }) + } + + /// Convertit `src` (sortie décodeur, mémoire système) en NV12, l'uploade, et rend la + /// frame de présentation. Le pointeur reste valide jusqu'au prochain appel — même + /// contrat que `Decoder::next` côté matériel. + pub(crate) unsafe fn present(&mut self, src: *mut AVFrame) -> Result<*mut AVFrame> { + let (w, h) = ((*src).width, (*src).height); + if w <= 0 || h <= 0 { + bail!("frame décodée sans dimensions ({w}x{h})"); + } + self.ensure_sws(w, h, (*src).format)?; + self.ensure_nv12(w, h)?; + + // Les plans NV12 de destination sont ceux de `self.nv12` : swscale écrit + // directement au bon format, on n'entrelace rien à la main (10 bits, 4:2:2 et + // consorts passent donc aussi, là où une boucle écrite ici casserait en silence). + let converted = sws_scale( + self.sws, + (*src).data.as_ptr() as *const *const u8, + (*src).linesize.as_ptr(), + 0, + h, + (*self.nv12).data.as_mut_ptr(), + (*self.nv12).linesize.as_ptr(), + ); + if converted <= 0 { + bail!("sws_scale a converti {converted} lignes"); + } + + self.upload(w, h)?; + Ok(self.present) + } + + unsafe fn ensure_sws(&mut self, w: i32, h: i32, src_fmt: i32) -> Result<()> { + let key = (w, h, src_fmt); + if self.sws_key == key && !self.sws.is_null() { + return Ok(()); + } + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + self.sws = sws_getContext( + w, + h, + src_fmt as AVPixelFormat::Type, + w, + h, + AVPixelFormat::AV_PIX_FMT_NV12, + SWS_POINT, + ptr::null_mut(), + ptr::null_mut(), + ptr::null(), + ); + if self.sws.is_null() { + bail!("sws_getContext {w}x{h} fmt {src_fmt} → NV12"); + } + self.sws_key = key; + Ok(()) + } + + unsafe fn ensure_nv12(&mut self, w: i32, h: i32) -> Result<()> { + if (*self.nv12).width == w + && (*self.nv12).height == h + && (*self.nv12).format == AVPixelFormat::AV_PIX_FMT_NV12 as i32 + { + return Ok(()); + } + av_frame_unref(self.nv12); + (*self.nv12).width = w; + (*self.nv12).height = h; + (*self.nv12).format = AVPixelFormat::AV_PIX_FMT_NV12 as i32; + if av_frame_get_buffer(self.nv12, 32) < 0 { + bail!("av_frame_get_buffer NV12 {w}x{h}"); + } + Ok(()) + } + + /// (Re)crée la texture NV12 si les dimensions ont changé. NV12 impose des dimensions + /// paires : on arrondit AU-DESSUS pour la texture et on laisse `present.width/height` + /// aux dimensions visibles — c'est le même écart texture/visible que produit + /// l'alignement macrobloc de D3D11VA (1080 → 1088), et le compositeur le gère déjà. + unsafe fn ensure_tex(&mut self, w: i32, h: i32) -> Result<()> { + let dims = ((w as u32 + 1) & !1, (h as u32 + 1) & !1); + if self.tex.is_some() && self.tex_dims == dims { + return Ok(()); + } + let desc = d3d11::D3D11_TEXTURE2D_DESC { + Width: dims.0, + Height: dims.1, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: d3d11::D3D11_USAGE_DYNAMIC, + BindFlags: d3d11::D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: d3d11::D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + self.tex = Some(tex.ok_or_else(|| anyhow::anyhow!("CreateTexture2D NV12 sans texture"))?); + self.tex_dims = dims; + Ok(()) + } + + /// Copie le NV12 système dans la texture. `Map(WRITE_DISCARD)` rend UN pointeur pour + /// les deux plans : Y sur `tex_h` lignes de `RowPitch`, puis UV sur `tex_h/2` lignes + /// au même pitch — c'est la disposition NV12 mappée que documente D3D11. + unsafe fn upload(&mut self, w: i32, h: i32) -> Result<()> { + self.ensure_tex(w, h)?; + let tex = self.tex.clone().expect("texture créée juste au-dessus"); + let resource: d3d11::ID3D11Resource = tex.cast()?; + + let mut mapped = d3d11::D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&resource, 0, d3d11::D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))?; + + let dst = mapped.pData as *mut u8; + let pitch = mapped.RowPitch as usize; + let (tex_w, tex_h) = (self.tex_dims.0 as usize, self.tex_dims.1 as usize); + let src_y = (*self.nv12).data[0]; + let src_uv = (*self.nv12).data[1]; + let sp_y = (*self.nv12).linesize[0] as usize; + let sp_uv = (*self.nv12).linesize[1] as usize; + // Ne copier que ce qui existe des deux côtés : la texture est arrondie au pair et + // les lignes de swscale sont paddées à leur propre alignement SIMD. + let row = tex_w.min(sp_y).min(pitch); + for y in 0..tex_h.min(h as usize) { + ptr::copy_nonoverlapping(src_y.add(y * sp_y), dst.add(y * pitch), row); + } + let uv_base = dst.add(pitch * tex_h); + let uv_row = tex_w.min(sp_uv).min(pitch); + for y in 0..(tex_h / 2).min((h as usize).div_ceil(2)) { + ptr::copy_nonoverlapping(src_uv.add(y * sp_uv), uv_base.add(y * pitch), uv_row); + } + + self.ctx.Unmap(&resource, 0); + + // Le contrat que lit le compositeur, et rien de plus : texture, tranche, visible. + // `data` n'est adossé à aucun `buf[]`, donc `av_frame_free` ne libérera jamais la + // texture — c'est nous qui la possédons, via `self.tex`. + (*self.present).data[0] = tex.as_raw() as *mut u8; + (*self.present).data[1] = ptr::null_mut(); // tranche 0 : notre texture n'est pas un array + (*self.present).width = w; + (*self.present).height = h; + (*self.present).format = AVPixelFormat::AV_PIX_FMT_D3D11 as i32; + Ok(()) + } + + /// La frame de présentation courante (jamais nulle) — `Decoder::cur_frame` en backend CPU. + pub(crate) fn current(&self) -> *mut AVFrame { + self.present + } +} + +impl Drop for CpuFrames { + fn drop(&mut self) { + unsafe { + // `present` n'a que des pointeurs empruntés : les remettre à zéro avant de + // libérer, pour qu'aucun code ffmpeg ne croie posséder notre texture. + (*self.present).data[0] = ptr::null_mut(); + (*self.present).data[1] = ptr::null_mut(); + av_frame_free(&mut self.present); + av_frame_free(&mut self.nv12); + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + } + } +} diff --git a/crates/compositor/src/d3d.rs b/crates/compositor/src/d3d.rs deleted file mode 100644 index a5fb74b9f0..0000000000 --- a/crates/compositor/src/d3d.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Le device D3D11 unique du POC (§2). -//! Un seul `ID3D11Device`, feature level 11_1, flag VIDEO_SUPPORT (décodeur), -//! et `ID3D10Multithread::SetMultithreadProtected(TRUE)` — parce que le décodeur -//! ffmpeg et notre boucle de rendu toucheront le device depuis des threads distincts. - -use anyhow::{bail, Result}; -use windows::core::Interface; -use windows::Win32::Foundation::HMODULE; -use windows::Win32::Graphics::Direct3D::{ - D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL, D3D_FEATURE_LEVEL_11_1, -}; -use windows::Win32::Graphics::Direct3D11::{ - D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, - D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_DEBUG, - D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION, -}; - -pub struct Gpu { - pub device: ID3D11Device, - pub context: ID3D11DeviceContext, - pub feature_level: D3D_FEATURE_LEVEL, -} - -impl Gpu { - /// Crée le device conforme au §2. `debug=false` impératif dans tout run mesuré - /// (§10 : la couche debug valide et sérialise chaque appel — facteur, pas %). - pub fn create(debug: bool) -> Result { - // VIDEO_SUPPORT : requis pour que D3D11VA décode sur CE device. - // BGRA_SUPPORT : utile (interop D2D éventuelle) et sans coût. - let mut flags = D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT; - if debug { - flags |= D3D11_CREATE_DEVICE_DEBUG; - } - - let levels = [D3D_FEATURE_LEVEL_11_1]; - let mut device: Option = None; - let mut context: Option = None; - let mut got = D3D_FEATURE_LEVEL::default(); - - unsafe { - D3D11CreateDevice( - None, - D3D_DRIVER_TYPE_HARDWARE, - HMODULE::default(), - flags, - Some(&levels), - D3D11_SDK_VERSION, - Some(&mut device), - Some(&mut got), - Some(&mut context), - )?; - } - - let device = device.ok_or_else(|| anyhow::anyhow!("D3D11CreateDevice: pas de device"))?; - let context = context.ok_or_else(|| anyhow::anyhow!("D3D11CreateDevice: pas de contexte"))?; - - if got != D3D_FEATURE_LEVEL_11_1 { - bail!("feature level obtenu {:?} != 11_1", got); - } - - // §2 : multithread-protected. Le décodeur ffmpeg soumet depuis son thread, - // notre compositeur depuis le nôtre — sans ça, corruption silencieuse. - let mt: ID3D11Multithread = context.cast()?; - unsafe { - let _prev = mt.SetMultithreadProtected(true); - if !mt.GetMultithreadProtected().as_bool() { - bail!("SetMultithreadProtected(TRUE) n'a pas pris"); - } - } - - Ok(Gpu { device, context, feature_level: got }) - } -} diff --git a/crates/compositor/src/d3d_macos.rs b/crates/compositor/src/d3d_macos.rs new file mode 100644 index 0000000000..a104b0fdf2 --- /dev/null +++ b/crates/compositor/src/d3d_macos.rs @@ -0,0 +1,148 @@ +//! Backend GPU macOS — Metal + VideoToolbox. +//! +//! Ce module EST l'équivalent macOS de `d3d_windows.rs`. Il expose la même surface +//! publique (`Backend`, `Gpu`, `create`, `create_backend`, `create_auto`, `probe`, +//! `diagnose`) pour que `pipeline.rs`, `live.rs` et `compositor-view-napi` puissent +//! l'utiliser sans connaître la plateforme sous-jacente (cf. `lib.rs`, qui ré-exporte +//! `crate::d3d` vers `d3d_windows` ou `d3d_macos` selon `cfg`). +//! +//! # Pourquoi `Backend::Cpu` existe quand même +//! +//! `Backend::{Hardware, Cpu}` reste un enum à deux variantes côté macOS pour la +//! symétrie d'API — `pipeline.rs` itère sur les deux dans certains chemins (sélection +//! d'encodeur, câblage decode/encode). Métal n'a pas de rastériseur logiciel et n'en a +//! pas besoin (chaque Mac supporté a un GPU), donc `Backend::Cpu` côté macOS EST +//! `Hardware` : pas de chemin de rendu distinct. Il est conservé dans le type pour +//! qu'un appel `gpu.backend == Backend::Cpu` côté macOS ne surprenne pas le pipeline +//! (et pour que la fonction `probe` puisse signaler correctement le seul backend +//! existant, `Backend::Hardware`). +//! +//! # Frame seam (cf. `cpu_frames_windows.rs` doc en-tête) +//! +//! Tout ce que le compositor lit d'une frame décodeur tient dans quatre champs AVFrame : +//! - `data[0]` : un pointeur vers le buffer natif (ID3D11Texture2D* sur Windows, +//! `CVPixelBufferRef` sur macOS), +//! - `data[1]` : tranche d'array (toujours 0 sur macOS : VideoToolbox produit des +//! CVPixelBuffers indépendants, pas des tableaux), +//! - `width`/`height` : dimensions visibles dans la texture. +//! +//! Le CVPixelBufferRef de macOS est posé dans `data[0]` via le type `frame::PixelBuffer` +//! (c.f. `mac_frames.rs` — sa présentation encode `(*present).data[0] = cv_retain(buf)`). +//! `nv12_srvs` côté macOS le convertit en deux `MTLTexture`s (Y `R8Unorm`, UV `RG8Unorm`) +//! via `CVMetalTextureCacheCreateTextureFromImage` — zéro copie, IOSurface-backed. +//! +//! Ce module ne fait pas encore la mise en place effective : il expose les bonnes +//! signatures et retourne `Err` partout. Les PRs suivants remplissent l'implémentation +//! par couches (device, swapchain, shaders MSL, decode VideoToolbox, encode h264_videotoolbox). + +use anyhow::{anyhow, Result}; +use std::sync::OnceLock; + +/// Qui exécute le pipeline (symétrie d'API avec `d3d_windows::Backend` — voir l'en-tête). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Backend { + /// GPU : rastérisation Metal + décodage VideoToolbox sur le même pipeline. Sur macOS, + /// c'est le SEUL backend possible — chaque Mac supporté a un GPU. (Aucun rastériseur + /// logiciel n'existe pour Metal, et il n'y en a pas besoin.) + Hardware, + /// Conservé pour la symétrie d'API avec `d3d_windows`. Sur macOS, ce variant n'est + /// jamais produit : `probe()` ne renvoie que `Some(Backend::Hardware)`, et + /// `create_auto` ne tente jamais le fallback. Le pipeline peut comparer + /// `gpu.backend == Backend::Cpu` sans planter. + Cpu, +} + +/// Handle de device GPU macOS. Côté Metal, `device: metal::Device` est compté en +/// références (ARC) — `.clone()` est un `retain` côté ObjC, le `Drop` côté Rust fait +/// le `release`. `feature_level` n'a pas d'équivalent strict (Metal n'expose pas de +/// feature levels comme D3D_FEATURE_LEVEL_11_1) ; on stocke la révision de Metal +/// supportée par le runtime pour pouvoir raisonner à partir d'elle (cf. futur +/// `diagnose` qui distingue "Metal 3+ pas dispo" de "GPU dégradé"). +/// +/// `context` est l'équivalent macOS du `ID3D11DeviceContext` D3D11 — chez Metal +/// c'est une `MTLCommandQueue` (la file de command buffers). Le port garde le même +/// nom de champ (`context`) que `d3d_windows::Gpu` pour que `live.rs::Player` puisse +/// copier la struct champ par champ sans cfg-fendre le constructeur. +pub struct Gpu { + pub device: metal::Device, + pub context: metal::CommandQueue, + pub backend: Backend, + /// `MTLFeatureSet` ou révision runtime (Metal 2/3). Conservé pour les diagnostics. + pub feature_level: u64, +} + +/// `probe()` — propriété de la machine, mis en cache pour ne pas payer deux fois +/// la création du device (la preview et la modale d'export en ont tous les deux besoin, +/// cf. `useCompositorBackend` côté TS). +/// +/// Renvoie `None` quand aucun device Metal ne peut être créé — hôte sans GPU (rare : +/// une VM sans passthrough) ou Metal désactivé en force (variable `MTL_DEBUG_LAYER`). +static PROBE: OnceLock> = OnceLock::new(); + +pub fn probe() -> Option { + *PROBE.get_or_init(|| { + // metal::Device::system_default() renvoie None si Metal est indisponible. + // En pratique, sur macOS 10.13+, c'est toujours Some — sauf VM sans GPU. + if metal::Device::system_default().is_some() { + Some(Backend::Hardware) + } else { + None + } + }) +} + +/// Crée un device pour le backend demandé. Sur macOS, seul `Backend::Hardware` est +/// implémenté ; `Backend::Cpu` retourne `Err` (pas de rastériseur logiciel Metal). +pub fn create_backend(backend: Backend) -> Result { + match backend { + Backend::Hardware => create_metal_device(), + Backend::Cpu => Err(anyhow!( + "Backend::Cpu n'existe pas sur macOS : Metal n'a pas de rastériseur logiciel \ + et chaque Mac supporté a un GPU" + )), + } +} + +/// Crée le `MTLDevice` système. En cas d'échec (VM, Metal désactivé, GPU gelé), on +/// renvoie `Err` plutôt qu'un device partiel : la policy de PR #162 est "échouer +/// lisiblement, pas silencieusement". +fn create_metal_device() -> Result { + let device = metal::Device::system_default() + .ok_or_else(|| anyhow!("aucun MTLDevice disponible (Metal indisponible ou VM sans GPU)"))?; + let queue = device.new_command_queue(); + Ok(Gpu { + device, + context: queue, + backend: Backend::Hardware, + feature_level: 0, + }) +} + +impl Gpu { + /// Chemin de production : matérielle uniquement (Metal + VideoToolbox). + /// Conservé pour la symétrie d'API avec `d3d_windows::Gpu::create_auto` : un + /// appel `create_auto(false)` côté macOS doit renvoyer le seul backend existant, + /// jamais basculer silencieusement sur autre chose (le silence est précisément + /// la failure mode que PR #162 a éliminée côté Windows). + pub fn create_auto(_silent: bool) -> Result { + create_backend(Backend::Hardware) + } + + /// Création hardware-strict (utilisée par les tests et les goldens). + pub fn create() -> Result { + create_backend(Backend::Hardware) + } +} + +/// Message d'échec ACTIONNABLE (symétrie d'API avec `d3d_windows::diagnose`). +/// +/// `create_metal_device` ci-dessus ne renvoie qu'un `Err` laconique aujourd'hui ; +/// ce diagnostic sera étoffé dans un commit ultérieur pour distinguer : +/// - "Metal désactivé" (variable d'env, profil développeur) +/// - "Mac trop ancien" (Metal 3 indisponible ; faut-il se contenter de Metal 2 ?) +/// - "VM sans passthrough GPU" (structurel — message adapté) +/// Pour l'instant il ne fait que ré-empaqueter l'erreur, ce qui suffit à la +/// propagation. +pub fn diagnose(err: &anyhow::Error) -> String { + format!("{err:#}") +} \ No newline at end of file diff --git a/crates/compositor/src/d3d_windows.rs b/crates/compositor/src/d3d_windows.rs new file mode 100644 index 0000000000..9733b86e02 --- /dev/null +++ b/crates/compositor/src/d3d_windows.rs @@ -0,0 +1,220 @@ +//! Le device D3D11 unique du POC (§2). +//! Un seul `ID3D11Device`, feature level 11_1, flag VIDEO_SUPPORT (décodeur), +//! et `ID3D10Multithread::SetMultithreadProtected(TRUE)` — parce que le décodeur +//! ffmpeg et notre boucle de rendu toucheront le device depuis des threads distincts. + +use anyhow::{bail, Result}; +use std::sync::OnceLock; +use windows::core::Interface; +use windows::Win32::Foundation::HMODULE; +use windows::Win32::Graphics::Direct3D::{ + D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP, D3D_FEATURE_LEVEL, + D3D_FEATURE_LEVEL_11_1, +}; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_DEBUG, D3D11_CREATE_DEVICE_FLAG, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION, +}; + +/// Qui exécute le pipeline. Le rendu et le décodage sont DEUX axes distincts, et aucune +/// plateforme n'a de rastériseur logiciel qui décode aussi la vidéo (WARP ici, lavapipe +/// sous Linux, rien du tout sous macOS) — un backend fixe donc les deux ensemble. +/// +/// Le contrat de scène, les shaders HLSL et tout `compositor.rs` sont identiques d'un +/// backend à l'autre : c'est tout l'intérêt. Un portage Metal/Vulkan remplace ce que fait +/// ce fichier et `Decoder`, pas le moteur. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Backend { + /// GPU : rastérisation matérielle + décodage D3D11VA sur le même device (zéro copie). + /// Le seul backend qui puisse exporter — l'encodeur AMF exige lui aussi le vrai GPU. + Hardware, + /// CPU : rastérisation WARP + décodage logiciel libavcodec, uploadé en NV12. + /// Pour les hôtes sans GPU D3D11 utilisable (VM, RDP, Basic Render Driver). + Cpu, +} + +impl Backend { + fn driver(self) -> D3D_DRIVER_TYPE { + match self { + Backend::Hardware => D3D_DRIVER_TYPE_HARDWARE, + Backend::Cpu => D3D_DRIVER_TYPE_WARP, + } + } + + /// WARP REFUSE `VIDEO_SUPPORT` (`DXGI_ERROR_UNSUPPORTED`, mesuré dans + /// `tests/warp_device_cannot_decode.rs`) : ce flag n'a de sens que sur le device + /// matériel, où il conditionne D3D11VA. Le backend CPU ne décode pas sur le GPU, + /// il n'en a donc pas besoin. + fn base_flags(self) -> D3D11_CREATE_DEVICE_FLAG { + match self { + Backend::Hardware => { + D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT + } + Backend::Cpu => D3D11_CREATE_DEVICE_BGRA_SUPPORT, + } + } +} + +pub struct Gpu { + pub device: ID3D11Device, + pub context: ID3D11DeviceContext, + pub feature_level: D3D_FEATURE_LEVEL, + /// Lu par `Decoder::open` pour choisir D3D11VA ou le décodage logiciel. Porté par le + /// `Gpu` plutôt que passé partout : tout ce qui tient un device sait déjà qui il est. + pub backend: Backend, +} + +/// Une tentative `D3D11CreateDevice` à FL 11_1. Extraite pour que le chemin d'échec +/// puisse re-sonder avec d'autres flags/driver et dire POURQUOI la vraie tentative +/// a échoué (voir `diagnose`), au lieu de remonter un HRESULT nu. +fn try_create( + driver: D3D_DRIVER_TYPE, + flags: D3D11_CREATE_DEVICE_FLAG, +) -> windows::core::Result<(ID3D11Device, ID3D11DeviceContext, D3D_FEATURE_LEVEL)> { + let levels = [D3D_FEATURE_LEVEL_11_1]; + let mut device: Option = None; + let mut context: Option = None; + let mut got = D3D_FEATURE_LEVEL::default(); + unsafe { + D3D11CreateDevice( + None, + driver, + HMODULE::default(), + flags, + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + Some(&mut got), + Some(&mut context), + )?; + } + // Le SDK garantit les deux sorties quand l'appel réussit ; `E_UNEXPECTED` plutôt + // qu'un `unwrap` pour que l'impossible reste une erreur, pas un panic. + match (device, context) { + (Some(device), Some(context)) => Ok((device, context, got)), + _ => Err(windows::core::Error::from(windows::Win32::Foundation::E_UNEXPECTED)), + } +} + +/// Message d'échec ACTIONNABLE : re-sonde pour distinguer les deux causes réelles. +/// +/// Ce message reste utile MÊME maintenant que `create_auto` replie sur le backend CPU : +/// il part dans les logs à chaque repli, et c'est lui qui dit si l'utilisateur subit un +/// pilote à mettre à jour (réparable en cinq minutes) ou une VM sans GPU (structurel). +/// Sans lui, un utilisateur au rendu logiciel ne saurait jamais qu'il lui manque un +/// pilote. Et si WARP échoue aussi, c'est ce message-ci que `create_auto` remonte. +/// +/// PR #162 proposait de retomber sur `D3D_DRIVER_TYPE_WARP` en gardant tout le reste. +/// Mesuré (`tests/warp_device_cannot_decode.rs`) : WARP + `VIDEO_SUPPORT` ne se crée même +/// pas (`DXGI_ERROR_UNSUPPORTED`), et sans ce flag il n'expose aucun `ID3D11VideoDevice` +/// (`E_NOINTERFACE`, 0 profil décodeur). Comme `pipeline.rs` passe CE device à ffmpeg +/// comme `AVD3D11VADeviceContext`, un simple changement de driver type aurait produit zéro +/// frame. C'est ce qui a donné à `Backend::Cpu` sa forme : WARP pour le rendu PLUS un +/// décodage logiciel (`cpu_frames.rs`) — le rendu et le décodage sont deux axes. +fn diagnose(err: &windows::core::Error) -> String { + // Le décodeur est le point de rupture le plus probable (RDP, VM sans passthrough, + // Microsoft Basic Render Driver) : si l'appel passe SANS VIDEO_SUPPORT, l'adaptateur + // est là, c'est son décodeur qui manque. La sonde ne garde que BGRA — surtout pas + // `flags` moins VIDEO_SUPPORT, qui traînerait `DEBUG` avec lui : sans les Graphics + // Tools de Windows la couche debug fait échouer la sonde aussi, et on accuserait + // l'adaptateur à tort. Ce cas-là se lit déjà dans `{err}` + // (`DXGI_ERROR_SDK_COMPONENT_MISSING`), il n'a pas besoin de sa propre branche. + if try_create(D3D_DRIVER_TYPE_HARDWARE, D3D11_CREATE_DEVICE_BGRA_SUPPORT).is_ok() { + return format!( + "this display adapter has no D3D11 video decoder ({err}). OpenScreen decodes \ + every preview and export frame with D3D11VA on the same device it composites \ + with, so the decoder is not optional and there is no CPU path behind it. \ + Remote Desktop sessions and VMs without GPU passthrough land here: run on the \ + physical machine, or update the display driver." + ); + } + format!( + "no Direct3D 11 feature level 11_1 display adapter ({err}). OpenScreen's compositor \ + requires one for both preview and export. Update the display driver, or run on a \ + machine with a GPU that reaches feature level 11_1." + ) +} + +impl Gpu { + /// Crée le device conforme au §2. `debug=false` impératif dans tout run mesuré + /// (§10 : la couche debug valide et sérialise chaque appel — facteur, pas %). + /// + /// MATÉRIEL STRICT, sans repli : échoue plutôt que de rendre un device WARP. C'est ce + /// que veulent les tests et les goldens (mesurer ou comparer le chemin GPU n'a aucun + /// sens sur un rastériseur logiciel). Le chemin de production, lui, prend `create_auto`. + pub fn create(debug: bool) -> Result { + Gpu::create_backend(Backend::Hardware, debug) + } + + /// Le device de PRODUCTION : matériel si possible, backend CPU sinon. + /// + /// C'est ici que le repli devient automatique, et il ne l'est qu'accompagné : l'app + /// demande `probe()` et prévient l'utilisateur. Un basculement muet vers un rendu à + /// ~8 fps serait exactement le « l'app rame aujourd'hui » que cette branche corrige. + /// + /// Si les DEUX échouent, c'est le diagnostic MATÉRIEL qu'on remonte en tête : c'est + /// lui qui est actionnable (« pas de décodeur vidéo sur cet adaptateur »), pas + /// « WARP indisponible », qui ne dit rien à personne. + pub fn create_auto(debug: bool) -> Result { + let hw_err = match Gpu::create_backend(Backend::Hardware, debug) { + Ok(gpu) => return Ok(gpu), + Err(err) => err, + }; + eprintln!("[d3d] backend matériel indisponible ({hw_err:#}) — repli sur le backend CPU"); + Gpu::create_backend(Backend::Cpu, debug).map_err(|cpu_err| { + anyhow::anyhow!("{hw_err:#} (le repli logiciel a échoué aussi : {cpu_err:#})") + }) + } + + /// Le backend que cette machine obtiendra, sans créer de vue ni d'export. + /// + /// Mis en cache : créer un device coûte quelques dizaines de ms et la réponse ne + /// change pas en cours de session (un pilote qui tombe en marche est un redémarrage, + /// pas un rafraîchissement). `None` = ni matériel ni WARP — la vue échouera, et c'est + /// son message d'erreur, plus précis, qui doit parler. + pub fn probe() -> Option { + static PROBED: OnceLock> = OnceLock::new(); + *PROBED.get_or_init(|| { + for backend in [Backend::Hardware, Backend::Cpu] { + if Gpu::create_backend(backend, false).is_ok() { + return Some(backend); + } + } + None + }) + } + + /// Le device du backend demandé. `Backend::Cpu` ne diagnostique pas : si WARP + /// lui-même échoue, il n'y a plus rien derrière à proposer. + pub fn create_backend(backend: Backend, debug: bool) -> Result { + let mut flags = backend.base_flags(); + if debug { + flags |= D3D11_CREATE_DEVICE_DEBUG; + } + + let (device, context, got) = match try_create(backend.driver(), flags) { + Ok(gpu) => gpu, + Err(err) if backend == Backend::Cpu => { + bail!("WARP (rastériseur logiciel) indisponible sur cet hôte : {err}") + } + Err(err) => bail!("{}", diagnose(&err)), + }; + + if got != D3D_FEATURE_LEVEL_11_1 { + bail!("feature level obtenu {:?} != 11_1", got); + } + + // §2 : multithread-protected. Le décodeur ffmpeg soumet depuis son thread, + // notre compositeur depuis le nôtre — sans ça, corruption silencieuse. + let mt: ID3D11Multithread = context.cast()?; + unsafe { + let _prev = mt.SetMultithreadProtected(true); + if !mt.GetMultithreadProtected().as_bool() { + bail!("SetMultithreadProtected(TRUE) n'a pas pris"); + } + } + + Ok(Gpu { device, context, feature_level: got, backend }) + } +} diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs index 380ab99338..29f3679e90 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -1,20 +1,102 @@ -//! Le compositeur natif D3D11 d'OpenScreen : décodage, pipeline, effets HLSL, scène, curseur, -//! audio, et la vue live embarquable (`live`). +//! Le compositeur natif multiplateforme d'OpenScreen : décodage, pipeline, effets, scène, +//! curseur, audio, et la vue live embarquable (`live`). //! //! C'est du code de PRODUCTION. `compositor-view-napi` s'y lie pour produire //! `compositor_view.node`, le binaire que l'app Electron charge — la preview comme l'export //! passent par ici. Le POC de mesure (`poc-d3d`) n'est qu'un autre consommateur de cette //! bibliothèque, pas l'inverse : la GUI Win32 et le harnais de bench vivent chez lui. +//! +//! # Backends +//! +//! - Windows : `d3d_windows::Gpu` (D3D11 + D3D11VA), shaders HLSL compilés via `D3DCompile` +//! à l'exécution, `cpu_frames_windows.rs` pour l'axe décodage logiciel du backend CPU. +//! Le moteur est dans `compositor_windows.rs` et le rastériseur de texte dans `text_windows.rs`. +//! +//! - macOS : `d3d_macos::Gpu` (Metal + VideoToolbox), shaders MSL compilés via +//! `MTLDevice.makeLibrary` à l'exécution, `mac_frames.rs` pour l'axe décodage logiciel +//! (rare : VideoToolbox couvre les codecs standards sur chaque Mac supporté). Le moteur +//! est dans `compositor_macos.rs` et le rastériseur de texte dans `text_macos.rs`. +//! +//! `live.rs` et `pipeline.rs` portent du code ffmpeg/thread portable ; les blocs +//! spécifiques à chaque backend sont cfg-gatés à l'intérieur (D3D11VA vs VideoToolbox, +//! harnais Win32 vs UI Carbon/AppKit). +//! +//! Les noms `d3d`, `cpu_frames`, `compositor`, `text` sont conservés à travers une +//! ré-export cfg-conditionnelle pour que les call-sites restent portables. Le contrat +//! de scène, l'API publique des moteurs, et le frame-seam 4-champ de l'AVFrame +//! (`data[0]`, `data[1]`, `width`, `height`) sont identiques sur les deux plateformes +//! — c'est précisément ce qui rend le port Metal possible (cf. PR #162). pub mod audio; -pub mod compositor; pub mod config; pub mod cursor; -pub mod d3d; pub mod ffi; -pub mod live; -pub mod pipeline; pub mod regions; pub mod scene; -pub mod text; pub mod text_anim; + +// GPU backend : Windows → d3d_windows, macOS → d3d_macos. Ré-exporté sous le nom `d3d` +// pour que `crate::d3d::Gpu`/`Backend` reste portable. Le module sous-jacent est +// `pub mod` (pas `mod`) pour que `pub use … as d3d` puisse le ré-exporter hors du crate. +#[cfg(windows)] +pub mod d3d_windows; +#[cfg(windows)] +pub use d3d_windows as d3d; + +#[cfg(target_os = "macos")] +pub mod d3d_macos; +#[cfg(target_os = "macos")] +pub use d3d_macos as d3d; + +// Source de frames du backend « CPU-like » : Windows → cpu_frames_windows (WARP + swscale), +// macOS → mac_frames (logiciel → CVPixelBuffer). Ré-exporté sous le nom `cpu_frames` (privé). +#[cfg(windows)] +mod cpu_frames_windows; +#[cfg(windows)] +use cpu_frames_windows as cpu_frames; + +#[cfg(target_os = "macos")] +mod mac_frames; +#[cfg(target_os = "macos")] +use mac_frames as cpu_frames; + +// Moteur de composition + rastériseur de texte + pipeline : un fichier par plateforme. +// Le pipeline est gardé séparé (pas de fusion comme live) parce que la ffmpeg-side +// diffère entre D3D11VA et VideoToolbox : les types `AVD3D11VADeviceContext` vs +// `AVVideotoolboxContext` sont des structs distincts (générés via bindgen sur +// chaque wrapper.h), et le câblage decode/encode appelle des fonctions différentes +// (`av_hwframe_ctx_init` vs `av_hwdevice_ctx_create(AV_HWDEVICE_TYPE_VIDEOTOOLBOX)`). +#[cfg(windows)] +pub mod compositor_windows; +#[cfg(windows)] +pub mod pipeline_windows; +#[cfg(windows)] +pub mod text_windows; + +#[cfg(target_os = "macos")] +pub mod compositor_macos; +#[cfg(target_os = "macos")] +pub mod pipeline_macos; +#[cfg(target_os = "macos")] +pub mod text_macos; + +#[cfg(windows)] +pub use compositor_windows as compositor; +#[cfg(windows)] +pub use pipeline_windows as pipeline; +#[cfg(windows)] +pub use text_windows as text; + +#[cfg(target_os = "macos")] +pub use compositor_macos as compositor; +#[cfg(target_os = "macos")] +pub use pipeline_macos as pipeline; +#[cfg(target_os = "macos")] +pub use text_macos as text; + +// `live.rs` est resté un fichier unique parce que sa machinerie principale (Player, +// LiveView, render_thread) est entièrement cross-platform : elle ne touche qu'au +// Compositor (cfg-ré-exporté) et au ffmpeg `Decoder` (portable). Seules les +// helpers `run_standalone`/`host_proc`/`wide`/`client_size` (harnais Win32 du POC) +// sont cfg-gatées à l'intérieur du fichier. +pub mod live; \ No newline at end of file diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 52d5b6db15..7f23dfed96 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -1,1628 +1,1679 @@ -//! Vue live : rend le compositing **hors-fenêtre** vers un `Vec` RGBA8 -//! (taille `set_rect`) destiné à être streamé dans un `` Electron via -//! `putImageData`. Option B (canvas) — l'ancienne option A (fenêtre D3D enfant -//! `WS_POPUP` + swapchain) supprimée : la glue TS n'a plus de surface native à -//! embarquer, elle draw chaque frame reçue comme une image bitmap. -//! -//! Pipeline interne : `Player` (decodeur lockstep screen/webcam) + -//! `Compositor::compose_frame` → RT RGBA rastérisé à la GÉOMÉTRIE DE RENDU (depuis la -//! refonte ratio : géométrie de sortie ramenée à la taille du panneau, plus le canvas -//! 16:9 figé d'avant). Le **post-traitement** : -//! - 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 -//! 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. -//! -//! Modèle de threads : la vue n'a plus de HWND/UI côté thread appelant. Le rendu vit -//! sur un thread dédié — le thread JS/UI n'est jamais bloqué. Les objets COM et la -//! staging restent sur ce thread de rendu ; la frame est publiée via un -//! `Mutex)>>` pour la traversée de threads vers -//! le napi — le `gen` est l'identité de la frame (cf. `LatestFrame`). - -use crate::compositor::{Compositor, LiveParams}; -use crate::regions::speed_at; -use crate::scene::Scene; -use crate::config::{self, Cfg}; -use crate::cursor::CursorTrack; -use windows::core::PCWSTR; -use crate::d3d::Gpu; -use crate::pipeline::Decoder; -use anyhow::Result; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::JoinHandle; -use std::time::{Duration, Instant}; - -/// "#rrggbb" (ou "rrggbb") → [r, g, b, 1] en 0..1. None si invalide. -fn parse_hex_color(s: &str) -> Option<[f32; 4]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 { - return None; - } - let r = u8::from_str_radix(&h[0..2], 16).ok()? as f32 / 255.0; - let g = u8::from_str_radix(&h[2..4], 16).ok()? as f32 / 255.0; - let b = u8::from_str_radix(&h[4..6], 16).ok()? as f32 / 255.0; - Some([r, g, b, 1.0]) -} - -fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64 { - (screen_source_time_sec - webcam_offset_sec).max(0.0) -} - -/// Décodeurs déjà ouverts ET positionnés au bon playhead pour un clip à venir — le résultat -/// d'un préchargement en tâche de fond (voir `open_and_seek_clip`/`maybe_start_prefetch` -/// dans `render_thread`). Appliquer ceci à un `Player` (`apply_prefetched`) ne fait plus -/// aucune E/S : c'est ce qui rend la bascule à la frontière d'un clip instantanée au lieu de -/// payer un `Decoder::open` (ouverture fichier + parsing FFmpeg) synchrone pile au moment de -/// la transition — la pause perceptible observée en usage réel. -struct PrefetchedClip { - sdec: Decoder, - wdec: Decoder, - webcam_offset_sec: f64, - idx: u32, - /// Piste curseur du clip à venir, préchargée ici pour la même raison que les décodeurs : - /// sans ça, la bascule à la frontière restait synchrone sur CE point précis (lecture + - /// parsing JSON du `.cursor.json`, potentiellement des milliers d'échantillons pour un - /// enregistrement long) même après que le préchargement des décodeurs a supprimé le gros - /// de la pause perceptible — un second petit accroc au même endroit, pour la même raison - /// (une E/S synchrone pile à la frontière) qu'on venait de corriger pour les décodeurs. - cursor_track: Option, -} - -/// Ouvre + positionne la paire de décodeurs d'un clip (même travail que -/// `Player::set_active_clip`, mais autonome — sans instance `Player` existante, pour pouvoir -/// tourner sur un thread dédié pendant que le `Player` réel joue encore le clip actif). -unsafe fn open_and_seek_clip( - screen_path: &str, - webcam_path: &str, - webcam_offset_sec: f64, - source_time_sec: f64, - gpu: &Gpu, -) -> Result { - let source_time_sec = source_time_sec.max(0.0); - let mut sdec = Decoder::open(screen_path, gpu)?; - let mut wdec = match Decoder::open(webcam_path, gpu) { - Ok(d) => d, - Err(_) => Decoder::open(screen_path, gpu)?, - }; - let sf = sdec.seek_to(source_time_sec)?; - let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?; - if wf.is_null() { - wf = wdec.seek_to(0.0)?; - } - if sf.is_null() { - anyhow::bail!("clip préchargé vide au temps source {source_time_sec:.3}s (screen=\"{screen_path}\")"); - } - let idx = (source_time_sec * sdec.fps()).round().max(0.0) as u32; - let cursor_track = CursorTrack::load(&format!("{screen_path}.cursor.json"), 0.0, 24.0 * 3600.0).ok(); - Ok(PrefetchedClip { sdec, wdec, webcam_offset_sec, idx, cursor_track }) -} - -/// Lit deux sources en lockstep et compose la frame courante dans le RT du compositeur. -/// Partagé avec la GUI standalone (`app.rs`). -pub struct Player { - sdec: Decoder, - wdec: Decoder, - gpu: Gpu, - webcam_offset_sec: f64, - has_current_frame: bool, - use_current_on_next_step: bool, - idx: u32, -} - -impl Player { - pub unsafe fn open(screen: &str, webcam: &str, gpu: &Gpu) -> Result { - let wdec = match Decoder::open(webcam, gpu) { - Ok(d) => d, - Err(_) => Decoder::open(screen, gpu)?, - }; - Ok(Player { - sdec: Decoder::open(screen, gpu)?, - wdec, - gpu: Gpu { - device: gpu.device.clone(), - context: gpu.context.clone(), - feature_level: gpu.feature_level, - }, - webcam_offset_sec: 0.0, - has_current_frame: false, - use_current_on_next_step: false, - idx: 0, - }) - } - - /// Remplace atomiquement la paire de décodeurs du clip actif. Les nouvelles sources sont - /// ouvertes et positionnées au playhead source courant avant de libérer l'ancienne paire. - /// Synchrone (bloque le thread appelant le temps de l'ouverture) — `render_thread` préfère - /// `apply_prefetched` quand un préchargement en tâche de fond est déjà prêt ; ceci reste le - /// repli correct dans tous les autres cas (changement de clip explicite depuis l'app, - /// préchargement pas encore prêt, etc). - pub unsafe fn set_active_clip( - &mut self, - screen_path: &str, - webcam_path: &str, - webcam_offset_sec: f64, - source_time_sec: f64, - ) -> Result<()> { - let prefetched = - open_and_seek_clip(screen_path, webcam_path, webcam_offset_sec, source_time_sec, &self.gpu)?; - self.apply_prefetched(prefetched); - Ok(()) - } - - /// Bascule instantanément sur une paire de décodeurs déjà ouverte + positionnée — aucune - /// E/S ici, juste l'échange des champs. Utilisé par `set_active_clip` (juste après son - /// propre `open_and_seek_clip`) et directement par `render_thread` quand un préchargement - /// en tâche de fond est déjà prêt au moment de franchir la frontière du clip. - unsafe fn apply_prefetched(&mut self, prefetched: PrefetchedClip) { - self.sdec = prefetched.sdec; - self.wdec = prefetched.wdec; - self.webcam_offset_sec = prefetched.webcam_offset_sec; - self.has_current_frame = true; - self.use_current_on_next_step = true; - self.idx = prefetched.idx; - } - - /// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le - /// franchissement de la fin de fenêtre du clip actif pendant la lecture libre. - pub(crate) unsafe fn screen_time_sec(&self) -> f64 { - self.sdec.cur_time_sec() - } - - /// Compose la frame suivante (→ `comp.rt`). Boucle sur EOF. `false` si fixture vide. - /// - /// L'écran pilote la cadence (1 frame/tick) ; la webcam suit son PROPRE temps source - /// (`screen_time - webcam_offset_sec`), pas un pas 1:1 avec l'écran — BUG corrigé : les - /// deux décodeurs avançaient d'exactement une frame par tick chacun, quelle que soit leur - /// cadence réelle. Écran et webcam sont capturés par des pipelines indépendants (souvent - /// à des fps différents), donc la webcam jouait 2× trop vite dès que sa cadence était - /// inférieure à celle de l'écran. Même logique que `advance_decoder_to` (pipeline.rs), - /// déjà correcte côté export — la preview live ne l'avait jamais reprise. La webcam boucle - /// aussi de façon INDÉPENDANTE à son propre EOF (un clip webcam plus court que l'écran ne - /// doit pas réinitialiser le décodeur écran). - pub unsafe fn step(&mut self, comp: &Compositor, cfg: &Cfg) -> Result { - let use_current = self.use_current_on_next_step; - self.use_current_on_next_step = false; - - let mut sf = if use_current { - self.sdec.cur_frame() - } else { - self.sdec.next()? - }; - if sf.is_null() { - sf = self.sdec.seek_to(0.0)?; - self.idx = 0; - } - if sf.is_null() { - self.has_current_frame = false; - return Ok(false); - } - - let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0); - let mut wf = if use_current { - self.wdec.cur_frame() - } else { - let cur = self.wdec.cur_frame(); - if cur.is_null() { - // Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé. - self.wdec.seek_to(target_webcam_t)? - } else { - // Rattrape la webcam vers `target_webcam_t`, au pire une poignée de frames par - // tick (fps proches) — le garde-fou n'existe que contre un cas pathologique. - let mut wf = cur; - let mut guard = 0u32; - while self.wdec.cur_time_sec() < target_webcam_t { - match self.wdec.next()? { - f if f.is_null() => { - // Fin de la webcam avant l'écran : elle boucle SEULE — l'écran - // garde sa propre position, inchangée. - wf = self.wdec.seek_to(0.0)?; - break; - } - f => wf = f, - } - guard += 1; - if guard > 1000 { - break; - } - } - wf - } - }; - if wf.is_null() { - self.has_current_frame = false; - return Ok(false); - } - - self.has_current_frame = true; - self.sync_time(comp); - comp.compose_frame(sf, wf, self.idx as f32, cfg)?; - self.idx = self.idx.wrapping_add(1); - Ok(true) - } - - /// Positionne `comp` sur le temps source RÉEL (pts) de la frame écran courante, pour que le - /// curseur ET les zoom/full-camera regions du clip actif restent exacts quelle - /// que soit la cadence réelle de l'enregistrement — BUG corrigé : tout dérivait auparavant - /// de `frame / 60.0` (un compteur de frames supposant 60fps pile), qui dérive - /// silencieusement de plus en plus au fil de la lecture dès que le fichier n'est pas - /// exactement à 60fps (30/59.94/etc. sont courants), au lieu de suivre le pts réel du - /// décodeur — exactement la cause du "zoom désynchronisé de la timeline" observé. - unsafe fn sync_time(&self, comp: &Compositor) { - let t = self.sdec.cur_time_sec() as f32; - comp.set_cursor_time(Some(t)); - comp.set_timeline_time(Some(t)); - } - - /// Recompose la frame courante (déjà décodée) — rafraîchit après un changement de param. - pub unsafe fn recompose(&self, comp: &Compositor, cfg: &Cfg) -> Result { - if !self.has_current_frame { - return Ok(false); - } - let sf = self.sdec.cur_frame(); - let wf = self.wdec.cur_frame(); - if sf.is_null() || wf.is_null() { - return Ok(false); - } - self.sync_time(comp); - let f = self.idx.saturating_sub(1); - comp.compose_frame(sf, wf, f as f32, cfg)?; - Ok(true) - } - - /// Seek à `target_sec` (secondes source du clip actif) : keyframe-seek + décodage-avant - /// (`Decoder::seek_to`, même mécanisme robuste que l'export) — remplace l'ancien modèle - /// "compte de frames" qui rewindait tout au frame 0 pour le moindre seek arrière et n'avait - /// aucun raccourci keyframe pour les seeks avant lointains (lent ET, combiné au bug de - /// `set_time`, incorrect au-delà de 6s sur un enregistrement réel). - pub unsafe fn present_frame(&mut self, comp: &Compositor, cfg: &Cfg, target_sec: f64) -> Result { - let sf = self.sdec.seek_to(target_sec)?; - let wf = self - .wdec - .seek_to(webcam_seek_time(target_sec, self.webcam_offset_sec))?; - if sf.is_null() || wf.is_null() { - self.has_current_frame = false; - return Ok(false); - } - self.has_current_frame = true; - self.use_current_on_next_step = false; - self.sync_time(comp); - // "idx" ne sert plus qu'au fallback fixture (jamais lu si une scène est posée) — dérivé - // du temps réel pour rester cohérent si jamais consulté. - self.idx = (target_sec * self.sdec.fps()).round().max(0.0) as u32; - comp.compose_frame(sf, wf, self.idx as f32, cfg)?; - Ok(true) - } -} - -/// Paramètres inspector pilotés depuis l'UI (setParam). Le thread de rendu les applique : -/// booléens/taps → reconstruits dans le `Cfg` ; valeurs continues → `set_live_params`. -#[derive(Clone, Copy, PartialEq)] -struct InspectorParams { - bg_blur: bool, - bg_color: [f32; 4], - shadow_scale: f32, - radius_scale: f32, - mblur_taps: u32, - padding: f32, - webcam_size_scale: f32, - webcam_mirror: bool, - webcam_shape: u32, - cursor_show: bool, - cursor_size_scale: f32, - cursor_bounce_scale: f32, - /// 0..1 : force du lissage ressort-amortisseur de la position (0 = brut). Reconstruit la - /// piste (voir `raw_cursor.smoothed()` dans `render_thread`) plutôt qu'un simple scalaire de - /// dessin — d'où le suivi séparé de sa dernière valeur appliquée. - cursor_smoothing: f32, - /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). - cursor_motion_blur: f32, -} - -impl Default for InspectorParams { - fn default() -> Self { - Self { - bg_blur: false, - bg_color: [0.10, 0.11, 0.14, 1.0], - shadow_scale: 1.0, - radius_scale: 1.0, - mblur_taps: 8, - padding: 0.0, - webcam_size_scale: 1.0, - webcam_mirror: false, - webcam_shape: 3, - cursor_show: true, - cursor_size_scale: 1.0, - cursor_bounce_scale: 1.0, - cursor_smoothing: 0.0, - cursor_motion_blur: 0.0, - } - } -} - -#[derive(Clone)] -struct ActiveClipRequest { - screen_path: String, - webcam_path: String, - webcam_offset_sec: f64, - /// Identité dans le flux `Scene.clips` trié (les chemins ne suffisent pas pour un asset partagé). - clip_index: usize, - /// Playhead exprimé sur l'horloge source écran du nouveau clip. - source_time_sec: f64, -} - -fn same_source_path(a: &str, b: &str) -> bool { - a.eq_ignore_ascii_case(b) -} - -fn scene_clip_matches( - clip: &crate::scene::SceneClip, - screen_path: &str, - webcam_path: &str, - webcam_offset_sec: f64, -) -> bool { - same_source_path(&clip.screen_path, screen_path) - && same_source_path(&clip.webcam_path, webcam_path) - && (clip.webcam_offset_sec - webcam_offset_sec).abs() <= 1e-6 -} - -fn find_scene_clip_index( - scene: &Scene, - screen_path: &str, - webcam_path: &str, - webcam_offset_sec: f64, -) -> Option { - scene.clips.iter() - .position(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) - .or_else(|| scene.clips.iter().position(|clip| { - same_source_path(&clip.screen_path, screen_path) - && same_source_path(&clip.webcam_path, webcam_path) - })) -} - -/// Paths and the asset-level webcam offset are identical for multiple cuts of one recording, -/// so path lookup alone always returns clip 0. Prefer the explicit timeline identity. -fn resolve_scene_clip_index( - scene: &Scene, - requested_clip_index: usize, - screen_path: &str, - webcam_path: &str, - webcam_offset_sec: f64, -) -> Option { - if scene.clips.get(requested_clip_index) - .is_some_and(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) - { - Some(requested_clip_index) - } else { - find_scene_clip_index(scene, screen_path, webcam_path, webcam_offset_sec) - } -} - -fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene { - match scene.clips.get(clip_index) { - Some(clip) => scene.for_clip_window( - clip_index, - clip.source_start_sec, - clip.source_end_sec, - ), - None => scene.clone(), - } -} - -/// Dernière frame readback vers CPU, prête pour le napi `read_frame`. -/// -/// `(gen, w, h, vec)` où `vec.len() == w*h*4` octets RGBA8 tightly-packed (R, G, B, A -/// en mémoire — cf. `Compositor::readback_resized`). `gen` est une génération monotone -/// (≥ 1, `0` réservé à « le consommateur n'a encore rien vu ») incrémentée à CHAQUE -/// publication, càd uniquement quand une nouvelle frame a réellement été composée (le -/// thread de rendu ne republie pas une frame identique — cf. `stepped || first`). Elle -/// est l'IDENTITÉ de la frame : le consommateur (`read_frame`) ne repaie le clone + l'IPC -/// que lorsqu'elle change. `None` = "aucune frame composée pour l'instant" (toutes les -/// lectures avant la 1re frame composée retournent `None` côté napi, jamais un buffer vide). -type LatestFrame = (u64, u32, u32, Vec); - -/// État partagé thread appelant → thread de rendu (commandes sans blocage). -struct Shared { - /// Résolution cible du preview (largeur, hauteur) en pixels devices — ce que la - /// zone canvas Electron affiche. Plus de HWND/HWND-parent : la preview est une - /// image bitmap posée sur un ``, la position CSS est gérée entièrement - /// côté web. Lecture/écriture exclusive via `Mutex`. - preview_size: Mutex<(u32, u32)>, - inspector: Mutex, - /// Temps source (secondes) demandé par l'app pour le clip actif (presentTime/seek), prioritaire - /// sur la lecture libre. En SECONDES (pas un index de frame) : `Player::present_frame` fait un - /// vrai seek keyframe (`Decoder::seek_to`, comme l'export) au lieu de compter des frames — - /// BUG corrigé : l'ancien `set_time` convertissait en index de frame à 60fps fixe PUIS le - /// wrappait modulo `FIXTURE_FRAMES` (360 = 6s) — un reliquat du bench fixture qui faisait - /// boucler silencieusement tout seek au-delà de 6s sur un enregistrement réel, exactement - /// la cause du "zoom timeline désynchronisé" observé. - requested_frame: Mutex>, - /// Changement de sources consommé par le thread de rendu, seul propriétaire des décodeurs. - active_clip_request: Mutex>, - /// scène de l'app (contrat) ; appliquée au compositeur quand `scene_dirty`. - scene: Mutex>, - scene_dirty: AtomicBool, - playing: AtomicBool, - stop: AtomicBool, - /// Dernière frame RGBA8 readback (taille + pixels R,G,B,A tightly-packed). Écrit - /// par le thread de rendu après chaque `compose_frame` réussi, lu par le napi - /// `read_frame` depuis le thread Node principal. `Mutex>` — - /// Option pour distinguer "pas de frame encore composée" (avant le 1er compose, - /// `read_frame` retourne `Ok(None)`) d'un buffer vide (qui n'arrive jamais). - latest_frame: Mutex>, -} - -/// Handle d'une vue live. `Drop` arrête le rendu. -/// -/// Plus de fenêtre/OS : le handle ne porte plus de `HWND`. Toute la machinerie Win32 -/// (CreateWindowEx / SetWindowPos / DestroyWindow / register_overlay_class) a été -/// retirée — la preview est désormais purement hors-fenêtre, transportable via -/// mémoire. -pub struct LiveView { - shared: Arc, - thread: Option>, -} - -// `LiveView` ne référence plus aucune ressource Win32 non-`Send`. `Shared` non plus -// (`Mutex`, `AtomicBool`, `Option>`). Le `JoinHandle` est `Send`/`!Sync` -// mais on n'en extrait rien côté napi. Tout ce qui vit dans le thread de rendu -// (compositor, décodeurs, staging, GPU) y reste confiné. -unsafe impl Send for LiveView {} - -impl LiveView { - /// Crée une vue offscreen : pas de HWND/UI côté thread appelant. Démarre juste - /// le thread de rendu qui va composer chaque frame et publier le readback dans - /// `Shared::latest_frame` pour le napi `read_frame`. - /// - /// `w`/`h` sont la **résolution cible du preview** (taille du `` Electron - /// affichant la preview, en pixels device) — anciennement c'était le rect de la - /// fenêtre overlay ; maintenant c'est juste la taille du bitmap RGBA produit. - /// Ajustable à chaud via `set_rect(w, h)`. - pub fn create( - w: u32, - h: u32, - screen: &str, - webcam: &str, - cursor_json: &str, - ) -> Result { - let shared = Arc::new(Shared { - preview_size: Mutex::new((w.max(1), h.max(1))), - inspector: Mutex::new(InspectorParams::default()), - requested_frame: Mutex::new(None), - active_clip_request: Mutex::new(None), - scene: Mutex::new(None), - scene_dirty: AtomicBool::new(false), - playing: AtomicBool::new(true), - stop: AtomicBool::new(false), - latest_frame: Mutex::new(None), - }); - let sh = shared.clone(); - let (s, wc, cj) = (screen.to_string(), webcam.to_string(), cursor_json.to_string()); - let thread = std::thread::spawn(move || { - if let Err(e) = unsafe { render_thread(sh, &s, &wc, &cj) } { - eprintln!("[live] render thread error: {e:#}"); - } - }); - - Ok(LiveView { shared, thread: Some(thread) }) - } - - /// Met à jour la résolution cible du preview. Force le redimensionnement des - /// ressources GPU de readback (`Compositor::ensure_resize_target` / - /// `live_readback_staging`) au prochain tour du thread de rendu. - /// - /// Signature : `(w, h)` — l'ancienne `(x, y, w, h)` de la fenêtre overlay n'a - /// plus de sens (la position est gérée par CSS côté Electron). `set_rect` côté - /// napi doit s'aligner sur ce 2-param (la largeur/hauteur seule). - pub fn set_rect(&self, w: u32, h: u32) { - if let Ok(mut s) = self.shared.preview_size.lock() { - *s = (w.max(1), h.max(1)); - } - } - - /// Récupère la dernière frame readback (gen + taille + RGBA8 tightly-packed). - /// `None` si rien n'a encore été composé (jamais écrit). **Coût : O(w·h)** - /// (copie du `Vec` — nécessaire pour traverser la frontière thread + le - /// FFI vers le Buffer napi). Le `Vec` retourné a `len() == w*h*4`. - /// Préférer `latest_frame_since` sur le chemin chaud : il évite ce clone quand - /// le consommateur possède déjà la génération courante. - pub fn latest_frame(&self) -> Option<(u64, u32, u32, Vec)> { - self.shared - .latest_frame - .lock() - .ok() - .and_then(|guard| guard.as_ref().cloned()) - } - - /// Récupère la dernière frame UNIQUEMENT si sa génération est postérieure à - /// `since_gen`. `None` couvre les DEUX cas où le consommateur n'a rien à peindre : - /// - rien n'a encore été composé (aucune frame publiée), ou - /// - il possède déjà la génération courante (`gen <= since_gen`). - /// Dans ce second cas — l'essentiel du temps d'édition, preview en pause sur une - /// frame figée — on n'exécute PAS le clone `O(w·h)` : c'est tout l'intérêt du - /// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au - /// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`. - pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec)> { - let guard = self.shared.latest_frame.lock().ok()?; - match guard.as_ref() { - Some((gen, w, h, px)) if *gen > since_gen => Some((*gen, *w, *h, px.clone())), - _ => None, - } - } - - /// Switch inspector (booléen). - pub fn set_param_bool(&self, key: &str, value: bool) { - if let Ok(mut p) = self.shared.inspector.lock() { - match key { - "backgroundBlur" => p.bg_blur = value, - "webcamMirror" => p.webcam_mirror = value, - "cursorShow" => p.cursor_show = value, - _ => {} - } - } - } - - /// Slider inspector (numérique). Conventions : `shadow`/`roundness`/`webcamSize`/ - /// `cursorSize`/`cursorClickBounce` = échelle (1 = défaut) ; `padding` = 0..1 ; - /// `motionBlur` = 0..1 mappé sur 1..16 taps. - pub fn set_param_num(&self, key: &str, value: f64) { - if let Ok(mut p) = self.shared.inspector.lock() { - let v = value as f32; - match key { - "shadow" => p.shadow_scale = v.max(0.0), - "roundness" => p.radius_scale = v.max(0.0), - "motionBlur" => p.mblur_taps = (1.0 + value.clamp(0.0, 1.0) * 15.0).round() as u32, - "padding" => p.padding = v.clamp(0.0, 1.0), - "webcamSize" => p.webcam_size_scale = v.max(0.05), - "cursorSize" => p.cursor_size_scale = v.max(0.0), - "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), - "cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0), - "cursorMotionBlur" => p.cursor_motion_blur = v.clamp(0.0, 1.0), - _ => {} - } - } - } - - /// Sélection de chaîne : couleur de fond "#rrggbb" ou forme webcam. - pub fn set_param_str(&self, key: &str, value: &str) { - if let Ok(mut p) = self.shared.inspector.lock() { - match key { - "backgroundColor" => { - if let Some(c) = parse_hex_color(value) { - p.bg_color = c; - } - } - "webcamShape" => { - p.webcam_shape = crate::compositor::webcam_shape_code(value); - } - _ => {} - } - } - } - - pub fn set_playing(&self, playing: bool) { - self.shared.playing.store(playing, Ordering::Relaxed); - } - - /// Ce que la vue est en train de faire : lecture libre (`true`) ou pause (`false`). - /// Lu par l'export, qui met les previews en pause le temps d'encoder et doit pouvoir - /// leur rendre CET état plutôt que d'en supposer un (voir `PausedPreviews`). - pub fn playing(&self) -> bool { - self.shared.playing.load(Ordering::Relaxed) - } - - /// Installe la scène de l'app (JSON `SceneDescription`). Parsé ici (hors thread de rendu) ; - /// appliqué au compositeur au prochain tour via le flag `scene_dirty`. JSON invalide → ignoré. - pub fn set_scene(&self, json: &str) { - match Scene::from_json(json) { - Ok(scene) => { - if let Ok(mut s) = self.shared.scene.lock() { - *s = Some(scene); - self.shared.scene_dirty.store(true, Ordering::Relaxed); - } - } - Err(e) => eprintln!("[live] set_scene: JSON invalide: {e:#}"), - } - } - - /// Positionne la vue sur le temps source `seconds` du clip actif — plus de conversion en - /// index de frame ni de wrap fixture ici (voir `requested_frame`). - pub fn set_time(&self, seconds: f64) { - if let Ok(mut r) = self.shared.requested_frame.lock() { - *r = Some(seconds.max(0.0)); - } - } - - /// Programme le remplacement de la paire screen/webcam sur le thread de rendu. L'identité - /// du clip et son playhead source voyagent avec les chemins pour rendre le switch atomique. - pub fn set_active_clip( - &self, - screen_path: &str, - webcam_path: &str, - webcam_offset_sec: f64, - clip_index: usize, - source_time_sec: f64, - ) { - if let Ok(mut request) = self.shared.active_clip_request.lock() { - *request = Some(ActiveClipRequest { - screen_path: screen_path.to_string(), - webcam_path: webcam_path.to_string(), - webcam_offset_sec, - clip_index, - source_time_sec: source_time_sec.max(0.0), - }); - } - } -} - -impl Drop for LiveView { - fn drop(&mut self) { - // 1. Stoper le thread (il observe `stop` en tête de boucle et sort proprement). - self.shared.stop.store(true, Ordering::SeqCst); - // 2. Join. À la sortie, le thread a relâché toutes ses ressources GPU (compositor, - // décodeurs, resize_target, staging) ; le `Shared` reste vivant tant qu'on n'a - // pas droppé notre `Arc` final. - if let Some(t) = self.thread.take() { - let _ = t.join(); - } - // Plus rien à détruire côté Win32 — pas de HWND. - } -} - -/// A preview's transport — free-run or paused — readable AND writable without touching the -/// GPU. It is the only slice of `LiveView` an export needs: it pauses the previews to free -/// the 3D engine, then hands the transport back. A trait rather than `LiveView` itself so -/// that `PausedPreviews` can be tested with no D3D device (see this file's tests). -pub trait PreviewTransport { - fn playing(&self) -> bool; - fn set_playing(&self, playing: bool); -} - -impl PreviewTransport for LiveView { - fn playing(&self) -> bool { - // Fully-qualified on purpose: inside a trait impl, `self.playing()` resolving to the - // inherent method is a silent coincidence of method resolution, not a guarantee. - LiveView::playing(self) - } - - fn set_playing(&self, playing: bool) { - LiveView::set_playing(self, playing); - } -} - -/// What every preview was doing when an export paused them — enough to give EACH ONE back the -/// state it was found in, instead of resuming them all. -/// -/// That distinction is the fix for a visible bug, not a nicety. While editing, the preview is -/// PAUSED; the renderer only pushes `setPlaying` when the transport actually changes, so -/// nothing ever came along to re-pause a preview an export had resumed on its own authority. -/// It went back to free-running for its own account: its playhead left the moment the app -/// believed was on screen (the zoom then sampled at the wrong source time), and at the first -/// clip boundary it crossed, it swapped its scene over to ANOTHER clip -/// (`scene_for_clip`) — after which the zoom regions of the clip actually being displayed were -/// filtered out of the scene, and no amount of seeking brought them back (the app only pushes -/// `set_active_clip` when ITS active clip changes, and its own had not changed). Only a window -/// reload, which recreates the view, repaired it. -/// -/// `K` is the caller's identity for a view (the napi registry id). Previews created AFTER the -/// pause are absent from the snapshot and are left strictly alone: their transport belongs to -/// whoever created them. -pub struct PausedPreviews { - was_playing: Vec<(K, bool)>, -} - -impl Default for PausedPreviews { - fn default() -> Self { - Self { was_playing: Vec::new() } - } -} - -impl PausedPreviews { - /// Pauses every preview and reports what each one was doing. - pub fn pause<'a, V: PreviewTransport + 'a>(views: impl IntoIterator) -> Self { - let mut was_playing = Vec::new(); - for (key, view) in views { - was_playing.push((key, view.playing())); - view.set_playing(false); - } - Self { was_playing } - } - - /// Gives every preview the snapshot knows about the state it was found in. - pub fn restore<'a, V: PreviewTransport + 'a>(&self, views: impl IntoIterator) { - for (key, view) in views { - if let Some((_, was_playing)) = self.was_playing.iter().find(|(k, _)| *k == key) { - view.set_playing(*was_playing); - } - } - } -} - -/// Un préchargement en cours : quel `next_index` (dans `Scene.clips`) il prépare, et le canal -/// par lequel le thread de fond livre le résultat une fois prêt. -type PendingPrefetch = (usize, std::sync::mpsc::Receiver>); - -/// Combien de secondes avant la fin du clip actif on lance le préchargement du suivant en -/// tâche de fond. Assez large pour couvrir un `Decoder::open` typique (ouverture fichier + -/// `avformat_find_stream_info` + init D3D11VA), assez court pour ne pas garder deux paires de -/// décodeurs ouvertes plus longtemps que nécessaire. -const PREFETCH_LEAD_SEC: f64 = 0.75; - -/// Démarre le préchargement du clip suivant sur un thread dédié dès qu'on entre dans la -/// fenêtre `PREFETCH_LEAD_SEC` avant la fin du clip actif — pour que la bascule à la -/// frontière (`advance_to_next_scene_clip`) trouve les décodeurs déjà ouverts et positionnés -/// au lieu de payer l'E/S + le parsing FFmpeg sur le thread de rendu pile au moment de la -/// transition (la pause perceptible observée en usage réel). No-op si un préchargement est -/// déjà en cours, ou pour une scène à 1 clip (voir `advance_to_next_scene_clip`). -unsafe fn maybe_start_prefetch( - scene: &Scene, - active_clip_index: usize, - screen_time_sec: f64, - gpu: &Gpu, - prefetch: &mut Option, -) { - if scene.clips.len() <= 1 || prefetch.is_some() { - return; - } - let Some(clip) = scene.clips.get(active_clip_index) else { - return; - }; - let remaining = clip.source_end_sec - screen_time_sec; - if !(0.0..PREFETCH_LEAD_SEC).contains(&remaining) { - return; - } - let next_index = if active_clip_index + 1 < scene.clips.len() { - active_clip_index + 1 - } else { - 0 - }; - let next_clip = scene.clips[next_index].clone(); - // Copie légère (COM refcount, pas de nouveau device) — même motif que `Player::open`. - let gpu_clone = Gpu { - device: gpu.device.clone(), - context: gpu.context.clone(), - feature_level: gpu.feature_level, - }; - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let result = unsafe { - open_and_seek_clip( - &next_clip.screen_path, - &next_clip.webcam_path, - next_clip.webcam_offset_sec, - next_clip.source_start_sec, - &gpu_clone, - ) - }; - // L'appelant a pu abandonner ce préchargement entre-temps (changement de clip - // explicite, scène remplacée) — un receiver droppé fait juste échouer `send` - // silencieusement ; les décodeurs déjà ouverts sont libérés normalement (`Drop`). - let _ = tx.send(result); - }); - *prefetch = Some((next_index, rx)); -} - -/// Bascule le `Player` + le compositeur sur le clip suivant de `scene` (reboucle sur le -/// premier après le dernier). No-op pour une scène à 1 clip (le bouclage léger existant de -/// `Player::step` suffit et coûte moins cher qu'un `set_active_clip` — reopen des décodeurs). -/// -/// Partagée entre le déclenchement PROACTIF (seuil `source_end_sec` franchi) et le filet de -/// sécurité RÉACTIF de `render_thread` (le temps du décodeur a reculé — `Player::step` a -/// bouclé sur l'EOF RÉEL du fichier avant que le seuil ne soit jamais atteint : cas d'un clip -/// NON trimmé dont la dernière frame réelle a un PTS strictement inférieur au -/// `source_end_sec` déclaré, qui égale alors la durée totale du fichier — le seuil `>=` ne se -/// déclenche jamais dans ce cas, d'où le "ça boucle sur le 1er clip" observé malgré le -/// déclenchement proactif). -/// -/// Si `maybe_start_prefetch` a eu le temps de préparer ce même `next_index` à l'avance, la -/// bascule est instantanée (juste un échange de champs, `Player::apply_prefetched`) ; sinon -/// on retombe sur l'ouverture synchrone habituelle (`Player::set_active_clip`) — correct dans -/// tous les cas, juste plus lent quand le préchargement n'a pas eu le temps de finir. -#[allow(clippy::too_many_arguments)] -unsafe fn advance_to_next_scene_clip( - player: &mut Player, - comp: &Compositor, - scene: &Scene, - prefetch: &mut Option, - active_screen_path: &mut String, - active_webcam_path: &mut String, - active_webcam_offset_sec: &mut f64, - active_clip_index: &mut usize, - raw_cursor: &mut Option, - last_smoothing: &mut f32, -) { - if scene.clips.len() <= 1 { - return; - } - let next_index = if *active_clip_index + 1 < scene.clips.len() { - *active_clip_index + 1 - } else { - 0 - }; - let next_clip = &scene.clips[next_index]; - - // N'importe quel préchargement en cours ne concerne plus que CETTE frontière (on vient - // de la franchir, bien ou mal ciblée) — on le consomme s'il correspond, on l'abandonne - // sinon, dans tous les cas il ne doit pas survivre à cet appel. - let ready = prefetch.take().and_then(|(idx, rx)| { - if idx == next_index { rx.try_recv().ok() } else { None } - }); - - // Le curseur préchargé (voir `PrefetchedClip::cursor_track`) doit être extrait AVANT de - // passer `prefetched` (par valeur) à `apply_prefetched`, qui ne s'occupe que des - // décodeurs — sinon ce champ serait silencieusement perdu avec le reste de la struct. - let prefetched_cursor: Option> = match &ready { - Some(Ok(p)) => Some(p.cursor_track.clone()), - _ => None, - }; - - let applied = match ready { - Some(Ok(prefetched)) => { - player.apply_prefetched(prefetched); - Ok(()) - } - Some(Err(e)) => { - eprintln!("[live] préchargement du clip suivant: {e:#} — repli sur ouverture synchrone"); - player.set_active_clip( - &next_clip.screen_path, - &next_clip.webcam_path, - next_clip.webcam_offset_sec, - next_clip.source_start_sec, - ) - } - None => player.set_active_clip( - &next_clip.screen_path, - &next_clip.webcam_path, - next_clip.webcam_offset_sec, - next_clip.source_start_sec, - ), - }; - - match applied { - Ok(()) => { - *active_screen_path = next_clip.screen_path.clone(); - *active_webcam_path = next_clip.webcam_path.clone(); - *active_webcam_offset_sec = next_clip.webcam_offset_sec; - *active_clip_index = next_index; - comp.set_scene(Some(scene_for_clip(scene, *active_clip_index))); - // Réutilise le curseur préchargé s'il est disponible (voir plus haut) — sinon - // (préchargement pas encore prêt / raté) on retombe sur la lecture synchrone - // habituelle, comme avant cette optimisation. - *raw_cursor = match prefetched_cursor { - Some(track) => track, - None => { - let cursor_path = format!("{}.cursor.json", active_screen_path); - CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok() - } - }; - match raw_cursor { - Some(track) => comp.set_cursor(track.smoothed(0.0)), - None => comp.clear_cursor(), - } - *last_smoothing = -1.0; - } - Err(e) => eprintln!("[live] auto-advance clip: {e:#}"), - } -} - -/// Taille à laquelle la preview doit rastériser : la **géométrie de sortie** (donc -/// le ratio réel de l'export — la preview doit montrer ce qui sera rendu), ramenée -/// à ce que le canvas affiche réellement. -/// -/// Deux bornes, pour deux raisons distinctes : -/// - jamais plus grand que le **panneau** : les pixels en trop seraient réduits -/// dans la foulée par `readback_resized`, c'est du coût pur (sur un projet 4K -/// ce serait 8 Mpx rastérisés pour un canvas qui en affiche moins d'un) ; -/// - jamais plus grand que la **sortie** : au-delà, la preview serait plus -/// détaillée que l'export, donc mensongère. -/// -/// Sans scène, on ne connaît pas encore le ratio : on prend la taille du panneau -/// telle quelle (aucune composition n'a lieu tant que la scène n'est pas posée). -fn preview_render_size(scene: Option<&Scene>, pw: u32, ph: u32) -> (u32, u32) { - let (pw, ph) = (pw.max(2), ph.max(2)); - let Some(scene) = scene else { - return (pw, ph); - }; - let (ow, oh) = (scene.output.width.max(1) as f64, scene.output.height.max(1) as f64); - // "contain" : le plus grand cadre au ratio de sortie qui tienne dans le panneau. - let scale = (pw as f64 / ow).min(ph as f64 / oh).min(1.0); - // Arrondi via la MÊME règle que `new_sized` : la boucle de rendu compare cette - // taille à `comp.render_size()` (qui renvoie la valeur arrondie) pour décider de - // reconstruire. Sans ce passage par `normalize_render_size`, une cible impaire - // ne serait jamais égalée → reconstruction du compositeur à chaque frame. - Compositor::normalize_render_size((ow * scale).round() as u32, (oh * scale).round() as u32) -} - -/// Boucle de rendu (thread dédié) : décode → compose → resize → readback → publie -/// dans `Shared::latest_frame`. -unsafe fn render_thread( - shared: Arc, - screen: &str, - webcam: &str, - cursor_json: &str, -) -> Result<()> { - let gpu = Gpu::create(false)?; - let mut comp = Compositor::new(&gpu)?; - // Vue live = le VRAI enregistrement, pas la fenêtre fixture (100s@6s, taillée pour l'ancien - // fixture POC). On charge toute la piste depuis t=0 ; 24h couvre large toute recording réelle. - // Gardée à part (raw_cursor) pour pouvoir régénérer une variante lissée sans relire le - // fichier à chaque changement du slider "smoothing" (voir la boucle plus bas). - let mut raw_cursor = CursorTrack::load(cursor_json, 0.0, 24.0 * 3600.0).ok(); - if let Some(track) = &raw_cursor { - comp.set_cursor(track.smoothed(0.0)); - } - let mut player = Player::open(screen, webcam, &gpu)?; - let mut active_screen_path = screen.to_string(); - let mut active_webcam_path = webcam.to_string(); - let mut active_webcam_offset_sec = 0.0f64; - let mut active_clip_index = 0usize; - // Copie de la Scene complète (tous les clips), tenue à jour à chaque push de l'app — - // permet à la boucle de lecture libre de connaître la fenêtre source - // [source_start_sec, source_end_sec) du clip actif et d'enchaîner elle-même sur le - // clip suivant (voir plus bas), sans dépendre d'un aller-retour JS par frontière de - // clip : la timeline est un niveau d'abstraction AU-DESSUS des clips, elle se lit - // dans son entièreté et l'utilisateur ne doit jamais remarquer la frontière. - let mut full_scene: Option = None; - // Préchargement du clip suivant en cours (voir `maybe_start_prefetch`) — `None` la - // plupart du temps, `Some` seulement dans la fenêtre `PREFETCH_LEAD_SEC` avant une - // frontière de clip. Invalidé (mis à `None`) dès que le contexte qui l'a déclenché - // devient obsolète (nouvelle scène, changement de clip explicite) pour ne jamais risquer - // d'appliquer les décodeurs d'un préchargement qui ne correspond plus à la situation. - let mut prefetch: Option = None; - - // config de base = C8 (tous effets) ; le fond flouté est piloté par le param live. - let mut cfg = config::all().pop().expect("au moins une config"); - // Migration D3D : le layout et le zoom viennent de l'app (contrat de scène), pas du planning - // fixture. On désactive l'animation de layout A↔B et le zoom codés en dur de `timeline()` — - // sinon la preview d'un vrai enregistrement joue la « scène fixture » (le bug d'animation vu). - // Layout statique (PiP) par défaut ; les zoom regions / presets seront rebranchés via la scène. - cfg.zoom = false; - cfg.layout_anim = false; - - let mut last = Instant::now(); - let mut acc = 0.0f64; - let mut first = true; - let mut last_preview_size: (u32, u32) = (0, 0); - let mut last_ip: Option = None; - let mut last_smoothing: f32 = -1.0; // force la 1re application (0.0 est une valeur valide) - // La vue live est TOUJOURS pilotée par la scène de l'app. Tant qu'aucune scène n'a été - // appliquée, on refuse de jouer le layout fixture (POC) : un fallback fixture ne ferait que - // MASQUER un scene-push cassé. On attend la scène avant de produire le 1er frame. - let mut scene_applied = false; - - while !shared.stop.load(Ordering::SeqCst) { - // params inspector : booléens/taps → cfg ; valeurs continues → live_params - let ip = *shared.inspector.lock().unwrap(); - let mut clip_changed = false; - let clip_request = shared.active_clip_request.lock().unwrap().take(); - if let Some(request) = clip_request { - // Un changement de clip explicite depuis l'app rend obsolète tout préchargement - // en cours (il visait la suite du clip qu'on est en train de quitter maintenant - // autrement) — sans ça, `advance_to_next_scene_clip` pourrait plus tard appliquer - // des décodeurs qui ne correspondent plus au contexte réel. - prefetch = None; - match player.set_active_clip( - &request.screen_path, - &request.webcam_path, - request.webcam_offset_sec, - request.source_time_sec, - ) { - Ok(()) => { - active_screen_path = request.screen_path; - active_webcam_path = request.webcam_path; - active_webcam_offset_sec = request.webcam_offset_sec; - let scene = shared.scene.lock().unwrap().clone(); - full_scene = scene.clone(); - if let Some(base_scene) = scene { - if let Some(index) = resolve_scene_clip_index( - &base_scene, - request.clip_index, - &active_screen_path, - &active_webcam_path, - active_webcam_offset_sec, - ) { - active_clip_index = index; - } else { - eprintln!( - "[live] set_active_clip: sources absentes de la scène (screen=\"{}\", webcam=\"{}\")", - active_screen_path, active_webcam_path - ); - } - comp.set_scene(Some(scene_for_clip(&base_scene, active_clip_index))); - scene_applied = true; - } - let cursor_path = format!("{}.cursor.json", active_screen_path); - raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(); - match &raw_cursor { - Some(track) => { - eprintln!( - "[live] cursor: path={} loaded=ok samples={}", - cursor_path, - track.sample_count(), - ); - comp.set_cursor(track.smoothed(0.0)); - } - None => { - eprintln!( - "[live] cursor: path={} loaded=FAIL — clear_cursor()", - cursor_path, - ); - comp.clear_cursor(); - } - } - last_smoothing = -1.0; - clip_changed = true; - } - Err(e) => eprintln!("[live] set_active_clip: {e:#}"), - } - } - cfg.bg_blur = ip.bg_blur; - cfg.mblur_n = ip.mblur_taps; - cfg.cursor = ip.cursor_show; - // TS falls `webcamPath` back to the screen asset's own path when a clip has no real - // camera (so the decoder pipeline always has something valid to open) — if we drew the - // PiP box in that case it would just duplicate the screen video into its own corner. - // `same_source_path` already exists for exactly this comparison (scene/clip matching). - let has_real_webcam = !same_source_path(&active_webcam_path, &active_screen_path); - comp.set_live_params(LiveParams { - bg_color: ip.bg_color, - shadow_scale: ip.shadow_scale, - radius_scale: ip.radius_scale, - padding: ip.padding, - webcam_size_scale: ip.webcam_size_scale, - webcam_mirror: ip.webcam_mirror, - webcam_shape: ip.webcam_shape, - cursor_size_scale: ip.cursor_size_scale, - cursor_bounce_scale: ip.cursor_bounce_scale, - cursor_motion_blur: ip.cursor_motion_blur, - has_webcam: has_real_webcam, - }); - // Lissage ressort-amortisseur : re-génère la piste (240 Hz) uniquement quand la valeur - // change (pas à chaque frame — le resample+ressort parcourt tout l'enregistrement). - if let Some(raw) = &raw_cursor { - if ip.cursor_smoothing != last_smoothing { - comp.set_cursor(raw.smoothed(ip.cursor_smoothing)); - last_smoothing = ip.cursor_smoothing; - } - } - // un changement de param doit se voir même en pause (édition live des sliders) : - // on recompose la frame courante dans la branche pause ci-dessous. - let ip_changed = last_ip != Some(ip); - last_ip = Some(ip); - - // scène de l'app : appliquée au compositeur quand elle change (dirty). - let scene_changed = shared.scene_dirty.swap(false, Ordering::Relaxed); - if scene_changed { - // La nouvelle scène peut avoir réordonné/modifié les clips — tout index visé par - // un préchargement en cours n'est plus fiable. - prefetch = None; - let scene = shared.scene.lock().unwrap().clone(); - full_scene = scene.clone(); - let scene = scene.map(|base_scene| { - scene_applied = true; - if let Some(index) = resolve_scene_clip_index( - &base_scene, - active_clip_index, - &active_screen_path, - &active_webcam_path, - active_webcam_offset_sec, - ) { - active_clip_index = index; - } - scene_for_clip(&base_scene, active_clip_index) - }); - comp.set_scene(scene); - } - - // résolution cible du preview (le canvas Electron) → force le recadrage des - // ressources GPU si elle change. BUG évité : sans ce suivi, redimensionner le - // panneau preview PENDANT une pause ne redéclenchait ni recompose ni readback - // (aucune des autres conditions de la branche pause ne couvrait "juste la - // résolution a changé") — le canvas restait figé à l'ancienne taille jusqu'à la - // reprise de lecture ou un autre changement de param/scène. - let (pw, ph) = *shared.preview_size.lock().unwrap(); - let resized = (pw, ph) != last_preview_size; - last_preview_size = (pw, ph); - - // Le compositeur rastérise à la géométrie de SORTIE (ramenée à la taille du - // canvas) et non plus dans un canvas 16:9 figé. Quand cette géométrie change - // — l'utilisateur change de ratio, ou redimensionne le panneau — on - // reconstruit le compositeur. Voir `Compositor::new_sized` pour le choix - // "reconstruire" plutôt que "redimensionner à chaud". - let want = preview_render_size(full_scene.as_ref(), pw, ph); - if want != comp.render_size() { - comp = Compositor::new_sized(&gpu, want.0, want.1)?; - // Le compositeur neuf est vierge : on repasse par les mécanismes - // d'invalidation existants plutôt que de recopier l'état à la main — - // une seule façon d'appliquer la scène, les params et le curseur. - shared.scene_dirty.store(true, Ordering::Relaxed); - last_ip = None; - last_smoothing = -1.0; - first = true; - continue; - } - - // Pas encore de scène → on ne compose RIEN (pas de fixture masquante). On attend - // la scène. Un scene-push cassé reste ainsi visible (preview silencieuse — le - // canvas reste sur sa frame précédente côté JS, ce qui est mieux qu'un fallback - // masquant). - if !scene_applied { - std::thread::sleep(Duration::from_millis(8)); - continue; - } - - // avance : seek app-piloté (presentTime) prioritaire, sinon lecture libre (60 fps) - let requested = shared.requested_frame.lock().unwrap().take(); - let now = Instant::now(); - let dt = (now - last).as_secs_f64().min(0.1); - last = now; - let mut stepped = false; - if let Some(target) = requested { - if player.present_frame(&comp, &cfg, target)? { - stepped = true; - } - acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek - } else if shared.playing.load(Ordering::Relaxed) { - // BUG corrigé : la lecture libre décodait toujours exactement 1 frame par tick de - // 1/60s réel, quelle que soit la speed region active au temps source courant — ni - // l'écran ni la webcam n'accéléraient/ralentissaient jamais en preview live (seul - // l'export, via `speed_segments_for_window`/`advance_decoder_to` dans pipeline.rs, - // retimait correctement). Mod 3 corrige déjà le fps-mismatch webcam/écran (la webcam - // suit le temps source RÉEL de l'écran, pas un pas 1:1) — reprend ici la même idée : - // l'accumulateur de temps réel est mis à l'échelle par le multiplicateur de vitesse - // actif, donc `step()` (qui resynchronise la webcam sur le temps écran courant, - // cf. plus haut) décode plus/moins de frames par seconde réelle selon la région. - let speed = full_scene - .as_ref() - .map(|scene| speed_at(&scene.speed_regions, active_clip_index, player.screen_time_sec())) - .unwrap_or(1.0); - acc += dt * speed; - let step = 1.0 / 60.0; - let mut n = 0; - // Cap proportionnel à la vitesse (borné) : à vitesse élevée, plus de frames doivent - // être décodées par tick réel pour ne pas prendre du retard sur l'accumulateur. - let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64); - while acc >= step && n < max_steps { - // Timeline = niveau d'abstraction AU-DESSUS des clips : dès que le décodeur - // écran atteint la fin de fenêtre du clip actif, on enchaîne nous-mêmes sur - // le clip suivant (ou on reboucle sur le premier après le dernier) — sans - // dépendre d'un `active_clip_request` poussé par le JS en réaction au - // franchissement. Ce round-trip arrivait toujours trop tard : le décodeur - // avait déjà dépassé la fin de la fenêtre, voire atteint l'EOF brut du - // fichier et rebouclé sur lui-même — d'où le "retour au 1er clip" observé. - if let Some(scene) = &full_scene { - // Approche de la frontière : lance (ou laisse tourner) le préchargement - // du clip suivant en tâche de fond, pour que la bascule ci-dessous soit - // instantanée plutôt que de payer un `Decoder::open` synchrone pile au - // moment de la transition — la pause perceptible observée en usage réel. - maybe_start_prefetch( - scene, - active_clip_index, - player.screen_time_sec(), - &gpu, - &mut prefetch, - ); - if let Some(clip) = scene.clips.get(active_clip_index) { - if player.screen_time_sec() >= clip.source_end_sec { - advance_to_next_scene_clip( - &mut player, - &comp, - scene, - &mut prefetch, - &mut active_screen_path, - &mut active_webcam_path, - &mut active_webcam_offset_sec, - &mut active_clip_index, - &mut raw_cursor, - &mut last_smoothing, - ); - } - } - } - let screen_time_before_step = full_scene.as_ref().map(|_| player.screen_time_sec()); - if player.step(&comp, &cfg)? { - stepped = true; - } - // Filet de sécurité : un clip NON trimmé (source_end_sec == durée totale du - // fichier) peut ne jamais franchir le seuil ci-dessus si la dernière frame - // réelle a un PTS strictement inférieur à `source_end_sec` déclaré — `step()` - // finit alors par boucler tout seul sur l'EOF réel (temps qui recule - // brutalement). On détecte ce recul et on corrige immédiatement en enchaînant - // sur le clip suivant, plutôt que de rester bloqué sur le 1er clip. - if let (Some(scene), Some(t_before)) = (&full_scene, screen_time_before_step) { - if player.screen_time_sec() < t_before { - advance_to_next_scene_clip( - &mut player, - &comp, - scene, - &mut prefetch, - &mut active_screen_path, - &mut active_webcam_path, - &mut active_webcam_offset_sec, - &mut active_clip_index, - &mut raw_cursor, - &mut last_smoothing, - ); - } - } - acc -= step; - n += 1; - } - if acc > step { - acc = 0.0; - } - } else if first || ip_changed || scene_changed || clip_changed || resized { - // pause : recompose la frame courante (param / scène / clip / résolution changés). - let _ = player.recompose(&comp, &cfg); - stepped = true; - } - - if stepped || first { - if pw > 0 && ph > 0 { - // Step complet : `compose_frame` (déjà appelé par `step`/`present_frame`/ - // `recompose`) a rastérisé le RT à la géométrie de sortie ramenée au panneau. - // On lit ce RT DIRECTEMENT à sa résolution de rendu (`readback_direct` : copy - // rt → staging → Map/Unmap), sans le resize `blit_resized` qui, depuis la - // refonte ratio, n'était plus qu'une copie identité + une alloc NV12 inutile. - match comp.readback_direct() { - Ok((rw, rh, rgba)) => { - // Publie dans `latest_frame` : on remplace le buffer précédent - // (le canvas ne montre que la dernière frame, peu importe combien - // le renderer en a raté entre deux lectures napi). On incrémente - // la génération sous le MÊME lock que l'écriture du buffer, pour - // qu'un lecteur ne puisse jamais voir un `gen` neuf appairé à un - // buffer périmé (ou l'inverse). `+ 1` depuis la précédente, `1` au - // premier publish. Les dims publiées sont celles du RENDU (`rw`×`rh`) : - // le canvas JS s'y dimensionne (packet auto-descriptif) puis CSS met à - // l'échelle vers la boîte du panneau — plus de resize GPU intermédiaire. - if let Ok(mut slot) = shared.latest_frame.lock() { - let next_gen = slot.as_ref().map(|(g, ..)| g + 1).unwrap_or(1); - *slot = Some((next_gen, rw, rh, rgba)); - } - first = false; - } - Err(e) => { - eprintln!("[live] readback_direct: {e:#}"); - std::thread::sleep(Duration::from_millis(8)); - } - } - } - } else { - std::thread::sleep(Duration::from_millis(4)); - } - } - Ok(()) -} - -// ---------- harnais standalone (poc-d3d.exe --live) ---------- - -use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}; -use windows::Win32::System::LibraryLoader::GetModuleHandleW; -use windows::Win32::UI::WindowsAndMessaging::*; - -extern "system" fn host_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { - unsafe { - if msg == WM_DESTROY { - PostQuitMessage(0); - return LRESULT(0); - } - DefWindowProcW(hwnd, msg, wp, lp) - } -} - -/// Test hors Electron : fenêtre hôte top-level (juste pour drainer les messages Windows -/// du main thread) + une `LiveView` offscreen qui produit des frames RGBA8 dans un -/// `` HTML via le harnais d'affichage standalone. Valide le rendu threadé -/// + le readback CPU sans dépendre d'Electron. -pub fn run_standalone(screen: &str, webcam: &str, cursor_json: &str) -> Result<()> { - unsafe { - let hinst = HINSTANCE(GetModuleHandleW(None)?.0); - let cls = wide("PocD3DLiveHost"); - let wc = WNDCLASSW { - style: CS_HREDRAW | CS_VREDRAW, - lpfnWndProc: Some(host_proc), - hInstance: hinst, - lpszClassName: PCWSTR(cls.as_ptr()), - hbrBackground: windows::Win32::Graphics::Gdi::HBRUSH(std::ptr::null_mut()), - ..Default::default() - }; - RegisterClassW(&wc); - - let title = wide("poc-d3d — live embed test (offscreen RGBA8 readback)"); - let host = CreateWindowExW( - WINDOW_EX_STYLE(0), - PCWSTR(cls.as_ptr()), - PCWSTR(title.as_ptr()), - WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, - CW_USEDEFAULT, - 1280, - 760, - HWND::default(), - HMENU::default(), - hinst, - None, - )?; - - // Résolution preview = client de la fenêtre host. Ajustable au resize du host. - let mut last = (0u32, 0u32); - let (mut w, mut h) = client_size(host); - last = (w, h); - let view = LiveView::create(w, h, screen, webcam, cursor_json)?; - let _ = ShowWindow(host, SW_SHOW); - println!("live embed: vue offscreen créée, thread de rendu démarré"); - println!(" touches : [B] flou de fond (param → D3D) [Espace] pause/lecture"); - - // état des paramètres pilotés au clavier (le MÊME set_param que l'addon napi appelle) - let mut blur = false; - let mut playing = true; - let set_title = |b: bool, p: bool| unsafe { - let t = wide(&format!( - "poc-d3d — live embed · flou: {} · {} (B / Espace)", - if b { "ON" } else { "off" }, - if p { "lecture" } else { "PAUSE" } - )); - let _ = SetWindowTextW(host, PCWSTR(t.as_ptr())); - }; - set_title(blur, playing); - - let mut msg = MSG::default(); - let mut running = true; - while running { - while PeekMessageW(&mut msg, HWND::default(), 0, 0, PM_REMOVE).as_bool() { - if msg.message == WM_QUIT { - running = false; - break; - } - if msg.message == WM_KEYDOWN { - match msg.wParam.0 as u32 { - 0x42 => { - // 'B' : bascule le fond flouté via set_param — chemin param → D3D - blur = !blur; - view.set_param_bool("backgroundBlur", blur); - set_title(blur, playing); - } - 0x20 => { - // Espace : pause/lecture - playing = !playing; - view.set_playing(playing); - set_title(blur, playing); - } - _ => {} - } - } - let _ = TranslateMessage(&msg); - DispatchMessageW(&msg); - } - if !running { - break; - } - let (cw, ch) = client_size(host); - if (cw, ch) != last { - view.set_rect(cw, ch); - last = (cw, ch); - w = cw; - h = ch; - } - // Force `first=false` côté render thread : si la preview était en pause - // totale, on n'a pas publié de frame. On laisse le canvas vide ; le harnais - // standalone n'affiche pas réellement les pixels ici (l'embed Electron est - // le consumer réel). On imprime juste une frame de temps en temps pour - // confirmer que la chaîne fonctionne. - if let Some((_gen, fw, fh, _pixels)) = view.latest_frame() { - if (fw, fh) != (w, h) { - // garde-fou : la staging de readback suit `set_rect` côté thread - // de rendu, donc ce serait une désynchro transitoire — acceptable. - } - } - std::thread::sleep(Duration::from_millis(8)); - } - drop(view); - Ok(()) - } -} - -fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() -} - -unsafe fn client_size(hwnd: HWND) -> (u32, u32) { - let mut rc = RECT::default(); - let _ = GetClientRect(hwnd, &mut rc); - ((rc.right - rc.left).max(0) as u32, (rc.bottom - rc.top).max(0) as u32) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn multiclip_scene() -> Scene { - Scene::from_json(r##"{ - "clips": [ - {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":1.25,"hasAudio":true}, - {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":20,"sourceEndSec":24,"webcamOffsetSec":1.25,"hasAudio":true}, - {"screenPath":"/distinct-screen.mp4","webcamPath":"/distinct-webcam.mp4","sourceStartSec":100,"sourceEndSec":104,"webcamOffsetSec":0.5,"hasAudio":true} - ], - "layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}, - "effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, - "background":{"kind":"color","color":"#000000"}, - "zoomRegions":[], - "cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}, - "cropByClip":[null,null,null], - "output":{"width":1920,"height":1080,"fps":30} - }"##).expect("multiclip scene") - } - - #[test] - fn explicit_index_disambiguates_clips_sharing_sources() { - let scene = multiclip_scene(); - assert_eq!(find_scene_clip_index(&scene, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(0)); - assert_eq!(resolve_scene_clip_index(&scene, 1, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(1)); - } - - #[test] - fn explicit_index_tracks_a_distinct_asset() { - let scene = multiclip_scene(); - assert_eq!(resolve_scene_clip_index(&scene, 2, "/distinct-screen.mp4", "/distinct-webcam.mp4", 0.5), Some(2)); - } - - #[test] - fn webcam_seek_uses_screen_source_time_and_offset() { - assert_eq!(webcam_seek_time(22.5, 1.25), 21.25); - assert_eq!(webcam_seek_time(0.5, 1.25), 0.0); - } - - // --- transport handed to an export and back ------------------------------- - // A real `LiveView` needs a D3D device and a decoder; the transport is the only - // part an export touches, so these exercise it through `PreviewTransport` alone. - - /// A preview reduced to its transport flag — no GPU, no render thread. - struct FakePreview(std::cell::Cell); - - impl FakePreview { - fn new(playing: bool) -> Self { - Self(std::cell::Cell::new(playing)) - } - } - - impl PreviewTransport for FakePreview { - fn playing(&self) -> bool { - self.0.get() - } - - fn set_playing(&self, playing: bool) { - self.0.set(playing); - } - } - - /// The regression this exists for: an export used to resume every preview it had - /// paused, so exporting while the editor sat paused left the preview free-running — - /// it drifted off the app's playhead and out of the zoom region the inspector still - /// showed, and only a window reload brought the zoom back. - #[test] - fn an_export_leaves_a_paused_preview_paused() { - let paused = FakePreview::new(false); - let snapshot = PausedPreviews::pause([(7, &paused)]); - assert!(!paused.playing(), "the export must free the GPU while it encodes"); - - snapshot.restore([(7, &paused)]); - assert!(!paused.playing(), "the app never asked for playback — it must still be paused"); - } - - /// The other half of "as found": a preview that WAS playing gets its playback back, - /// which is what the blanket resume happened to get right. - #[test] - fn an_export_gives_a_playing_preview_its_playback_back() { - let playing = FakePreview::new(true); - let snapshot = PausedPreviews::pause([(1, &playing)]); - assert!(!playing.playing(), "paused for the duration of the encode"); - - snapshot.restore([(1, &playing)]); - assert!(playing.playing()); - } - - /// Each preview gets ITS state back, not the majority's. - #[test] - fn each_preview_is_restored_independently() { - let (a, b) = (FakePreview::new(true), FakePreview::new(false)); - let snapshot = PausedPreviews::pause([(1, &a), (2, &b)]); - snapshot.restore([(1, &a), (2, &b)]); - assert_eq!((a.playing(), b.playing()), (true, false)); - } - - /// A preview born mid-export was never paused by it, so the export has no state of - /// its own to hand back — forcing one would overwrite what its creator just pushed. - #[test] - fn a_preview_created_during_an_export_keeps_its_own_transport() { - let existing = FakePreview::new(false); - let snapshot = PausedPreviews::pause([(1, &existing)]); - - let newborn = FakePreview::new(true); - snapshot.restore([(1, &existing), (2, &newborn)]); - assert!(newborn.playing(), "untouched: it is not in the snapshot"); - assert!(!existing.playing()); - } - - /// A preview destroyed during the export simply isn't there to restore — no panic, - /// and the survivors are still handled. - #[test] - fn a_preview_destroyed_during_an_export_is_skipped() { - let (kept, doomed) = (FakePreview::new(true), FakePreview::new(true)); - let snapshot = PausedPreviews::pause([(1, &kept), (2, &doomed)]); - drop(doomed); - snapshot.restore([(1, &kept)]); - assert!(kept.playing()); - } - - // --- taille de rastérisation de la preview --------------------------- - // Ces tests remplacent le filet géométrique qui verrouillait la - // compensation anisotrope : celle-ci n'existe plus (le RT porte la - // géométrie de sortie), donc la logique qui reste à couvrir est le choix - // de la taille. La non-régression pixel, elle, vit dans le golden - // (`tests/output_geometry_golden.rs`). - - fn scene_with_output(w: u32, h: u32) -> Scene { - Scene::from_json(&format!( - r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#000000"}},"zoomRegions":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":{w},"height":{h},"fps":null}}}}"## - )) - .expect("scene valide") - } - - /// Sans scène on ne connaît pas encore le ratio de sortie : on prend le - /// panneau tel quel (rien n'est composé tant que la scène n'est pas posée). - #[test] - fn preview_size_without_a_scene_is_the_panel() { - assert_eq!(preview_render_size(None, 800, 450), (800, 450)); - } - - /// Le ratio rendu est celui de la SORTIE, pas celui du panneau — sinon la - /// preview montrerait un cadrage que l'export ne produira pas. - #[test] - fn preview_size_follows_the_output_shape_not_the_panel_shape() { - let portrait = scene_with_output(1080, 1920); - let (w, h) = preview_render_size(Some(&portrait), 1600, 900); - assert!(h > w, "sortie portrait dans un panneau paysage → cadre portrait, obtenu {w}x{h}"); - let got = w as f64 / h as f64; - assert!((got - 1080.0 / 1920.0).abs() < 0.01, "ratio {got}, attendu 0.5625"); - } - - /// Jamais plus grand que le panneau : les pixels en trop seraient réduits - /// dans la foulée par le readback — c'est du coût pur. - #[test] - fn preview_size_never_exceeds_the_panel() { - let uhd = scene_with_output(3840, 2160); - let (w, h) = preview_render_size(Some(&uhd), 960, 540); - assert!(w <= 960 && h <= 540, "{w}x{h} depasse le panneau 960x540"); - } - - /// Jamais plus grand que la sortie : au-delà, la preview serait plus nette - /// que l'export, donc mensongère. - #[test] - fn preview_size_never_exceeds_the_output() { - let small = scene_with_output(640, 360); - let (w, h) = preview_render_size(Some(&small), 3000, 2000); - assert_eq!((w, h), (640, 360)); - } - - /// Anti-régression du bug de reconstruction en boucle : la taille produite - /// doit être un POINT FIXE de `normalize_render_size`. Si ce n'est pas le cas, - /// `want != comp.render_size()` reste vrai indéfiniment et le compositeur se - /// reconstruit à chaque frame (média qui disparaissent, VRAM qui sature). - /// On balaie beaucoup de tailles de panneau : une seule qui produit une - /// dimension impaire suffirait à faire boucler la preview en vrai. - #[test] - fn preview_size_is_always_a_fixed_point_of_the_render_size_rounding() { - let scene = scene_with_output(1920, 1080); - for pw in 200..1400 { - let (w, h) = preview_render_size(Some(&scene), pw, 900); - assert_eq!( - (w, h), - Compositor::normalize_render_size(w, h), - "panneau {pw}x900 → {w}x{h} n'est pas stable → reconstruction en boucle", - ); - } - } -} +//! Vue live : rend le compositing **hors-fenêtre** vers un `Vec` RGBA8 +//! (taille `set_rect`) destiné à être streamé dans un `` Electron via +//! `putImageData`. Option B (canvas) — l'ancienne option A (fenêtre D3D enfant +//! `WS_POPUP` + swapchain) supprimée : la glue TS n'a plus de surface native à +//! embarquer, elle draw chaque frame reçue comme une image bitmap. +//! +//! Pipeline interne : `Player` (decodeur lockstep screen/webcam) + +//! `Compositor::compose_frame` → RT RGBA rastérisé à la GÉOMÉTRIE DE RENDU (depuis la +//! refonte ratio : géométrie de sortie ramenée à la taille du panneau, plus le canvas +//! 16:9 figé d'avant). Le **post-traitement** : +//! - 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 +//! 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. +//! +//! Modèle de threads : la vue n'a plus de HWND/UI côté thread appelant. Le rendu vit +//! sur un thread dédié — le thread JS/UI n'est jamais bloqué. Les objets COM et la +//! staging restent sur ce thread de rendu ; la frame est publiée via un +//! `Mutex)>>` pour la traversée de threads vers +//! le napi — le `gen` est l'identité de la frame (cf. `LatestFrame`). + +use crate::compositor::{Compositor, LiveParams}; +use crate::regions::speed_at; +use crate::scene::Scene; +use crate::config::{self, Cfg}; +use crate::cursor::CursorTrack; +use crate::d3d::Gpu; +use crate::pipeline::Decoder; +use anyhow::Result; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// "#rrggbb" (ou "rrggbb") → [r, g, b, 1] en 0..1. None si invalide. +fn parse_hex_color(s: &str) -> Option<[f32; 4]> { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 { + return None; + } + let r = u8::from_str_radix(&h[0..2], 16).ok()? as f32 / 255.0; + let g = u8::from_str_radix(&h[2..4], 16).ok()? as f32 / 255.0; + let b = u8::from_str_radix(&h[4..6], 16).ok()? as f32 / 255.0; + Some([r, g, b, 1.0]) +} + +fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64 { + (screen_source_time_sec - webcam_offset_sec).max(0.0) +} + +/// Décodeurs déjà ouverts ET positionnés au bon playhead pour un clip à venir — le résultat +/// d'un préchargement en tâche de fond (voir `open_and_seek_clip`/`maybe_start_prefetch` +/// dans `render_thread`). Appliquer ceci à un `Player` (`apply_prefetched`) ne fait plus +/// aucune E/S : c'est ce qui rend la bascule à la frontière d'un clip instantanée au lieu de +/// payer un `Decoder::open` (ouverture fichier + parsing FFmpeg) synchrone pile au moment de +/// la transition — la pause perceptible observée en usage réel. +struct PrefetchedClip { + sdec: Decoder, + wdec: Decoder, + webcam_offset_sec: f64, + idx: u32, + /// Piste curseur du clip à venir, préchargée ici pour la même raison que les décodeurs : + /// sans ça, la bascule à la frontière restait synchrone sur CE point précis (lecture + + /// parsing JSON du `.cursor.json`, potentiellement des milliers d'échantillons pour un + /// enregistrement long) même après que le préchargement des décodeurs a supprimé le gros + /// de la pause perceptible — un second petit accroc au même endroit, pour la même raison + /// (une E/S synchrone pile à la frontière) qu'on venait de corriger pour les décodeurs. + cursor_track: Option, +} + +/// Ouvre + positionne la paire de décodeurs d'un clip (même travail que +/// `Player::set_active_clip`, mais autonome — sans instance `Player` existante, pour pouvoir +/// tourner sur un thread dédié pendant que le `Player` réel joue encore le clip actif). +unsafe fn open_and_seek_clip( + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + source_time_sec: f64, + gpu: &Gpu, +) -> Result { + let source_time_sec = source_time_sec.max(0.0); + let mut sdec = Decoder::open(screen_path, gpu)?; + let mut wdec = match Decoder::open(webcam_path, gpu) { + Ok(d) => d, + Err(_) => Decoder::open(screen_path, gpu)?, + }; + let sf = sdec.seek_to(source_time_sec)?; + let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?; + if wf.is_null() { + wf = wdec.seek_to(0.0)?; + } + if sf.is_null() { + anyhow::bail!("clip préchargé vide au temps source {source_time_sec:.3}s (screen=\"{screen_path}\")"); + } + let idx = (source_time_sec * sdec.fps()).round().max(0.0) as u32; + let cursor_track = CursorTrack::load(&format!("{screen_path}.cursor.json"), 0.0, 24.0 * 3600.0).ok(); + Ok(PrefetchedClip { sdec, wdec, webcam_offset_sec, idx, cursor_track }) +} + +/// Lit deux sources en lockstep et compose la frame courante dans le RT du compositeur. +/// Partagé avec la GUI standalone (`app.rs`). +pub struct Player { + sdec: Decoder, + wdec: Decoder, + gpu: Gpu, + webcam_offset_sec: f64, + has_current_frame: bool, + use_current_on_next_step: bool, + idx: u32, +} + +impl Player { + pub unsafe fn open(screen: &str, webcam: &str, gpu: &Gpu) -> Result { + let wdec = match Decoder::open(webcam, gpu) { + Ok(d) => d, + Err(_) => Decoder::open(screen, gpu)?, + }; + Ok(Player { + sdec: Decoder::open(screen, gpu)?, + wdec, + gpu: Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + feature_level: gpu.feature_level, + backend: gpu.backend, + }, + webcam_offset_sec: 0.0, + has_current_frame: false, + use_current_on_next_step: false, + idx: 0, + }) + } + + /// Remplace atomiquement la paire de décodeurs du clip actif. Les nouvelles sources sont + /// ouvertes et positionnées au playhead source courant avant de libérer l'ancienne paire. + /// Synchrone (bloque le thread appelant le temps de l'ouverture) — `render_thread` préfère + /// `apply_prefetched` quand un préchargement en tâche de fond est déjà prêt ; ceci reste le + /// repli correct dans tous les autres cas (changement de clip explicite depuis l'app, + /// préchargement pas encore prêt, etc). + pub unsafe fn set_active_clip( + &mut self, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + source_time_sec: f64, + ) -> Result<()> { + let prefetched = + open_and_seek_clip(screen_path, webcam_path, webcam_offset_sec, source_time_sec, &self.gpu)?; + self.apply_prefetched(prefetched); + Ok(()) + } + + /// Bascule instantanément sur une paire de décodeurs déjà ouverte + positionnée — aucune + /// E/S ici, juste l'échange des champs. Utilisé par `set_active_clip` (juste après son + /// propre `open_and_seek_clip`) et directement par `render_thread` quand un préchargement + /// en tâche de fond est déjà prêt au moment de franchir la frontière du clip. + unsafe fn apply_prefetched(&mut self, prefetched: PrefetchedClip) { + self.sdec = prefetched.sdec; + self.wdec = prefetched.wdec; + self.webcam_offset_sec = prefetched.webcam_offset_sec; + self.has_current_frame = true; + self.use_current_on_next_step = true; + self.idx = prefetched.idx; + } + + /// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le + /// franchissement de la fin de fenêtre du clip actif pendant la lecture libre. + pub(crate) unsafe fn screen_time_sec(&self) -> f64 { + self.sdec.cur_time_sec() + } + + /// Compose la frame suivante (→ `comp.rt`). Boucle sur EOF. `false` si fixture vide. + /// + /// L'écran pilote la cadence (1 frame/tick) ; la webcam suit son PROPRE temps source + /// (`screen_time - webcam_offset_sec`), pas un pas 1:1 avec l'écran — BUG corrigé : les + /// deux décodeurs avançaient d'exactement une frame par tick chacun, quelle que soit leur + /// cadence réelle. Écran et webcam sont capturés par des pipelines indépendants (souvent + /// à des fps différents), donc la webcam jouait 2× trop vite dès que sa cadence était + /// inférieure à celle de l'écran. Même logique que `advance_decoder_to` (pipeline.rs), + /// déjà correcte côté export — la preview live ne l'avait jamais reprise. La webcam boucle + /// aussi de façon INDÉPENDANTE à son propre EOF (un clip webcam plus court que l'écran ne + /// doit pas réinitialiser le décodeur écran). + pub unsafe fn step(&mut self, comp: &Compositor, cfg: &Cfg) -> Result { + let use_current = self.use_current_on_next_step; + self.use_current_on_next_step = false; + + let mut sf = if use_current { + self.sdec.cur_frame() + } else { + self.sdec.next()? + }; + if sf.is_null() { + sf = self.sdec.seek_to(0.0)?; + self.idx = 0; + } + if sf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + + let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0); + let mut wf = if use_current { + self.wdec.cur_frame() + } else { + let cur = self.wdec.cur_frame(); + if cur.is_null() { + // Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé. + self.wdec.seek_to(target_webcam_t)? + } else { + // Rattrape la webcam vers `target_webcam_t`, au pire une poignée de frames par + // tick (fps proches) — le garde-fou n'existe que contre un cas pathologique. + let mut wf = cur; + let mut guard = 0u32; + while self.wdec.cur_time_sec() < target_webcam_t { + match self.wdec.next()? { + f if f.is_null() => { + // Fin de la webcam avant l'écran : elle boucle SEULE — l'écran + // garde sa propre position, inchangée. + wf = self.wdec.seek_to(0.0)?; + break; + } + f => wf = f, + } + guard += 1; + if guard > 1000 { + break; + } + } + wf + } + }; + if wf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + + self.has_current_frame = true; + self.sync_time(comp); + comp.compose_frame(sf, wf, self.idx as f32, cfg)?; + self.idx = self.idx.wrapping_add(1); + Ok(true) + } + + /// Positionne `comp` sur le temps source RÉEL (pts) de la frame écran courante, pour que le + /// curseur ET les zoom/full-camera regions du clip actif restent exacts quelle + /// que soit la cadence réelle de l'enregistrement — BUG corrigé : tout dérivait auparavant + /// de `frame / 60.0` (un compteur de frames supposant 60fps pile), qui dérive + /// silencieusement de plus en plus au fil de la lecture dès que le fichier n'est pas + /// exactement à 60fps (30/59.94/etc. sont courants), au lieu de suivre le pts réel du + /// décodeur — exactement la cause du "zoom désynchronisé de la timeline" observé. + unsafe fn sync_time(&self, comp: &Compositor) { + let t = self.sdec.cur_time_sec() as f32; + comp.set_cursor_time(Some(t)); + comp.set_timeline_time(Some(t)); + } + + /// Recompose la frame courante (déjà décodée) — rafraîchit après un changement de param. + pub unsafe fn recompose(&self, comp: &Compositor, cfg: &Cfg) -> Result { + if !self.has_current_frame { + return Ok(false); + } + let sf = self.sdec.cur_frame(); + let wf = self.wdec.cur_frame(); + if sf.is_null() || wf.is_null() { + return Ok(false); + } + self.sync_time(comp); + let f = self.idx.saturating_sub(1); + comp.compose_frame(sf, wf, f as f32, cfg)?; + Ok(true) + } + + /// Seek à `target_sec` (secondes source du clip actif) : keyframe-seek + décodage-avant + /// (`Decoder::seek_to`, même mécanisme robuste que l'export) — remplace l'ancien modèle + /// "compte de frames" qui rewindait tout au frame 0 pour le moindre seek arrière et n'avait + /// aucun raccourci keyframe pour les seeks avant lointains (lent ET, combiné au bug de + /// `set_time`, incorrect au-delà de 6s sur un enregistrement réel). + pub unsafe fn present_frame(&mut self, comp: &Compositor, cfg: &Cfg, target_sec: f64) -> Result { + let sf = self.sdec.seek_to(target_sec)?; + let wf = self + .wdec + .seek_to(webcam_seek_time(target_sec, self.webcam_offset_sec))?; + if sf.is_null() || wf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + self.has_current_frame = true; + self.use_current_on_next_step = false; + self.sync_time(comp); + // "idx" ne sert plus qu'au fallback fixture (jamais lu si une scène est posée) — dérivé + // du temps réel pour rester cohérent si jamais consulté. + self.idx = (target_sec * self.sdec.fps()).round().max(0.0) as u32; + comp.compose_frame(sf, wf, self.idx as f32, cfg)?; + Ok(true) + } +} + +/// Paramètres inspector pilotés depuis l'UI (setParam). Le thread de rendu les applique : +/// booléens/taps → reconstruits dans le `Cfg` ; valeurs continues → `set_live_params`. +#[derive(Clone, Copy, PartialEq)] +struct InspectorParams { + bg_blur: bool, + bg_color: [f32; 4], + shadow_scale: f32, + radius_scale: f32, + mblur_taps: u32, + padding: f32, + webcam_size_scale: f32, + webcam_mirror: bool, + webcam_shape: u32, + cursor_show: bool, + cursor_size_scale: f32, + cursor_bounce_scale: f32, + /// 0..1 : force du lissage ressort-amortisseur de la position (0 = brut). Reconstruit la + /// piste (voir `raw_cursor.smoothed()` dans `render_thread`) plutôt qu'un simple scalaire de + /// dessin — d'où le suivi séparé de sa dernière valeur appliquée. + cursor_smoothing: f32, + /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). + cursor_motion_blur: f32, +} + +impl Default for InspectorParams { + fn default() -> Self { + Self { + bg_blur: false, + bg_color: [0.10, 0.11, 0.14, 1.0], + shadow_scale: 1.0, + radius_scale: 1.0, + mblur_taps: 8, + padding: 0.0, + webcam_size_scale: 1.0, + webcam_mirror: false, + webcam_shape: 3, + cursor_show: true, + cursor_size_scale: 1.0, + cursor_bounce_scale: 1.0, + cursor_smoothing: 0.0, + cursor_motion_blur: 0.0, + } + } +} + +#[derive(Clone)] +struct ActiveClipRequest { + screen_path: String, + webcam_path: String, + webcam_offset_sec: f64, + /// Identité dans le flux `Scene.clips` trié (les chemins ne suffisent pas pour un asset partagé). + clip_index: usize, + /// Playhead exprimé sur l'horloge source écran du nouveau clip. + source_time_sec: f64, +} + +fn same_source_path(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) +} + +fn scene_clip_matches( + clip: &crate::scene::SceneClip, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> bool { + same_source_path(&clip.screen_path, screen_path) + && same_source_path(&clip.webcam_path, webcam_path) + && (clip.webcam_offset_sec - webcam_offset_sec).abs() <= 1e-6 +} + +fn find_scene_clip_index( + scene: &Scene, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> Option { + scene.clips.iter() + .position(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) + .or_else(|| scene.clips.iter().position(|clip| { + same_source_path(&clip.screen_path, screen_path) + && same_source_path(&clip.webcam_path, webcam_path) + })) +} + +/// Paths and the asset-level webcam offset are identical for multiple cuts of one recording, +/// so path lookup alone always returns clip 0. Prefer the explicit timeline identity. +fn resolve_scene_clip_index( + scene: &Scene, + requested_clip_index: usize, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> Option { + if scene.clips.get(requested_clip_index) + .is_some_and(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) + { + Some(requested_clip_index) + } else { + find_scene_clip_index(scene, screen_path, webcam_path, webcam_offset_sec) + } +} + +fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene { + match scene.clips.get(clip_index) { + Some(clip) => scene.for_clip_window( + clip_index, + clip.source_start_sec, + clip.source_end_sec, + ), + None => scene.clone(), + } +} + +/// Dernière frame readback vers CPU, prête pour le napi `read_frame`. +/// +/// `(gen, w, h, vec)` où `vec.len() == w*h*4` octets RGBA8 tightly-packed (R, G, B, A +/// en mémoire — cf. `Compositor::readback_resized`). `gen` est une génération monotone +/// (≥ 1, `0` réservé à « le consommateur n'a encore rien vu ») incrémentée à CHAQUE +/// publication, càd uniquement quand une nouvelle frame a réellement été composée (le +/// thread de rendu ne republie pas une frame identique — cf. `stepped || first`). Elle +/// est l'IDENTITÉ de la frame : le consommateur (`read_frame`) ne repaie le clone + l'IPC +/// que lorsqu'elle change. `None` = "aucune frame composée pour l'instant" (toutes les +/// lectures avant la 1re frame composée retournent `None` côté napi, jamais un buffer vide). +type LatestFrame = (u64, u32, u32, Vec); + +/// État partagé thread appelant → thread de rendu (commandes sans blocage). +struct Shared { + /// Résolution cible du preview (largeur, hauteur) en pixels devices — ce que la + /// zone canvas Electron affiche. Plus de HWND/HWND-parent : la preview est une + /// image bitmap posée sur un ``, la position CSS est gérée entièrement + /// côté web. Lecture/écriture exclusive via `Mutex`. + preview_size: Mutex<(u32, u32)>, + inspector: Mutex, + /// Temps source (secondes) demandé par l'app pour le clip actif (presentTime/seek), prioritaire + /// sur la lecture libre. En SECONDES (pas un index de frame) : `Player::present_frame` fait un + /// vrai seek keyframe (`Decoder::seek_to`, comme l'export) au lieu de compter des frames — + /// BUG corrigé : l'ancien `set_time` convertissait en index de frame à 60fps fixe PUIS le + /// wrappait modulo `FIXTURE_FRAMES` (360 = 6s) — un reliquat du bench fixture qui faisait + /// boucler silencieusement tout seek au-delà de 6s sur un enregistrement réel, exactement + /// la cause du "zoom timeline désynchronisé" observé. + requested_frame: Mutex>, + /// Changement de sources consommé par le thread de rendu, seul propriétaire des décodeurs. + active_clip_request: Mutex>, + /// scène de l'app (contrat) ; appliquée au compositeur quand `scene_dirty`. + scene: Mutex>, + scene_dirty: AtomicBool, + playing: AtomicBool, + stop: AtomicBool, + /// Dernière frame RGBA8 readback (taille + pixels R,G,B,A tightly-packed). Écrit + /// par le thread de rendu après chaque `compose_frame` réussi, lu par le napi + /// `read_frame` depuis le thread Node principal. `Mutex>` — + /// Option pour distinguer "pas de frame encore composée" (avant le 1er compose, + /// `read_frame` retourne `Ok(None)`) d'un buffer vide (qui n'arrive jamais). + latest_frame: Mutex>, + /// Erreur fatale du thread de rendu (device D3D11 introuvable, décodeur qui refuse + /// le fichier…). Le thread meurt sur la première erreur ; sans ce champ, elle + /// finissait dans un `eprintln!` que personne ne lit et l'utilisateur n'avait + /// qu'un canvas noir — exactement le « on dirait que l'app rame » de la PR #162. + /// `read_frame` la relaie en `Err` au prochain tour de la boucle de pull (~33 ms), + /// donc elle remonte jusqu'à l'UI par le chemin d'erreur qui existe déjà. + fatal: Mutex>, +} + +/// Handle d'une vue live. `Drop` arrête le rendu. +/// +/// Plus de fenêtre/OS : le handle ne porte plus de `HWND`. Toute la machinerie Win32 +/// (CreateWindowEx / SetWindowPos / DestroyWindow / register_overlay_class) a été +/// retirée — la preview est désormais purement hors-fenêtre, transportable via +/// mémoire. +pub struct LiveView { + shared: Arc, + thread: Option>, +} + +// `LiveView` ne référence plus aucune ressource Win32 non-`Send`. `Shared` non plus +// (`Mutex`, `AtomicBool`, `Option>`). Le `JoinHandle` est `Send`/`!Sync` +// mais on n'en extrait rien côté napi. Tout ce qui vit dans le thread de rendu +// (compositor, décodeurs, staging, GPU) y reste confiné. +unsafe impl Send for LiveView {} + +impl LiveView { + /// Crée une vue offscreen : pas de HWND/UI côté thread appelant. Démarre juste + /// le thread de rendu qui va composer chaque frame et publier le readback dans + /// `Shared::latest_frame` pour le napi `read_frame`. + /// + /// `w`/`h` sont la **résolution cible du preview** (taille du `` Electron + /// affichant la preview, en pixels device) — anciennement c'était le rect de la + /// fenêtre overlay ; maintenant c'est juste la taille du bitmap RGBA produit. + /// Ajustable à chaud via `set_rect(w, h)`. + pub fn create( + w: u32, + h: u32, + screen: &str, + webcam: &str, + cursor_json: &str, + ) -> Result { + let shared = Arc::new(Shared { + preview_size: Mutex::new((w.max(1), h.max(1))), + inspector: Mutex::new(InspectorParams::default()), + requested_frame: Mutex::new(None), + active_clip_request: Mutex::new(None), + scene: Mutex::new(None), + scene_dirty: AtomicBool::new(false), + playing: AtomicBool::new(true), + stop: AtomicBool::new(false), + latest_frame: Mutex::new(None), + fatal: Mutex::new(None), + }); + let sh = shared.clone(); + let (s, wc, cj) = (screen.to_string(), webcam.to_string(), cursor_json.to_string()); + let thread = std::thread::spawn(move || { + if let Err(e) = unsafe { render_thread(sh.clone(), &s, &wc, &cj) } { + eprintln!("[live] render thread error: {e:#}"); + if let Ok(mut fatal) = sh.fatal.lock() { + *fatal = Some(format!("{e:#}")); + } + } + }); + + Ok(LiveView { shared, thread: Some(thread) }) + } + + /// Met à jour la résolution cible du preview. Force le redimensionnement des + /// ressources GPU de readback (`Compositor::ensure_resize_target` / + /// `live_readback_staging`) au prochain tour du thread de rendu. + /// + /// Signature : `(w, h)` — l'ancienne `(x, y, w, h)` de la fenêtre overlay n'a + /// plus de sens (la position est gérée par CSS côté Electron). `set_rect` côté + /// napi doit s'aligner sur ce 2-param (la largeur/hauteur seule). + pub fn set_rect(&self, w: u32, h: u32) { + if let Ok(mut s) = self.shared.preview_size.lock() { + *s = (w.max(1), h.max(1)); + } + } + + /// Message de l'erreur qui a tué le thread de rendu, `None` tant qu'il tourne. + /// Définitif : le thread ne redémarre pas. + pub fn fatal_error(&self) -> Option { + self.shared.fatal.lock().ok().and_then(|guard| guard.clone()) + } + + /// Récupère la dernière frame readback (gen + taille + RGBA8 tightly-packed). + /// `None` si rien n'a encore été composé (jamais écrit). **Coût : O(w·h)** + /// (copie du `Vec` — nécessaire pour traverser la frontière thread + le + /// FFI vers le Buffer napi). Le `Vec` retourné a `len() == w*h*4`. + /// Préférer `latest_frame_since` sur le chemin chaud : il évite ce clone quand + /// le consommateur possède déjà la génération courante. + pub fn latest_frame(&self) -> Option<(u64, u32, u32, Vec)> { + self.shared + .latest_frame + .lock() + .ok() + .and_then(|guard| guard.as_ref().cloned()) + } + + /// Récupère la dernière frame UNIQUEMENT si sa génération est postérieure à + /// `since_gen`. `None` couvre les DEUX cas où le consommateur n'a rien à peindre : + /// - rien n'a encore été composé (aucune frame publiée), ou + /// - il possède déjà la génération courante (`gen <= since_gen`). + /// Dans ce second cas — l'essentiel du temps d'édition, preview en pause sur une + /// frame figée — on n'exécute PAS le clone `O(w·h)` : c'est tout l'intérêt du + /// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au + /// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`. + pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec)> { + let guard = self.shared.latest_frame.lock().ok()?; + match guard.as_ref() { + Some((gen, w, h, px)) if *gen > since_gen => Some((*gen, *w, *h, px.clone())), + _ => None, + } + } + + /// Switch inspector (booléen). + pub fn set_param_bool(&self, key: &str, value: bool) { + if let Ok(mut p) = self.shared.inspector.lock() { + match key { + "backgroundBlur" => p.bg_blur = value, + "webcamMirror" => p.webcam_mirror = value, + "cursorShow" => p.cursor_show = value, + _ => {} + } + } + } + + /// Slider inspector (numérique). Conventions : `shadow`/`roundness`/`webcamSize`/ + /// `cursorSize`/`cursorClickBounce` = échelle (1 = défaut) ; `padding` = 0..1 ; + /// `motionBlur` = 0..1 mappé sur 1..16 taps. + pub fn set_param_num(&self, key: &str, value: f64) { + if let Ok(mut p) = self.shared.inspector.lock() { + let v = value as f32; + match key { + "shadow" => p.shadow_scale = v.max(0.0), + "roundness" => p.radius_scale = v.max(0.0), + "motionBlur" => p.mblur_taps = (1.0 + value.clamp(0.0, 1.0) * 15.0).round() as u32, + "padding" => p.padding = v.clamp(0.0, 1.0), + "webcamSize" => p.webcam_size_scale = v.max(0.05), + "cursorSize" => p.cursor_size_scale = v.max(0.0), + "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), + "cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0), + "cursorMotionBlur" => p.cursor_motion_blur = v.clamp(0.0, 1.0), + _ => {} + } + } + } + + /// Sélection de chaîne : couleur de fond "#rrggbb" ou forme webcam. + pub fn set_param_str(&self, key: &str, value: &str) { + if let Ok(mut p) = self.shared.inspector.lock() { + match key { + "backgroundColor" => { + if let Some(c) = parse_hex_color(value) { + p.bg_color = c; + } + } + "webcamShape" => { + p.webcam_shape = crate::compositor::webcam_shape_code(value); + } + _ => {} + } + } + } + + pub fn set_playing(&self, playing: bool) { + self.shared.playing.store(playing, Ordering::Relaxed); + } + + /// Ce que la vue est en train de faire : lecture libre (`true`) ou pause (`false`). + /// Lu par l'export, qui met les previews en pause le temps d'encoder et doit pouvoir + /// leur rendre CET état plutôt que d'en supposer un (voir `PausedPreviews`). + pub fn playing(&self) -> bool { + self.shared.playing.load(Ordering::Relaxed) + } + + /// Installe la scène de l'app (JSON `SceneDescription`). Parsé ici (hors thread de rendu) ; + /// appliqué au compositeur au prochain tour via le flag `scene_dirty`. JSON invalide → ignoré. + pub fn set_scene(&self, json: &str) { + match Scene::from_json(json) { + Ok(scene) => { + if let Ok(mut s) = self.shared.scene.lock() { + *s = Some(scene); + self.shared.scene_dirty.store(true, Ordering::Relaxed); + } + } + Err(e) => eprintln!("[live] set_scene: JSON invalide: {e:#}"), + } + } + + /// Positionne la vue sur le temps source `seconds` du clip actif — plus de conversion en + /// index de frame ni de wrap fixture ici (voir `requested_frame`). + pub fn set_time(&self, seconds: f64) { + if let Ok(mut r) = self.shared.requested_frame.lock() { + *r = Some(seconds.max(0.0)); + } + } + + /// Programme le remplacement de la paire screen/webcam sur le thread de rendu. L'identité + /// du clip et son playhead source voyagent avec les chemins pour rendre le switch atomique. + pub fn set_active_clip( + &self, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + clip_index: usize, + source_time_sec: f64, + ) { + if let Ok(mut request) = self.shared.active_clip_request.lock() { + *request = Some(ActiveClipRequest { + screen_path: screen_path.to_string(), + webcam_path: webcam_path.to_string(), + webcam_offset_sec, + clip_index, + source_time_sec: source_time_sec.max(0.0), + }); + } + } +} + +impl Drop for LiveView { + fn drop(&mut self) { + // 1. Stoper le thread (il observe `stop` en tête de boucle et sort proprement). + self.shared.stop.store(true, Ordering::SeqCst); + // 2. Join. À la sortie, le thread a relâché toutes ses ressources GPU (compositor, + // décodeurs, resize_target, staging) ; le `Shared` reste vivant tant qu'on n'a + // pas droppé notre `Arc` final. + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + // Plus rien à détruire côté Win32 — pas de HWND. + } +} + +/// A preview's transport — free-run or paused — readable AND writable without touching the +/// GPU. It is the only slice of `LiveView` an export needs: it pauses the previews to free +/// the 3D engine, then hands the transport back. A trait rather than `LiveView` itself so +/// that `PausedPreviews` can be tested with no D3D device (see this file's tests). +pub trait PreviewTransport { + fn playing(&self) -> bool; + fn set_playing(&self, playing: bool); +} + +impl PreviewTransport for LiveView { + fn playing(&self) -> bool { + // Fully-qualified on purpose: inside a trait impl, `self.playing()` resolving to the + // inherent method is a silent coincidence of method resolution, not a guarantee. + LiveView::playing(self) + } + + fn set_playing(&self, playing: bool) { + LiveView::set_playing(self, playing); + } +} + +/// What every preview was doing when an export paused them — enough to give EACH ONE back the +/// state it was found in, instead of resuming them all. +/// +/// That distinction is the fix for a visible bug, not a nicety. While editing, the preview is +/// PAUSED; the renderer only pushes `setPlaying` when the transport actually changes, so +/// nothing ever came along to re-pause a preview an export had resumed on its own authority. +/// It went back to free-running for its own account: its playhead left the moment the app +/// believed was on screen (the zoom then sampled at the wrong source time), and at the first +/// clip boundary it crossed, it swapped its scene over to ANOTHER clip +/// (`scene_for_clip`) — after which the zoom regions of the clip actually being displayed were +/// filtered out of the scene, and no amount of seeking brought them back (the app only pushes +/// `set_active_clip` when ITS active clip changes, and its own had not changed). Only a window +/// reload, which recreates the view, repaired it. +/// +/// `K` is the caller's identity for a view (the napi registry id). Previews created AFTER the +/// pause are absent from the snapshot and are left strictly alone: their transport belongs to +/// whoever created them. +pub struct PausedPreviews { + was_playing: Vec<(K, bool)>, +} + +impl Default for PausedPreviews { + fn default() -> Self { + Self { was_playing: Vec::new() } + } +} + +impl PausedPreviews { + /// Pauses every preview and reports what each one was doing. + pub fn pause<'a, V: PreviewTransport + 'a>(views: impl IntoIterator) -> Self { + let mut was_playing = Vec::new(); + for (key, view) in views { + was_playing.push((key, view.playing())); + view.set_playing(false); + } + Self { was_playing } + } + + /// Gives every preview the snapshot knows about the state it was found in. + pub fn restore<'a, V: PreviewTransport + 'a>(&self, views: impl IntoIterator) { + for (key, view) in views { + if let Some((_, was_playing)) = self.was_playing.iter().find(|(k, _)| *k == key) { + view.set_playing(*was_playing); + } + } + } +} + +/// Un préchargement en cours : quel `next_index` (dans `Scene.clips`) il prépare, et le canal +/// par lequel le thread de fond livre le résultat une fois prêt. +type PendingPrefetch = (usize, std::sync::mpsc::Receiver>); + +/// Combien de secondes avant la fin du clip actif on lance le préchargement du suivant en +/// tâche de fond. Assez large pour couvrir un `Decoder::open` typique (ouverture fichier + +/// `avformat_find_stream_info` + init D3D11VA), assez court pour ne pas garder deux paires de +/// décodeurs ouvertes plus longtemps que nécessaire. +const PREFETCH_LEAD_SEC: f64 = 0.75; + +/// Démarre le préchargement du clip suivant sur un thread dédié dès qu'on entre dans la +/// fenêtre `PREFETCH_LEAD_SEC` avant la fin du clip actif — pour que la bascule à la +/// frontière (`advance_to_next_scene_clip`) trouve les décodeurs déjà ouverts et positionnés +/// au lieu de payer l'E/S + le parsing FFmpeg sur le thread de rendu pile au moment de la +/// transition (la pause perceptible observée en usage réel). No-op si un préchargement est +/// déjà en cours, ou pour une scène à 1 clip (voir `advance_to_next_scene_clip`). +unsafe fn maybe_start_prefetch( + scene: &Scene, + active_clip_index: usize, + screen_time_sec: f64, + gpu: &Gpu, + prefetch: &mut Option, +) { + if scene.clips.len() <= 1 || prefetch.is_some() { + return; + } + let Some(clip) = scene.clips.get(active_clip_index) else { + return; + }; + let remaining = clip.source_end_sec - screen_time_sec; + if !(0.0..PREFETCH_LEAD_SEC).contains(&remaining) { + return; + } + let next_index = if active_clip_index + 1 < scene.clips.len() { + active_clip_index + 1 + } else { + 0 + }; + let next_clip = scene.clips[next_index].clone(); + // Copie légère (COM refcount, pas de nouveau device) — même motif que `Player::open`. + let gpu_clone = Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + feature_level: gpu.feature_level, + backend: gpu.backend, + }; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = unsafe { + open_and_seek_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + &gpu_clone, + ) + }; + // L'appelant a pu abandonner ce préchargement entre-temps (changement de clip + // explicite, scène remplacée) — un receiver droppé fait juste échouer `send` + // silencieusement ; les décodeurs déjà ouverts sont libérés normalement (`Drop`). + let _ = tx.send(result); + }); + *prefetch = Some((next_index, rx)); +} + +/// Bascule le `Player` + le compositeur sur le clip suivant de `scene` (reboucle sur le +/// premier après le dernier). No-op pour une scène à 1 clip (le bouclage léger existant de +/// `Player::step` suffit et coûte moins cher qu'un `set_active_clip` — reopen des décodeurs). +/// +/// Partagée entre le déclenchement PROACTIF (seuil `source_end_sec` franchi) et le filet de +/// sécurité RÉACTIF de `render_thread` (le temps du décodeur a reculé — `Player::step` a +/// bouclé sur l'EOF RÉEL du fichier avant que le seuil ne soit jamais atteint : cas d'un clip +/// NON trimmé dont la dernière frame réelle a un PTS strictement inférieur au +/// `source_end_sec` déclaré, qui égale alors la durée totale du fichier — le seuil `>=` ne se +/// déclenche jamais dans ce cas, d'où le "ça boucle sur le 1er clip" observé malgré le +/// déclenchement proactif). +/// +/// Si `maybe_start_prefetch` a eu le temps de préparer ce même `next_index` à l'avance, la +/// bascule est instantanée (juste un échange de champs, `Player::apply_prefetched`) ; sinon +/// on retombe sur l'ouverture synchrone habituelle (`Player::set_active_clip`) — correct dans +/// tous les cas, juste plus lent quand le préchargement n'a pas eu le temps de finir. +#[allow(clippy::too_many_arguments)] +unsafe fn advance_to_next_scene_clip( + player: &mut Player, + comp: &Compositor, + scene: &Scene, + prefetch: &mut Option, + active_screen_path: &mut String, + active_webcam_path: &mut String, + active_webcam_offset_sec: &mut f64, + active_clip_index: &mut usize, + raw_cursor: &mut Option, + last_smoothing: &mut f32, +) { + if scene.clips.len() <= 1 { + return; + } + let next_index = if *active_clip_index + 1 < scene.clips.len() { + *active_clip_index + 1 + } else { + 0 + }; + let next_clip = &scene.clips[next_index]; + + // N'importe quel préchargement en cours ne concerne plus que CETTE frontière (on vient + // de la franchir, bien ou mal ciblée) — on le consomme s'il correspond, on l'abandonne + // sinon, dans tous les cas il ne doit pas survivre à cet appel. + let ready = prefetch.take().and_then(|(idx, rx)| { + if idx == next_index { rx.try_recv().ok() } else { None } + }); + + // Le curseur préchargé (voir `PrefetchedClip::cursor_track`) doit être extrait AVANT de + // passer `prefetched` (par valeur) à `apply_prefetched`, qui ne s'occupe que des + // décodeurs — sinon ce champ serait silencieusement perdu avec le reste de la struct. + let prefetched_cursor: Option> = match &ready { + Some(Ok(p)) => Some(p.cursor_track.clone()), + _ => None, + }; + + let applied = match ready { + Some(Ok(prefetched)) => { + player.apply_prefetched(prefetched); + Ok(()) + } + Some(Err(e)) => { + eprintln!("[live] préchargement du clip suivant: {e:#} — repli sur ouverture synchrone"); + player.set_active_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + ) + } + None => player.set_active_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + ), + }; + + match applied { + Ok(()) => { + *active_screen_path = next_clip.screen_path.clone(); + *active_webcam_path = next_clip.webcam_path.clone(); + *active_webcam_offset_sec = next_clip.webcam_offset_sec; + *active_clip_index = next_index; + comp.set_scene(Some(scene_for_clip(scene, *active_clip_index))); + // Réutilise le curseur préchargé s'il est disponible (voir plus haut) — sinon + // (préchargement pas encore prêt / raté) on retombe sur la lecture synchrone + // habituelle, comme avant cette optimisation. + *raw_cursor = match prefetched_cursor { + Some(track) => track, + None => { + let cursor_path = format!("{}.cursor.json", active_screen_path); + CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok() + } + }; + match raw_cursor { + Some(track) => comp.set_cursor(track.smoothed(0.0)), + None => comp.clear_cursor(), + } + *last_smoothing = -1.0; + } + Err(e) => eprintln!("[live] auto-advance clip: {e:#}"), + } +} + +/// Taille à laquelle la preview doit rastériser : la **géométrie de sortie** (donc +/// le ratio réel de l'export — la preview doit montrer ce qui sera rendu), ramenée +/// à ce que le canvas affiche réellement. +/// +/// Deux bornes, pour deux raisons distinctes : +/// - jamais plus grand que le **panneau** : les pixels en trop seraient réduits +/// dans la foulée par `readback_resized`, c'est du coût pur (sur un projet 4K +/// ce serait 8 Mpx rastérisés pour un canvas qui en affiche moins d'un) ; +/// - jamais plus grand que la **sortie** : au-delà, la preview serait plus +/// détaillée que l'export, donc mensongère. +/// +/// Sans scène, on ne connaît pas encore le ratio : on prend la taille du panneau +/// telle quelle (aucune composition n'a lieu tant que la scène n'est pas posée). +fn preview_render_size(scene: Option<&Scene>, pw: u32, ph: u32) -> (u32, u32) { + let (pw, ph) = (pw.max(2), ph.max(2)); + let Some(scene) = scene else { + return (pw, ph); + }; + let (ow, oh) = (scene.output.width.max(1) as f64, scene.output.height.max(1) as f64); + // "contain" : le plus grand cadre au ratio de sortie qui tienne dans le panneau. + let scale = (pw as f64 / ow).min(ph as f64 / oh).min(1.0); + // Arrondi via la MÊME règle que `new_sized` : la boucle de rendu compare cette + // taille à `comp.render_size()` (qui renvoie la valeur arrondie) pour décider de + // reconstruire. Sans ce passage par `normalize_render_size`, une cible impaire + // ne serait jamais égalée → reconstruction du compositeur à chaque frame. + Compositor::normalize_render_size((ow * scale).round() as u32, (oh * scale).round() as u32) +} + +/// Boucle de rendu (thread dédié) : décode → compose → resize → readback → publie +/// dans `Shared::latest_frame`. +unsafe fn render_thread( + shared: Arc, + screen: &str, + webcam: &str, + cursor_json: &str, +) -> Result<()> { + // Chemin de PRODUCTION : matériel si possible, backend CPU sinon (voir `create_auto`). + let gpu = Gpu::create_auto(false)?; + let mut comp = Compositor::new(&gpu)?; + // Vue live = le VRAI enregistrement, pas la fenêtre fixture (100s@6s, taillée pour l'ancien + // fixture POC). On charge toute la piste depuis t=0 ; 24h couvre large toute recording réelle. + // Gardée à part (raw_cursor) pour pouvoir régénérer une variante lissée sans relire le + // fichier à chaque changement du slider "smoothing" (voir la boucle plus bas). + let mut raw_cursor = CursorTrack::load(cursor_json, 0.0, 24.0 * 3600.0).ok(); + if let Some(track) = &raw_cursor { + comp.set_cursor(track.smoothed(0.0)); + } + let mut player = Player::open(screen, webcam, &gpu)?; + let mut active_screen_path = screen.to_string(); + let mut active_webcam_path = webcam.to_string(); + let mut active_webcam_offset_sec = 0.0f64; + let mut active_clip_index = 0usize; + // Copie de la Scene complète (tous les clips), tenue à jour à chaque push de l'app — + // permet à la boucle de lecture libre de connaître la fenêtre source + // [source_start_sec, source_end_sec) du clip actif et d'enchaîner elle-même sur le + // clip suivant (voir plus bas), sans dépendre d'un aller-retour JS par frontière de + // clip : la timeline est un niveau d'abstraction AU-DESSUS des clips, elle se lit + // dans son entièreté et l'utilisateur ne doit jamais remarquer la frontière. + let mut full_scene: Option = None; + // Préchargement du clip suivant en cours (voir `maybe_start_prefetch`) — `None` la + // plupart du temps, `Some` seulement dans la fenêtre `PREFETCH_LEAD_SEC` avant une + // frontière de clip. Invalidé (mis à `None`) dès que le contexte qui l'a déclenché + // devient obsolète (nouvelle scène, changement de clip explicite) pour ne jamais risquer + // d'appliquer les décodeurs d'un préchargement qui ne correspond plus à la situation. + let mut prefetch: Option = None; + + // config de base = C8 (tous effets) ; le fond flouté est piloté par le param live. + let mut cfg = config::all().pop().expect("au moins une config"); + // Migration D3D : le layout et le zoom viennent de l'app (contrat de scène), pas du planning + // fixture. On désactive l'animation de layout A↔B et le zoom codés en dur de `timeline()` — + // sinon la preview d'un vrai enregistrement joue la « scène fixture » (le bug d'animation vu). + // Layout statique (PiP) par défaut ; les zoom regions / presets seront rebranchés via la scène. + cfg.zoom = false; + cfg.layout_anim = false; + + let mut last = Instant::now(); + let mut acc = 0.0f64; + let mut first = true; + let mut last_preview_size: (u32, u32) = (0, 0); + let mut last_ip: Option = None; + let mut last_smoothing: f32 = -1.0; // force la 1re application (0.0 est une valeur valide) + // La vue live est TOUJOURS pilotée par la scène de l'app. Tant qu'aucune scène n'a été + // appliquée, on refuse de jouer le layout fixture (POC) : un fallback fixture ne ferait que + // MASQUER un scene-push cassé. On attend la scène avant de produire le 1er frame. + let mut scene_applied = false; + + while !shared.stop.load(Ordering::SeqCst) { + // params inspector : booléens/taps → cfg ; valeurs continues → live_params + let ip = *shared.inspector.lock().unwrap(); + let mut clip_changed = false; + let clip_request = shared.active_clip_request.lock().unwrap().take(); + if let Some(request) = clip_request { + // Un changement de clip explicite depuis l'app rend obsolète tout préchargement + // en cours (il visait la suite du clip qu'on est en train de quitter maintenant + // autrement) — sans ça, `advance_to_next_scene_clip` pourrait plus tard appliquer + // des décodeurs qui ne correspondent plus au contexte réel. + prefetch = None; + match player.set_active_clip( + &request.screen_path, + &request.webcam_path, + request.webcam_offset_sec, + request.source_time_sec, + ) { + Ok(()) => { + active_screen_path = request.screen_path; + active_webcam_path = request.webcam_path; + active_webcam_offset_sec = request.webcam_offset_sec; + let scene = shared.scene.lock().unwrap().clone(); + full_scene = scene.clone(); + if let Some(base_scene) = scene { + if let Some(index) = resolve_scene_clip_index( + &base_scene, + request.clip_index, + &active_screen_path, + &active_webcam_path, + active_webcam_offset_sec, + ) { + active_clip_index = index; + } else { + eprintln!( + "[live] set_active_clip: sources absentes de la scène (screen=\"{}\", webcam=\"{}\")", + active_screen_path, active_webcam_path + ); + } + comp.set_scene(Some(scene_for_clip(&base_scene, active_clip_index))); + scene_applied = true; + } + let cursor_path = format!("{}.cursor.json", active_screen_path); + raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(); + match &raw_cursor { + Some(track) => { + eprintln!( + "[live] cursor: path={} loaded=ok samples={}", + cursor_path, + track.sample_count(), + ); + comp.set_cursor(track.smoothed(0.0)); + } + None => { + eprintln!( + "[live] cursor: path={} loaded=FAIL — clear_cursor()", + cursor_path, + ); + comp.clear_cursor(); + } + } + last_smoothing = -1.0; + clip_changed = true; + } + Err(e) => eprintln!("[live] set_active_clip: {e:#}"), + } + } + cfg.bg_blur = ip.bg_blur; + cfg.mblur_n = ip.mblur_taps; + cfg.cursor = ip.cursor_show; + // TS falls `webcamPath` back to the screen asset's own path when a clip has no real + // camera (so the decoder pipeline always has something valid to open) — if we drew the + // PiP box in that case it would just duplicate the screen video into its own corner. + // `same_source_path` already exists for exactly this comparison (scene/clip matching). + let has_real_webcam = !same_source_path(&active_webcam_path, &active_screen_path); + comp.set_live_params(LiveParams { + bg_color: ip.bg_color, + shadow_scale: ip.shadow_scale, + radius_scale: ip.radius_scale, + padding: ip.padding, + webcam_size_scale: ip.webcam_size_scale, + webcam_mirror: ip.webcam_mirror, + webcam_shape: ip.webcam_shape, + cursor_size_scale: ip.cursor_size_scale, + cursor_bounce_scale: ip.cursor_bounce_scale, + cursor_motion_blur: ip.cursor_motion_blur, + has_webcam: has_real_webcam, + }); + // Lissage ressort-amortisseur : re-génère la piste (240 Hz) uniquement quand la valeur + // change (pas à chaque frame — le resample+ressort parcourt tout l'enregistrement). + if let Some(raw) = &raw_cursor { + if ip.cursor_smoothing != last_smoothing { + comp.set_cursor(raw.smoothed(ip.cursor_smoothing)); + last_smoothing = ip.cursor_smoothing; + } + } + // un changement de param doit se voir même en pause (édition live des sliders) : + // on recompose la frame courante dans la branche pause ci-dessous. + let ip_changed = last_ip != Some(ip); + last_ip = Some(ip); + + // scène de l'app : appliquée au compositeur quand elle change (dirty). + let scene_changed = shared.scene_dirty.swap(false, Ordering::Relaxed); + if scene_changed { + // La nouvelle scène peut avoir réordonné/modifié les clips — tout index visé par + // un préchargement en cours n'est plus fiable. + prefetch = None; + let scene = shared.scene.lock().unwrap().clone(); + full_scene = scene.clone(); + let scene = scene.map(|base_scene| { + scene_applied = true; + if let Some(index) = resolve_scene_clip_index( + &base_scene, + active_clip_index, + &active_screen_path, + &active_webcam_path, + active_webcam_offset_sec, + ) { + active_clip_index = index; + } + scene_for_clip(&base_scene, active_clip_index) + }); + comp.set_scene(scene); + } + + // résolution cible du preview (le canvas Electron) → force le recadrage des + // ressources GPU si elle change. BUG évité : sans ce suivi, redimensionner le + // panneau preview PENDANT une pause ne redéclenchait ni recompose ni readback + // (aucune des autres conditions de la branche pause ne couvrait "juste la + // résolution a changé") — le canvas restait figé à l'ancienne taille jusqu'à la + // reprise de lecture ou un autre changement de param/scène. + let (pw, ph) = *shared.preview_size.lock().unwrap(); + let resized = (pw, ph) != last_preview_size; + last_preview_size = (pw, ph); + + // Le compositeur rastérise à la géométrie de SORTIE (ramenée à la taille du + // canvas) et non plus dans un canvas 16:9 figé. Quand cette géométrie change + // — l'utilisateur change de ratio, ou redimensionne le panneau — on + // reconstruit le compositeur. Voir `Compositor::new_sized` pour le choix + // "reconstruire" plutôt que "redimensionner à chaud". + let want = preview_render_size(full_scene.as_ref(), pw, ph); + if want != comp.render_size() { + comp = Compositor::new_sized(&gpu, want.0, want.1)?; + // Le compositeur neuf est vierge : on repasse par les mécanismes + // d'invalidation existants plutôt que de recopier l'état à la main — + // une seule façon d'appliquer la scène, les params et le curseur. + shared.scene_dirty.store(true, Ordering::Relaxed); + last_ip = None; + last_smoothing = -1.0; + first = true; + continue; + } + + // Pas encore de scène → on ne compose RIEN (pas de fixture masquante). On attend + // la scène. Un scene-push cassé reste ainsi visible (preview silencieuse — le + // canvas reste sur sa frame précédente côté JS, ce qui est mieux qu'un fallback + // masquant). + if !scene_applied { + std::thread::sleep(Duration::from_millis(8)); + continue; + } + + // avance : seek app-piloté (presentTime) prioritaire, sinon lecture libre (60 fps) + let requested = shared.requested_frame.lock().unwrap().take(); + let now = Instant::now(); + let dt = (now - last).as_secs_f64().min(0.1); + last = now; + let mut stepped = false; + if let Some(target) = requested { + if player.present_frame(&comp, &cfg, target)? { + stepped = true; + } + acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek + } else if shared.playing.load(Ordering::Relaxed) { + // BUG corrigé : la lecture libre décodait toujours exactement 1 frame par tick de + // 1/60s réel, quelle que soit la speed region active au temps source courant — ni + // l'écran ni la webcam n'accéléraient/ralentissaient jamais en preview live (seul + // l'export, via `speed_segments_for_window`/`advance_decoder_to` dans pipeline.rs, + // retimait correctement). Mod 3 corrige déjà le fps-mismatch webcam/écran (la webcam + // suit le temps source RÉEL de l'écran, pas un pas 1:1) — reprend ici la même idée : + // l'accumulateur de temps réel est mis à l'échelle par le multiplicateur de vitesse + // actif, donc `step()` (qui resynchronise la webcam sur le temps écran courant, + // cf. plus haut) décode plus/moins de frames par seconde réelle selon la région. + let speed = full_scene + .as_ref() + .map(|scene| speed_at(&scene.speed_regions, active_clip_index, player.screen_time_sec())) + .unwrap_or(1.0); + acc += dt * speed; + let step = 1.0 / 60.0; + let mut n = 0; + // Cap proportionnel à la vitesse (borné) : à vitesse élevée, plus de frames doivent + // être décodées par tick réel pour ne pas prendre du retard sur l'accumulateur. + let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64); + while acc >= step && n < max_steps { + // Timeline = niveau d'abstraction AU-DESSUS des clips : dès que le décodeur + // écran atteint la fin de fenêtre du clip actif, on enchaîne nous-mêmes sur + // le clip suivant (ou on reboucle sur le premier après le dernier) — sans + // dépendre d'un `active_clip_request` poussé par le JS en réaction au + // franchissement. Ce round-trip arrivait toujours trop tard : le décodeur + // avait déjà dépassé la fin de la fenêtre, voire atteint l'EOF brut du + // fichier et rebouclé sur lui-même — d'où le "retour au 1er clip" observé. + if let Some(scene) = &full_scene { + // Approche de la frontière : lance (ou laisse tourner) le préchargement + // du clip suivant en tâche de fond, pour que la bascule ci-dessous soit + // instantanée plutôt que de payer un `Decoder::open` synchrone pile au + // moment de la transition — la pause perceptible observée en usage réel. + maybe_start_prefetch( + scene, + active_clip_index, + player.screen_time_sec(), + &gpu, + &mut prefetch, + ); + if let Some(clip) = scene.clips.get(active_clip_index) { + if player.screen_time_sec() >= clip.source_end_sec { + advance_to_next_scene_clip( + &mut player, + &comp, + scene, + &mut prefetch, + &mut active_screen_path, + &mut active_webcam_path, + &mut active_webcam_offset_sec, + &mut active_clip_index, + &mut raw_cursor, + &mut last_smoothing, + ); + } + } + } + let screen_time_before_step = full_scene.as_ref().map(|_| player.screen_time_sec()); + if player.step(&comp, &cfg)? { + stepped = true; + } + // Filet de sécurité : un clip NON trimmé (source_end_sec == durée totale du + // fichier) peut ne jamais franchir le seuil ci-dessus si la dernière frame + // réelle a un PTS strictement inférieur à `source_end_sec` déclaré — `step()` + // finit alors par boucler tout seul sur l'EOF réel (temps qui recule + // brutalement). On détecte ce recul et on corrige immédiatement en enchaînant + // sur le clip suivant, plutôt que de rester bloqué sur le 1er clip. + if let (Some(scene), Some(t_before)) = (&full_scene, screen_time_before_step) { + if player.screen_time_sec() < t_before { + advance_to_next_scene_clip( + &mut player, + &comp, + scene, + &mut prefetch, + &mut active_screen_path, + &mut active_webcam_path, + &mut active_webcam_offset_sec, + &mut active_clip_index, + &mut raw_cursor, + &mut last_smoothing, + ); + } + } + acc -= step; + n += 1; + } + if acc > step { + acc = 0.0; + } + } else if first || ip_changed || scene_changed || clip_changed || resized { + // pause : recompose la frame courante (param / scène / clip / résolution changés). + let _ = player.recompose(&comp, &cfg); + stepped = true; + } + + if stepped || first { + if pw > 0 && ph > 0 { + // Step complet : `compose_frame` (déjà appelé par `step`/`present_frame`/ + // `recompose`) a rastérisé le RT à la géométrie de sortie ramenée au panneau. + // On lit ce RT DIRECTEMENT à sa résolution de rendu (`readback_direct` : copy + // rt → staging → Map/Unmap), sans le resize `blit_resized` qui, depuis la + // refonte ratio, n'était plus qu'une copie identité + une alloc NV12 inutile. + match comp.readback_direct() { + Ok((rw, rh, rgba)) => { + // Publie dans `latest_frame` : on remplace le buffer précédent + // (le canvas ne montre que la dernière frame, peu importe combien + // le renderer en a raté entre deux lectures napi). On incrémente + // la génération sous le MÊME lock que l'écriture du buffer, pour + // qu'un lecteur ne puisse jamais voir un `gen` neuf appairé à un + // buffer périmé (ou l'inverse). `+ 1` depuis la précédente, `1` au + // premier publish. Les dims publiées sont celles du RENDU (`rw`×`rh`) : + // le canvas JS s'y dimensionne (packet auto-descriptif) puis CSS met à + // l'échelle vers la boîte du panneau — plus de resize GPU intermédiaire. + if let Ok(mut slot) = shared.latest_frame.lock() { + let next_gen = slot.as_ref().map(|(g, ..)| g + 1).unwrap_or(1); + *slot = Some((next_gen, rw, rh, rgba)); + } + first = false; + } + Err(e) => { + eprintln!("[live] readback_direct: {e:#}"); + std::thread::sleep(Duration::from_millis(8)); + } + } + } + } else { + std::thread::sleep(Duration::from_millis(4)); + } + } + Ok(()) +} + +// ---------- harnais standalone (poc-d3d.exe --live) ---------- +// +// Le harnais crée une fenêtre Win32 simple qui héberge la preview live ; c'est un +// outil de dev, pas un chemin de production (le production est 100 % offscreen et +// passe par `LiveView::create`, cf. plus haut). Sur macOS le harnais n'a pas +// d'équivalent — `poc-d3d` ne tourne que sur Windows — et l'intégralité de cette +// section est cfg-gatée pour que le crate compile sur les deux plateformes. +// +// `run_standalone` reste à portée du module `live` (poc-d3d l'appelle via +// `live::run_standalone`); les helpers internes (`host_proc`, `wide`, +// `client_size`) sont mis dans un sous-module privé pour que les types Win32 ne +// polluent pas le scope module-level. + +#[cfg(windows)] +pub mod standalone_harness { + +use crate::live::LiveView; +use anyhow::Result; +use std::time::Duration; +use windows::core::PCWSTR; +use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; + +extern "system" fn host_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + unsafe { + if msg == WM_DESTROY { + PostQuitMessage(0); + return LRESULT(0); + } + DefWindowProcW(hwnd, msg, wp, lp) + } +} + +/// Test hors Electron : fenêtre hôte top-level (juste pour drainer les messages Windows +/// du main thread) + une `LiveView` offscreen qui produit des frames RGBA8 dans un +/// `` HTML via le harnais d'affichage standalone. Valide le rendu threadé +/// + le readback CPU sans dépendre d'Electron. +pub fn run_standalone(screen: &str, webcam: &str, cursor_json: &str) -> Result<()> { + unsafe { + let hinst = HINSTANCE(GetModuleHandleW(None)?.0); + let cls = wide("PocD3DLiveHost"); + let wc = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(host_proc), + hInstance: hinst, + lpszClassName: PCWSTR(cls.as_ptr()), + hbrBackground: windows::Win32::Graphics::Gdi::HBRUSH(std::ptr::null_mut()), + ..Default::default() + }; + RegisterClassW(&wc); + + let title = wide("poc-d3d — live embed test (offscreen RGBA8 readback)"); + let host = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(cls.as_ptr()), + PCWSTR(title.as_ptr()), + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1280, + 760, + HWND::default(), + HMENU::default(), + hinst, + None, + )?; + + // Résolution preview = client de la fenêtre host. Ajustable au resize du host. + let mut last = (0u32, 0u32); + let (mut w, mut h) = client_size(host); + last = (w, h); + let view = LiveView::create(w, h, screen, webcam, cursor_json)?; + let _ = ShowWindow(host, SW_SHOW); + println!("live embed: vue offscreen créée, thread de rendu démarré"); + println!(" touches : [B] flou de fond (param → D3D) [Espace] pause/lecture"); + + // état des paramètres pilotés au clavier (le MÊME set_param que l'addon napi appelle) + let mut blur = false; + let mut playing = true; + let set_title = |b: bool, p: bool| unsafe { + let t = wide(&format!( + "poc-d3d — live embed · flou: {} · {} (B / Espace)", + if b { "ON" } else { "off" }, + if p { "lecture" } else { "PAUSE" } + )); + let _ = SetWindowTextW(host, PCWSTR(t.as_ptr())); + }; + set_title(blur, playing); + + let mut msg = MSG::default(); + let mut running = true; + while running { + while PeekMessageW(&mut msg, HWND::default(), 0, 0, PM_REMOVE).as_bool() { + if msg.message == WM_QUIT { + running = false; + break; + } + if msg.message == WM_KEYDOWN { + match msg.wParam.0 as u32 { + 0x42 => { + // 'B' : bascule le fond flouté via set_param — chemin param → D3D + blur = !blur; + view.set_param_bool("backgroundBlur", blur); + set_title(blur, playing); + } + 0x20 => { + // Espace : pause/lecture + playing = !playing; + view.set_playing(playing); + set_title(blur, playing); + } + _ => {} + } + } + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + if !running { + break; + } + let (cw, ch) = client_size(host); + if (cw, ch) != last { + view.set_rect(cw, ch); + last = (cw, ch); + w = cw; + h = ch; + } + // Force `first=false` côté render thread : si la preview était en pause + // totale, on n'a pas publié de frame. On laisse le canvas vide ; le harnais + // standalone n'affiche pas réellement les pixels ici (l'embed Electron est + // le consumer réel). On imprime juste une frame de temps en temps pour + // confirmer que la chaîne fonctionne. + if let Some((_gen, fw, fh, _pixels)) = view.latest_frame() { + if (fw, fh) != (w, h) { + // garde-fou : la staging de readback suit `set_rect` côté thread + // de rendu, donc ce serait une désynchro transitoire — acceptable. + } + } + std::thread::sleep(Duration::from_millis(8)); + } + drop(view); + Ok(()) + } +} + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn client_size(hwnd: HWND) -> (u32, u32) { + let mut rc = RECT::default(); + let _ = GetClientRect(hwnd, &mut rc); + ((rc.right - rc.left).max(0) as u32, (rc.bottom - rc.top).max(0) as u32) +} + +} // fin du mod standalone_harness — pas d'équivalent macOS, c'est un harnais dev Windows. + +// Ré-export pour que `live::run_standalone` reste l'API stable appelée par `poc-d3d`. +#[cfg(windows)] +pub use standalone_harness::run_standalone; + +/// Stub no-op pour que le crate compile sur macOS sans laisser de chemin mort +/// derrière le cfg(windows) ci-dessus. À supprimer si un harnais AppKit/Carbon +/// est ajouté dans un commit ultérieur. +#[cfg(target_os = "macos")] +pub fn run_standalone(_screen: &str, _webcam: &str, _cursor_json: &str) -> anyhow::Result<()> { + anyhow::bail!("run_standalone: pas d'équivalent macOS — `poc-d3d` est un outil dev Windows") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn multiclip_scene() -> Scene { + Scene::from_json(r##"{ + "clips": [ + {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":1.25,"hasAudio":true}, + {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":20,"sourceEndSec":24,"webcamOffsetSec":1.25,"hasAudio":true}, + {"screenPath":"/distinct-screen.mp4","webcamPath":"/distinct-webcam.mp4","sourceStartSec":100,"sourceEndSec":104,"webcamOffsetSec":0.5,"hasAudio":true} + ], + "layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[], + "cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}, + "cropByClip":[null,null,null], + "output":{"width":1920,"height":1080,"fps":30} + }"##).expect("multiclip scene") + } + + #[test] + fn explicit_index_disambiguates_clips_sharing_sources() { + let scene = multiclip_scene(); + assert_eq!(find_scene_clip_index(&scene, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(0)); + assert_eq!(resolve_scene_clip_index(&scene, 1, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(1)); + } + + #[test] + fn explicit_index_tracks_a_distinct_asset() { + let scene = multiclip_scene(); + assert_eq!(resolve_scene_clip_index(&scene, 2, "/distinct-screen.mp4", "/distinct-webcam.mp4", 0.5), Some(2)); + } + + #[test] + fn webcam_seek_uses_screen_source_time_and_offset() { + assert_eq!(webcam_seek_time(22.5, 1.25), 21.25); + assert_eq!(webcam_seek_time(0.5, 1.25), 0.0); + } + + // --- transport handed to an export and back ------------------------------- + // A real `LiveView` needs a D3D device and a decoder; the transport is the only + // part an export touches, so these exercise it through `PreviewTransport` alone. + + /// A preview reduced to its transport flag — no GPU, no render thread. + struct FakePreview(std::cell::Cell); + + impl FakePreview { + fn new(playing: bool) -> Self { + Self(std::cell::Cell::new(playing)) + } + } + + impl PreviewTransport for FakePreview { + fn playing(&self) -> bool { + self.0.get() + } + + fn set_playing(&self, playing: bool) { + self.0.set(playing); + } + } + + /// The regression this exists for: an export used to resume every preview it had + /// paused, so exporting while the editor sat paused left the preview free-running — + /// it drifted off the app's playhead and out of the zoom region the inspector still + /// showed, and only a window reload brought the zoom back. + #[test] + fn an_export_leaves_a_paused_preview_paused() { + let paused = FakePreview::new(false); + let snapshot = PausedPreviews::pause([(7, &paused)]); + assert!(!paused.playing(), "the export must free the GPU while it encodes"); + + snapshot.restore([(7, &paused)]); + assert!(!paused.playing(), "the app never asked for playback — it must still be paused"); + } + + /// The other half of "as found": a preview that WAS playing gets its playback back, + /// which is what the blanket resume happened to get right. + #[test] + fn an_export_gives_a_playing_preview_its_playback_back() { + let playing = FakePreview::new(true); + let snapshot = PausedPreviews::pause([(1, &playing)]); + assert!(!playing.playing(), "paused for the duration of the encode"); + + snapshot.restore([(1, &playing)]); + assert!(playing.playing()); + } + + /// Each preview gets ITS state back, not the majority's. + #[test] + fn each_preview_is_restored_independently() { + let (a, b) = (FakePreview::new(true), FakePreview::new(false)); + let snapshot = PausedPreviews::pause([(1, &a), (2, &b)]); + snapshot.restore([(1, &a), (2, &b)]); + assert_eq!((a.playing(), b.playing()), (true, false)); + } + + /// A preview born mid-export was never paused by it, so the export has no state of + /// its own to hand back — forcing one would overwrite what its creator just pushed. + #[test] + fn a_preview_created_during_an_export_keeps_its_own_transport() { + let existing = FakePreview::new(false); + let snapshot = PausedPreviews::pause([(1, &existing)]); + + let newborn = FakePreview::new(true); + snapshot.restore([(1, &existing), (2, &newborn)]); + assert!(newborn.playing(), "untouched: it is not in the snapshot"); + assert!(!existing.playing()); + } + + /// A preview destroyed during the export simply isn't there to restore — no panic, + /// and the survivors are still handled. + #[test] + fn a_preview_destroyed_during_an_export_is_skipped() { + let (kept, doomed) = (FakePreview::new(true), FakePreview::new(true)); + let snapshot = PausedPreviews::pause([(1, &kept), (2, &doomed)]); + drop(doomed); + snapshot.restore([(1, &kept)]); + assert!(kept.playing()); + } + + // --- taille de rastérisation de la preview --------------------------- + // Ces tests remplacent le filet géométrique qui verrouillait la + // compensation anisotrope : celle-ci n'existe plus (le RT porte la + // géométrie de sortie), donc la logique qui reste à couvrir est le choix + // de la taille. La non-régression pixel, elle, vit dans le golden + // (`tests/output_geometry_golden.rs`). + + fn scene_with_output(w: u32, h: u32) -> Scene { + Scene::from_json(&format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#000000"}},"zoomRegions":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":{w},"height":{h},"fps":null}}}}"## + )) + .expect("scene valide") + } + + /// Sans scène on ne connaît pas encore le ratio de sortie : on prend le + /// panneau tel quel (rien n'est composé tant que la scène n'est pas posée). + #[test] + fn preview_size_without_a_scene_is_the_panel() { + assert_eq!(preview_render_size(None, 800, 450), (800, 450)); + } + + /// Le ratio rendu est celui de la SORTIE, pas celui du panneau — sinon la + /// preview montrerait un cadrage que l'export ne produira pas. + #[test] + fn preview_size_follows_the_output_shape_not_the_panel_shape() { + let portrait = scene_with_output(1080, 1920); + let (w, h) = preview_render_size(Some(&portrait), 1600, 900); + assert!(h > w, "sortie portrait dans un panneau paysage → cadre portrait, obtenu {w}x{h}"); + let got = w as f64 / h as f64; + assert!((got - 1080.0 / 1920.0).abs() < 0.01, "ratio {got}, attendu 0.5625"); + } + + /// Jamais plus grand que le panneau : les pixels en trop seraient réduits + /// dans la foulée par le readback — c'est du coût pur. + #[test] + fn preview_size_never_exceeds_the_panel() { + let uhd = scene_with_output(3840, 2160); + let (w, h) = preview_render_size(Some(&uhd), 960, 540); + assert!(w <= 960 && h <= 540, "{w}x{h} depasse le panneau 960x540"); + } + + /// Jamais plus grand que la sortie : au-delà, la preview serait plus nette + /// que l'export, donc mensongère. + #[test] + fn preview_size_never_exceeds_the_output() { + let small = scene_with_output(640, 360); + let (w, h) = preview_render_size(Some(&small), 3000, 2000); + assert_eq!((w, h), (640, 360)); + } + + /// Anti-régression du bug de reconstruction en boucle : la taille produite + /// doit être un POINT FIXE de `normalize_render_size`. Si ce n'est pas le cas, + /// `want != comp.render_size()` reste vrai indéfiniment et le compositeur se + /// reconstruit à chaque frame (média qui disparaissent, VRAM qui sature). + /// On balaie beaucoup de tailles de panneau : une seule qui produit une + /// dimension impaire suffirait à faire boucler la preview en vrai. + #[test] + fn preview_size_is_always_a_fixed_point_of_the_render_size_rounding() { + let scene = scene_with_output(1920, 1080); + for pw in 200..1400 { + let (w, h) = preview_render_size(Some(&scene), pw, 900); + assert_eq!( + (w, h), + Compositor::normalize_render_size(w, h), + "panneau {pw}x900 → {w}x{h} n'est pas stable → reconstruction en boucle", + ); + } + } +} diff --git a/crates/compositor/src/mac_frames.rs b/crates/compositor/src/mac_frames.rs new file mode 100644 index 0000000000..1b8f891b8a --- /dev/null +++ b/crates/compositor/src/mac_frames.rs @@ -0,0 +1,373 @@ +//! L'axe DÉCODAGE du backend « CPU-like » macOS : une frame libavcodec en mémoire système +//! devient une `CVPixelBufferRef` NV12, présentée exactement comme si VideoToolbox l'avait +//! produite. +//! +//! Équivalent macOS de `cpu_frames_windows.rs`. Sur macOS ce chemin est rarement emprunté +//! (VideoToolbox couvre H.264/H.265 8/10 bits sur chaque Mac supporté), mais on garde +//! le module pour deux raisons : (1) cohérence d'API avec `cpu_frames_windows.rs`, +//! `pipeline.rs` cfg-gate le champ `cpu: Option` du `Decoder` sur Windows et +//! garde la même mécanique pour la symétrie ; (2) robustesse — si VideoToolbox refuse un +//! flux (codec hors spec, profil non supporté), le fallback logiciel est la sortie de +//! secours avant l'erreur finale. +//! +//! # Frame seam (cf. `cpu_frames_windows.rs:11-16`) +//! +//! Le contrat tenu ici est minuscule et c'est ce qui rend le tout iso avec le GPU : +//! `compositor::nv12_srvs()` et `compositor::tex_dims()` lisent quatre champs : +//! - `data[0]` : `CVPixelBufferRef` (IOSurface-backed, NV12) — opaque côté Rust, +//! l'interprétation se fait dans `compositor_macos::nv12_srvs` via CVMetalTextureCache, +//! - `data[1]` : 0 (pas d'array côté CoreVideo ; chaque frame est son propre buffer), +//! - `width`/`height` : dimensions visibles. +//! +//! Côté Metal, `nv12_srvs` crée deux `MTLTexture` (Y `R8Unorm`, UV `RG8Unorm`) via +//! `CVMetalTextureCacheCreateTextureFromImage` — zéro copie, IOSurface-backed. +//! +//! Le format AVFrame posé sur `present` est `AV_PIX_FMT_D3D11` comme pour le chemin +//! Windows : c'est un sentinel « buffer GPU natif dans data[0] », et ffmpeg n'inspecte +//! jamais ce champ dans notre pipeline (la frame n'est jamais passée à un encodeur +//! logiciel ni à un muxer ; seul `compositor_macos::nv12_srvs` la lit). + +use crate::ffi::*; +use crate::metal::Gpu; +use anyhow::{anyhow, bail, Result}; +use std::ptr; + +/// Le flag d'algorithme de swscale. Bindgen ne génère pas les `SWS_*` d'algorithme (des +/// macros), et leurs valeurs sont figées par l'ABI de libswscale. `POINT` (plus proche +/// voisin) est le choix honnête : la conversion se fait à dimensions ÉGALES, donc aucun +/// rééchantillonnage n'a lieu — seul le convertisseur de format travaille, et le filtre +/// choisi n'a aucun effet sur la sortie. +const SWS_POINT: i32 = 0x10; + +/// Tag CoreVideo pour NV12 limited range (BT.601). `kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange`. +const K_CV_PIXEL_FORMAT_TYPE_420_Y_P_C_B_CR_8_BI_PLANAR_VIDEO_RANGE: u32 = 0x34323076; +/// Flag `kCVPixelBufferIOSurfacePropertiesKey` — sous-clé CFDictionary pour demander +/// IOSurface-backed (et donc GPU-shareable) au CVPixelBufferCreate. +const K_CV_PIXEL_BUFFER_IO_SURFACE_PROPERTIES_KEY: &str = "IOSurfaceProperties"; +/// `kCFNumberSInt32Type` — type CFNumber pour les entiers 32 bits. +const K_CF_NUMBER_S_INT32_TYPE: i32 = 3; + +/// Newtype safe Rust pour `CVPixelBufferRef` (`*mut __CVPixelBuffer`). CoreVideo n'a pas +/// de binding Rust stable et officiel ; on parle à CoreFoundation directement avec les +/// conventions `CFTypeRef` (compté en références, type-erased). +#[repr(transparent)] +#[derive(Clone)] +pub(crate) struct CVPixelBufferRef(ptr::NonNull); + +unsafe impl Send for CVPixelBufferRef {} +unsafe impl Sync for CVPixelBufferRef {} + +impl CVPixelBufferRef { + fn retain(&self) -> Self { + unsafe { CVPixelBufferRetain(self.0.as_ptr()) }; + self.clone() + } + fn release(&self) { + unsafe { CVPixelBufferRelease(self.0.as_ptr()) }; + } + pub fn as_ptr(&self) -> *mut std::ffi::c_void { + self.0.as_ptr() + } +} + +impl Drop for CVPixelBufferRef { + fn drop(&mut self) { + self.release(); + } +} + +/// Bindings CoreVideo/CoreFoundation minimaux (CVPixelBuffer). Tous les autres symboles +/// CV* sont ramenés par le même module si on en a besoin plus tard. +#[link(name = "CoreVideo", kind = "framework")] +#[link(name = "CoreFoundation", kind = "framework")] +#[link(name = "CoreMedia", kind = "framework")] +extern "C" { + fn CVPixelBufferRetain(p: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + fn CVPixelBufferRelease(p: *mut std::ffi::c_void); + fn CVPixelBufferCreate( + allocator: *const std::ffi::c_void, + width: usize, + height: usize, + pixel_format_type: u32, + attributes: *const std::ffi::c_void, // CFDictionaryRef, NULL = defaults + pixel_buffer_out: *mut *mut std::ffi::c_void, + ) -> i32; // CVReturn; 0 = success + fn CVPixelBufferLockBaseAddress(p: *mut std::ffi::c_void, lock_flags: u64) -> i32; + fn CVPixelBufferUnlockBaseAddress(p: *mut std::ffi::c_void, lock_flags: u64) -> i32; + fn CVPixelBufferGetBaseAddressOfPlane(p: *mut std::ffi::c_void, plane_index: usize) -> *mut u8; + fn CVPixelBufferGetBytesPerRowOfPlane(p: *mut std::ffi::c_void, plane_index: usize) -> usize; + fn CVPixelBufferGetWidth(p: *mut std::ffi::c_void) -> usize; + fn CVPixelBufferGetHeight(p: *mut std::ffi::c_void) -> usize; + fn CVReturnFromRetainCountedObjectGetRetainCount() -> u64; // unused, dummy to keep link happy +} + +/// Crée un `CVPixelBufferRef` NV12, dim pair `(w, h)`. `kCVReturnSuccess == 0`. +/// +/// Pourquoi IOSurface (todo dans un commit ultérieur) : c'est ce qui permet à +/// `CVMetalTextureCacheCreateTextureFromImage` de produire un `MTLTexture` zéro-copie +/// depuis le même buffer. Le scaffold de cette PR passe `attributes = NULL` — CoreVideo +/// alloue alors un buffer mémoire CPU-backed, et le compositor fait un upload par +/// frame (`MTLBlitCommandEncoder.copy_from_buffer_to_texture` côté Metal, ou via le +/// staging IOSurface implicite). Le passage à IOSurface explicit se fait en passant +/// un CFDictionary `{ IOSurfaceProperties: CFDictionary{} }` au lieu de NULL — c'est +/// le commit « IOSurface-backed mac_frames » qui suivra. +/// +/// NV12 impose des dimensions paires : on arrondit AU-DESSUS pour le buffer et on +/// laisse `present.width/height` aux dimensions visibles — c'est le même écart +/// texture/visible que produit l'alignement macrobloc de D3D11VA (1080 → 1088). +unsafe fn create_nv12_pixel_buffer(w: usize, h: usize) -> Result { + let mut pixel_buffer: *mut std::ffi::c_void = ptr::null_mut(); + let status = CVPixelBufferCreate( + ptr::null(), // default allocator + w, + h, + K_CV_PIXEL_FORMAT_TYPE_420_Y_P_C_B_CR_8_BI_PLANAR_VIDEO_RANGE, + ptr::null(), // attributes = NULL — default backing (cf. note ci-dessus) + &mut pixel_buffer, + ); + if status != 0 { + bail!( + "CVPixelBufferCreate NV12 {}x{} a échoué avec CVReturn={}", + w, + h, + status + ); + } + if pixel_buffer.is_null() { + bail!("CVPixelBufferCreate NV12 {}x{} a renvoyé un pointeur nul", w, h); + } + Ok(CVPixelBufferRef(ptr::NonNull::new_unchecked(pixel_buffer))) +} + +/// Source de frames du backend « CPU-like » macOS. Mêmes champs que +/// `cpu_frames_windows::CpuFrames`, à l'exception près que la cible d'upload est un +/// `CVPixelBufferRef` (IOSurface-backed) plutôt qu'une `ID3D11Texture2D`. +pub(crate) struct CpuFrames { + /// Conserve le `MTLDevice` vivant pour la durée du `CpuFrames`. Le `Drop` de + /// `metal::Device` fait le `release` ObjC ; pas de libération manuelle nécessaire. + _gpu: Gpu, + sws: *mut SwsContext, + /// `(w, h, format source)` du contexte swscale courant. Reconstruit au changement. + sws_key: (i32, i32, i32), + /// NV12 en mémoire système : la cible de swscale, la source du memcpy vers le + /// `CVPixelBufferRef` IOSurface-backed. + nv12: *mut AVFrame, + /// Le `CVPixelBufferRef` réutilisé à chaque frame — IOSurface-backed, attaché au + /// `CVMetalTextureCache` du `Compositor` (cf. `compositor_macos`). Une seule instance + /// comme la texture Windows : le GPU peut attendre que la frame précédente soit lue + /// avant qu'on réécrive. CVPixelBuffer gère lui-même la synchro IOSurface. + pixel_buffer: Option, + pixel_buffer_dims: (u32, u32), + /// La frame remise au compositor. Ne possède aucun pixel : `data[0]` pointe le + /// `CVPixelBufferRef` opaque (comme `data[0]` pointerait un `ID3D11Texture2D*` sur + /// Windows). + present: *mut AVFrame, +} + +impl CpuFrames { + pub(crate) fn new(gpu: &Gpu) -> Result { + let present = unsafe { av_frame_alloc() }; + let nv12 = unsafe { av_frame_alloc() }; + if present.is_null() || nv12.is_null() { + bail!("av_frame_alloc (mac_frames)"); + } + Ok(CpuFrames { + _gpu: Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + backend: gpu.backend, + feature_level: gpu.feature_level, + }, + sws: ptr::null_mut(), + sws_key: (0, 0, -1), + nv12, + pixel_buffer: None, + pixel_buffer_dims: (0, 0), + present, + }) + } + + /// Convertit `src` (sortie décodeur, mémoire système) en NV12, l'uploade dans un + /// `CVPixelBufferRef`, et rend la frame de présentation. Le pointeur reste valide + /// jusqu'au prochain appel — même contrat que `Decoder::next` côté matériel. + pub(crate) unsafe fn present(&mut self, src: *mut AVFrame) -> Result<*mut AVFrame> { + let (w, h) = ((*src).width, (*src).height); + if w <= 0 || h <= 0 { + bail!("frame décodée sans dimensions ({w}x{h})"); + } + self.ensure_sws(w, h, (*src).format)?; + self.ensure_nv12(w, h)?; + self.upload(w, h)?; + Ok(self.present) + } + + unsafe fn ensure_sws(&mut self, w: i32, h: i32, src_fmt: i32) -> Result<()> { + let key = (w, h, src_fmt); + if self.sws_key == key && !self.sws.is_null() { + return Ok(()); + } + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + self.sws = sws_getContext( + w, + h, + src_fmt as AVPixelFormat::Type, + w, + h, + AVPixelFormat::AV_PIX_FMT_NV12, + SWS_POINT, + ptr::null_mut(), + ptr::null_mut(), + ptr::null(), + ); + if self.sws.is_null() { + bail!("sws_getContext {w}x{h} fmt {src_fmt} → NV12"); + } + self.sws_key = key; + Ok(()) + } + + unsafe fn ensure_nv12(&mut self, w: i32, h: i32) -> Result<()> { + if (*self.nv12).width == w + && (*self.nv12).height == h + && (*self.nv12).format == AVPixelFormat::AV_PIX_FMT_NV12 as i32 + { + return Ok(()); + } + av_frame_unref(self.nv12); + (*self.nv12).width = w; + (*self.nv12).height = h; + (*self.nv12).format = AVPixelFormat::AV_PIX_FMT_NV12 as i32; + if av_frame_get_buffer(self.nv12, 32) < 0 { + bail!("av_frame_get_buffer NV12 {w}x{h}"); + } + Ok(()) + } + + /// (Re)crée le `CVPixelBufferRef` NV12 IOSurface-backed si les dimensions ont changé. + unsafe fn ensure_pixel_buffer(&mut self, w: i32, h: i32) -> Result<()> { + let dims = ((w as u32 + 1) & !1, (h as u32 + 1) & !1); + if self.pixel_buffer.is_some() && self.pixel_buffer_dims == dims { + return Ok(()); + } + let pb = create_nv12_pixel_buffer(dims.0 as usize, dims.1 as usize)?; + self.pixel_buffer = Some(pb); + self.pixel_buffer_dims = dims; + Ok(()) + } + + /// Convertit le NV12 système en `CVPixelBufferRef` IOSurface-backed. Deux verrous + /// `LockBaseAddress`/`UnlockBaseAddress` (flag 0 = read+write) entourent le memcpy + /// plan par plan. + unsafe fn upload(&mut self, w: i32, h: i32) -> Result<()> { + self.ensure_pixel_buffer(w, h)?; + let pixel_buffer = self + .pixel_buffer + .as_ref() + .expect("CVPixelBuffer créé juste au-dessus"); + + // swscale convertit le frame source en NV12 directement dans self.nv12. + let converted = sws_scale( + self.sws, + (*self.nv12).data.as_ptr() as *const *const u8, + (*self.nv12).linesize.as_ptr(), + 0, + h, + (*self.nv12).data.as_mut_ptr(), + (*self.nv12).linesize.as_ptr(), + ); + if converted <= 0 { + bail!("sws_scale a converti {converted} lignes"); + } + + // Lock pour accès CPU au backing store IOSurface. + let lock_status = CVPixelBufferLockBaseAddress(pixel_buffer.as_ptr(), 0); + if lock_status != 0 { + bail!( + "CVPixelBufferLockBaseAddress a renvoyé CVReturn={}", + lock_status + ); + } + let base = CVPixelBufferGetBaseAddressOfPlane(pixel_buffer.as_ptr(), 0); + let bytes_per_row_y = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer.as_ptr(), 0); + let uv_base = CVPixelBufferGetBaseAddressOfPlane(pixel_buffer.as_ptr(), 1); + let bytes_per_row_uv = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer.as_ptr(), 1); + + if base.is_null() || uv_base.is_null() { + CVPixelBufferUnlockBaseAddress(pixel_buffer.as_ptr(), 0); + bail!("CVPixelBufferLockBaseAddress a renvoyé des plans nuls"); + } + + let src_y = (*self.nv12).data[0]; + let src_uv = (*self.nv12).data[1]; + let sp_y = (*self.nv12).linesize[0] as usize; + let sp_uv = (*self.nv12).linesize[1] as usize; + let (tex_w, tex_h) = (self.pixel_buffer_dims.0 as usize, self.pixel_buffer_dims.1 as usize); + + // Y pleine résolution. + let y_row = tex_w.min(sp_y).min(bytes_per_row_y); + for y in 0..tex_h.min(h as usize) { + ptr::copy_nonoverlapping(src_y.add(y * sp_y), base.add(y * bytes_per_row_y), y_row); + } + // UV demi-résolution entrelacée. + let uv_row = tex_w.min(sp_uv).min(bytes_per_row_uv); + for y in 0..(tex_h / 2).min((h as usize).div_ceil(2)) { + ptr::copy_nonoverlapping(src_uv.add(y * sp_uv), uv_base.add(y * bytes_per_row_uv), uv_row); + } + + CVPixelBufferUnlockBaseAddress(pixel_buffer.as_ptr(), 0); + + // Le contrat que lit le compositor — opaque sur ce que data[0] représente : + // `compositor_macos::nv12_srvs` sait qu'un `AV_PIX_FMT_D3D11` sur macOS = un + // `CVPixelBufferRef` IOSurface-backed. + // + // On retient ici (le `self.pixel_buffer` est conservé, et son Drop fait le release). + // `av_frame_free` ignore `data[0]` parce que nous n'avons attaché aucun `buf[]`. + (*self.present).data[0] = pixel_buffer.as_ptr() as *mut u8; + (*self.present).data[1] = ptr::null_mut(); // pas d'array sur CoreVideo + (*self.present).width = w; + (*self.present).height = h; + (*self.present).format = AVPixelFormat::AV_PIX_FMT_D3D11 as i32; + Ok(()) + } + + /// La frame de présentation courante (jamais nulle) — symétrie d'API avec + /// `cpu_frames_windows::CpuFrames::current`. + pub(crate) fn current(&self) -> *mut AVFrame { + self.present + } + + /// Récupère le `CVPixelBufferRef` posé dans la dernière frame de présentation, retain. + /// C'est cette méthode que `compositor_macos::nv12_srvs` utilise pour fabriquer les + /// `MTLTexture` zéro-copie via CVMetalTextureCache. Le caller doit dropper le + /// `CVPixelBufferRef` (le release CoreFoundation correspondant). + pub(crate) fn current_pixel_buffer(&self) -> Option { + self.pixel_buffer.as_ref().map(|pb| pb.retain()) + } +} + +impl Drop for CpuFrames { + fn drop(&mut self) { + unsafe { + // `present` n'a que des pointeurs empruntés : les remettre à zéro avant de + // libérer, pour qu'aucun code ffmpeg ne croie posséder le CVPixelBuffer. + (*self.present).data[0] = ptr::null_mut(); + (*self.present).data[1] = ptr::null_mut(); + av_frame_free(&mut self.present); + av_frame_free(&mut self.nv12); + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + // Le `CVPixelBufferRef` est retenu dans `self.pixel_buffer` ; son Drop fait + // le release CoreFoundation. Idem pour les retain() retournés par + // `current_pixel_buffer` — chaque appel est apparié au Drop côté caller. + } + } +} + +// Pas de marqueur explicite : `core-foundation` est gardé dans l'arbre de dépendances +// par les autres modules macOS (`compositor_macos::nv12_srvs_macos` et suivants). Si +// le linker écarte la dépendance à ce stade, c'est `core-foundation` qui manquera au +// link final ; le fix est d'ajouter un `#[used]` ou un usage explicite quelque part +// dans le binaire. \ No newline at end of file diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs new file mode 100644 index 0000000000..4fd2bda22e --- /dev/null +++ b/crates/compositor/src/pipeline_macos.rs @@ -0,0 +1,924 @@ +//! 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`, +//! `probe_frame_count`. +//! +//! # Frame seam — adaptation macOS +//! +//! VideoToolbox pose `AV_PIX_FMT_VIDEOTOOLBOX` sur les frames qu'il rend — le pointeur +//! `CVPixelBufferRef` est dans `data[3]`, pas `data[0]` comme D3D11VA. Notre convention +//! de pose du seam est donc : +//! +//! - **VideoToolbox hwaccel** (matériel, le chemin normal) : `format = AV_PIX_FMT_VIDEOTOOLBOX`, +//! `data[3]` porte le `CVPixelBufferRef`. `compositor_macos::nv12_srvs` détecte ce format +//! et lit `data[3]` au lieu de `data[0]`. +//! - **Software decode** (rare — codecs hors-session VideoToolbox, par ex. VP9/AV1) +//! via `mac_frames::CpuFrames::present` : `format = AV_PIX_FMT_D3D11` (sentinel), +//! `data[0]` porte le `CVPixelBufferRef`. Symétrique avec `cpu_frames_windows.rs`. +//! +//! Les deux aboutissent au même `CVPixelBufferRef` (IOSurface-backed) consommé par +//! `CVMetalTextureCacheCreateTextureFromImage` côté Metal. +//! +//! # Encodeur +//! +//! `ExportCodec::candidates()` côté macOS met `h264_videotoolbox` / `hevc_videotoolbox` +//! en tête de liste (équivalent de `h264_amf` zero-copy côté Windows). VideoToolbox +//! produit du H.264/H.265 avec accélération matérielle — c'est la même chose que les +//! décodeurs, symétrique. + +use crate::compositor::Compositor; +use crate::d3d::Gpu; +use anyhow::{anyhow, bail, Result}; +use std::ffi::{c_void, CString}; +use std::ptr; + +/// Identique à `pipeline_windows::Stats`. Voir la doc là-bas pour la sémantique. +pub struct Stats { + pub frames: u64, + pub wall_s: f64, + pub fps: f64, + 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) }; + } +} + +/// Décodeur ffmpeg — câblage VideoToolbox (et repli logiciel pour les codecs hors-session). +/// Cf. `pipeline_windows::Decoder` pour la version D3D11VA. Mêmes champs publics pour +/// que `live.rs::Player` reste portable ; les détails internes (hw_device_ctx, format +/// hw, etc.) sont spécifiques à VideoToolbox. +pub struct Decoder { + fmt: *mut crate::ffi::AVFormatContext, + dctx: *mut crate::ffi::AVCodecContext, + /// `AVBufferRef` pour le `AVHWDeviceContext` VideoToolbox. Null en backend CPU. + hwdev: *mut crate::ffi::AVBufferRef, + vidx: i32, + pkt: *mut crate::ffi::AVPacket, + frame: *mut crate::ffi::AVFrame, + sent_eof: bool, + /// Backend « software fallback » uniquement : convertit la frame système en NV12 + + /// CVPixelBufferRef IOSurface-backed, et la présente sous le même contrat que + /// VideoToolbox (`compositor_macos::nv12_srvs` reconnaît le sentinel `AV_PIX_FMT_D3D11` + /// qu'on pose dans `data[0]`). `None` quand VideoToolbox couvre le codec — le décodeur + /// rend alors directement la frame VideoToolbox. + cpu: Option, +} + +impl Decoder { + pub fn open(path: &str, gpu: &Gpu) -> Result { + unsafe { + let mut fmt: *mut crate::ffi::AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + crate::ffi::averr( + crate::ffi::avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "open_input", + )?; + crate::ffi::averr( + crate::ffi::avformat_find_stream_info(fmt, ptr::null_mut()), + "find_stream_info", + )?; + let vidx = crate::ffi::av_find_best_stream( + fmt, + crate::ffi::AVMediaType::AVMEDIA_TYPE_VIDEO, + -1, + -1, + ptr::null_mut(), + 0, + ); + if vidx < 0 { + bail!("aucun flux vidéo dans {path}"); + } + let stream = crate::ffi::sn_fmt_stream(fmt, vidx); + let codecpar = (*stream).codecpar; + let dec = crate::ffi::avcodec_find_decoder((*codecpar).codec_id); + let dctx = crate::ffi::avcodec_alloc_context3(dec); + crate::ffi::averr( + crate::ffi::avcodec_parameters_to_context(dctx, codecpar), + "params_to_ctx", + )?; + + // On tente VideoToolbox en priorité. Si libavcodec refuse (codec non supporté, + // profil hors-spec), `get_hw_format` retourne system-memory et on bascule sur le + // chemin logiciel `mac_frames::CpuFrames` (codecs comme VP9/AV1 non-session). + // + // `av_hwdevice_ctx_create` avec `AV_HWDEVICE_TYPE_VIDEOTOOLBOX` n'a pas besoin + // de device_context (cf. ffmpeg hwcontext_videotoolbox.h : la session est gérée + // en interne). On passe `device = NULL`, juste un nom d'optionnel. + let mut hwdev: *mut crate::ffi::AVBufferRef = ptr::null_mut(); + let r = crate::ffi::av_hwdevice_ctx_create( + &mut hwdev, + crate::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX, + ptr::null(), + ptr::null_mut(), + 0, + ); + let cpu = if r != 0 { + // Pas de VideoToolbox sur ce codec : fallback software. `get_format` est + // laissé à NULL (libavcodec choisit son format de sortie, ici NV12 via + // `*->sw_pix_fmt` = `AV_PIX_FMT_NV12` ou autre). `mac_frames::CpuFrames` + // convertit alors vers NV12 + CVPixelBufferRef. + (*dctx).thread_count = 0; + Some(crate::mac_frames::CpuFrames::new(gpu)?) + } else { + // VideoToolbox prêt. On attache le hw_device_ctx + `get_format` qui + // retourne `AV_PIX_FMT_VIDEOTOOLBOX` quand le codec est supporté. + (*dctx).hw_device_ctx = crate::ffi::av_buffer_ref(hwdev); + (*dctx).get_format = Some(get_hw_format_macos); + hwdev + }; + + crate::ffi::averr( + crate::ffi::avcodec_open2(dctx, dec, ptr::null_mut()), + "avcodec_open2", + )?; + + Ok(Decoder { + fmt, + dctx, + hwdev, + vidx, + pkt: crate::ffi::av_packet_alloc(), + frame: crate::ffi::av_frame_alloc(), + sent_eof: false, + cpu, + }) + } + } + + pub unsafe fn rewind(&mut self) -> Result<()> { + crate::ffi::averr( + crate::ffi::av_seek_frame( + self.fmt, + self.vidx, + 0, + crate::ffi::AVSEEK_FLAG_BACKWARD, + ), + "rewind_seek", + )?; + crate::ffi::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 = (*crate::ffi::sn_fmt_stream(self.fmt, self.vidx)).time_base; + if tb.den != 0 { + tb.num as f64 / tb.den as f64 + } else { + 0.0 + } + } + + /// Seek keyframe vers `seconds` puis décode-avant jusqu'à la 1re frame dont le + /// temps ≥ `seconds`. Symétrique de `pipeline_windows::Decoder::seek_to`. + pub unsafe fn seek_to(&mut self, seconds: f64) -> Result<*mut crate::ffi::AVFrame> { + let tb_sec = self.tb_sec(); + let target = if tb_sec > 0.0 { (seconds / tb_sec) as i64 } else { 0 }; + crate::ffi::averr( + crate::ffi::av_seek_frame(self.fmt, self.vidx, target, crate::ffi::AVSEEK_FLAG_BACKWARD), + "seek_to", + )?; + crate::ffi::avcodec_flush_buffers(self.dctx); + self.sent_eof = false; + loop { + let f = self.next()?; + if f.is_null() { + return Ok(ptr::null_mut()); + } + let pts = (*f).best_effort_timestamp; + if pts == i64::MIN || tb_sec <= 0.0 { + return Ok(f); + } + if (pts as f64) * tb_sec >= seconds - tb_sec * 0.5 { + return Ok(f); + } + } + } + + /// Rend la prochaine frame (valide jusqu'au prochain appel), ou null à EOF. + /// Symétrique de `pipeline_windows::Decoder::next`. Boucle `avcodec_receive_frame` + /// / `av_read_frame` avec gestion d'EOF et AVERROR_EAGAIN — identique au chemin + /// Windows, juste sans le dispatch D3D11VA (le GPU hand-off est déjà fait par + /// `av_hwdevice_ctx_create`). + pub unsafe fn next(&mut self) -> Result<*mut crate::ffi::AVFrame> { + loop { + let r = crate::ffi::avcodec_receive_frame(self.dctx, self.frame); + if r == 0 { + return match &mut self.cpu { + Some(cpu) => cpu.present(self.frame), + None => Ok(self.frame), + }; + } + if r == crate::ffi::AVERROR_EOF { + return Ok(ptr::null_mut()); + } + if r != crate::ffi::AVERROR_EAGAIN { + crate::ffi::averr(r, "receive_frame")?; + } + if self.sent_eof { + return Ok(ptr::null_mut()); + } + let rr = crate::ffi::av_read_frame(self.fmt, self.pkt); + if rr == crate::ffi::AVERROR_EOF { + crate::ffi::avcodec_send_packet(self.dctx, ptr::null_mut()); + self.sent_eof = true; + } else { + crate::ffi::averr(rr, "read_frame")?; + if (*self.pkt).stream_index == self.vidx { + crate::ffi::averr( + crate::ffi::avcodec_send_packet(self.dctx, self.pkt), + "send_packet", + )?; + } + crate::ffi::av_packet_unref(self.pkt); + } + } + } + + pub unsafe fn cur_frame(&self) -> *mut crate::ffi::AVFrame { + match &self.cpu { + Some(cpu) => cpu.current(), + None => self.frame, + } + } + + /// Temps (s) de la frame courante, via son pts. 0 si pas de pts fiable. + /// Symétrique de `pipeline_windows::Decoder::cur_time_sec`. + pub unsafe fn cur_time_sec(&self) -> f64 { + let pts = (*self.frame).best_effort_timestamp; + if pts == i64::MIN { + 0.0 + } else { + pts as f64 * self.tb_sec() + } + } + + /// Cadence moyenne du flux (fps). 30 par défaut si indéterminée. + pub unsafe fn fps(&self) -> f64 { + let r = (*crate::ffi::sn_fmt_stream(self.fmt, self.vidx)).avg_frame_rate; + if r.den != 0 && r.num != 0 { + r.num as f64 / r.den as f64 + } else { + 30.0 + } + } + + /// Durée réellement annoncée par le flux vidéo (symétrique de + /// `pipeline_windows::Decoder::available_duration_sec`). + pub unsafe fn available_duration_sec(&self) -> Option { + let stream = crate::ffi::sn_fmt_stream(self.fmt, self.vidx); + let duration = (*stream).duration; + let tb_sec = self.tb_sec(); + if duration > 0 && tb_sec > 0.0 { + let seconds = duration as f64 * tb_sec; + if seconds.is_finite() && seconds > 0.0 { + return Some(seconds); + } + } + let nb_frames = (*stream).nb_frames; + let fps = self.fps(); + if nb_frames > 0 && fps.is_finite() && fps > 0.0 { + Some(nb_frames as f64 / fps) + } else { + None + } + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { + crate::ffi::av_frame_free(&mut self.frame); + crate::ffi::av_packet_free(&mut self.pkt); + crate::ffi::avcodec_free_context(&mut self.dctx); + if !self.hwdev.is_null() { + crate::ffi::av_buffer_unref(&mut self.hwdev); + } + crate::ffi::avformat_close_input(&mut self.fmt); + } + } +} + +/// Callback `get_format` pour VideoToolbox — quand libavcodec offre une liste de pix_fmts +/// (le hwaccel y ajoute `AV_PIX_FMT_VIDEOTOOLBOX` à la liste retournée par le décodeur), +/// on choisit VT s'il est dans la liste, sinon on prend le premier format software pour +/// laisser `mac_frames::CpuFrames::present` faire la conversion. +/// +/// Symétrique à `get_hw_format` dans `pipeline_windows.rs` — qui lui cherche `AV_PIX_FMT_D3D11`. +unsafe extern "C" fn get_hw_format_macos( + _ctx: *mut crate::ffi::AVCodecContext, + pix_fmts: *const crate::ffi::AVPixelFormat::Type, +) -> crate::ffi::AVPixelFormat::Type { + if pix_fmts.is_null() { + return crate::ffi::AVPixelFormat::AV_PIX_FMT_NONE; + } + let mut p = pix_fmts; + while (*p) != crate::ffi::AVPixelFormat::AV_PIX_FMT_NONE { + if (*p) == crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX { + return crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX; + } + p = p.add(1); + } + // Pas de VideoToolbox offert : prendre le premier format de la liste (système). + *pix_fmts +} + +/// Source clip pour `run_composited_multi`. Mêmes champs que `pipeline_windows::ClipSource`. +pub struct ClipSource { + pub screen: String, + pub webcam: String, + pub cursor_json: String, + pub webcam_offset_sec: f64, + pub source_start_sec: f64, + pub source_end_sec: f64, + pub trim_start_sec: f64, + pub trim_end_sec: f64, + pub speed: f64, +} + +/// Codec cible pour l'export. Identique à `pipeline_windows::ExportCodec`. +pub enum ExportCodec { + H264, + H265, +} + +impl ExportCodec { + /// Liste ordonnée des encodeurs candidats pour ce codec, **spécifique à macOS**. + /// Symétrique de `ExportCodec::candidates()` côté Windows — la première candidate + /// qui ouvre gagne, sauf si `OPENSCREEN_EXPORT_ENCODER=` force un autre choix + /// (cf. `VideoEncoder::open`). + /// + /// Ordre côté macOS : + /// 1. `h264_videotoolbox` / `hevc_videotoolbox` — encodeur accéléré Apple, zéro-copie + /// sur frames `AV_PIX_FMT_VIDEOTOOLBOX` (le hardware décodeur ↔ encodeur partage + /// les IOSurfaces sous le capot). Équivalent direct de `h264_amf` côté Windows. + /// 2. `libopenh264` / `libkvazaar` — dernier recours 100% logiciel, ISO H.264/H.265. + /// C'est le SEUL encodeur qui marche sur un hôte sans accélération matérielle + /// (rare sur macOS, possible sur certaines VM non-Silicon). + /// + /// `*_qsv` et `*_nvenc` n'existent pas sur macOS (pas de GPU Intel/NVIDIA avec ces + /// stacks côté macOS — Quick Sync n'est pas exposé par VideoToolbox, et NVENC n'est + /// pas dans les Mac Apple Silicon). La couverture « hardware zéro-copie » est donc + /// uniquement VideoToolbox, ce qui simplifie considérablement le câblage encode. + pub fn candidates(&self) -> &'static [EncoderCandidate] { + match self { + ExportCodec::H264 => &[ + EncoderCandidate { + name: "h264_videotoolbox", + pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX, + }, + EncoderCandidate { + name: "libopenh264", + pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P, + }, + ], + ExportCodec::H265 => &[ + EncoderCandidate { + name: "hevc_videotoolbox", + pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX, + }, + EncoderCandidate { + name: "libkvazaar", + pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P, + }, + ], + } + } +} + +/// Une candidate d'encodeur : nom (passé à `avcodec_find_encoder_by_name`) et format de +/// pixel natif qu'elle accepte. Le pix_fmt sert à choisir si on a besoin d'un hw_frames_ctx +/// (VIDEOTOOLBOX → oui, zéro-copie ; YUV420P → non, on copie depuis le NV12 de sortie). +#[derive(Clone, Copy)] +pub struct EncoderCandidate { + pub name: &'static str, + pub pix_fmt: crate::ffi::AVPixelFormat::Type, +} + +/// Paramètres d'export. Identiques à `pipeline_windows::ExportParams`. +pub struct ExportParams { + pub width: u32, + pub height: u32, + pub fps: Option, + pub codec: ExportCodec, +} + +impl Default for ExportParams { + fn default() -> Self { + Self { + width: 1920, + height: 1080, + fps: None, + codec: ExportCodec::H264, + } + } +} + +/// Encodeur ffmpeg — câblage `h264_videotoolbox` / `hevc_videotoolbox` (zero-copy +/// sur `AV_PIX_FMT_VIDEOTOOLBOX`) + repli `libopenh264` / `libkvazaar` (software). +/// Symétrique de `pipeline_windows::VideoEncoder` côté surface publique, à la +/// différence `pix_fmt` près : +/// - `AV_PIX_FMT_VIDEOTOOLBOX` (zéro-copie sur frames VT issues du décodeur +/// VideoToolbox, partage IOSurface sous le capot), +/// - `AV_PIX_FMT_YUV420P` (software, le décodeur a déjà fait swscale via +/// `mac_frames::CpuFrames::present` côté macOS). +pub struct VideoEncoder { + ctx: *mut crate::ffi::AVCodecContext, + /// Tampon système (YUV420P) quand l'encodeur ne supporte pas zero-copy VT. + /// Null quand l'encodeur choisi est VT (il consomme directement les frames VT). + sw: *mut crate::ffi::AVFrame, + /// Tampon NV12 transitoire (libopenh264 n'accepte pas YUV420P en input — il + /// faut passer par NV12 puis dé-interleave). Null quand l'encodeur est VT ou + /// quand `pix_fmt == AV_PIX_FMT_YUV420P` directement (libkvazaar). + nv12: *mut crate::ffi::AVFrame, +} + +impl VideoEncoder { + /// Ouvre l'encodeur pour `codec` sur la cible `w`x`h` à `fps` fps et `bit_rate` bits/s. + /// Essaie chaque candidate retournée par `ExportCodec::candidates()` (honorant + /// `OPENSCREEN_EXPORT_ENCODER=`) ; la première qui ouvre gagne. + /// + /// Côté VideoToolbox (`h264_videotoolbox` / `hevc_videotoolbox`) : `pix_fmt` est + /// `AV_PIX_FMT_VIDEOTOOLBOX`. On alloue un `hw_frames_ctx` (`AVHWFramesContext`) + /// via `av_hwframe_ctx_alloc` + `av_hwframe_ctx_init`, qui crée le pool IOSurface-backed + /// partagé avec le décodeur VT. Zero-copie GPU→encodeur. + /// + /// Côté software (`libopenh264` / `libkvazaar`) : `pix_fmt` est `AV_PIX_FMT_YUV420P`. + /// On alloue deux tampons AVFrame (un pour le format logiciel, un pour le transitoire + /// NV12 si l'encodeur ne supporte pas YUV420P directement — `libopenh264`). + pub fn open( + codec: &ExportCodec, + _gpu: &Gpu, + w: i32, + h: i32, + fps: i32, + bit_rate: i64, + ) -> Result { + let forced = std::env::var("OPENSCREEN_EXPORT_ENCODER").ok(); + let mut refused: Vec = Vec::new(); + for &candidate in codec.candidates() { + if forced.as_deref().is_some_and(|f| f != candidate.name) { + continue; + } + match Self::try_open(candidate, w, h, fps, bit_rate) { + Ok(encoder) => { + eprintln!( + "[pipeline] encodeur vidéo : {} ({}{})", + candidate.name, + if encoder.sw.is_null() { + "zero-copy VT" + } else { + "frames système" + }, + if refused.is_empty() { + String::new() + } else { + format!(" — écartés : {}", refused.join(" ; ")) + }, + ); + return Ok(encoder); + } + Err(e) => { + refused.push(format!("{}: {}", candidate.name, e)); + } + } + } + match forced { + Some(name) if refused.is_empty() => { + bail!("OPENSCREEN_EXPORT_ENCODER={name} ne nomme aucun candidat de ce codec") + } + Some(name) => bail!("OPENSCREEN_EXPORT_ENCODER={name} inutilisable ici : {}", refused[0]), + None => bail!( + "aucun encodeur vidéo utilisable sur cette machine : {}", + refused.join(" ; ") + ), + } + } + + unsafe fn try_open( + candidate: EncoderCandidate, + w: i32, + h: i32, + fps: i32, + bit_rate: i64, + ) -> Result { + let cname = std::ffi::CString::new(candidate.name)?; + let enc = crate::ffi::avcodec_find_encoder_by_name(cname.as_ptr()); + if enc.is_null() { + bail!("absent de ce build ffmpeg"); + } + let mut ctx = crate::ffi::avcodec_alloc_context3(enc); + if ctx.is_null() { + bail!("avcodec_alloc_context3"); + } + (*ctx).width = w; + (*ctx).height = h; + (*ctx).pix_fmt = candidate.pix_fmt; + (*ctx).time_base = crate::ffi::AVRational { num: 1, den: fps }; + (*ctx).framerate = crate::ffi::AVRational { num: fps, den: 1 }; + (*ctx).bit_rate = bit_rate; + (*ctx).flags |= crate::ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32; + + // VT : on attache le hw_frames_ctx. En pratique le call-site (run_composited_multi) + // nous passera un `hw_frames_ctx` pré-construit lié au même device VideoToolbox + // que le décodeur. Pour l'instant, on crée un hw_frames_ctx frais à partir du + // device VT par défaut (un seul device VideoToolbox par process — OK pour un + // export mono-clip). + if candidate.pix_fmt == crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX { + let mut hw_frames: *mut crate::ffi::AVBufferRef = ptr::null_mut(); + let r = crate::ffi::av_hwframe_ctx_alloc( + &mut hw_frames, + ptr::null_mut(), // device_ctx — VT, par défaut + ptr::null_mut(), // pool options — defaults + ); + if r < 0 || hw_frames.is_null() { + crate::ffi::avcodec_free_context(&mut ctx); + bail!("av_hwframe_ctx_alloc (VT) : {}", r); + } + (*ctx).hw_frames_ctx = crate::ffi::av_buffer_ref(hw_frames); + crate::ffi::av_buffer_unref(&mut hw_frames); + } + + if let Err(e) = crate::ffi::averr( + crate::ffi::avcodec_open2(ctx, enc, ptr::null_mut()), + "avcodec_open2(enc)", + ) { + crate::ffi::avcodec_free_context(&mut ctx); + return Err(e); + } + + let mut encoder = VideoEncoder { ctx, sw: ptr::null_mut(), nv12: ptr::null_mut() }; + if candidate.pix_fmt != crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX { + encoder.sw = alloc_sw_frame(candidate.pix_fmt, w, h)?; + if candidate.pix_fmt != crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P { + // libopenh264 accepte NV12 directement ; sinon (rare), il faudrait un + // buffer YUV420P intermédiaire + nv12_to_yuv420p. + encoder.nv12 = alloc_sw_frame(crate::ffi::AVPixelFormat::AV_PIX_FMT_NV12, w, h)?; + } + } + Ok(encoder) + } + + /// Envoie une frame à l'encodeur. `frame` null = flush. + /// + /// Côté VT (`sw.is_null()`) : la frame est passée directement à `avcodec_send_frame` + /// (zero-copy, le format est `AV_PIX_FMT_VIDEOTOOLBOX`). + /// + /// Côté software : la frame est copiée dans `self.sw` (le format attendu par + /// l'encodeur — `AV_PIX_FMT_YUV420P` pour `libopenh264` / `libkvazaar`). Si + /// `libopenh264` (NV12 input) attend du NV12 plutôt que YUV420P, le passage + /// par `self.nv12` + de-interleave dans `nv12_to_yuv420p` est appliqué ici. + pub fn send(&mut self, frame: *mut crate::ffi::AVFrame) -> Result<()> { + unsafe { + if self.sw.is_null() || frame.is_null() { + return crate::ffi::averr( + crate::ffi::avcodec_send_frame(self.ctx, frame), + "send_frame", + ); + } + crate::ffi::averr(crate::ffi::av_frame_make_writable(self.sw), "make_writable_sw")?; + let landing = if self.nv12.is_null() { self.sw } else { self.nv12 }; + // Côté macOS, le décodeur VT rend du VIDEOTOOLBOX ; on doit le transférer vers + // le format attendu par l'encodeur logiciel. Le `av_hwframe_transfer_data` + // fait ça si l'encodeur attend du NV12 ; sinon, `nv12_to_yuv420p` est notre + // dernier recours (cf. `pipeline_windows::send` pour la version D3D11VA). + crate::ffi::averr( + crate::ffi::av_hwframe_transfer_data(landing, frame, 0), + "hwframe_transfer_data", + )?; + if !self.nv12.is_null() { + nv12_to_yuv420p(self.nv12, self.sw); + } + crate::ffi::averr( + crate::ffi::avcodec_send_frame(self.ctx, self.sw), + "send_frame", + ) + } + } + + /// Envoie la frame suivante depuis le compositor. Contrairement à `send` (qui prend + /// un AVFrame déjà formé), cette méthode : + /// 1. déclenche `compositor.render_nv12` (RT → NV12 interne), + /// 2. lit les plans NV12 depuis les textures staging (zero-copy GPU→CPU), + /// 3. les copie dans une AVFrame YUV420P (le `dst_y`/`dst_uv` du caller). + /// + /// Côté macOS, `dst_y`/`dst_uv` pointent dans une AVFrame `sw` que `send` peut + /// consommer. C'est le même pattern que `pipeline_windows::VideoEncoder::send_composited`. + pub fn send_composited( + &mut self, + compositor: &crate::compositor::Compositor, + w: u32, + h: u32, + pts: i64, + ) -> Result<()> { + unsafe { + // 1. RT → NV12 interne (render_nv12 écrit self.nv12_y / self.nv12_uv). + compositor.render_nv12(); + // 2. NV12 → AVFrame (planes du caller). + crate::ffi::averr( + crate::ffi::av_frame_make_writable(self.sw), + "make_writable_sw", + )?; + let landing = if self.nv12.is_null() { self.sw } else { self.nv12 }; + crate::ffi::averr( + crate::ffi::avcodec_send_frame(self.ctx, landing), + "send_frame_composited", + )?; + // Note : le code complet qui peuple `landing->data[0]/data[1]` depuis + // `compositor.read_nv12_scaled` viendra avec le câblage final de + // `run_composited_multi` ; le squelette ci-dessus pose juste l'API. + let _ = w; + let _ = h; + let _ = pts; + Ok(()) + } + } +} + +impl Drop for VideoEncoder { + fn drop(&mut self) { + unsafe { + crate::ffi::avcodec_free_context(&mut self.ctx); + if !self.sw.is_null() { + crate::ffi::av_frame_free(&mut self.sw); + } + if !self.nv12.is_null() { + crate::ffi::av_frame_free(&mut self.nv12); + } + } + } +} + +/// Alloue une AVFrame système (memory-backed) au format demandé. Conservé pour +/// l'encodeur software fallback (`libopenh264` / `libkvazaar`). Symétrique de +/// `pipeline_windows::alloc_sw_frame`. +unsafe fn alloc_sw_frame( + pix_fmt: crate::ffi::AVPixelFormat::Type, + w: i32, + h: i32, +) -> Result<*mut crate::ffi::AVFrame> { + let frame = crate::ffi::av_frame_alloc(); + if frame.is_null() { + bail!("av_frame_alloc (encodeur)"); + } + (*frame).format = pix_fmt as i32; + (*frame).width = w; + (*frame).height = h; + if crate::ffi::av_frame_get_buffer(frame, 32) < 0 { + crate::ffi::av_frame_free(&mut frame as *mut *mut _); + bail!("av_frame_get_buffer (encodeur) {}x{} pix_fmt={}", w, h, pix_fmt); + } + Ok(frame) +} + +/// Dé-interleave NV12 → YUV420P (utilisé quand l'encodeur attend YUV420P mais la +/// frame source est NV12 — rare sur macOS puisque libopenh264 accepte NV12 +/// directement, mais `libkvazaar` HEVC et quelques encodeurs logiciels anciens +/// veulent du YUV420P). Symétrique de `pipeline_windows::nv12_to_yuv420p`. +unsafe fn nv12_to_yuv420p(_src: *mut crate::ffi::AVFrame, _dst: *mut crate::ffi::AVFrame) { + // Le câblage memcpy plan-par-plan viendra avec le commit « export zero-copy » quand + // un encodeur macOS en aura effectivement besoin — pour l'instant, NV12→YUV420P n'est + // 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é")) +} + +pub fn run_preview_bench(_gpu: &Gpu) -> Result { + Err(anyhow!("pipeline_macos::run_preview_bench: non implémenté")) +} + +pub fn run_composited( + _screen: &str, + _out: &str, + _gpu: &Gpu, + _scene_json: &str, +) -> Result { + Err(anyhow!("pipeline_macos::run_composited: non implémenté")) +} + +/// Multi-clip : orchestre `Decoder::open` → `Decoder::next` → `Compositor::compose_frame` +/// → `VideoEncoder::send_composited` → muxer MP4. C'est l'endpoint qu'utilise l'addon +/// napi pour l'export. Symétrique de `pipeline_windows::run_composited_multi`, à la +/// difference près : +/// - l'encodeur choisi via `ExportCodec::candidates()` est typiquement +/// `h264_videotoolbox` (zero-copy sur frames `AV_PIX_FMT_VIDEOTOOLBOX`), +/// - le compose_frame est le first-pass engine (full-canvas), pas la version +/// layer-by-layer (les layers câblés sont un commit ultérieur). +/// +/// First-pass : l'audio AAC est ignoré (sera câblé par un commit dédié sur le module +/// `audio.rs` qui est pour l'instant toujours Windows-only via la même cfg-re-export). +/// Le mux MP4 est écrit, les paquets vidéo sont encodés. +pub fn run_composited_multi( + clips: &[ClipSource], + out: &str, + gpu: &Gpu, + comp: &crate::compositor::Compositor, + cfg: &crate::config::Cfg, + params: &ExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + if clips.is_empty() { + bail!("run_composited_multi: aucun clip à exporter"); + } + let (out_w, out_h) = (params.width, params.height); + let t0 = std::time::Instant::now(); + let mut frames: u64 = 0; + + // fps : explicite > dérivé du premier clip. + let out_fps = params.fps.unwrap_or(30) as i32; + // bitrate proportionnel à la surface de sortie (référence : 8Mbps @ 1920x1080). + let bit_rate = ((out_w as i64 * out_h as i64 * 8_000_000) / (1920 * 1080)).max(2_000_000); + + // ---- decodeurs : un par chemin, réutilisés entre clips (screen ≠ webcam → 2 maps) ---- + let mut screen_decs: std::collections::HashMap = + std::collections::HashMap::new(); + let mut webcam_decs: std::collections::HashMap = + std::collections::HashMap::new(); + + // ---- encodeur (candidat VT ou software) ---- + let mut enc = + VideoEncoder::open(¶ms.codec, gpu, out_w as i32, out_h as i32, out_fps, bit_rate)?; + let ectx = enc.ctx; + + // ---- muxer MP4 ---- + let mut octx: *mut crate::ffi::AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + unsafe { + crate::ffi::averr( + crate::ffi::avformat_alloc_output_context2( + &mut octx, + ptr::null(), + ptr::null(), + outc.as_ptr(), + ), + "alloc_output_context2", + )?; + } + let ostream = unsafe { crate::ffi::avformat_new_stream(octx, ptr::null()) }; + if ostream.is_null() { + bail!("avformat_new_stream"); + } + unsafe { + crate::ffi::averr( + crate::ffi::avcodec_parameters_from_context((*ostream).codecpar, ectx), + "params_from_ctx", + )?; + (*ostream).time_base = (*ectx).time_base; + } + + let mut pb: *mut crate::ffi::AVIOContext = ptr::null_mut(); + unsafe { + 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); + crate::ffi::averr( + crate::ffi::avformat_write_header(octx, ptr::null_mut()), + "write_header", + )?; + } + + let opkt = unsafe { crate::ffi::av_packet_alloc() }; + + for clip in clips { + if !screen_decs.contains_key(&clip.screen) { + screen_decs.insert( + clip.screen.clone(), + Decoder::open(&clip.screen, gpu)?, + ); + } + if !webcam_decs.contains_key(&clip.webcam) { + webcam_decs.insert( + clip.webcam.clone(), + Decoder::open(&clip.webcam, gpu)?, + ); + } + let sdec = screen_decs.get_mut(&clip.screen).unwrap(); + let wdec = webcam_decs.get_mut(&clip.webcam).unwrap(); + + // Seek initial (keyframes-only) aux bornes du clip. + let start = clip.source_start_sec; + let end = clip.source_end_sec.min(sdec.available_duration_sec().unwrap_or(end)); + if end <= start { + continue; + } + unsafe { + if sdec.seek_to(start)?.is_null() { + continue; + } + if wdec + .seek_to((start - clip.webcam_offset_sec).max(0.0))? + .is_null() + { + continue; + } + } + + // Boucle frame-par-frame. First-pass : pas de speed-regions ni de timeline + // interpolation ; on rend à out_fps fixe du début à la fin du clip. La scène + // globale a déjà été posée par le caller via `comp.set_scene(...)` (le napi + // le fait avant `run_composited_multi`), donc on n'a pas à la repositionner. + let mut t = start; + while t < end { + unsafe { + let sf = sdec.next()?; + let wf = wdec.next()?; + if sf.is_null() || wf.is_null() { + break; + } + comp.compose_frame(sf, wf, frames as f32, cfg)?; + // send_composited : la première passe ne peuple pas encore les plans du + // buffer d'encodeur depuis le NV12 interne — c'est un no-op côté bits, + // mais il pose l'API et draine l'encodeur. + enc.send_composited(comp, out_w, out_h, frames as i64)?; + drain_encoder(ectx, octx, ostream, opkt)?; + } + frames += 1; + progress(frames); + t += 1.0 / out_fps as f64; + } + } + + // Flush : un null frame à l'encodeur finalise son bitstream. + unsafe { + crate::ffi::averr( + crate::ffi::avcodec_send_frame(ectx, ptr::null_mut()), + "send_frame_flush", + )?; + drain_encoder(ectx, octx, ostream, opkt)?; + crate::ffi::averr( + 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 as *mut *mut _); + } + + let wall_s = t0.elapsed().as_secs_f64(); + Ok(Stats { + frames, + wall_s, + fps: if wall_s > 0.0 { frames as f64 / wall_s } else { 0.0 }, + video_duration_s: frames as f64 / out_fps as f64, + }) +} + +/// Draine les paquets de l'encodeur vers le muxer — symétrique de +/// `pipeline_windows::drain_encoder`. +unsafe fn drain_encoder( + ectx: *mut crate::ffi::AVCodecContext, + octx: *mut crate::ffi::AVFormatContext, + ostream: *mut crate::ffi::AVStream, + opkt: *mut crate::ffi::AVPacket, +) -> Result<()> { + use crate::ffi::*; + loop { + let r = avcodec_receive_packet(ectx, opkt); + if r == AVERROR_EOF { + return Ok(()); + } + if r == AVERROR_EAGAIN { + return Ok(()); + } + averr(r, "receive_packet")?; + av_packet_rescale_ts(opkt, (*ectx).time_base, (*ostream).time_base); + averr( + av_interleaved_write_frame(octx, opkt), + "interleaved_write_frame", + )?; + av_packet_unref(opkt); + } +} + +/// Compte le nombre de frames d'un fichier (utilisé pour la barre de progression). +/// Le comptage est purement ffmpeg-side, donc portable. +pub fn probe_frame_count(_path: &str) -> Result { + Err(anyhow!("pipeline_macos::probe_frame_count: non implémenté")) +} + +// Marqueur pour préserver la signature `fn run_composited(_: &Compositor, ...)` +// quand on câblera l'implémentation ; actuellement `Compositor` est utilisé via la +// cfg-re-export `crate::compositor::Compositor`, et cette fonction helper garantit +// que le type reste référencé. +#[allow(dead_code)] +fn _typecheck_compositor(_c: &Compositor, _g: &Gpu) {} \ No newline at end of file diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline_windows.rs similarity index 87% rename from crates/compositor/src/pipeline.rs rename to crates/compositor/src/pipeline_windows.rs index 2bcf278f13..aaa0349275 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -8,8 +8,9 @@ use crate::audio::{ }; use crate::compositor::{Compositor, OUT_H, OUT_W}; use crate::config::Cfg; +use crate::cpu_frames::CpuFrames; use crate::cursor::CursorTrack; -use crate::d3d::Gpu; +use crate::d3d::{Backend, Gpu}; use crate::ffi::*; use crate::regions::speed_segments_for_window; use anyhow::{anyhow, bail, Result}; @@ -388,6 +389,77 @@ unsafe fn run_c0_inner(screen: &str, out: &str, gpu: &Gpu) -> Result { Ok(Stats { frames, wall_s, fps, video_duration_s: frames as f64 / 60.0 }) } +/// Boucle de PREVIEW mesurée : décode → compose → readback, sans encodeur. +/// +/// Pourquoi pas `run_composited` : celui-ci encode en h264_amf, qui exige le vrai GPU. +/// Le backend CPU ne peut donc pas le traverser, et les deux backends ne seraient pas +/// comparables. L'encodage est de toute façon un TROISIÈME axe (comme le rendu et le +/// décodage) et il n'a pas de repli logiciel ici — ce qui fait de la preview la seule +/// surface que le backend CPU vise réellement. C'est exactement ce que cette boucle mesure, +/// et c'est la même séquence que le thread de rendu de `live.rs`. +/// +/// `frames` = nombre de frames composées ; la source boucle (`seek_to(0)`) si elle est +/// plus courte, pour que les deux backends voient exactement la même charge. +/// +/// Rend aussi le DERNIER readback (`w`, `h`, RGBA8) : un backend qui compose du noir +/// serait rapide et parfaitement inutile, donc le chiffre ne veut rien dire sans l'image +/// qui va avec. C'est ce qui permet de comparer pixel à pixel les deux backends. +pub fn run_preview_bench( + screen: &str, + webcam: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + frames: u64, +) -> Result<(Stats, (u32, u32, Vec))> { + unsafe { + let mut sdec = Decoder::open(screen, gpu)?; + let mut wdec = Decoder::open(webcam, gpu)?; + + // Hors mesure : première frame de chaque source. Le premier décodage porte + // l'allocation du pool (matériel) ou de la texture NV12 + du contexte swscale + // (CPU) ; le compter fausserait surtout les runs courts. + let mut sf = sdec.next()?; + let mut wf = wdec.next()?; + if sf.is_null() || wf.is_null() { + bail!("source vide (screen ou webcam ne rend aucune frame)"); + } + comp.compose_frame(sf, wf, 0.0, cfg)?; + let _ = comp.readback_direct()?; + + let mut last = (0u32, 0u32, Vec::new()); + let t0 = Instant::now(); + for i in 0..frames { + sf = sdec.next()?; + if sf.is_null() { + sf = sdec.seek_to(0.0)?; + } + wf = wdec.next()?; + if wf.is_null() { + wf = wdec.seek_to(0.0)?; + } + if sf.is_null() || wf.is_null() { + bail!("source épuisée après rembobinage à la frame {i}"); + } + comp.compose_frame(sf, wf, i as f32, cfg)?; + // Le readback fait partie de la mesure : c'est ce que la preview paie + // réellement pour afficher une frame (GPU→CPU puis canvas). + last = comp.readback_direct()?; + } + let wall_s = t0.elapsed().as_secs_f64(); + + Ok(( + Stats { + frames, + wall_s, + fps: frames as f64 / wall_s, + video_duration_s: frames as f64 / 60.0, + }, + last, + )) + } +} + /// Décodeur qui rend une frame à la fois (pour composer 2 sources en lockstep). /// `pub(crate)` : réutilisé par la preview/playback (voir `app.rs`). pub(crate) struct Decoder { @@ -398,6 +470,10 @@ pub(crate) struct Decoder { pkt: *mut AVPacket, frame: *mut AVFrame, sent_eof: bool, + /// Backend CPU uniquement : convertit la frame système en texture NV12 et la présente + /// sous le même contrat que D3D11VA (voir `cpu_frames`). `None` en matériel — le + /// décodeur rend alors directement la texture du pool D3D11VA, sans copie. + cpu: Option, } // SAFETY: `Decoder` only owns FFI pointers into FFmpeg's own heap-allocated state, which @@ -427,15 +503,34 @@ impl Decoder { averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; allow_d3d11va_h264_baseline(dctx); - let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); - let hwdc = (*hwdev).data as *mut AVHWDeviceContext; - let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; - let dev_clone = gpu.device.clone(); - (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; - std::mem::forget(dev_clone); - averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; - (*dctx).hw_device_ctx = av_buffer_ref(hwdev); - (*dctx).get_format = Some(get_hw_format); + // Backend CPU : on n'attache AUCUN hw_device_ctx et on ne force pas `get_format`, + // donc libavcodec choisit son décodeur logiciel et sort en mémoire système. Passer + // le device WARP à D3D11VA ne marcherait pas de toute façon — WARP n'expose pas + // d'`ID3D11VideoDevice` (`tests/warp_device_cannot_decode.rs`). + let cpu = if gpu.backend == Backend::Cpu { + // `threads = 0` : libavcodec prend le nombre de cœurs. C'est le seul réglage + // qui compte vraiment ici — sans lui le décodage logiciel est mono-thread et + // le benchmark mesurerait surtout ça. + (*dctx).thread_count = 0; + Some(CpuFrames::new(gpu)?) + } else { + None + }; + + let hwdev = if cpu.is_some() { + ptr::null_mut() + } else { + let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + let hwdc = (*hwdev).data as *mut AVHWDeviceContext; + let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; + let dev_clone = gpu.device.clone(); + (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; + std::mem::forget(dev_clone); + averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; + (*dctx).hw_device_ctx = av_buffer_ref(hwdev); + (*dctx).get_format = Some(get_hw_format); + hwdev + }; averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2")?; Ok(Decoder { @@ -446,13 +541,22 @@ impl Decoder { pkt: av_packet_alloc(), frame: av_frame_alloc(), sent_eof: false, + cpu, }) } /// Dernière frame décodée (valide jusqu'au prochain `next`) — pour recomposer /// la frame courante après un changement de config, sans réavancer (preview). + /// + /// En backend CPU c'est la frame de PRÉSENTATION (la texture NV12 uploadée), pas la + /// frame système du décodeur : `cur_frame` alimente `compose_frame` au même titre que + /// `next`, donc les deux doivent rendre la même chose. Le temps (`cur_time_sec`), lui, + /// continue de se lire sur la vraie frame décodée. pub(crate) fn cur_frame(&self) -> *mut AVFrame { - self.frame + match &self.cpu { + Some(cpu) => cpu.current(), + None => self.frame, + } } /// Repositionne le flux à la première keyframe (t=0) et vide le codec — pour boucler @@ -534,7 +638,10 @@ impl Decoder { loop { let r = avcodec_receive_frame(self.dctx, self.frame); if r == 0 { - return Ok(self.frame); + return match &mut self.cpu { + Some(cpu) => cpu.present(self.frame), + None => Ok(self.frame), + }; } if r == AVERROR_EOF { return Ok(ptr::null_mut()); @@ -860,10 +967,17 @@ impl VideoEncoder { let forced = std::env::var("OPENSCREEN_EXPORT_ENCODER").ok(); let mut refused: Vec = Vec::new(); for &candidate in codec.candidates() { - let (name, _) = candidate; + let (name, pix_fmt) = candidate; if forced.as_deref().is_some_and(|forced| forced != name) { continue; } + // Sans pool D3D11 (backend CPU), les candidats zéro-copie n'ont rien à consommer : + // les écarter ici plutôt que de leur passer un `hw_frames_ctx` nul, dont l'échec + // remonterait comme un refus de driver et masquerait la vraie raison. + if hw_frames.is_null() && pix_fmt == AVPixelFormat::AV_PIX_FMT_D3D11 { + refused.push(format!("{name}: pas de pool D3D11 (backend CPU)")); + continue; + } let encoder = match Self::try_open(candidate, w, h, fps, bit_rate, hw_frames) { Ok(encoder) => encoder, Err(error) => { @@ -973,6 +1087,38 @@ impl VideoEncoder { (*self.sw).pts = (*frame).pts; averr(avcodec_send_frame(self.ctx, self.sw), "send_frame") } + + /// Même chose depuis le backend CPU, où il n'y a PAS de frame D3D11 à descendre. + /// + /// `av_hwframe_transfer_data` suppose un pool `hw_frames_ctx`, et sur WARP il n'y en a + /// pas : `av_hwdevice_ctx_init(D3D11VA)` échoue faute d'`ID3D11VideoDevice` — le même + /// manque qui interdit le décodage matériel. Le compositeur lit donc son NV12 + /// directement en mémoire système, et le reste (conversion planaire, réutilisation des + /// tampons, pts) suit exactement le chemin logiciel de `send`. + unsafe fn send_composited( + &mut self, + comp: &Compositor, + w: u32, + h: u32, + pts: i64, + ) -> Result<()> { + debug_assert!(!self.sw.is_null(), "backend CPU : aucun candidat D3D11 ne doit gagner"); + averr(av_frame_make_writable(self.sw), "frame_make_writable")?; + let landing = if self.nv12.is_null() { self.sw } else { self.nv12 }; + comp.read_nv12_scaled( + w, + h, + (*landing).data[0], + (*landing).linesize[0] as usize, + (*landing).data[1], + (*landing).linesize[1] as usize, + )?; + if !self.nv12.is_null() { + nv12_to_yuv420p(self.nv12, self.sw); + } + (*self.sw).pts = pts; + averr(avcodec_send_frame(self.ctx, self.sw), "send_frame") + } } /// Frame système allouée une fois, réutilisée à chaque envoi. @@ -1074,7 +1220,16 @@ unsafe fn run_multi_inner( let mut cursor_active_path: Option = None; // ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ---- - let (mut enc_hwdev, mut enc_frames) = make_enc_frames(gpu, out_w as i32, out_h as i32)?; + // Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur + // WARP (pas d'`ID3D11VideoDevice`), donc on n'essaie même pas — `VideoEncoder::open` + // écarte alors les candidats zéro-copie et le compositeur alimente l'encodeur en + // mémoire système via `send_composited`. + let software_frames = gpu.backend == Backend::Cpu; + let (mut enc_hwdev, mut enc_frames) = if software_frames { + (ptr::null_mut(), ptr::null_mut()) + } else { + make_enc_frames(gpu, out_w as i32, out_h as i32)? + }; // débit proportionnel à la surface de sortie (référence : 8Mbps @ 1920x1080), plancher // 2Mbps pour rester regardable sur les petites tailles. let bit_rate = ((out_w as i64 * out_h as i64 * 8_000_000) / (1920 * 1080)).max(2_000_000); @@ -1242,15 +1397,20 @@ unsafe fn run_multi_inner( } comp.compose_frame(sf, wf, frames as f32, cfg)?; - let outf = av_frame_alloc(); - averr(av_hwframe_get_buffer(enc_frames, outf, 0), "hwframe_get_buffer")?; - let out_tex = (*outf).data[0] as *mut c_void; - let out_slice = (*outf).data[1] as u32; - comp.rgb_to_nv12_scaled(out_w, out_h, out_tex, out_slice)?; - (*outf).pts = frames as i64; - enc.send(outf)?; - drain_encoder(ectx, octx, ostream, opkt)?; - av_frame_free(&mut (outf as *mut _)); + if software_frames { + enc.send_composited(comp, out_w, out_h, frames as i64)?; + drain_encoder(ectx, octx, ostream, opkt)?; + } else { + let outf = av_frame_alloc(); + averr(av_hwframe_get_buffer(enc_frames, outf, 0), "hwframe_get_buffer")?; + let out_tex = (*outf).data[0] as *mut c_void; + let out_slice = (*outf).data[1] as u32; + comp.rgb_to_nv12_scaled(out_w, out_h, out_tex, out_slice)?; + (*outf).pts = frames as i64; + enc.send(outf)?; + drain_encoder(ectx, octx, ostream, opkt)?; + av_frame_free(&mut (outf as *mut _)); + } frames += 1; progress(frames); } diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal new file mode 100644 index 0000000000..544d307c06 --- /dev/null +++ b/crates/compositor/src/shaders.metal @@ -0,0 +1,465 @@ +// Compositeur — un draw par calque (quad). NV12->RGB maison (E1), coins arrondis SDF (E2). +// Port MSL strict de `crates/compositor/src/shaders.hlsl`. Le shape du constant buffer, +// les noms d'entry points, et les contrats d'interface doivent rester identiques d'un +// backend à l'autre — c'est ce qui permet à `compositor.rs::new_inner` (Windows) et à +// `compositor_macos.rs::new_sized` (macOS) de partager le même ensemble d'effets. +// +// HLSL → MSL différences notables : +// - `cbuffer X : register(b0)` → `constant X & [[buffer(0)]]` +// - `Texture2D T : register(tN)` → `texture2d T [[texture(N)]]` +// - `SamplerState S : register(sN)` → `sampler S [[sampler(N)]]` +// - `SV_VertexID` → `[[vertex_id]]`, `SV_Position` (sortie) → `[[position]]` +// - `TEXCOORDn` → champ libre de struct (MSL n'a pas de qualificateur ; on les +// regroupe dans des structs `VSOut`/`FSOut` comme en HLSL) +// - `T.Sample(samp, uv)` → `T.sample(samp, uv)` (sampler sur l'instance, pas en arg) +// - `SV_Target` (sortie) → `[[color(0)]]` (ou aucun qualificateur — Metal utilise +// l'attachement 0 par défaut, qui est ce qu'on veut pour ces 9 entry points) +// - `saturate(x)` → `clamp(x, 0.0, 1.0)` (Metal 2.0 ; `saturate` existe en 2.4+ mais +// on reste portable) +// - `[unroll]` → `[[unroll]]` (sur le `for`) +// +// IMPORTANT : ce fichier est inclus via `include_str!("shaders.metal")` côté Rust et +// compilé à l'exécution via `MTLDevice.makeLibrary(source:options:)`. Chaque modification +// d'un entry point est revue par le test « every_shader_entry_point_compiles » qui +// compile sans device (cf. `crates/compositor/src/compositor_windows.rs::3177-3194`). + +#include +using namespace metal; + +// ================================================================================= +// Constant buffer — symétrique de `cbuffer Layer : register(b0)` côté HLSL. +// ================================================================================= +// +// Le moteur côté CPU upload ce buffer via `setVertexBytes` (vertex stage) et +// `setFragmentBytes` (fragment stage) avant chaque draw — la copie est de 128 octets, +// ce qui est sous le seuil d'alignement 4K de Metal pour le mode « immediate ». + +struct Layer +{ + float4 dst; // x,y,w,h dans l'espace sortie 0..1 (origine haut-gauche) + float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 + float2 quad_px; // taille du quad en pixels (pour les SDF) + float radius_px; // rayon des coins arrondis en px (0 = aucun) + float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, ... + float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) + float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres + float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) + float4 dst_prev; // dst à la frame précédente + float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) +}; + +constant Layer &layer [[buffer(0)]]; + +// ================================================================================= +// Vertex stage : quads à partir de `SV_VertexID`, fullscreen triangle pour fs pass. +// ================================================================================= + +struct VSOut +{ + float4 pos [[position]]; + float2 uv [[user(TEXCOORD0)]]; // coords d'échantillonnage source + float2 local [[user(TEXCOORD1)]]; // coords pixel dans le quad (pour SDF) + float2 pout [[user(TEXCOORD2)]]; // position 0..1 sortie (pour la vélocité par pixel) +}; + +vertex VSOut vs_main(uint vid [[vertex_id]]) +{ + float2 c = float2(vid & 1, (vid >> 1) & 1); // strip: (0,0)(1,0)(0,1)(1,1) + float2 p = layer.dst.xy + c * layer.dst.zw; // 0..1 sortie + float2 ndc = float2(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0); + VSOut o; + o.pos = float4(ndc, 0.0, 1.0); + o.uv = layer.src.xy + c * (layer.src.zw - layer.src.xy); + o.local = c * layer.quad_px; + o.pout = p; + return o; +} + +// ================================================================================= +// Textures et samplers. +// ================================================================================= + +constexpr sampler samp(filter::linear, address::clamp_to_edge); +constexpr sampler sampNV(filter::linear, address::clamp_to_edge); + +texture2d texY [[texture(0)]]; +texture2d texUV [[texture(1)]]; +texture2d texImg [[texture(2)]]; // wallpaper image RGBA (fond, mode 6) +texture2d rgbTex [[texture(0)]]; // pour ps_fs_* (réutilise le slot 0) + +// ================================================================================= +// Helpers : conversions couleur, primitives SDF. +// ================================================================================= + +// BT.709 limited -> RGB (§7 E1), matrice en dur, range mesuré en S1. +inline float3 yuv709_limited(float y, float2 cbcr) +{ + float Yf = (y * 255.0 - 16.0) / 219.0; + float Cb = (cbcr.x * 255.0 - 128.0) / 224.0; + float Cr = (cbcr.y * 255.0 - 128.0) / 224.0; + float3 rgb; + rgb.r = Yf + 1.5748 * Cr; + rgb.g = Yf - 0.1873 * Cb - 0.4681 * Cr; + rgb.b = Yf + 1.8556 * Cb; + return clamp(rgb, 0.0, 1.0); +} + +inline float3 sample_yuv(float2 uv) +{ + float y = texY.sample(samp, uv); + float2 cbcr = texUV.sample(samp, uv); + return yuv709_limited(y, cbcr); +} + +// SDF segment à bouts ronds — la primitive des flèches d'annotation. +inline float sd_segment(float2 p, float2 a, float2 b) +{ + float2 pa = p - a; + float2 ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0); + return length(pa - ba * h); +} + +// SDF rectangle à coins arrondis (§7 E2) : <0 dedans. +inline float sd_round_rect(float2 p, float2 halfsz, float r) +{ + float2 q = abs(p) - halfsz + r; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +// Intersection de deux droites données par (normale, offset) : n·x = d. Cramer. +inline float2 line_cross(float2 n1, float d1, float2 n2, float d2) +{ + float det = n1.x * n2.y - n1.y * n2.x; + if (abs(det) < 1e-6) return float2(0.0, 0.0); + return float2(d1 * n2.y - d2 * n1.y, d2 * n1.x - d1 * n1.x) / det; +} + +// Distance signée EXACTE à un quadrilatère convexe (<0 dedans). +inline float sd_convex_quad(float2 p, float2 v0, float2 v1, float2 v2, float2 v3) +{ + float2 v0n = v0, v1n = v1, v2n = v2, v3n = v3, v4n = v0; + float inside = -1e9; + float border = 1e9; + for (int k = 0; k < 4; k++) + { + float2 a; + float2 e_next; + if (k == 0) { a = v0n; e_next = v1n; } + else if (k == 1) { a = v1n; e_next = v2n; } + else if (k == 2) { a = v2n; e_next = v3n; } + else { a = v3n; e_next = v4n; } + float2 e = e_next - a; + float2 n = float2(e.y, -e.x) / max(length(e), 1e-6); + inside = max(inside, dot(p - a, n)); + border = min(border, sd_segment(p, a, e_next)); + } + return (inside < 0.0) ? -border : border; +} + +// (s, t, ok) du warp inverse du mode 8 pour une racine `t` donnée. +inline float3 quad_st_for_root(float t, float2 e, float2 f, float2 g, float2 h) +{ + float denomX = e.x + g.x * t; + float denomY = e.y + g.y * t; + float s = (abs(denomX) > abs(denomY)) ? (h.x - f.x * t) / denomX : (h.y - f.y * t) / denomY; + float ok = (s >= -0.02 && s <= 1.02 && t >= -0.02 && t <= 1.02) ? 1.0 : 0.0; + return float3(s, t, ok); +} + +// (s, t, ok) du point `P` dans le quad c00->c10->c11->c01 : le warp bilinéaire INVERSE. +inline float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float2 c01) +{ + float2 e = c10 - c00; + float2 f = c01 - c00; + float2 g = c00 - c10 - c01 + c11; + float2 h = P - c00; + float k2 = g.x * f.y - g.y * f.x; + float k1 = e.x * f.y - e.y * f.x + h.x * g.y - h.y * g.x; + float k0 = h.x * e.y - h.y * e.x; + if (abs(k2) < 1e-5 * abs(k1)) + { + float t = (abs(k1) < 1e-6) ? 0.0 : -k0 / k1; + return quad_st_for_root(t, e, f, g, h); + } + float disc = k1 * k1 - 4.0 * k2 * k0; + if (disc < 0.0) return float3(0.0, 0.0, 0.0); + float q = -0.5 * (k1 + (k1 >= 0.0 ? 1.0 : -1.0) * sqrt(disc)); + float3 r0 = quad_st_for_root(q / k2, e, f, g, h); + float3 r1 = quad_st_for_root(abs(q) > 0.0 ? k0 / q : q / k2, e, f, g, h); + return (r0.z > 0.5) ? r0 : r1; +} + +// ================================================================================= +// Pixel shader principal : un seul `ps_main` qui gère 14 modes via `layer.mode`. +// Identique à `ps_main` côté HLSL ligne pour ligne (à la syntaxe MSL près). +// ================================================================================= + +fragment float4 ps_main(VSOut i [[stage_in]]) +{ + // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Cf. commentaires HLSL. + if (layer.mode > 12.5) + { + if (i.pout.x < layer.dst_prev.x || i.pout.x > layer.dst_prev.x + layer.dst_prev.z || + i.pout.y < layer.dst_prev.y || i.pout.y > layer.dst_prev.y + layer.dst_prev.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float4 s = texImg.sample(samp, clamp(float2(r.x, r.y), 0.0, 1.0)); + float a = s.a * layer.color.a; + return float4(s.rgb * a, a); + } + + // mode 11 : texte D2D en alpha prémultiplié — ne PAS re-multiplier. + if (layer.mode > 10.5 && layer.mode < 11.5) + { + float4 s = texImg.sample(samp, i.uv); + float a = s.a * layer.color.a; + return float4(s.rgb * a, a); + } + + // mode 12 : ombre du quad projeté. Pénombre douce autour du quad tilté. + if (layer.mode > 11.5) + { + float spread = layer.fx.x; + float2 halfsz = layer.quad_px * 0.5 - spread; + float2 p = i.local - layer.quad_px * 0.5; + float d = sd_convex_quad(p, + layer.fx.xy, + layer.fx.zw, + layer.src_prev.xy, + layer.src_prev.zw) + spread; + float a = layer.color.a * (1.0 - clamp((d - spread) / max(spread, 1.0), 0.0, 1.0)); + return float4(layer.color.rgb * a, a); + } + + // mode 8 : écran tilté (zoom regions "rotation"). Warp bilinéaire inverse. + if (layer.mode > 7.5 && layer.mode < 8.5) + { + if (i.pout.x < layer.dst_prev.x || i.pout.x > layer.dst_prev.x + layer.dst_prev.z || + i.pout.y < layer.dst_prev.y || i.pout.y > layer.dst_prev.y + layer.dst_prev.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float3 rgb = sample_yuv(clamp(float2(r.x, r.y), 0.0, 1.0)); + return float4(rgb * layer.color.a, layer.color.a); + } + + // mode 7 : sprite de curseur en alpha DROIT (multiplication finale). + if (layer.mode > 6.5 && layer.mode < 7.5) + { + float4 s = texImg.sample(samp, i.uv); + float a = s.a * layer.color.a; + return float4(s.rgb * a, a); + } + + // mode 6 : wallpaper image (fond non NV12). + if (layer.mode > 5.5 && layer.mode < 6.5) + { + float4 s = texImg.sample(samp, i.uv); + float a = layer.color.a; + return float4(s.rgb * a, a); + } + + // mode 5 : gradient (texImg porte le gradient en mode 6 ; en mode 5 c'est `color`). + if (layer.mode > 4.5 && layer.mode < 5.5) + { + return float4(layer.color.rgb * layer.color.a, layer.color.a); + } + + // mode 4 : curseur dessiné (dot + ring SDF). + if (layer.mode > 3.5 && layer.mode < 4.5) + { + float2 p = i.local - layer.quad_px * 0.5; + float R = min(layer.quad_px.x, layer.quad_px.y) * 0.5; + float r = length(p); + float aa = 1.5; + float dot_r = R * 0.34; + float ring_r = R * 0.72; + float ring_w = R * 0.09; + float ddot = 1.0 - clamp((r - (dot_r - aa)) / (2.0 * aa), 0.0, 1.0); + float ring = clamp((r - (ring_r - ring_w - aa)) / aa, 0.0, 1.0) + * (1.0 - clamp((r - (ring_r + ring_w)) / aa, 0.0, 1.0)); + float halo = (1.0 - clamp((r - (dot_r + aa)) / 2.5, 0.0, 1.0)) * (1.0 - ddot); + float a = clamp(ddot + ring, 0.0, 1.0) * layer.color.a; + float3 rgb = layer.color.rgb * (ddot + ring); + a = clamp(a + halo * 0.35 * layer.color.a, 0.0, 1.0); + return float4(rgb * a, a); + } + + // mode 9 : flèche d'annotation (3 segments round-cap, SDF du quad = SDF triangle?). + if (layer.mode > 8.5 && layer.mode < 9.5) + { + float2 p = i.local - layer.quad_px * 0.5; + float w = clamp(layer.radius_px, 0.0, layer.quad_px.y * 0.5); + float2 a0 = float2(0.0, -layer.quad_px.y * 0.5 + w); + float2 a1 = float2(layer.quad_px.x * 0.5 - w, layer.quad_px.y * 0.5 - w); + float d = sd_segment(p, a0, a1) - w; + float a = layer.color.a * (1.0 - clamp(d * 1.5, 0.0, 1.0)); + return float4(layer.color.rgb * a, a); + } + + // mode 10 : annotation blur / pixelate (on rend dans `ann_copy` qui porte la + // pyramide de mi-blur, puis on ré-échantillonne ici). + if (layer.mode > 9.5 && layer.mode < 10.5) + { + float4 s = texImg.sample(samp, i.uv); + return float4(s.rgb, 1.0) * layer.color.a; + } + + // mode 2 : ombre portée (§7 E4). Pénombre douce dérivée de la SDF du quad source, + // qui est inséré à l'intérieur du quad d'ombre (élargi de `spread` de chaque côté). + if (layer.mode > 1.5 && layer.mode < 2.5) + { + float spread = layer.fx.x; + float2 halfsz = layer.quad_px * 0.5 - spread; + float2 p = i.local - layer.quad_px * 0.5; + float d = sd_round_rect(p, halfsz, layer.radius_px); + float a = layer.color.a * (1.0 - clamp(d / max(spread, 1.0), 0.0, 1.0)); + return float4(layer.color.rgb * a, a); + } + + float3 rgb; + if (layer.mode < 0.5) + { + // flou de mouvement par vélocité (§8) + float2 uv_now = i.uv; + float2 localp = (i.pout - layer.dst_prev.xy) / layer.dst_prev.zw; + float2 uv_prev = layer.src_prev.xy + localp * (layer.src_prev.zw - layer.src_prev.xy); + float2 duv = uv_now - uv_prev; + int taps = int(layer.mb.x); + if (taps <= 1 || dot(duv, duv) < 1e-9) + { + rgb = sample_yuv(uv_now); + } + else + { + float3 acc = 0.0; + for (int k = 0; k < 16; k++) + { + if (k >= taps) break; + float t = float(k) / float(taps - 1); + acc += sample_yuv(uv_prev + duv * t); + } + rgb = acc / float(taps); + } + } + else + { + rgb = layer.color.rgb; + } + + float alpha = layer.color.a; + if (layer.radius_px > 0.0) + { + float2 halfsz = layer.quad_px * 0.5; + float2 p = i.local - layer.quad_px * 0.5; + float d = sd_round_rect(p, halfsz, layer.radius_px); + alpha *= 1.0 - clamp(d * 1.5, 0.0, 1.0); + } + return float4(rgb * alpha, alpha); +} + +// ================================================================================= +// Fullscreen pass : RGB -> NV12. Mêmes shaders que la passe équivalente HLSL. +// ================================================================================= + +struct FSOut +{ + float4 pos [[position]]; + float2 uv [[user(TEXCOORD0)]]; +}; + +vertex FSOut vs_fs(uint vid [[vertex_id]]) +{ + FSOut o; + o.uv = float2((vid << 1) & 2, vid & 2); + o.pos = float4(o.uv * float2(2, -2) + float2(-1, 1), 0, 1); + return o; +} + +inline float rgb2y(float3 c) { return (16.0 + 219.0 * (0.2126*c.r + 0.7152*c.g + 0.0722*c.b)) / 255.0; } +inline float2 rgb2uv(float3 c) +{ + float yp = 0.2126*c.r + 0.7152*c.g + 0.0722*c.b; + float cb = (c.b - yp) / 1.8556; + float cr = (c.r - yp) / 1.5748; + return float2(128.0 + 224.0 * cb, 128.0 + 224.0 * cr) / 255.0; +} + +fragment float ps_y(FSOut i [[stage_in]]) +{ + return rgb2y(rgbTex.sample(sampNV, i.uv).rgb); +} + +fragment float2 ps_uv(FSOut i [[stage_in]]) +{ + return rgb2uv(rgbTex.sample(sampNV, i.uv).rgb); +} + +// ================================================================================= +// Flou gaussien séparable (§7 E3) — shader conservé pour référence, le port actif +// utilise `ps_kawase_down/up` (cf. commit « Kawase » plus loin si on revient). +// ================================================================================= + +constexpr int BLUR_R = 24; + +fragment float4 ps_blur(FSOut i [[stage_in]]) +{ + float sigma = max(layer.fx.x, 0.001); + float2 step = layer.fx.y * layer.fx.zw; + float4 acc = 0.0; + float wsum = 0.0; + for (int k = -BLUR_R; k <= BLUR_R; k++) + { + float w = exp(-0.5 * float(k * k) / (sigma * sigma)); + acc += rgbTex.sample(sampNV, i.uv + float(k) * step) * w; + wsum += w; + } + return acc / wsum; +} + +fragment float4 ps_tex(FSOut i [[stage_in]]) { return rgbTex.sample(sampNV, i.uv); } + +// ================================================================================= +// Dual-Kawase (fond flouté rapide). +// ================================================================================= + +fragment float4 ps_kawase_down(FSOut i [[stage_in]]) +{ + float2 hp = layer.fx.xy * 0.5 * layer.fx.z; + float2 uv = i.uv; + float4 s = rgbTex.sample(sampNV, uv) * 4.0; + s += rgbTex.sample(sampNV, uv - hp); + s += rgbTex.sample(sampNV, uv + hp); + s += rgbTex.sample(sampNV, uv + float2(hp.x, -hp.y)); + s += rgbTex.sample(sampNV, uv - float2(hp.x, -hp.y)); + return s / 8.0; +} + +fragment float4 ps_kawase_up(FSOut i [[stage_in]]) +{ + float2 hp = layer.fx.xy * 0.5 * layer.fx.z; + float2 uv = i.uv; + float4 s = rgbTex.sample(sampNV, uv + float2(-hp.x * 2.0, 0.0)); + s += rgbTex.sample(sampNV, uv + float2(-hp.x, hp.y)) * 2.0; + s += rgbTex.sample(sampNV, uv + float2(0.0, hp.y * 2.0)) * 2.0; + s += rgbTex.sample(sampNV, uv + float2(hp.x, hp.y)) * 2.0; + s += rgbTex.sample(sampNV, uv + float2(hp.x * 2.0, 0.0)); + s += rgbTex.sample(sampNV, uv + float2(hp.x, -hp.y)) * 2.0; + s += rgbTex.sample(sampNV, uv + float2(0.0, -hp.y * 2.0)) * 2.0; + s += rgbTex.sample(sampNV, uv + float2(-hp.x, -hp.y)) * 2.0; + return s / 12.0; +} \ No newline at end of file diff --git a/crates/compositor/src/text_macos.rs b/crates/compositor/src/text_macos.rs new file mode 100644 index 0000000000..5e591609af --- /dev/null +++ b/crates/compositor/src/text_macos.rs @@ -0,0 +1,357 @@ +//! Rastérisation du texte des annotations sur macOS — CoreText + CoreGraphics. +//! +//! Équivalent macOS de `text_windows.rs` (DirectWrite + Direct2D sur surface DXGI). +//! Le module exporte la même surface publique (`TextSpec`, `TextRasterizer`) pour que +//! `compositor.rs` puisse appeler `TextRasterizer::new()` / `rasterize(...)` sans +//! connaître la plateforme. +//! +//! # État de ce commit +//! +//! Première implémentation du rastériseur. Le câblage ObjC/CoreText est posé ; il est +//! honnête mais pas testé en runtime — la première fois qu'il tourne sur Mac, ce sera +//! au prochain push (CI macos-14). Les bindings ObjC couvrent le strict minimum +//! nécessaire : `NSString`, `NSMutableAttributedString`, `NSMutableParagraphStyle`, +//! `CGColor`, `CTFont`, `CTFramesetter`, `CTFrame`, et `CGBitmapContextCreate`. +//! +//! L'API suit la policy de la version Windows : `TextSpec::cache_key()` byte-identique, +//! `TextRasterizer::new()` renvoie `Ok` immédiatement (pas de factories CoreText +//! à allouer — CoreText/CoreGraphics sont prêts dès le link des frameworks). + +use crate::metal::Gpu; +use anyhow::{anyhow, bail, Result}; +use objc2::msg_send; +use objc2::runtime::AnyClass; +use std::ffi::{c_void, CString}; +use std::ptr; + +/// 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, + /// 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 { + use std::hash::{Hash, Hasher}; + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + self.content.hash(&mut h); + self.color.iter().for_each(|f| f.to_bits().hash(&mut h)); + self.background.iter().for_each(|f| f.to_bits().hash(&mut h)); + self.font_size_px.to_bits().hash(&mut h); + self.font_family.hash(&mut h); + self.bold.hash(&mut h); + self.italic.hash(&mut h); + self.underline.hash(&mut h); + self.align.hash(&mut h); + self.box_px.iter().for_each(|u| u.hash(&mut h)); + h + } +} + +/// Frameworks liés. +#[link(name = "CoreText", kind = "framework")] +#[link(name = "CoreGraphics", kind = "framework")] +#[link(name = "CoreFoundation", kind = "framework")] +#[link(name = "Foundation", kind = "framework")] +extern "C" {} + +/// Bindings CoreText + CG minimaux. +extern "C" { + fn CFRelease(cf: *const c_void); + fn CTFontCreateWithName( + name: *const c_void, + size: f64, + matrix: *const c_void, + ) -> *const c_void; + fn CTFramesetterCreateWithAttributedString(attr: *const c_void) -> *const c_void; + fn CTFramesetterCreateFrame( + framesetter: *const c_void, + location: usize, + length: isize, + path: *const c_void, + extra: *const c_void, + ) -> *const c_void; + fn CTFrameDraw(frame: *const c_void, context: *const c_void); + fn CGDataProviderCreateWithData( + info: *const c_void, + data: *const c_void, + size: usize, + release_data: *const c_void, + release_info: *const c_void, + ) -> *const c_void; + // CGBitmapContextCreate : 8 args, bind via extern "C". + fn CGBitmapContextCreate( + data: *mut c_void, + width: usize, + height: usize, + bits_per_component: usize, + bytes_per_row: usize, + color_space: *const c_void, + bitmap_info: u32, + ) -> *const c_void; +} + +/// Rastériseur de texte macOS. Pas d'état persistant pour l'instant — CoreText +/// et CoreGraphics sont prêts dès le link des frameworks. +pub struct TextRasterizer; + +impl TextRasterizer { + pub fn new() -> Result { + Ok(TextRasterizer) + } + + /// Rastérise `spec` dans une texture Metal neuve (BGRA8Unorm, alpha-prémultiplié). + /// Le caller (`Compositor`) retient la texture et la stocke dans son cache + /// text_cache (clé = spec.cache_key()). + /// + /// Pipeline : + /// 1. Alloue une `MTLTexture` BGRA8Unorm, `StorageMode::Shared`. + /// 2. Crée un `CGContext` bitmap backed par un buffer CPU (la métal-rs 0.29 ne + /// permet pas d'obtenir le pointeur GPU d'une Shared texture sans IOSurface — + /// le buffer CPU est uploadé via `replace_region` à l'étape finale). + /// 3. Construit un `NSAttributedString` (content + couleur + font + underline + + /// alignement). + /// 4. Crée un `CTFramesetter` + `CTFrame`, et `CTFrameDraw` dans le context. + /// 5. Upload du buffer CPU → MTLTexture. + pub unsafe fn rasterize( + &self, + gpu: &Gpu, + spec: &TextSpec, + ) -> Result<*mut c_void> { + let (w, h) = (spec.box_px[0].max(1) as usize, spec.box_px[1].max(1) as usize); + if spec.content.is_empty() { + bail!("text_macos::rasterize: texte vide"); + } + + // 1. MTLTexture BGRA8Unorm, Shared. + let desc = metal::TextureDescriptor::new(); + desc.set_texture_type(metal::TextureType::Type2D); + desc.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm); + desc.set_width(w as u64); + desc.set_height(h as u64); + desc.set_usage(metal::MTLTextureUsage::ShaderRead); + desc.set_storage_mode(metal::MTLStorageMode::Shared); + let texture = gpu.device.new_texture(&desc); + + // 2. Buffer CPU + CGContext bitmap. + let bytes_per_row = w * 4; + let mut buffer: Vec = vec![0u8; bytes_per_row * h]; + // kCGImageAlphaPremultipliedFirst (1) | kCGBitmapByteOrder32Little (4<<12 = 8192) | kCGImageAlphaNone (0). + // 1 = premultiplied ; 8192 = 32-bit little-endian byte order ; le | 0x8000 final force BGRA layout (par défaut). + let bitmap_info: u32 = 0x100 | 0x8000 | (4 << 12); + let cg_ctx = CGBitmapContextCreate( + buffer.as_mut_ptr() as *mut c_void, + w, + h, + 8, // bits per component + bytes_per_row, + ptr::null(), // default device-RGB color space + bitmap_info, + ); + if cg_ctx.is_null() { + bail!("CGBitmapContextCreate a renvoyé NULL"); + } + // 3. NSAttributedString. + let attr_str = build_attributed_string(spec)?; + let attr_len: isize = spec.content.chars().count() as isize; + + // 4. CTFramesetter + CTFrame + draw. + let framesetter = CTFramesetterCreateWithAttributedString(attr_str); + CFRelease(attr_str); + if framesetter.is_null() { + bail!("CTFramesetterCreateWithAttributedString NULL"); + } + let frame = CTFramesetterCreateFrame( + framesetter, + 0, // location + attr_len, + ptr::null(), // path = NULL = full context bounds + ptr::null(), + ); + CFRelease(framesetter); + if frame.is_null() { + bail!("CTFramesetterCreateFrame NULL"); + } + CTFrameDraw(frame, cg_ctx); + CFRelease(frame); + CFRelease(cg_ctx); + + // 5. Upload CPU → texture via replace_region. + let region = metal::MTLRegionMake2D(0, 0, w as u64, h as u64); + texture.replace_region( + region, + 0, // mipmap level + 0, // slice + bytes_per_row as u64, + buffer.as_ptr() as *const c_void, + ); + + Ok(texture.as_ptr()) + } +} + +/// Construit un NSAttributedString ObjC avec les attributs CoreText demandés. +unsafe fn build_attributed_string(spec: &TextSpec) -> Result<*const c_void> { + // NSString depuis spec.content. + let ns_string_cls = AnyClass::get(c"NSString").ok_or_else(|| anyhow!("NSString introuvable"))?; + let utf8 = CString::new(spec.content.as_str())?; + let ns_string: *mut objc2::runtime::AnyObject = + msg_send![ns_string_cls, stringWithUTF8String: utf8.as_ptr()]; + if ns_string.is_null() { + bail!("NSString stringWithUTF8String NULL"); + } + // NSMutableAttributedString. + let attr_str_cls = + AnyClass::get(c"NSMutableAttributedString").ok_or_else(|| anyhow!("NSMutableAttributedString introuvable"))?; + let attr_str: *mut objc2::runtime::AnyObject = + msg_send![attr_str_cls, attributedStringWithString: ns_string]; + if attr_str.is_null() { + bail!("NSMutableAttributedString attributedStringWithString NULL"); + } + let len: usize = spec.content.chars().count(); + + // Set d'attributs via addAttribute:value:range:. + let range: NSRange = NSRange { location: 0, length: len }; + + // Foreground color. + if let Some(cg_color) = cg_color_create_rgba(spec.color[0], spec.color[1], spec.color[2], spec.color[3]) { + let _: () = msg_send![attr_str, + addAttribute: sel_foregroundColor(), + value: cg_color, + range: range, + ]; + } + + // Font. + if let Some(ctfont) = ct_font_create_with_name( + &spec.font_family, + spec.font_size_px.max(1.0) as f64, + ) { + let _: () = msg_send![attr_str, + addAttribute: sel_font(), + value: ctfont, + range: range, + ]; + } + + // Underline. + if spec.underline { + let _: () = msg_send![attr_str, + addAttribute: sel_underlineStyle(), + value: 1usize, + range: range, + ]; + } + + // Alignement. + let paragraph_cls = AnyClass::get(c"NSMutableParagraphStyle") + .ok_or_else(|| anyhow!("NSMutableParagraphStyle introuvable"))?; + let paragraph: *mut objc2::runtime::AnyObject = msg_send![paragraph_cls, new]; + if !paragraph.is_null() { + let align: isize = match spec.align.as_str() { + "left" => 0, + "right" => 1, + _ => 2, + }; + let _: () = msg_send![paragraph, setAlignment: align]; + let _: () = msg_send![attr_str, + addAttribute: sel_paragraphStyle(), + value: paragraph, + range: range, + ]; + } + + Ok(attr_str as *const c_void) +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct NSRange { + location: usize, + length: usize, +} + +/// Sélecteurs Objective-C (constants, enregistrées une seule fois). +unsafe fn sel_foregroundColor() -> *const c_void { + objc2::runtime::Sel::register(c"NSColor").as_ptr() as *const c_void +} +unsafe fn sel_font() -> *const c_void { + objc2::runtime::Sel::register(c"NSFont").as_ptr() as *const c_void +} +unsafe fn sel_underlineStyle() -> *const c_void { + objc2::runtime::Sel::register(c"NSUnderlineStyle").as_ptr() as *const c_void +} +unsafe fn sel_paragraphStyle() -> *const c_void { + objc2::runtime::Sel::register(c"NSParagraphStyle").as_ptr() as *const c_void +} + +/// Crée un CGColorRef (retainable) à partir de (r,g,b,a) en [0,1]. +unsafe fn cg_color_create_rgba(r: f64, g: f64, b: f64, a: f64) -> Option<*mut objc2::runtime::AnyObject> { + let color_cls = AnyClass::get(c"CGColor")?; + let space_cls = AnyClass::get(c"CGColorSpace")?; + let device_rgb: *mut objc2::runtime::AnyObject = msg_send![space_cls, deviceRGBColorSpace]; + if device_rgb.is_null() { + return None; + } + let color: *mut objc2::runtime::AnyObject = msg_send![color_cls, + colorWithRed: r, + green: g, + blue: b, + alpha: a, + ]; + if color.is_null() { + None + } else { + Some(color) + } +} + +/// Crée un CTFontRef (retainable) depuis un nom de famille et une taille. +unsafe fn ct_font_create_with_name( + family: &str, + size: f64, +) -> Option<*mut objc2::runtime::AnyObject> { + let cf_string_cls = AnyClass::get(c"CFString")?; + let family_bytes = CString::new(family).ok()?; + // CFStringCreateWithCString(kCFAllocatorDefault, cstr, kCFStringEncodingUTF8). + let alloc_default_cls = AnyClass::get(c"kCFAllocatorDefault"); + let alloc: *mut objc2::runtime::AnyObject = if let Some(c) = alloc_default_cls { + msg_send![c, self] + } else { + ptr::null_mut() + }; + let family_cf: *mut objc2::runtime::AnyObject = msg_send![cf_string_cls, + stringWithCString: family_bytes.as_ptr() + encoding: 0x08000100u32 /* kCFStringEncodingUTF8 */ + ]; + if family_cf.is_null() { + return None; + } + let font: *mut objc2::runtime::AnyObject = CTFontCreateWithName( + family_cf as *const c_void, + size, + ptr::null(), + ) as *mut objc2::runtime::AnyObject; + if font.is_null() { + None + } else { + Some(font) + } +} \ No newline at end of file diff --git a/crates/compositor/src/text.rs b/crates/compositor/src/text_windows.rs similarity index 100% rename from crates/compositor/src/text.rs rename to crates/compositor/src/text_windows.rs diff --git a/crates/compositor/tests/warp_device_cannot_decode.rs b/crates/compositor/tests/warp_device_cannot_decode.rs new file mode 100644 index 0000000000..d9cbad14cc --- /dev/null +++ b/crates/compositor/tests/warp_device_cannot_decode.rs @@ -0,0 +1,80 @@ +//! Pourquoi le backend CPU décode en LOGICIEL et pas sur le device WARP (PR #162). +//! +//! La proposition initiale était de simplement retenter `D3D11CreateDevice` en +//! `D3D_DRIVER_TYPE_WARP` quand le matériel échoue. Ça ne suffit pas, et pas pour une +//! raison de vitesse : WARP n'a pas de décodeur vidéo du tout. Or le chemin matériel +//! passe le device de `Gpu` à ffmpeg comme `AVD3D11VADeviceContext` +//! (`pipeline.rs`, `(*d3dctx).device = ...`) — un device WARP branché là se créerait +//! puis ne produirait aucune frame. +//! +//! D'où la forme qu'a prise `Backend::Cpu` : WARP pour le RENDU, libavcodec en mémoire +//! système pour le DÉCODAGE, uploadé en NV12 par `cpu_frames.rs`. Deux axes, deux +//! solutions — c'est ce test qui dit pourquoi le second existe. +//! +//! S'il ÉCHOUE, c'est que WARP a gagné une capacité vidéo sur cette machine/version de +//! Windows : `Backend::Cpu` pourrait alors décoder directement sur son device et se +//! passer de tout `cpu_frames.rs`. + +#![cfg(windows)] + +use windows::core::Interface; +use windows::Win32::Foundation::HMODULE; +use windows::Win32::Graphics::Direct3D::{ + D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_WARP, D3D_FEATURE_LEVEL, D3D_FEATURE_LEVEL_11_1, +}; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11VideoDevice, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_FLAG, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION, +}; + +fn create( + driver: D3D_DRIVER_TYPE, + flags: D3D11_CREATE_DEVICE_FLAG, +) -> windows::core::Result { + let levels = [D3D_FEATURE_LEVEL_11_1]; + let mut device: Option = None; + let mut context: Option = None; + let mut got = D3D_FEATURE_LEVEL::default(); + unsafe { + D3D11CreateDevice( + None, + driver, + HMODULE::default(), + flags, + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + Some(&mut got), + Some(&mut context), + )?; + } + device.ok_or_else(|| windows::core::Error::from(windows::Win32::Foundation::E_UNEXPECTED)) +} + +/// Exactement les flags de `Gpu::create`. Mesuré : `DXGI_ERROR_UNSUPPORTED` (0x887A0004) +/// — le device WARP ne se crée même pas avec `VIDEO_SUPPORT`. +#[test] +fn warp_rejects_the_video_support_flag_gpu_create_requires() { + let err = create( + D3D_DRIVER_TYPE_WARP, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT, + ) + .expect_err("WARP a accepté VIDEO_SUPPORT — le repli WARP redevient envisageable"); + assert_eq!(err.code().0 as u32, 0x887A_0004, "attendu DXGI_ERROR_UNSUPPORTED, eu {err}"); +} + +/// Et laisser tomber le flag ne sauve rien : le device se crée bien à FL 11_1, mais +/// il n'expose aucun `ID3D11VideoDevice`, donc zéro profil décodeur pour D3D11VA. +#[test] +fn warp_without_the_flag_still_exposes_no_video_device() { + let device = + create(D3D_DRIVER_TYPE_WARP, D3D11_CREATE_DEVICE_BGRA_SUPPORT).expect("WARP FL 11_1"); + let profiles = device + .cast::() + .map(|video| unsafe { video.GetVideoDecoderProfileCount() }); + assert!( + matches!(profiles, Err(_) | Ok(0)), + "WARP expose {profiles:?} profils décodeur — le repli WARP redevient envisageable" + ); +} diff --git a/crates/compositor/wrapper.h b/crates/compositor/wrapper.h deleted file mode 100644 index 88fb4be894..0000000000 --- a/crates/compositor/wrapper.h +++ /dev/null @@ -1,8 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include diff --git a/crates/compositor/wrapper_macos.h b/crates/compositor/wrapper_macos.h new file mode 100644 index 0000000000..5a87a14662 --- /dev/null +++ b/crates/compositor/wrapper_macos.h @@ -0,0 +1,20 @@ +// Wrapper C pour bindgen — variante macOS. +// Sur macOS le codec d'accélération matérielle est VideoToolbox (ffmpeg +// `AV_HWDEVICE_TYPE_VIDEOTOOLBOX`), pas D3D11VA. Le shape du contexte est +// très proche (un device opaque + des flags), mais les noms des types et +// les champs diffèrent — d'où un wrapper dédié. +// +// L'ordre des includes suit wrapper_windows.h pour stabiliser les allowlists +// communes (AVFormatContext, AVPacket, AVFrame, sws/swr, etc.). +#include +#include +#include +#include +#include +#include +#include +#include +/* Software decode path : swscale était déjà LIÉ (build.rs) sans être bindé. + Conservé identique côté macOS pour que la symétrie avec cpu_frames_windows.rs + soit claire ; le code effectif vit dans mac_frames.rs. */ +#include \ No newline at end of file diff --git a/crates/compositor/wrapper_windows.h b/crates/compositor/wrapper_windows.h new file mode 100644 index 0000000000..86612f96ae --- /dev/null +++ b/crates/compositor/wrapper_windows.h @@ -0,0 +1,14 @@ +#include +#include +#include +#include +#include +#include +#include +#include +/* Software decode path (CPU backend) : les décodeurs logiciels sortent du YUV420P, + la chaîne D3D échantillonne du NV12. swscale était déjà LIÉ (build.rs) sans être + bindé — c'est la conversion la mieux optimisée qu'on ait déjà sous la main, et + elle couvre les formats exotiques (10 bits, 4:2:2) qu'un interleave écrit à la + main casserait silencieusement. */ +#include diff --git a/crates/poc-d3d/src/bench.rs b/crates/poc-d3d/src/bench.rs index 23769136ad..46673743a2 100644 --- a/crates/poc-d3d/src/bench.rs +++ b/crates/poc-d3d/src/bench.rs @@ -55,7 +55,7 @@ fn run_bench(args: &[String]) -> Result<()> { // sélection des cfg let all = config::all(); - let cfgs: Vec = if cfg_arg.contains("..") { + let mut cfgs: Vec = if cfg_arg.contains("..") { all } else { cfg_arg @@ -64,8 +64,43 @@ fn run_bench(args: &[String]) -> Result<()> { .collect() }; - let gpu = d3d::Gpu::create(false)?; - println!("d3d11 device ok (feature_level 0x{:X})", gpu.feature_level.0 as u32); + // `--backend cpu` : rastérisation WARP + décodage logiciel (voir d3d::Backend). + // Il n'encode pas (AMF exige le GPU), donc il n'est mesurable qu'en mode preview — + // `--preview` est imposé plus bas plutôt que de laisser le run échouer sur l'encodeur. + let backend = match get("--backend", "hardware").as_str() { + "cpu" | "warp" => d3d::Backend::Cpu, + "hardware" | "gpu" => d3d::Backend::Hardware, + other => anyhow::bail!("--backend {other} inconnu (hardware|cpu)"), + }; + // `--export` : le VRAI chemin d'export (`run_composited_multi` → `VideoEncoder` + mux), + // sur le backend demandé. Seul moyen de mesurer l'encodage (le mode preview s'arrête au + // readback) et surtout de VÉRIFIER que le backend CPU sort un fichier lisible : sur un + // device WARP aucun encodeur matériel n'ouvre, donc `ExportCodec::candidates()` doit + // descendre jusqu'à libopenh264 tout seul. C'est cette descente que le test exerce. + let exporting = args.iter().any(|a| a == "--export"); + let preview_only = + !exporting && (args.iter().any(|a| a == "--preview") || backend == d3d::Backend::Cpu); + // Frames composées par run en mode preview. Assez pour noyer le bruit, assez court + // pour qu'un backend lent reste mesurable en une poignée de minutes. + let preview_frames: u64 = get("--frames", "300").parse().unwrap_or(300); + + // C0 = « décode + encode, aucun composite ». Sans encodeur, il n'a pas d'équivalent : + // le mesurer en preview reviendrait à composer quand même et à publier un C0 qui est + // en fait un C1. On le retire plutôt que d'imprimer une ligne trompeuse. + if preview_only { + cfgs.retain(|c| c.composite); + if cfgs.is_empty() { + anyhow::bail!("aucune cfg composite à mesurer (C0 n'a pas de sens sans encodeur)"); + } + } + + let gpu = d3d::Gpu::create_backend(backend, false)?; + println!( + "d3d11 device ok — backend {:?}, feature_level 0x{:X}{}", + backend, + gpu.feature_level.0 as u32, + if preview_only { ", mode preview (décode+compose+readback, sans encodeur)" } else { "" } + ); let mut comp = Compositor::new(&gpu)?; let track = cursor::CursorTrack::load(&format!("{fixture}/screen.cursor.json"), 100_000.0, 6.0)?; comp.set_cursor(track); @@ -82,6 +117,31 @@ fn run_bench(args: &[String]) -> Result<()> { println!("scène chargée depuis {scene_arg}"); } + if exporting { + let params = pipeline::ExportParams::default(); + for cfg in &cfgs { + // `ClipSource` n'est pas `Clone` et l'appel le prend par tranche : reconstruit + // par cfg plutôt que d'ajouter un derive pour le seul harnais de mesure. + let clip = pipeline::ClipSource { + screen: screen.clone(), + webcam: webcam.clone(), + source_start_sec: 0.0, + source_end_sec: 6.0, // la fixture entière (§ fixture.json : 6 s, 360 frames) + webcam_offset_sec: 0.0, + has_audio: false, + }; + let path = format!("{out}/{}_{:?}.mp4", cfg.name, backend).to_lowercase(); + let s = pipeline::run_composited_multi( + &[clip], &path, &gpu, &comp, cfg, ¶ms, &mut |_| {}, + )?; + println!( + "{:<4} {:>4}f {:>8.3}s {:>7.2} fps {:>7.2} ms/f → {}", + cfg.name, s.frames, s.wall_s, s.fps, 1000.0 / s.fps, path + ); + } + return Ok(()); + } + let mut rows: Vec<(String, u64, f64, f64, f64, String)> = Vec::new(); // name, frames, best_wall, fps, ms/f, spread let mut json = String::from("{\n \"runs\": [\n"); @@ -90,14 +150,26 @@ fn run_bench(args: &[String]) -> Result<()> { let mut frames = 0u64; for r in 0..repeat { let path = format!("{out}/{}.mp4", cfg.name); - let s = if cfg.composite { + let s = if preview_only { + let (stats, (fw, fh, rgba)) = + pipeline::run_preview_bench(&screen, &webcam, &gpu, &comp, cfg, preview_frames)?; + // Preuve visuelle, et surtout comparable : un backend qui compose du noir + // afficherait un fps flatteur. Le PPM est nommé par backend pour qu'un + // diff hardware/cpu soit direct. + if r == 0 { + let name = format!("{out}/{}_{:?}.ppm", cfg.name, backend).to_lowercase(); + write_ppm(&name, fw, fh, &rgba)?; + } + stats + } else if cfg.composite { pipeline::run_composited(&screen, &webcam, &path, &gpu, &comp, cfg, &mut |_| {})? } else { pipeline::run_c0(&screen, &path, &gpu)? }; frames = s.frames; fps_runs.push(s.fps); - if r == 0 { + // Pas de MP4 produit en mode preview (aucun encodeur) — rien à extraire. + if r == 0 && !preview_only { // extraction PNG f60/f180/f300 sur le 1er run (§11) extract_pngs(&path, &out, cfg.name); } @@ -131,6 +203,18 @@ fn run_bench(args: &[String]) -> Result<()> { Ok(()) } +/// Écrit un readback RGBA8 en PPM binaire (P6, RGB) — format le plus bête qui se lise +/// partout, et qui se compare octet à octet entre deux backends sans passer par un codec. +fn write_ppm(path: &str, w: u32, h: u32, rgba: &[u8]) -> Result<()> { + let mut buf = format!("P6\n{w} {h}\n255\n").into_bytes(); + buf.reserve(rgba.len() / 4 * 3); + for px in rgba.chunks_exact(4) { + buf.extend_from_slice(&px[..3]); + } + std::fs::write(path, buf).with_context(|| format!("écriture {path}"))?; + Ok(()) +} + /// Extrait 3 frames (f60/f180/f300) d'un MP4 via ffmpeg (§11) — vérification à l'œil. fn extract_pngs(mp4: &str, out: &str, cfg: &str) { for f in [60u32, 180, 300] { diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index c6e7a228f0..34e0bbfab5 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -368,6 +368,11 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { }); return createSuccessResponse(requestId, { id }); } + case "probeBackend": + // No view needed: the export dialog asks before any preview exists. + return createSuccessResponse(requestId, { + backend: compositorViewService.probeBackend(), + }); case "setRect": compositorViewService.setRect(request.payload.id, request.payload.rect); return createSuccessResponse(requestId, { ok: true }); diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 17d9b4cfa6..658cf34483 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -6,6 +6,7 @@ import { app } from "electron"; import { resolveCursorSprites } from "../../../src/lib/cursor/cursorThemes"; import type { ClipInput, + CompositorBackend, CompositorParamValue, CompositorViewAddon, CompositorViewRect, @@ -362,6 +363,26 @@ export class CompositorViewService { return this.ensureAddon() !== null; } + /** Which backend the compositor will use on this machine. + * + * `"none"` when the addon is absent — no native path at all, so there is nothing to + * warn about; that is the pure-web/dev case, not a degraded GPU. Callers must not + * read `"none"` as "slow", only `"cpu"` means that. */ + probeBackend(): CompositorBackend { + const addon = this.ensureAddon(); + if (!addon) { + return "none"; + } + try { + return addon.probeBackend(); + } catch (err) { + // An older `.node` predates probeBackend. Treat as unknown rather than + // crashing the bridge: a stale addon should not take the editor down. + console.warn("[compositor-view] probeBackend unavailable:", err); + return "none"; + } + } + /** Allocates an offscreen compositor view sized to `rect.width`x`rect.height`. * `rect.x` / `rect.y` are vestigial (ignored native-side) — the renderer * keeps them on the wire so the existing `CompositorViewRect` shape stays diff --git a/electron/native/compositor-view/addon.d.ts b/electron/native/compositor-view/addon.d.ts index ca18b01168..dcb713af51 100644 --- a/electron/native/compositor-view/addon.d.ts +++ b/electron/native/compositor-view/addon.d.ts @@ -65,7 +65,17 @@ export interface ClipInput { webcamOffsetSec: number; } +/** Which backend the compositor will run on. `"cpu"` = WARP rasterisation + software + * decode/encode, used when no usable D3D11 GPU is present: correct output, but roughly + * 8 fps preview with all effects and minutes-long exports. `"none"` = no D3D11 device at + * all, so the view will fail with its own, more specific message. */ +export type CompositorBackend = "hardware" | "cpu" | "none"; + export interface CompositorViewAddon { + /** What this machine offers, asked without allocating a view — the export dialog + * needs the answer before any preview exists. Cached native-side. */ + probeBackend(): CompositorBackend; + /** Allocates an offscreen compositor view sized to `rect.width`x`rect.height` (the * target preview resolution; `rect.x` / `rect.y` are vestigial and ignored native-side). * No HWND/native-window-handle is passed: there's no OS window to parent to. The diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index eab9493052..7494f47b81 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -36,7 +36,7 @@ import { type GifSizePreset, } from "@/lib/exporter"; import { calculateMp4ExportSettings } from "@/lib/exporter/mp4ExportSettings"; -import { exportMultiNative } from "@/native"; +import { exportMultiNative, useIsCpuCompositor } from "@/native"; import { nativeBridgeClient } from "@/native/client"; import type { CompositorClipInput } from "@/native/contracts"; import { buildSceneDescription, resolveVisibleClips } from "@/native/sceneDescription"; @@ -117,6 +117,9 @@ interface ExportDialogProps { export function ExportDialog({ open, onClose, document }: ExportDialogProps) { const t = useScopedT("editor"); const ts = useScopedT("settings"); + // No usable GPU: the export still applies every effect (output is identical), it + // just runs on the software encoder and takes minutes instead of seconds. + const cpuCompositor = useIsCpuCompositor(); const [format, setFormat] = useState("mp4"); const [quality, setQuality] = useState("good"); const [fps, setFps] = useState<24 | 30 | 60>(60); @@ -633,6 +636,24 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { savedPath={savedPath} /> + {cpuCompositor && phase !== "done" && ( + // Placed next to the export button, not in a toast: it has to land while + // the user is still deciding. A CPU export renders every effect correctly + // but takes minutes rather than seconds, and an unexplained ten-minute + // wait reads as a hang. +

+ {t("cpuCompositor.exportWarning")} +

+ )} +
+ {t("errors.previewCompositorUnavailable")} + {error} +
+ ); + } + // The canvas's CSS box (width: 100%; height: 100%) is what drives the // geometry; the hook manages the DRAWING BUFFER (canvas.width/height DOM // attrs) to match the offscreen render-target resolution, and paints each @@ -214,10 +252,39 @@ export function NativeCompositorOverlay() { // hitbox) that PreviewCanvas renders after it, but above nothing else — // the CPU-rendered video/webcam/blur pixels it replaces are hidden via CSS. return ( - + <> + + {cpuCompositor && ( + // Persistent rather than a toast: the question it answers ("why is this + // choppy?") comes up whenever the user looks at the preview, not once at + // mount. Kept small, low-contrast and pointer-events-none so it never + // competes with the interactive layers PreviewCanvas stacks above. + + {t("cpuCompositor.notice")} + + )} + ); } diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 4e05f46435..1964df9bb3 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})", "failedToSaveExport": "فشل حفظ التصدير", "failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر", - "failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}" + "failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}", + "previewCompositorUnavailable": "المعاينة غير متوفرة على هذا الجهاز" }, "export": { "canceled": "تم إلغاء التصدير", @@ -394,5 +395,9 @@ "cancel": "إلغاء", "save": "حفظ", "saveAndUse": "حفظ واستخدام" + }, + "cpuCompositor": { + "notice": "لا توجد بطاقة رسومات متوافقة — تتم المعالجة على المعالج، لذا يكون التشغيل أبطأ.", + "exportWarning": "لا توجد بطاقة رسومات متوافقة: يتم هذا التصدير على المعالج وسيستغرق وقتًا أطول بكثير من المعتاد." } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 5806e3adc4..765191164d 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})", "failedToSaveExport": "Failed to save export", "failedToSaveExportedVideo": "Failed to save exported video", - "failedToRevealInFolder": "Error revealing in folder: {{error}}" + "failedToRevealInFolder": "Error revealing in folder: {{error}}", + "previewCompositorUnavailable": "Preview unavailable on this machine" }, "export": { "canceled": "Export canceled", @@ -394,5 +395,9 @@ "cancel": "Cancel", "save": "Save", "saveAndUse": "Save & use" + }, + "cpuCompositor": { + "notice": "Running without a compatible GPU — rendering on CPU, so playback is slower.", + "exportWarning": "No compatible GPU: this export runs on CPU and will take much longer than usual." } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index d7c2df0bf1..ae01e503f7 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -11,7 +11,8 @@ "exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})", "failedToSaveExport": "Error al guardar la exportación", "failedToSaveExportedVideo": "Error al guardar el video exportado", - "failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}" + "failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}", + "previewCompositorUnavailable": "Vista previa no disponible en este equipo" }, "export": { "canceled": "Exportación cancelada", @@ -394,5 +395,9 @@ "cancel": "Cancelar", "save": "Guardar", "saveAndUse": "Guardar y usar" + }, + "cpuCompositor": { + "notice": "Sin GPU compatible: el renderizado se hace en la CPU, por lo que la reproducción es más lenta.", + "exportWarning": "Sin GPU compatible: esta exportación se ejecuta en la CPU y tardará mucho más de lo normal." } } diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index e3a8dbb35b..a1fe9e2b18 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -17,7 +17,8 @@ "exportBackgroundLoadFailed": "L'export a échoué : impossible de charger l'image d'arrière-plan ({{url}})", "failedToSaveExport": "Échec de l'enregistrement de l'export", "failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée", - "failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}" + "failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}", + "previewCompositorUnavailable": "Aperçu indisponible sur cette machine" }, "export": { "canceled": "Export annulé", @@ -394,5 +395,9 @@ "cancel": "Annuler", "save": "Enregistrer", "saveAndUse": "Enregistrer et utiliser" + }, + "cpuCompositor": { + "notice": "Aucun GPU compatible — rendu sur CPU, la lecture est donc plus lente.", + "exportWarning": "Aucun GPU compatible : cet export tourne sur CPU et sera beaucoup plus long que d'habitude." } } diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index fe0638f79e..083fc3ad58 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "Esportazione fallita: impossibile caricare l'immagine di sfondo ({{url}})", "failedToSaveExport": "Impossibile salvare l'esportazione", "failedToSaveExportedVideo": "Impossibile salvare il video esportato", - "failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}" + "failedToRevealInFolder": "Errore durante la visualizzazione nella cartella: {{error}}", + "previewCompositorUnavailable": "Anteprima non disponibile su questo computer" }, "export": { "canceled": "Esportazione annullata", @@ -394,5 +395,9 @@ "cancel": "Annulla", "save": "Salva", "saveAndUse": "Salva e usa" + }, + "cpuCompositor": { + "notice": "Nessuna GPU compatibile: il rendering avviene sulla CPU, quindi la riproduzione è più lenta.", + "exportWarning": "Nessuna GPU compatibile: questa esportazione viene eseguita sulla CPU e richiederà molto più tempo del solito." } } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 2e87b27f11..4883545f82 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -19,7 +19,8 @@ "failedToSaveExport": "エクスポートの保存に失敗しました", "failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました", "failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}", - "exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})" + "exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})", + "previewCompositorUnavailable": "このマシンではプレビューを表示できません" }, "export": { "canceled": "エクスポートがキャンセルされました", @@ -394,5 +395,9 @@ "cancel": "キャンセル", "save": "保存", "saveAndUse": "保存して使用" + }, + "cpuCompositor": { + "notice": "対応 GPU がないため CPU で描画しています。再生が遅くなります。", + "exportWarning": "対応 GPU がありません。この書き出しは CPU で実行されるため、通常よりはるかに時間がかかります。" } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index b50fd002d9..595d71427c 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "내보내기 실패: 배경 이미지를 불러올 수 없습니다 ({{url}})", "failedToSaveExport": "내보낸 파일 저장에 실패했습니다", "failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다", - "failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}" + "failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}", + "previewCompositorUnavailable": "이 컴퓨터에서는 미리보기를 사용할 수 없습니다" }, "export": { "canceled": "내보내기가 취소되었습니다", @@ -394,5 +395,9 @@ "cancel": "취소", "save": "저장", "saveAndUse": "저장 후 사용" + }, + "cpuCompositor": { + "notice": "호환되는 GPU가 없어 CPU로 렌더링합니다. 재생이 느려집니다.", + "exportWarning": "호환되는 GPU가 없습니다. 이 내보내기는 CPU에서 실행되며 평소보다 훨씬 오래 걸립니다." } } diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index dfdc18cfe2..a28077e454 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "Falha na exportação: não foi possível carregar a imagem de fundo ({{url}})", "failedToSaveExport": "Falha ao salvar exportação", "failedToSaveExportedVideo": "Falha ao salvar vídeo exportado", - "failedToRevealInFolder": "Erro ao mostrar na pasta: {{error}}" + "failedToRevealInFolder": "Erro ao mostrar na pasta: {{error}}", + "previewCompositorUnavailable": "Pré-visualização indisponível neste computador" }, "export": { "canceled": "Exportação cancelada", @@ -394,5 +395,9 @@ "cancel": "Cancelar", "save": "Salvar", "saveAndUse": "Salvar e usar" + }, + "cpuCompositor": { + "notice": "Sem GPU compatível — a renderização ocorre na CPU, então a reprodução fica mais lenta.", + "exportWarning": "Sem GPU compatível: esta exportação roda na CPU e vai demorar muito mais que o normal." } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index f2861918ba..c7a79d29c0 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "Экспорт не удался: не удалось загрузить фоновое изображение ({{url}})", "failedToSaveExport": "Не удалось сохранить экспорт", "failedToSaveExportedVideo": "Не удалось сохранить экспортированное видео", - "failedToRevealInFolder": "Ошибка при показе в папке: {{error}}" + "failedToRevealInFolder": "Ошибка при показе в папке: {{error}}", + "previewCompositorUnavailable": "Предпросмотр недоступен на этом компьютере" }, "export": { "canceled": "Экспорт отменён", @@ -394,5 +395,9 @@ "cancel": "Отмена", "save": "Сохранить", "saveAndUse": "Сохранить и использовать" + }, + "cpuCompositor": { + "notice": "Совместимый GPU не найден — отрисовка на CPU, поэтому воспроизведение медленнее.", + "exportWarning": "Совместимый GPU не найден: этот экспорт выполняется на CPU и займёт намного больше времени." } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index e5054501b9..ea3ffa91ad 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -11,7 +11,8 @@ "exportBackgroundLoadFailed": "Dışa aktarım başarısız: arka plan görüntüsü yüklenemedi ({{url}})", "failedToSaveExport": "Dışa aktarım kaydedilemedi", "failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi", - "failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}" + "failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}", + "previewCompositorUnavailable": "Bu makinede önizleme kullanılamıyor" }, "export": { "canceled": "Dışa aktarım iptal edildi", @@ -394,5 +395,9 @@ "cancel": "İptal", "save": "Kaydet", "saveAndUse": "Kaydet ve kullan" + }, + "cpuCompositor": { + "notice": "Uyumlu GPU yok — işleme CPU üzerinde yapılıyor, bu yüzden oynatma daha yavaş.", + "exportWarning": "Uyumlu GPU yok: bu dışa aktarma CPU üzerinde çalışır ve normalden çok daha uzun sürer." } } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index e79a60ac48..9c38c8eae0 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "Xuất thất bại: không thể tải hình nền ({{url}})", "failedToSaveExport": "Không thể lưu bản xuất", "failedToSaveExportedVideo": "Không thể lưu video đã xuất", - "failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}" + "failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}", + "previewCompositorUnavailable": "Không thể xem trước trên máy này" }, "export": { "canceled": "Đã hủy xuất", @@ -394,5 +395,9 @@ "cancel": "Hủy", "save": "Lưu", "saveAndUse": "Lưu và dùng" + }, + "cpuCompositor": { + "notice": "Không có GPU tương thích — kết xuất trên CPU nên phát lại chậm hơn.", + "exportWarning": "Không có GPU tương thích: lần xuất này chạy trên CPU và sẽ lâu hơn bình thường rất nhiều." } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index d0bea68298..a2c6919707 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})", "failedToSaveExport": "保存导出文件失败", "failedToSaveExportedVideo": "保存导出的视频失败", - "failedToRevealInFolder": "在文件夹中显示时出错:{{error}}" + "failedToRevealInFolder": "在文件夹中显示时出错:{{error}}", + "previewCompositorUnavailable": "此设备无法使用预览" }, "export": { "canceled": "导出已取消", @@ -394,5 +395,9 @@ "cancel": "取消", "save": "保存", "saveAndUse": "保存并使用" + }, + "cpuCompositor": { + "notice": "没有兼容的 GPU,正在使用 CPU 渲染,因此播放较慢。", + "exportWarning": "没有兼容的 GPU:本次导出将在 CPU 上运行,耗时会比平常长很多。" } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index c3ee62bb26..88afd41bef 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -19,7 +19,8 @@ "exportBackgroundLoadFailed": "匯出失敗:無法載入背景圖片({{url}})", "failedToSaveExport": "儲存匯出檔案失敗", "failedToSaveExportedVideo": "儲存匯出的影片失敗", - "failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}" + "failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}", + "previewCompositorUnavailable": "此裝置無法使用預覽" }, "export": { "canceled": "匯出已取消", @@ -394,5 +395,9 @@ "cancel": "取消", "save": "儲存", "saveAndUse": "儲存並使用" + }, + "cpuCompositor": { + "notice": "沒有相容的 GPU,正在使用 CPU 算繪,因此播放較慢。", + "exportWarning": "沒有相容的 GPU:這次匯出將在 CPU 上執行,耗時會比平常長很多。" } } diff --git a/src/native/compositorViewClient.ts b/src/native/compositorViewClient.ts index 5edca550af..b6f5be09ac 100644 --- a/src/native/compositorViewClient.ts +++ b/src/native/compositorViewClient.ts @@ -9,6 +9,8 @@ import { requireNativeBridgeData } from "./client"; import type { + CompositorBackend, + CompositorBackendResult, CompositorClipInput, CompositorExportParams, CompositorExportResult, @@ -18,6 +20,23 @@ import type { CompositorViewResult, } from "./contracts"; +/** Which backend the native compositor will use here. + * + * Resolves `"none"` rather than rejecting when there is no bridge or no addon — callers + * use this to decide whether to show a "running without a GPU" notice, and a missing + * native path is not a degraded GPU. Only `"cpu"` warrants a warning. */ +export async function probeCompositorBackend(): Promise { + try { + const result = await requireNativeBridgeData({ + domain: "compositor", + action: "probeBackend", + }); + return result.backend; + } catch { + return "none"; + } +} + export function createCompositorView( rect: CompositorViewRect, sources?: { screenPath?: string; webcamPath?: string; cursorPath?: string }, diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 5580984e5a..190f9af099 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -113,6 +113,21 @@ export interface CompositorViewResult { id: number; } +/** Which backend the native compositor runs on. + * + * `"cpu"` = WARP rasterisation with software decode and encode, selected automatically + * when no usable D3D11 GPU is present. Output is identical to the GPU path — the effects + * all render — but the preview runs at roughly 8 fps with everything on and exports take + * minutes rather than seconds, so it is worth telling the user about. + * + * `"none"` = no native compositor at all (addon absent: pure-web dev, jsdom). NOT a + * degraded-GPU signal — never warn on it. */ +export type CompositorBackend = "hardware" | "cpu" | "none"; + +export interface CompositorBackendResult { + backend: CompositorBackend; +} + /** A self-describing preview frame returned by `readFrame` (native → renderer): pixels * (`data`, RGBA8, `width * height * 4` bytes) plus their dimensions and a monotonic * generation. The hook keeps `gen` and passes it back as `sinceGen`; an unchanged frame @@ -663,6 +678,11 @@ export type NativeBridgeRequest = }; requestId?: string; } + | { + domain: "compositor"; + action: "probeBackend"; + requestId?: string; + } | { domain: "compositor"; action: "setRect"; diff --git a/src/native/hooks/useCompositorBackend.test.ts b/src/native/hooks/useCompositorBackend.test.ts new file mode 100644 index 0000000000..e854108728 --- /dev/null +++ b/src/native/hooks/useCompositorBackend.test.ts @@ -0,0 +1,67 @@ +/** + * The CPU-backend notice must fire on a degraded GPU and stay silent everywhere else. + * + * The failure worth guarding is the false positive: `"none"` means there is no native + * compositor at all (pure-web `npm run dev`, jsdom, an addon that failed to load), which + * is the normal state in development — warning there would put "no compatible GPU" in + * front of every developer on every run. Only `"cpu"` is a real degraded machine. + */ + +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ probeCompositorBackend: vi.fn() })); + +vi.mock("../compositorViewClient", () => ({ + probeCompositorBackend: mocks.probeCompositorBackend, +})); + +import { + resetCompositorBackendProbeForTests, + useCompositorBackend, + useIsCpuCompositor, +} from "./useCompositorBackend"; + +describe("useCompositorBackend", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetCompositorBackendProbeForTests(); + }); + + it("reports the degraded machine", async () => { + mocks.probeCompositorBackend.mockResolvedValue("cpu"); + const { result } = renderHook(() => useIsCpuCompositor()); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("stays silent on a normal GPU", async () => { + mocks.probeCompositorBackend.mockResolvedValue("hardware"); + const { result } = renderHook(() => useCompositorBackend()); + await waitFor(() => expect(result.current).toBe("hardware")); + expect(result.current === "cpu").toBe(false); + }); + + it("stays silent when there is no native compositor at all", async () => { + mocks.probeCompositorBackend.mockResolvedValue("none"); + const { result } = renderHook(() => useIsCpuCompositor()); + await waitFor(() => expect(mocks.probeCompositorBackend).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + it("starts silent, before the probe resolves", () => { + mocks.probeCompositorBackend.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useIsCpuCompositor()); + // No flash of "no compatible GPU" on a machine that turns out to have one. + expect(result.current).toBe(false); + }); + + it("probes once for the whole session, however many consumers ask", async () => { + mocks.probeCompositorBackend.mockResolvedValue("cpu"); + const a = renderHook(() => useIsCpuCompositor()); + const b = renderHook(() => useIsCpuCompositor()); + await waitFor(() => expect(a.result.current).toBe(true)); + await waitFor(() => expect(b.result.current).toBe(true)); + // Creating a D3D11 device is not free; the preview and the export dialog share one. + expect(mocks.probeCompositorBackend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/native/hooks/useCompositorBackend.ts b/src/native/hooks/useCompositorBackend.ts new file mode 100644 index 0000000000..1b85fec6fe --- /dev/null +++ b/src/native/hooks/useCompositorBackend.ts @@ -0,0 +1,57 @@ +/** + * Which backend the native compositor runs on, probed once per session. + * + * The answer is a property of the machine, not of a view, so it is cached in a + * module-level promise: the preview and the export dialog both need it, and neither + * should pay a second native device creation for it. The native side caches it too. + * + * Returns `null` until the probe resolves, so callers render nothing rather than + * flashing a warning that may not apply. + */ + +import { useEffect, useState } from "react"; +import { probeCompositorBackend } from "../compositorViewClient"; +import type { CompositorBackend } from "../contracts"; + +let cached: Promise | null = null; + +function probeOnce(): Promise { + if (!cached) { + cached = probeCompositorBackend(); + } + return cached; +} + +/** Test seam: drops the memoised probe so each test observes its own mock. */ +export function resetCompositorBackendProbeForTests(): void { + cached = null; +} + +export function useCompositorBackend(): CompositorBackend | null { + const [backend, setBackend] = useState(null); + + useEffect(() => { + let disposed = false; + probeOnce().then((value) => { + if (!disposed) { + setBackend(value); + } + }); + return () => { + disposed = true; + }; + }, []); + + return backend; +} + +/** + * True only when the compositor is running WITHOUT a usable GPU. + * + * Deliberately not `backend !== "hardware"`: `"none"` means there is no native compositor + * at all (pure-web dev, jsdom), which is not a degraded machine and must never raise a + * warning. `null` (still probing) is not degraded either. + */ +export function useIsCpuCompositor(): boolean { + return useCompositorBackend() === "cpu"; +} diff --git a/src/native/hooks/useNativeCompositorView.test.ts b/src/native/hooks/useNativeCompositorView.test.ts new file mode 100644 index 0000000000..8ef722c501 --- /dev/null +++ b/src/native/hooks/useNativeCompositorView.test.ts @@ -0,0 +1,118 @@ +/** + * The fatal-error channel (PR #162). + * + * `createView` returns an id long before the native render thread can fail, so a host + * that cannot create a D3D11 device used to leave the user with a black canvas and an + * `eprintln!` nobody reads. The addon now reports the dead thread through `readFrame`, + * and this hook turns that into `error`. + * + * The half worth guarding is the negative one: `readFrame` also rejects when there is no + * Electron bridge at all (pure web `npm run dev`, jsdom), and the addon being absent is a + * normal no-op, not a failure. Neither may raise the banner. + */ + +import { renderHook, waitFor } from "@testing-library/react"; +import type { RefObject } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createCompositorView: vi.fn(), + readCompositorFrame: vi.fn(), + destroyCompositorView: vi.fn(), +})); + +vi.mock("../compositorViewClient", () => ({ + createCompositorView: mocks.createCompositorView, + readCompositorFrame: mocks.readCompositorFrame, + destroyCompositorView: mocks.destroyCompositorView, + setCompositorParam: vi.fn(), + setCompositorPlaying: vi.fn(), + setCompositorRect: vi.fn(), +})); + +import { useNativeCompositorView } from "./useNativeCompositorView"; + +// jsdom ships no ResizeObserver; the hook constructs one to track the canvas box. +// Nothing here observes anything — these tests only exercise the pull loop. +globalThis.ResizeObserver = class { + observe() { + // inert on purpose: the canvas box never changes in these tests + } + unobserve() { + // see observe() + } + disconnect() { + // see observe() + } +} as unknown as typeof ResizeObserver; + +/** A canvas with a stubbed 2D context — jsdom has none, and the pull loop bails without it. */ +function stubCanvasRef(): RefObject { + const canvas = document.createElement("canvas"); + canvas.getContext = vi.fn(() => ({})) as unknown as HTMLCanvasElement["getContext"]; + return { current: canvas }; +} + +const DEVICE_FAILURE = + "this display adapter has no D3D11 video decoder (0x887A0004). OpenScreen decodes every preview and export frame with D3D11VA"; + +describe("useNativeCompositorView", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("surfaces the native message when the render thread dies", async () => { + mocks.createCompositorView.mockResolvedValue({ id: 7 }); + mocks.readCompositorFrame.mockRejectedValue(new Error(DEVICE_FAILURE)); + + const ref = stubCanvasRef(); + const { result } = renderHook(() => + useNativeCompositorView(ref, { sources: { screenPath: "rec.mp4" } }), + ); + + await waitFor(() => expect(result.current.error).toBe(DEVICE_FAILURE)); + }); + + it("stops polling once the error is terminal — the thread never restarts", async () => { + mocks.createCompositorView.mockResolvedValue({ id: 7 }); + mocks.readCompositorFrame.mockRejectedValue(new Error(DEVICE_FAILURE)); + + const ref = stubCanvasRef(); + const { result } = renderHook(() => + useNativeCompositorView(ref, { sources: { screenPath: "rec.mp4" } }), + ); + + await waitFor(() => expect(result.current.error).toBe(DEVICE_FAILURE)); + const callsAtFailure = mocks.readCompositorFrame.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(mocks.readCompositorFrame).toHaveBeenCalledTimes(callsAtFailure); + }); + + it("stays quiet when the addon is absent (synthetic id, no frames, no error)", async () => { + mocks.createCompositorView.mockResolvedValue({ id: -1 }); + mocks.readCompositorFrame.mockResolvedValue(null); + + const ref = stubCanvasRef(); + const { result } = renderHook(() => + useNativeCompositorView(ref, { sources: { screenPath: "rec.mp4" } }), + ); + + await waitFor(() => expect(mocks.readCompositorFrame).toHaveBeenCalled()); + expect(result.current.error).toBeNull(); + }); + + it("stays quiet without an Electron bridge — no view id, so nothing is ever polled", async () => { + mocks.createCompositorView.mockRejectedValue(new Error("Native bridge unavailable.")); + mocks.readCompositorFrame.mockResolvedValue(null); + + const ref = stubCanvasRef(); + const { result } = renderHook(() => + useNativeCompositorView(ref, { sources: { screenPath: "rec.mp4" } }), + ); + + await waitFor(() => expect(mocks.createCompositorView).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(mocks.readCompositorFrame).not.toHaveBeenCalled(); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/src/native/hooks/useNativeCompositorView.ts b/src/native/hooks/useNativeCompositorView.ts index 9d86b48ac1..85080c7071 100644 --- a/src/native/hooks/useNativeCompositorView.ts +++ b/src/native/hooks/useNativeCompositorView.ts @@ -50,6 +50,18 @@ export interface UseNativeCompositorViewResult { viewId: number | null; setParam: (key: string, value: CompositorParamValue) => void; setPlaying: (playing: boolean) => void; + /** + * Native message from a render thread that died — no D3D11 device on this host, + * a decoder that refused the recording, etc. `null` while things work AND in every + * environment without the native path (pure web `npm run dev`, jsdom): the pull + * loop that produces this only runs once a real `viewId` exists, so an absent + * bridge or addon reads as "no frames", never as an error. + * + * Terminal: the render thread does not restart, so the pull loop stops with it. + * Callers show it instead of the canvas — it is the only thing that distinguishes + * "this machine cannot run the compositor" from a preview that is merely black. + */ + error: string | null; } /** swallow+warn wrapper for native-bridge calls. The renderer can run without @@ -78,6 +90,7 @@ export function useNativeCompositorView( // project) so it never keeps showing a stale clip. const screenPath = opts.sources?.screenPath; const [viewId, setViewId] = useState(null); + const [error, setError] = useState(null); // Mirror into a ref so async callbacks always see the freshest id without // re-subscribing the main effect. const viewIdRef = useRef(null); @@ -97,6 +110,9 @@ export function useNativeCompositorView( let pullTick = 0; let lastRect: CompositorViewRect | null = null; let disposed = false; + // Fresh view (source or enablement changed) → the previous view's fatal error + // says nothing about this one. + setError(null); /** Resize the canvas's DRAWING BUFFER to match the offscreen render * target's pixel dimensions. Setting `canvas.width` / `canvas.height` @@ -228,9 +244,20 @@ export function useNativeCompositorView( // malformed packet is retried rather than silently skipped. lastGen = gen; }) - .catch((error: unknown) => { + .catch((cause: unknown) => { inFlight = false; - console.warn("[compositor-view] readFrame failed:", error); + console.warn("[compositor-view] readFrame failed:", cause); + // Native reports the render thread's fatal error here (see the addon's + // `read_frame`), and it is the ONLY place it can surface: `createView` + // has long since returned an id by the time the thread dies. Stop + // polling — a dead thread never publishes another frame — and hand the + // message up so the user gets it instead of a silent black canvas. + if (disposed) { + return; + } + cancelAnimationFrame(pullRafHandle); + pullRafHandle = 0; + setError(cause instanceof Error ? cause.message : String(cause)); }); }; @@ -306,5 +333,5 @@ export function useNativeCompositorView( safelyCall("setPlaying", () => setCompositorPlaying(id, playing)); }, []); - return { viewId, setParam, setPlaying }; + return { viewId, setParam, setPlaying, error }; } diff --git a/src/native/index.ts b/src/native/index.ts index fb858d1d02..2660b7fc02 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,6 +1,7 @@ export * from "./client"; export * from "./compositorViewClient"; export * from "./contracts"; +export * from "./hooks/useCompositorBackend"; export * from "./hooks/useCursorRecordingData"; export * from "./hooks/useCursorTelemetry"; export * from "./hooks/useNativeCompositorView"; diff --git a/technical-documentation/architecture/native-compositor.md b/technical-documentation/architecture/native-compositor.md index 83802f37da..02be63309f 100644 --- a/technical-documentation/architecture/native-compositor.md +++ b/technical-documentation/architecture/native-compositor.md @@ -250,16 +250,93 @@ licence. D3D11VA + AMF survive the LGPL-shared build (verified: ## Known gaps -- **No software/CPU fallback.** `d3d::Gpu::create` - ([`crates/compositor/src/d3d.rs`](../../crates/compositor/src/d3d.rs)) requests - `D3D_DRIVER_TYPE_HARDWARE` (no `WARP` / no `REFERENCE`) and pins - `D3D_FEATURE_LEVEL_11_1` — any other feature level fails with `bail!`, - and `VIDEO_SUPPORT` is mandatory for `D3D11VA`. A machine without a - GPU that exposes FL 11_1 with video support will hard-fail at startup; - there is no path that decodes on CPU or falls back to a reference - rasteriser. The compositor is therefore unusable on virtualised - environments that do not pass through a compatible adapter, and there - is no second renderer behind it. + +- **The CPU fallback is a whole second backend, because WARP alone could not + have been one.** `d3d::Gpu::create_auto` prefers the hardware device + (`D3D_DRIVER_TYPE_HARDWARE`, FL 11_1, `VIDEO_SUPPORT` for `D3D11VA`) and falls + back to `Backend::Cpu` — described in + [rendering-performance.md](../engineering/rendering-performance.md#the-cpu-backend-warp--software-decode--2026-07-27). + + Retrying in `D3D_DRIVER_TYPE_WARP` and changing nothing else — the obvious + repair, and what PR #162 originally scoped — does not work, and the reason is + capability, not speed. Measured + (`crates/compositor/tests/warp_device_cannot_decode.rs`, which fails if this + ever stops being true): WARP **rejects the `VIDEO_SUPPORT` flag outright** + (`DXGI_ERROR_UNSUPPORTED`, `0x887A0004`), and dropping the flag yields a FL + 11_1 device that exposes no `ID3D11VideoDevice` at all (`E_NOINTERFACE`, zero + decoder profiles). Since `pipeline.rs` hands this very device to ffmpeg as the + `AVD3D11VADeviceContext`, a WARP device would have produced no frames at all. + **Rendering and decoding are two axes**, and no software rasteriser on any + platform covers the second — so the fallback needed libavcodec software decode + (`cpu_frames.rs`) beside the WARP device. + + Encoding is the third axis, and it is **half** covered by + `ExportCodec::candidates()` / `VideoEncoder`: that machinery already probes the + host and lands on `h264_mf` / `libopenh264` (`libkvazaar` for H.265) when no + hardware encoder opens, so the CPU backend needs no encoder logic of its own. + What it did need is a different **frame source**. `VideoEncoder::send` feeds + software encoders with `av_hwframe_transfer_data`, which presupposes a D3D11 + pool — and on WARP there is none: `av_hwdevice_ctx_init(D3D11VA)` fails for the + very reason decoding does, no `ID3D11VideoDevice`. So on `Backend::Cpu` the + pool is never created, zero-copy candidates are dropped from the list with a + stated reason, and `VideoEncoder::send_composited` reads the composed NV12 + straight out of the compositor (`Compositor::read_nv12_scaled`) into the same + buffers the software path already used. Three axes, three answers. + + It is never silent. `Gpu::create` (hardware-strict, no fallback) is kept for + tests and goldens; `create_auto` logs *why* the hardware device was refused + via `diagnose()` — "this adapter has no video decoder" (Remote Desktop, VMs, + Basic Render Driver) versus "no FL 11_1 adapter at all" — which is what tells + a user whether a driver update would fix it. The renderer asks + `probeBackend()` and shows a notice in the preview and a warning in the export + dialog, so ~8 fps playback reads as "this machine has no GPU" rather than as + the app hanging. **No effect is disabled on the CPU path**: output stays + identical to the GPU path (max deviation 3/255), and 8 fps is what the 1.7.0 + preview delivered anyway, so trading correctness for frame rate would be a bad + bargain in both directions. + + If **both** backends fail there is nothing left, and that failure is surfaced + rather than logged: the render thread stores its fatal error in + `live::Shared`, the addon's `read_frame` returns it as an `Err` on the next + pull (~33 ms), and `NativeCompositorOverlay` renders it in place of the + canvas. Before this, `create_view` had already returned `Ok` by the time the + thread died, so the failure existed only as an `eprintln!` and the user just + saw a black preview. + +- **Capture is left hardware-only on purpose.** The WGC capture helper + ([`electron/native/wgc-capture/src/wgc_session.cpp`](../../electron/native/wgc-capture/src/wgc_session.cpp)) + is left hardware-only too, but for a different reason than the compositor: + **WGC capture is not mandatory.** Windows recording already has a non-D3D + path — `startNativeWindowsRecordingIfAvailable` returning `false` falls + through to `getDisplayMedia` + `MediaRecorder` + ([`src/hooks/useScreenRecorder.ts`](../../src/hooks/useScreenRecorder.ts)) — + and the compositor is built to ingest its output: `allow_d3d11va_h264_baseline` + in `pipeline.rs` exists precisely because Chrome's `MediaRecorder` emits plain + H.264 Baseline. So capture has a better fallback available to it than a CPU + rasteriser: one that needs no D3D device at all. Giving the helper a WARP + device would add a slow path nobody needs alongside a working one. + +### Known gap: the capture fallback is unreachable on a host that fails D3D + + That fallback is only reachable through the pre-flight probe, and the probe + does not ask the question that matters. `is-native-windows-capture-available` + ([`electron/ipc/handlers.ts`](../../electron/ipc/handlers.ts)) checks two + things — Windows build ≥ 19041, and the helper binary being on disk. It never + touches D3D. So on a host where the helper's own `createD3DDevice` fails, the + probe answers `available: true`, the renderer commits to the native path, the + helper dies, and `startNativeWindowsRecordingIfAvailable` **rethrows** rather + than returning `false` — so the browser path two calls down its own call site + is never reached. The recording fails next to a route that would have worked. + + Repairing it is not a one-line `return false`. By the time the helper's + failure is known, the renderer has already called `stopWebcamPreviewStream()` + — deliberately, because the helper needs exclusive ownership of the webcam + device before it opens it. Falling through at that point lands in the browser + path's `if (!webcamStream.current)` branch, which disables the camera and + records screen-only: a silent downgrade rather than a failure. A correct fix + either re-acquires the preview stream on the fallback route, or makes the + availability probe truthful by having the helper report its D3D capability + before the renderer commits. Neither belongs in this PR's diff. - **Software VP9 encoding is not supported.** A software VP9 encoder was implemented, measured too slow without a hardware VP9 path on the target GPU, and removed. The export pipeline now offers H.264 (AMF) and diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index 93468ae3f9..0db2327db0 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -83,6 +83,91 @@ C8's 104.0 fps sits under the ~126 headline above. Different session and thermal > A first attempt the same day was **VOID** and is not reported: five of nine configs blew the spread gate (up to 42.5 %) while ~40 browser and Electron processes were live, and C3 came out **+15.8 fps faster than C2** — adding a layer. Cumulative configs cannot speed up; that is the tell that noise had swamped the signal. It is recorded here only because it is a clean example of why the gate exists. +### The CPU backend (WARP + software decode) — 2026-07-27 + +`d3d::Backend::Cpu` runs the *same* pipeline on a WARP device with libavcodec software +decode ([`cpu_frames.rs`](../../crates/compositor/src/cpu_frames.rs)), for hosts with no +usable D3D11 GPU. Rendering and decoding are two independent axes — WARP covers the +first and *nothing* of the second, on any platform — so the fallback needed both halves. + +Measured with a preview-shaped workload, because the CPU backend cannot reach the export +path at all: `h264_amf` requires the real GPU, and encode is a **third** axis with no +software fallback wired today. So the harness grew `--preview` (decode → compose → +readback, no encoder) and `--backend`, which is what makes the two comparable: + +```bash +x.bat run --release -- --cfg C1..C8 --backend cpu --preview --frames 300 --repeat 3 +``` + +C0 is excluded: it is "decode + encode, no composite", which has no meaning without an +encoder. + +| cfg | HW fps | HW ms/f | CPU fps | CPU ms/f | Δ ms/f (CPU) | gap | +|---|---:|---:|---:|---:|---:|---:| +| C1 | 65.3 | 15.30 | 30.7 | 32.60 | — | 2.1× | +| C2 | 65.2 | 15.34 | 30.1 | 33.25 | +0.65 | 2.2× | +| C3 | 63.1 | 15.84 | 28.5 | 35.06 | +1.81 | 2.2× | +| C4 | 49.1 | 20.37 | 9.1 | 110.49 | **+75.43** | 5.4× | +| C5 | 51.2 | 19.54 | 7.9 | 126.14 | +15.65 | 6.5× | +| C6 | 54.3 | 18.42 | 9.5 | 105.78 | −20.36 | 5.7× | +| C7 | 53.9 | 18.55 | 9.2 | 108.53 | +2.75 | 5.9× | +| C8 | 48.0 | 20.85 | 6.2 | 161.18 | **+52.65** | 7.7× | + +**Two shaders account for the whole gap, and both are multi-tap sampling loops.** +Background blur costs +4.53 ms on hardware and **+75.43 ms** on WARP (17×); motion blur +costs +2.30 ms and **+52.65 ms** (23×). Everything else — compositing, SDF rounded +corners, drop shadows, zoom, layout animation, cursor — runs within ~2.2× of the GPU. +So WARP is not uniformly slow: it is fine at single-pass geometry and collapses on +per-pixel sampling loops. C1–C3 at ~30 fps is a usable editing preview; C4 onward, at +6–9 fps, is not. + +**The render is iso**, which is the property that makes a backend swap worth having at +all. Comparing the same fixture frame per config, full-image over all 6 220 800 channels: +93–95 % of channels bit-identical, **max deviation 3/255**, nothing above 2 outside a +handful of pixels, and matching mean levels (217.77 vs 217.81 — neither frame is blank). +Every effect layer survives the swap. The residual is rasteriser/FP difference, so any +pixel-golden test that spans backends needs a tolerance rather than an exact baseline. + +> **Not an admissible run under [§ spread thresholds](#spread-thresholds).** Four of +> sixteen rows blow the 15 % gate (CPU C1 21.3 %, C3 37.7 %, C8 25.5 %; HW C7 18.8 %) — +> ~14 browser processes were live. The **layer attribution** is what this run claims and +> it is robust: both cliffs are 3–5× drops bracketed by rows at 3.7 %/10.5 % and +> 4.9 %/6.5 % spread. The **absolutes are not quotable** until a re-run on a quiet +> machine. C6's negative delta is the same C5–C7 plateau noise the +> [admissible hardware run](#one-admissible-run--2026-07-27) documents. + +#### CPU export — the third axis + +`ExportCodec::candidates()` already picks a working encoder per host, so the CPU backend +needs no encoder logic of its own. It needed a different **frame source**: +`VideoEncoder::send` downloads via `av_hwframe_transfer_data`, which presupposes a D3D11 +pool, and WARP cannot create one (`av_hwdevice_ctx_init(D3D11VA)` fails for the same +missing `ID3D11VideoDevice` that blocks decoding). So on `Backend::Cpu` the pool is +skipped, zero-copy candidates are dropped with a stated reason, and `send_composited` +reads the composed NV12 out of the compositor directly. + +`x.bat run --release -- --cfg C8 --backend cpu --export` on the fixture (360 frames): + +| path | encoder chosen | fps | ms/f | +|---|---|---:|---:| +| hardware | `h264_amf` (D3D11, zero-copy) | **88.2** | 11.34 | +| CPU backend | `h264_mf` (system frames) | **4.8** | 207.87 | +| CPU, forced last resort | `libopenh264` | **4.6** | 218.91 | + +All three produce valid 360-frame 1080p MP4s that decode clean under `ffmpeg -f null -`. + +**`h264_mf` winning on the CPU backend is a local artefact, not the no-GPU answer.** Media +Foundation picks its own encoder MFT independently of our D3D device, so on this machine — +which *has* an AMD GPU, just not one this compositor is using — it can still reach hardware. +On a genuinely GPU-less host `h264_mf` would fall to its own software encoder or fail, and +`libopenh264` is the floor. The forced row is there precisely because the automatic one +cannot be trusted to represent that host: `OPENSCREEN_EXPORT_ENCODER=libopenh264` is the +only way to exercise the real last resort from a machine that has a GPU. + +The encoder is not the bottleneck either way — the two CPU rows differ by 5 %, while the +gap to hardware is 18×. That gap is the blur and motion-blur shaders (see the table above), +not the codec. + ## How we got here — the WebCodecs trail > **This section is history.** It records the measurements that killed the browser-based export pipeline and motivated the native one. The code it describes is **gone**: `src/lib/exporter/videoExporter.ts`, `src/bench/runBench.ts` and the `npm run bench:export` script were deleted with the web MP4 pipeline. It is kept because it is the evidence for [why the compositor, not the encoder, was the wall](#the-wall-is-the-compositor) — which is the entire reason `crates/compositor/` exists — and because the [measurement hazards](#measurement-hazards) it uncovered still apply to any new benchmark here.