diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a719360f5..18b1582ad1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,114 @@ 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. + # + # `cargo test`, NOT just `cargo check` — and that difference is the whole point. + # Three of the bugs that kept the first macOS run from rendering anything are + # invisible to a type-check and are each pinned by a test now: + # * `shaders.metal` is compiled AT RUNTIME by `new_library_with_source`, so a + # whole file of invalid MSL (HLSL's global `cbuffer`/`Texture2D` have no MSL + # equivalent) type-checks perfectly — `every_shader_entry_point_compiles` + # catches it. macos-14 runners have a real Metal device, so it runs for real. + # * `AVERROR(EAGAIN)` is -11 on Windows/Linux and -35 on macOS. A hardcoded -11 + # compiles anywhere and silently decodes zero frames — + # `averror_constants_match_the_ffmpeg_macros` confronts it with shim.c. + # * pipeline-state creation rejects shader/attachment mismatches only at + # `newRenderPipelineState` time — `the_compositor_builds_on_the_system_device`. + rust-macos-compositor-check: + name: Rust test (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 + # MAC_FFMPEG_DIR only reaches build.rs because it now takes precedence over + # FFMPEG_DIR on macOS. crates/.cargo/config.toml sets FFMPEG_DIR in a GLOBAL + # [env] block (cargo has no [target..env] — the macOS section in that file + # is inert and cargo warns "unused key"), so before that change this job pointed + # bindgen at the win64 tree and could never have gone green. + - name: cargo test (compositor, aarch64-apple-darwin) + env: + MAC_FFMPEG_DIR: /opt/homebrew/opt/ffmpeg + run: | + cd crates + cargo test -p openscreen-compositor --lib --tests + - name: cargo build (napi addon) + env: + MAC_FFMPEG_DIR: /opt/homebrew/opt/ffmpeg + run: | + cd crates + cargo build -p compositor-view-napi --release + + # The Windows half of the compositor had NO type-check on any pull request. + # `ci.yml`'s only Rust job is the macOS one above; `compositor_windows.rs` — the + # 3654-line D3D11 engine that every Windows user actually runs — is compiled + # exclusively by `build.yml`'s `build-windows` step (`npm run build:win`), and + # build.yml triggers only on `push: tags: v*` or `workflow_dispatch`. A typo in + # that file therefore surfaced when someone cut a release, not when they pushed it. + # + # That gap is what makes any cross-platform refactor of the compositor a blind + # edit, so it gets closed before the refactor rather than after. + # + # `cargo check`, not `build`: the point is "does the Windows engine still compile", + # and check is roughly half the wall-time of a full build on a windows runner. + rust-windows-compositor-check: + name: Rust check (Windows compositor) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + # bindgen needs libclang. `crates/.cargo/config.toml` pins LIBCLANG_PATH to + # C:\Program Files\LLVM\bin, which is where the windows-latest image already + # has LLVM — so the pin resolves with no extra install. + - name: Check LLVM is where the cargo config expects it + shell: bash + run: ls "/c/Program Files/LLVM/bin/libclang.dll" + # Vendors the pinned BtbN LGPL-shared build into + # crates/thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared — the exact directory + # FFMPEG_DIR points at (crates/.cargo/config.toml). build.rs reads the headers + # from there and links the import libs. + - name: Vendor the pinned ffmpeg + run: npm run fetch:ffmpeg + - name: cargo check (compositor + napi addon) + shell: bash + run: | + cd crates + cargo check -p openscreen-compositor -p compositor-view-napi --all-targets + 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 9b2e428810..15a9970459 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::gif_export::{GifExportParams, GifStats}; use openscreen_compositor::live::{LiveView, PausedPreviews}; use openscreen_compositor::scene::Scene; @@ -52,6 +52,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, @@ -138,7 +156,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); @@ -357,7 +386,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 9f2c0aa1e8..1a4692bfdc 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,10 +17,30 @@ 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 + # Native GIF export (slice 1 — behind a feature flag, the renderer still uses # `gif.js` in `src/lib/exporter/gifExporter.ts`). The GIF89a writer, the LZW # encoder, and the median-cut palette are all hand-rolled in `gif_export.rs` — # no new crate deps, no GPL pull-ins, no swscale round-trip. See # `gif_export.rs` for the readback path and the wall-time expectations; the # bench in `crates/poc-d3d/src/bench.rs` is the honest signal. + +# 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" diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index dd34657962..7c4e9d4780 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -1,36 +1,116 @@ 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); + if target_is_macos { + point_libclang_at_the_xcode_toolchain(); } - println!("cargo:rerun-if-changed=wrapper.h"); + // 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). + // Sur macOS, `MAC_FFMPEG_DIR` passe AVANT `FFMPEG_DIR`. Ce n'est pas une préférence + // de style : `crates/.cargo/config.toml` pose `FFMPEG_DIR` dans un `[env]` global — + // cargo n'a pas de `[target..env]`, la section macOS y est inerte — donc + // `FFMPEG_DIR` est TOUJOURS renseigné, et pointe sur l'arbre win64. Le lire d'abord + // rendait le fallback macOS ci-dessous inatteignable : bindgen partait sur + // `thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared/include`, qui n'existe pas sur un Mac. + let ff = if target_is_macos { + // `MAC_FFMPEG_DIR` explicite (CI / dev), sinon l'arbre vendorisé attendu sous + // `crates/thirdparty/`, aligné sur la disposition Windows. `FFMPEG_DIR` ne sert + // de recours que s'il désigne un arbre qui existe VRAIMENT — c'est-à-dire quand + // un dev l'a posé à la main pour macOS, jamais quand il vient du pin Windows. + env::var("MAC_FFMPEG_DIR") + .ok() + .filter(|v| Path::new(v).join("include").exists()) + .or_else(|| { + // `thirdparty/` est frère de `compositor/`, sous `crates/` — c'est aussi + // ce que le pin Windows désigne (`relative = true` dans + // `crates/.cargo/config.toml`, relatif au dossier de la config). + // build.rs s'exécute avec cwd = racine du crate, pas `crates/`, donc on + // remonte depuis CARGO_MANIFEST_DIR plutôt que d'écrire un chemin relatif + // qui viserait `crates/compositor/thirdparty/`. + let candidate = Path::new(&env::var("CARGO_MANIFEST_DIR").ok()?) + .parent()? + .join("thirdparty") + .join("ffmpeg-n8.1.2-macos64-lgpl-shared"); + candidate + .join("include") + .exists() + .then(|| candidate.to_string_lossy().to_string()) + }) + .or_else(|| { + env::var("FFMPEG_DIR") + .ok() + .filter(|v| Path::new(v).join("include").exists()) + }) + } else { + env::var("FFMPEG_DIR").ok() + }; + + 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); + } + } + + // 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 +118,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"); @@ -47,3 +150,46 @@ fn main() { .write_to_file(out.join("ffi.rs")) .expect("écriture ffi.rs"); } + +/// Pose `LIBCLANG_PATH` sur la toolchain Xcode/CommandLineTools active, pour bindgen. +/// +/// Nécessaire parce que `crates/.cargo/config.toml` pose `LIBCLANG_PATH` dans un `[env]` +/// GLOBAL, avec la valeur Windows (`C:\Program Files\LLVM\bin`). Cargo n'a pas de +/// `[target..env]` — la section macOS du fichier est inerte, cargo la signale +/// d'ailleurs en `unused key` — donc sur un Mac clang-sys reçoit un chemin Windows, +/// n'y trouve rien, et n'essaie même pas la découverte par défaut : il échoue sur +/// « Unable to find libclang ». On écrase donc la valeur ici, dans le seul processus +/// qui la lit. +/// +/// `xcrun --find clang` donne `/usr/bin/clang` ; `libclang.dylib` est deux +/// niveaux plus haut, dans `/usr/lib`. Ça marche pour Xcode.app comme pour +/// CommandLineTools, et suit un `xcode-select` qui bouge — ce qu'un chemin en dur ne +/// ferait pas. Un `LIBCLANG_PATH` déjà valide (dev qui pointe une LLVM Homebrew) est +/// respecté. +fn point_libclang_at_the_xcode_toolchain() { + println!("cargo:rerun-if-env-changed=LIBCLANG_PATH"); + if let Ok(v) = env::var("LIBCLANG_PATH") { + if Path::new(&v).join("libclang.dylib").exists() { + return; + } + } + let clang = std::process::Command::new("xcrun") + .args(["--find", "clang"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let lib_dir = clang + .as_deref() + .map(Path::new) + // /usr/bin/clang -> /usr + .and_then(|p| p.parent()?.parent().map(|usr| usr.join("lib"))) + .filter(|d| d.join("libclang.dylib").exists()); + match lib_dir { + Some(d) => env::set_var("LIBCLANG_PATH", d), + // Rien trouvé : retirer la valeur Windows plutôt que la laisser saboter la + // découverte par défaut de clang-sys, qui sait aussi chercher toute seule. + None => env::remove_var("LIBCLANG_PATH"), + } +} \ No newline at end of file diff --git a/crates/compositor/shim.c b/crates/compositor/shim.c index b95af05eca..5f0741ed32 100644 --- a/crates/compositor/shim.c +++ b/crates/compositor/shim.c @@ -1,9 +1,21 @@ // Accesseurs pour les champs d'AVFormatContext que bindgen rend opaque // (struct atteinte seulement par pointeur -> blob opaque). Compilé contre les -// VRAIS headers ffmpeg 8.x par MSVC : offsets corrects, immunisé contre la version. +// VRAIS headers ffmpeg 8.x par le compilateur natif de la cible : offsets +// corrects, immunisé contre la version ET contre la plateforme. +#include #include +#include AVStream* sn_fmt_stream(AVFormatContext* s, int i) { return s->streams[i]; } unsigned sn_fmt_nb_streams(AVFormatContext* s) { return s->nb_streams; } AVIOContext* sn_fmt_get_pb(AVFormatContext* s) { return s->pb; } void sn_fmt_set_pb(AVFormatContext* s, AVIOContext* p) { s->pb = p; } + +// AVERROR(EAGAIN) n'est PAS une constante portable : il vaut -11 sur Windows et +// Linux (EAGAIN=11) mais -35 sur macOS et les BSD (EAGAIN=35). Les trois copies +// Rust de cette valeur étaient écrites en dur à -11, donc sur macOS la boucle +// avcodec_receive_frame ne reconnaissait jamais « redonne-moi un paquet » : elle +// traitait -35 comme une erreur fatale et AUCUNE frame n'était jamais décodée. +// La faire calculer ici la rend juste par construction sur chaque cible. +int sn_averror_eagain(void) { return AVERROR(EAGAIN); } +int sn_averror_eof(void) { return AVERROR_EOF; } diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 9b15cdae9f..3c37326f8e 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -14,8 +14,9 @@ pub const AUDIO_OUTPUT_CHANNELS: usize = 2; pub const AUDIO_BITRATE: i64 = 128_000; pub const AUDIO_BOUNDARY_FADE_SAMPLES: usize = 240; -const AVERROR_EAGAIN: i32 = -11; -const AVERROR_EOF: i32 = -541478725; +// Valeurs partagées : `AVERROR(EAGAIN)` dépend de la plateforme (-11 vs -35), et ce +// module est compilé sur les deux. Cf. `crate::ffi`. +use crate::ffi::{AVERROR_EAGAIN, AVERROR_EOF}; const AVSEEK_FLAG_BACKWARD: i32 = 1; const DEFAULT_FRAME_SEC: f64 = 0.04; const MIN_FRAME_SEC: f64 = 0.005; diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs new file mode 100644 index 0000000000..d92174d267 --- /dev/null +++ b/crates/compositor/src/compositor_macos.rs @@ -0,0 +1,1973 @@ +//! 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_FRAMES`) 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). +//! +//! # Chemin de lecture CPU +//! +//! Metal n'a pas d'équivalent de `ID3D11DeviceContext::Map` sur une ressource +//! `Private`. Les cibles de rendu (`rt`, `nv12_y`, `nv12_uv`) sont donc en +//! `StorageMode::Private`, et chaque passe se termine par un `MTLBlitCommandEncoder` +//! vers un miroir `Shared` (`rt_read`, `nv12_read_y`, `nv12_read_uv`) sur lequel +//! `getBytes` est légal. Le `waitUntilCompleted` qui suit est ce qui rend +//! `readback_direct` synchrone, comme son homologue Windows : sans lui, la preview +//! lirait le contenu de la frame précédente (ou du noir au premier tour). + +use crate::config::Cfg; +use crate::d3d::Gpu; +use crate::ffi::AVFrame; +// Le constant buffer est le MÊME struct des deux côtés — cf. `frame_geometry`. +// Constant buffer, params runtime et constantes de sortie : une seule définition pour +// les deux backends — cf. `frame_geometry`, qui documente les divergences que +// l'unification a corrigées. +pub use crate::frame_geometry::{ + live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, +}; +use crate::frame_geometry::{parse_hex, FrameGeometryInput, SCREEN_SHADOW_OFFSET_FRAC, + SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, + WEBCAM_SHADOW_SPREAD_FRAC}; +use crate::scene::{Scene, SceneBackground}; +use anyhow::{anyhow, Result}; +use metal::foreign_types::ForeignType; +use std::cell::RefCell; + +// --------------------------------------------------------------------------- +// CVMetalTextureCache — le pont CVPixelBuffer → MTLTexture +// --------------------------------------------------------------------------- + +/// Newtype safe Rust pour `CVMetalTextureCacheRef` (`*mut __CVMetalTextureCache`). +pub(crate) struct CVMetalTextureCache(std::ptr::NonNull); + +unsafe impl Send for CVMetalTextureCache {} +unsafe impl Sync for CVMetalTextureCache {} + +#[link(name = "CoreVideo", kind = "framework")] +#[link(name = "CoreFoundation", kind = "framework")] +#[link(name = "Metal", kind = "framework")] +extern "C" { + fn CVMetalTextureCacheCreate( + allocator: *const std::ffi::c_void, + cache_attributes: *const std::ffi::c_void, + metal_device: *const std::ffi::c_void, // id + texture_attributes: *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, + texture_attributes: *const std::ffi::c_void, + // `MTLPixelFormat` est un `NSUInteger`, donc 64 bits sur arm64/x86_64. Le + // déclarer `u32` laissait la moitié haute du registre indéfinie côté appelé. + pixel_format: u64, + width: usize, + height: usize, + plane_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; + + fn CFRelease(cf: *const std::ffi::c_void); + + fn CVPixelBufferGetWidthOfPlane(p: *mut std::ffi::c_void, plane_index: usize) -> usize; + fn CVPixelBufferGetHeightOfPlane(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; +} + +/// `retain` ObjC sur un `id`. `CVMetalTextureGetTexture` rend une référence +/// *empruntée* au `CVMetalTextureRef` qui la porte : relâcher ce dernier sans +/// retenir la texture donne un `id` mort. Et ne jamais le relâcher — +/// ce que faisait la première version — fuit un objet CoreVideo par plan et par +/// frame, soit 120 fuites par seconde en preview 60 fps. +extern "C" { + fn objc_retain(obj: *mut std::ffi::c_void) -> *mut std::ffi::c_void; +} + +impl CVMetalTextureCache { + /// Crée un `CVMetalTextureCache` lié au `MTLDevice` donné. + 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 cache attributes + metal_device, + std::ptr::null(), // default texture attributes + &mut cache, + ) + }; + if status != 0 || cache.is_null() { + return Err(anyhow!( + "CVMetalTextureCacheCreate a échoué (CVReturn={status}, cache={cache:?})" + )); + } + Ok(CVMetalTextureCache(unsafe { + std::ptr::NonNull::new_unchecked(cache) + })) + } + + /// Wrappe le plan `plane_index` d'un `CVPixelBufferRef` en `MTLTexture`, zéro copie + /// (le `MTLTexture` partage l'IOSurface du `CVPixelBuffer`). + /// + /// Pas de cache `(pixel_buffer, plane)` côté Rust : `CVMetalTextureCache` EST déjà + /// ce cache — il rend la même texture pour le même IOSurface. Un second cache indexé + /// sur l'ADRESSE du `CVPixelBufferRef` est en plus faux dès que le pool VideoToolbox + /// recycle une adresse, et ne se vide jamais. + pub(crate) fn make_texture_from_pixel_buffer( + &self, + pixel_buffer: *mut std::ffi::c_void, + plane_index: usize, + pixel_format: metal::MTLPixelFormat, + ) -> Result { + let (w, h) = unsafe { + ( + CVPixelBufferGetWidthOfPlane(pixel_buffer, plane_index), + CVPixelBufferGetHeightOfPlane(pixel_buffer, plane_index), + ) + }; + if w == 0 || h == 0 { + return Err(anyhow!( + "CVPixelBuffer plan {plane_index} vide ({w}x{h}) — buffer non planaire ?" + )); + } + 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(), + pixel_format as u64, + w, + h, + plane_index, + &mut cv_texture, + ) + }; + if status != 0 || cv_texture.is_null() { + return Err(anyhow!( + "CVMetalTextureCacheCreateTextureFromImage a échoué (CVReturn={status}, plane={plane_index}, {w}x{h}, fmt={pixel_format:?})" + )); + } + let borrowed = unsafe { CVMetalTextureGetTexture(cv_texture) }; + if borrowed.is_null() { + unsafe { CFRelease(cv_texture) }; + return Err(anyhow!( + "CVMetalTextureGetTexture a renvoyé un id nul (plane={plane_index})" + )); + } + // retain la texture, puis relâche le CVMetalTextureRef : la `metal::Texture` + // rendue possède désormais sa propre référence, et son `Drop` fera le release. + let owned = unsafe { objc_retain(borrowed) }; + unsafe { CFRelease(cv_texture) }; + Ok(unsafe { metal::Texture::from_ptr(owned as *mut metal::MTLTexture) }) + } + + /// Libère les textures que CoreVideo garde en cache. À appeler quand les + /// `CVPixelBuffer` sources changent de dimensions (les entrées cachées pointent + /// alors sur l'IOSurface précédent). + pub(crate) fn flush(&self) { + unsafe { CVMetalTextureCacheFlush(self.0.as_ptr(), 0) }; + } +} + +impl Drop for CVMetalTextureCache { + fn drop(&mut self) { + unsafe { + CVMetalTextureCacheFlush(self.0.as_ptr(), 0); + // `CVMetalTextureCacheRef` est un CFType : c'est `CFRelease` qui le libère. + // La version précédente ne faisait que le flush et fuitait le cache lui-même. + CFRelease(self.0.as_ptr()); + } + } +} + +// --------------------------------------------------------------------------- +// Compositor +// --------------------------------------------------------------------------- + +/// Le moteur de composition. 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`. +/// +/// **First-pass engine** : `compose_frame` rend la couche écran en plein cadre (mode 0 +/// du méga-shader `ps_main`). Les couches suivantes — webcam, coins arrondis, ombres, +/// pyramide Kawase, motion blur — existent déjà dans `shaders.metal` mais ne sont pas +/// encore pilotées ici ; c'est ce que couvre le commit « couches » à suivre. +pub struct Compositor { + gpu: Gpu, + render_w: u32, + render_h: u32, + scene: RefCell>, + cursor: RefCell>, + cursor_time: RefCell>, + timeline_time: RefCell>, + live_params: RefCell, + metal_texture_cache: CVMetalTextureCache, + /// Dernier command buffer soumis, gardé pour pouvoir l'attendre AU MOMENT où le CPU lit + /// vraiment. Soumettre puis attendre tout de suite vide le pipeline à chaque frame : + /// le GPU finit, le CPU décode et encode pendant que le GPU dort, et on paie la latence + /// d'un aller-retour complet par passe au lieu de laisser les deux se recouvrir. + last_cmd: RefCell>, + /// Wallpapers décodés, indexés par chemin (ou par data-URI pour les annotations image). + /// Le décode + upload coûte des millisecondes ; le faire à chaque frame ferait chuter la + /// preview sur un fond image. + img_cache: RefCell>, + + // --- Engine : render targets --- + /// Render target principal RGBA8. Cible de `compose_frame`. `Private` : c'est une + /// cible de rendu pure, jamais lue par le CPU (c'est `rt_read` qui l'est). + rt: metal::Texture, + /// Miroir `Shared` de `rt`, rempli par blit à la fin de `compose_frame` — la seule + /// façon d'atteindre `getBytes` depuis une cible `Private`. + rt_read: metal::Texture, + /// NV12 interne : plan Y `R8Unorm`, plan UV `RG8Unorm` (demi-résolution). + nv12_y: metal::Texture, + nv12_uv: metal::Texture, + /// Miroirs `Shared` des deux plans, pour `read_nv12_scaled`. + nv12_read_y: metal::Texture, + nv12_read_uv: metal::Texture, + + // --- Engine : shaders compilés --- + /// MSL library compilée dans `new_sized`. Conservée : les pipeline states en + /// dépendent, et un futur commit recompilera des variantes à partir d'elle. + _library: metal::Library, + /// Pipeline state pour la passe principale (`vs_main` + `ps_main`). + pipeline_main: metal::RenderPipelineState, + /// Pipeline states pour les passes fullscreen (`vs_fs` + `ps_y`/`ps_uv`/`ps_tex`). + pipeline_fs_y: metal::RenderPipelineState, + pipeline_fs_uv: metal::RenderPipelineState, + /// Composite plein écran d'une texture sur le RT (`vs_fs` + `ps_tex`), en « over ». + /// C'est la passe qui rapatrie l'accumulation de traînée sur la scène. + pipeline_fs_tex: metal::RenderPipelineState, + /// `vs_main` + `ps_main` en additif : les échantillons de traînée du curseur. + pipeline_add: metal::RenderPipelineState, + /// Buffer d'accumulation ISOLÉ (transparent) pour la traînée. Accumuler directement sur + /// le RT reviendrait à AJOUTER du blanc à ce qui est déjà dessous : sur un fond clair, + /// le curseur disparaît. Même raisonnement que côté D3D11. + accum: metal::Texture, + /// Pyramide dual-Kawase du flou de fond : demi, quart, huitième de la taille de rendu. + /// Dérivée de la taille de rendu et non d'une constante — sinon le rayon effectif du + /// flou changerait avec la résolution de sortie. + blur_half: metal::Texture, + blur_quarter: metal::Texture, + blur_eighth: metal::Texture, + pipeline_kdown: metal::RenderPipelineState, + pipeline_kup: metal::RenderPipelineState, + /// Copie MIPMAPPÉE du render target, pour les annotations « flou ». On ne peut pas + /// échantillonner la cible sur laquelle on dessine, et le mode 10 lit un niveau de mip + /// pour flouter à coût constant. + ann_copy: metal::Texture, + /// Images d'annotation, indexées par ID d'annotation (pas par data-URL : celle-ci pèse + /// souvent des mégaoctets et la hacher à chaque frame coûterait plus que le décodage). + /// La longueur sert de garde-fou quand l'utilisateur change l'image. + ann_img_cache: RefCell>, + /// Textes rastérisés, indexés par ID, avec la `cache_key` du spec pour invalider. + text_cache: RefCell>, + text_raster: Option, +} + +/// Descripteur de texture — les six cibles ne diffèrent que par format, taille et +/// storage, donc autant ne l'écrire qu'une fois. +fn make_texture( + device: &metal::Device, + format: metal::MTLPixelFormat, + w: u32, + h: u32, + storage: metal::MTLStorageMode, + usage: metal::MTLTextureUsage, +) -> metal::Texture { + let desc = metal::TextureDescriptor::new(); + desc.set_texture_type(metal::MTLTextureType::D2); + desc.set_pixel_format(format); + desc.set_width(w as u64); + desc.set_height(h as u64); + desc.set_storage_mode(storage); + desc.set_usage(usage); + device.new_texture(&desc) +} + +/// Comment un draw se mélange à ce qui est déjà dans la cible. +#[derive(Clone, Copy, PartialEq)] +enum Blend { + /// Opaque : la conversion NV12 et le composite fullscreen écrasent. + Replace, + /// « over » alpha prémultiplié — la passe de composition normale. + Over, + /// Additif pondéré par la couleur de blend : chaque échantillon de traînée entre pour + /// `1/taps`. C'est `OMSetBlendState(blend_add, [w,w,w,w])` côté D3D11. + Add, +} + +/// Un pipeline state à une seule pièce jointe couleur. +fn make_pipeline( + device: &metal::Device, + library: &metal::Library, + vs: &str, + fs: &str, + format: metal::MTLPixelFormat, + blend: Blend, +) -> Result { + let vs_fn = library + .get_function(vs, None) + .map_err(|e| anyhow!("MTLLibrary::get_function('{vs}') : {e}"))?; + let fs_fn = library + .get_function(fs, None) + .map_err(|e| anyhow!("MTLLibrary::get_function('{fs}') : {e}"))?; + + let desc = metal::RenderPipelineDescriptor::new(); + desc.set_vertex_function(Some(&vs_fn)); + desc.set_fragment_function(Some(&fs_fn)); + // metal-rs n'expose pas de constructeur pour + // `RenderPipelineColorAttachmentDescriptor` : la pièce jointe 0 se configure sur + // le tableau que le descripteur possède déjà. + let ca = desc + .color_attachments() + .object_at(0) + .ok_or_else(|| anyhow!("RenderPipelineDescriptor::color_attachments(0) est nul"))?; + ca.set_pixel_format(format); + if blend != Blend::Replace { + ca.set_blending_enabled(true); + ca.set_rgb_blend_operation(metal::MTLBlendOperation::Add); + ca.set_alpha_blend_operation(metal::MTLBlendOperation::Add); + let (src, dst) = match blend { + Blend::Over => (metal::MTLBlendFactor::One, metal::MTLBlendFactor::OneMinusSourceAlpha), + Blend::Add => (metal::MTLBlendFactor::BlendColor, metal::MTLBlendFactor::One), + Blend::Replace => unreachable!(), + }; + ca.set_source_rgb_blend_factor(src); + ca.set_destination_rgb_blend_factor(dst); + ca.set_source_alpha_blend_factor(src); + ca.set_destination_alpha_blend_factor(dst); + } + device + .new_render_pipeline_state(&desc) + .map_err(|e| anyhow!("new_render_pipeline_state({vs}+{fs}) : {e}")) +} + +impl Compositor { + /// Crée le moteur sur le GPU donné. Équivalent Metal de + /// `compositor_windows::Compositor::new`. + 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, miroirs `Shared`), + /// - 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 cache = CVMetalTextureCache::new(gpu.device.as_ptr() as *const std::ffi::c_void)?; + + let device = &gpu.device; + let rt_usage = metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead; + + let rt = make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + rw, + rh, + metal::MTLStorageMode::Private, + rt_usage, + ); + let rt_read = make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + rw, + rh, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + let nv12_y = make_texture( + device, + metal::MTLPixelFormat::R8Unorm, + rw, + rh, + metal::MTLStorageMode::Private, + rt_usage, + ); + // NV12 : le plan chroma est entrelacé ET demi-résolution dans les deux axes. + // Le dimensionner comme le plan luma — ce que faisait la première version — + // produisait un UV 4x trop grand, donc un `read_nv12_scaled` qui lit au-delà + // de ce que la passe a écrit. + let nv12_uv = make_texture( + device, + metal::MTLPixelFormat::RG8Unorm, + rw / 2, + rh / 2, + metal::MTLStorageMode::Private, + rt_usage, + ); + let nv12_read_y = make_texture( + device, + metal::MTLPixelFormat::R8Unorm, + rw, + rh, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + let nv12_read_uv = make_texture( + device, + metal::MTLPixelFormat::RG8Unorm, + rw / 2, + rh / 2, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + + // --- Compilation MSL --- + let msl_source = include_str!("shaders.metal"); + let library = device + .new_library_with_source(msl_source, &metal::CompileOptions::new()) + .map_err(|e| anyhow!("MTLDevice::new_library_with_source a échoué : {e}"))?; + + let pipeline_main = make_pipeline( + device, + &library, + "vs_main", + "ps_main", + metal::MTLPixelFormat::RGBA8Unorm, + Blend::Over, + )?; + let pipeline_fs_y = make_pipeline( + device, + &library, + "vs_fs", + "ps_y", + metal::MTLPixelFormat::R8Unorm, + Blend::Replace, + )?; + let pipeline_fs_uv = make_pipeline( + device, + &library, + "vs_fs", + "ps_uv", + metal::MTLPixelFormat::RG8Unorm, + Blend::Replace, + )?; + let pipeline_fs_tex = make_pipeline( + device, + &library, + "vs_fs", + "ps_tex", + metal::MTLPixelFormat::RGBA8Unorm, + Blend::Over, + )?; + let pipeline_add = make_pipeline( + device, + &library, + "vs_main", + "ps_main", + metal::MTLPixelFormat::RGBA8Unorm, + Blend::Add, + )?; + let accum = make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + rw, + rh, + metal::MTLStorageMode::Private, + rt_usage, + ); + let mut pyramid = [2u32, 4, 8].map(|d| { + make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + (rw / d).max(1), + (rh / d).max(1), + metal::MTLStorageMode::Private, + rt_usage, + ) + }); + let blur_eighth = pyramid[2].clone(); + let blur_quarter = pyramid[1].clone(); + let blur_half = std::mem::replace(&mut pyramid[0], blur_quarter.clone()); + let pipeline_kdown = make_pipeline( + device, &library, "vs_fs", "ps_kawase_down", + metal::MTLPixelFormat::RGBA8Unorm, Blend::Replace, + )?; + let pipeline_kup = make_pipeline( + device, &library, "vs_fs", "ps_kawase_up", + metal::MTLPixelFormat::RGBA8Unorm, Blend::Replace, + )?; + let ann_copy = { + let d = metal::TextureDescriptor::new(); + d.set_texture_type(metal::MTLTextureType::D2); + d.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + d.set_width(rw as u64); + d.set_height(rh as u64); + d.set_storage_mode(metal::MTLStorageMode::Private); + d.set_usage(rt_usage); + // Assez de niveaux pour que `log2(rayon)` du mode 10 en trouve toujours un. + d.set_mipmap_level_count( + (32 - rw.max(rh).max(1).leading_zeros()).max(1) as u64, + ); + device.new_texture(&d) + }; + + 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: cache, + last_cmd: RefCell::new(None), + img_cache: RefCell::new(std::collections::HashMap::new()), + rt, + rt_read, + nv12_y, + nv12_uv, + nv12_read_y, + nv12_read_uv, + _library: library, + pipeline_main, + pipeline_fs_y, + pipeline_fs_uv, + pipeline_fs_tex, + pipeline_add, + accum, + blur_half, + blur_quarter, + blur_eighth, + pipeline_kdown, + pipeline_kup, + ann_copy, + ann_img_cache: RefCell::new(std::collections::HashMap::new()), + text_cache: RefCell::new(std::collections::HashMap::new()), + text_raster: crate::text::TextRasterizer::new().ok(), + }) + } + + /// 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() + } + + /// Le `CVPixelBufferRef` porté par une frame, quel que soit le chemin de décodage : + /// - `AV_PIX_FMT_VIDEOTOOLBOX` : frame brute VideoToolbox, `data[3]` (convention ffmpeg) ; + /// - `AV_PIX_FMT_D3D11` : sentinel posé par `mac_frames::CpuFrames::present`, `data[0]`. + /// + /// Les deux aboutissent au même buffer IOSurface-backed ; `CVMetalTextureCache` n'a + /// pas de préférence. + unsafe fn pixel_buffer_of(frame: *const AVFrame) -> Option<*mut std::ffi::c_void> { + if frame.is_null() { + return None; + } + 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 None, + }; + if pb.is_null() { + None + } else { + Some(pb) + } + } + + /// Dimensions réelles (texture, alignée pair) du `CVPixelBufferRef` posé dans la + /// frame. API symétrique de `compositor_windows::tex_dims`. + pub unsafe fn tex_dims(&self, frame: *const AVFrame) -> (u32, u32) { + match Self::pixel_buffer_of(frame) { + Some(pb) => ( + CVPixelBufferGetWidth(pb) as u32, + CVPixelBufferGetHeight(pb) as u32, + ), + None => (0, 0), + } + } + + /// Crée les `MTLTexture` Y (`R8Unorm`) et UV (`RG8Unorm`) de la frame. Zéro copie : + /// les textures Metal partagent l'IOSurface du `CVPixelBuffer`. API symétrique de + /// `compositor_windows::nv12_srvs`. + pub unsafe fn nv12_srvs( + &self, + frame: *const AVFrame, + ) -> Result<(metal::Texture, metal::Texture)> { + let pb = Self::pixel_buffer_of(frame).ok_or_else(|| { + anyhow!( + "nv12_srvs: pas de CVPixelBufferRef (format={}, ni sentinel D3D11 ni VIDEOTOOLBOX)", + if frame.is_null() { -1 } else { (*frame).format } + ) + })?; + let cache = &self.metal_texture_cache; + let y = cache.make_texture_from_pixel_buffer(pb, 0, metal::MTLPixelFormat::R8Unorm)?; + let uv = cache.make_texture_from_pixel_buffer(pb, 1, metal::MTLPixelFormat::RG8Unorm)?; + Ok((y, uv)) + } + + /// Vide le `CVMetalTextureCache` — API symétrique de + /// `compositor_windows::Compositor::clear_srv_cache`, même contrat côté appelant + /// (`live.rs` l'appelle sans savoir sur quelle plateforme il tourne) : à invoquer + /// quand un jeu de décodeurs vient d'être fermé, pour ne pas garder de textures + /// pointant sur un IOSurface déjà libéré. + /// + /// Pas de `HashMap` keyée par adresse à vider ici (contrairement à Windows) — voir + /// la doc de `CVMetalTextureCache` : CoreVideo est déjà ce cache et le réutilise par + /// IOSurface, pas par pointeur Rust. `flush()` est donc la vidange elle-même. + pub fn clear_srv_cache(&self) { + self.metal_texture_cache.flush(); + } + + /// Les verbes de dessin, côté Metal. Mêmes noms et mêmes paramètres que leurs + /// homologues de `compositor_windows.rs` — c'est ce qui rend les deux moitiés + /// « dessin » comparables ligne à ligne. + /// + /// `ps_main` lit `LayerCB` au fragment ET `vs_main` le lit au vertex (il en tire le + /// quad), donc les deux étages sont liés à chaque draw. + unsafe fn draw_layer( + &self, + enc: &metal::RenderCommandEncoderRef, + cb: &LayerCB, + tex: Option<(&metal::Texture, &metal::Texture)>, + ) { + let bytes = std::mem::size_of::() as u64; + let ptr = cb as *const LayerCB as *const std::ffi::c_void; + enc.set_vertex_bytes(0, bytes, ptr); + enc.set_fragment_bytes(0, bytes, ptr); + if let Some((y, uv)) = tex { + enc.set_fragment_texture(0, Some(y)); + enc.set_fragment_texture(1, Some(uv)); + } + enc.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4); + } + + /// Quad de couleur pleine / gradient / ombre — tout ce qui n'échantillonne pas la vidéo. + unsafe fn draw_solid(&self, enc: &metal::RenderCommandEncoderRef, cb: &LayerCB) { + self.draw_layer(enc, cb, None); + } + + /// Quad vidéo NV12 (mode 0) : les deux plans de la frame décodée. + unsafe fn draw_video( + &self, + enc: &metal::RenderCommandEncoderRef, + cb: &LayerCB, + y: &metal::Texture, + uv: &metal::Texture, + ) { + self.draw_layer(enc, cb, Some((y, uv))); + } + + /// Ombre portée (mode 2) — port mot pour mot de `compositor_windows::draw_shadow` : + /// le quad est élargi de `spread` de chaque côté et décalé de `offset_px`, et le + /// shader dérive la pénombre de la SDF du rect arrondi inscrit. + #[allow(clippy::too_many_arguments)] + unsafe fn draw_shadow( + &self, + enc: &metal::RenderCommandEncoderRef, + dst: [f32; 4], + size_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: f32, + ) { + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let (sx, sy) = (spread / rw, spread / rh); + let (ox, oy) = (offset_px[0] / rw, offset_px[1] / rh); + let cb = LayerCB { + dst: [dst[0] - sx + ox, dst[1] - sy + oy, dst[2] + 2.0 * sx, dst[3] + 2.0 * sy], + quad_px: [size_px[0] + 2.0 * spread, size_px[1] + 2.0 * spread], + radius_px: radius, + mode: 2.0, + color: [0.0, 0.0, 0.0, opacity], + fx: [spread, 0.0, 0.0, 0.0], + mb: [0.0, 1.0, 1.0, 0.0], + ..Default::default() + }; + self.draw_solid(enc, &cb); + } + + + /// Décode un fichier image (jpg/png) — ou une data-URI — en `MTLTexture` RGBA8. + /// + /// Miroir de `compositor_windows::load_image_srv`. Les annotations image stockent une + /// data URL plutôt qu'un chemin (cf. `types.ts`), d'où les deux entrées. + fn load_image_texture(&self, path: &str) -> Result<(metal::Texture, u32, u32)> { + let img = if let Some(bytes) = crate::frame_geometry::decode_data_uri(path) { + image::load_from_memory(&bytes) + .map_err(|e| anyhow!("data URI image ({} octets) : {e}", bytes.len()))? + .to_rgba8() + } else { + image::open(path) + .map_err(|e| anyhow!("wallpaper {path} : {e}"))? + .to_rgba8() + }; + let (w, h) = (img.width(), img.height()); + let pixels = img.into_raw(); + let tex = make_texture( + &self.gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + w, + h, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + tex.replace_region( + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { width: w as u64, height: h as u64, depth: 1 }, + }, + 0, + pixels.as_ptr() as *const std::ffi::c_void, + (w * 4) as u64, + ); + Ok((tex, w, h)) + } + + /// Fond wallpaper image, cover-fit sur le ratio de SORTIE (mode 6). + /// + /// Le crop de recouvrement se calcule contre le vrai ratio de sortie, pas contre celui + /// de la texture : sinon l'image, déjà cover-fittée, se fait re-déformer. + unsafe fn draw_image_bg( + &self, + enc: &metal::RenderCommandEncoderRef, + path: &str, + output_aspect: f32, + ) -> Result<()> { + // Emprunt isolé dans un `let` pour qu'il soit relâché AVANT le `borrow_mut` — + // même piège que côté Windows (double emprunt RefCell à la première frame image). + let cached = self.img_cache.borrow().get(path).cloned(); + let (tex, iw, ih) = match cached { + Some(v) => v, + None => { + let loaded = self.load_image_texture(path)?; + self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); + loaded + } + }; + let ai = iw as f32 / ih.max(1) as f32; + let ao = output_aspect; + let (u0, v0, u1, v1) = if ai > ao { + let vis = ao / ai; // rogne horizontalement + ((1.0 - vis) * 0.5, 0.0, 1.0 - (1.0 - vis) * 0.5, 1.0) + } else { + let vis = ai / ao; // rogne verticalement + (0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5) + }; + enc.set_fragment_texture(2, Some(&tex)); + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [u0, v0, u1, v1], + mode: 6.0, + ..Default::default() + }, + ); + Ok(()) + } + + + + /// Une passe plein écran : `source` -> `target` avec `pipeline`, `fx` dans le LayerCB. + /// Le viewport découle de la taille de l'attachement, donc pas de `RSSetViewports`. + unsafe fn fs_pass( + &self, + cmd: &metal::CommandBufferRef, + target: &metal::Texture, + source: &metal::Texture, + pipeline: &metal::RenderPipelineState, + fx: [f32; 4], + ) -> Result<()> { + let e = self.begin_pass( + cmd, + target, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)), + pipeline, + )?; + let cb = LayerCB { fx, ..Default::default() }; + e.set_fragment_bytes( + 0, + std::mem::size_of::() as u64, + &cb as *const LayerCB as *const std::ffi::c_void, + ); + e.set_fragment_texture(0, Some(source)); + e.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + e.end_encoding(); + Ok(()) + } + + /// Dual-Kawase sur le contenu courant du RT : trois passes DOWN puis trois UP, la + /// dernière réécrivant le RT. Port des six `fs_pass` de `compositor_windows::blur_bg`, + /// mêmes tailles et mêmes texels. + unsafe fn blur_bg(&self, cmd: &metal::CommandBufferRef) -> Result<()> { + let off = 2.2; // spread par passe + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let (hw, hh) = (rw * 0.5, rh * 0.5); + // DOWN : texel = 1/(dims de la SOURCE échantillonnée) + self.fs_pass(cmd, &self.blur_half, &self.rt, &self.pipeline_kdown, [1.0 / rw, 1.0 / rh, off, 0.0])?; + self.fs_pass(cmd, &self.blur_quarter, &self.blur_half, &self.pipeline_kdown, [1.0 / hw, 1.0 / hh, off, 0.0])?; + self.fs_pass(cmd, &self.blur_eighth, &self.blur_quarter, &self.pipeline_kdown, [2.0 / hw, 2.0 / hh, off, 0.0])?; + // UP + self.fs_pass(cmd, &self.blur_quarter, &self.blur_eighth, &self.pipeline_kup, [4.0 / hw, 4.0 / hh, off, 0.0])?; + self.fs_pass(cmd, &self.blur_half, &self.blur_quarter, &self.pipeline_kup, [2.0 / hw, 2.0 / hh, off, 0.0])?; + self.fs_pass(cmd, &self.rt, &self.blur_half, &self.pipeline_kup, [1.0 / hw, 1.0 / hh, off, 0.0])?; + Ok(()) + } + + + /// Ombre d'un écran incliné en 3D : la pénombre suit le QUADRILATÈRE projeté (mode 12), + /// pas son rect englobant. Port de `compositor_windows::draw_quad_shadow`. + #[allow(clippy::too_many_arguments)] + unsafe fn draw_quad_shadow( + &self, + enc: &metal::RenderCommandEncoderRef, + corners: &[(f32, f32); 4], + center_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: f32, + ) { + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + // La boîte doit contenir la pénombre entière, sinon elle se coupe net. + let box_w = (max_x - min_x) + 2.0 * spread; + let box_h = (max_y - min_y) + 2.0 * spread; + let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x + spread, y - min_y + spread] }; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + self.draw_solid( + enc, + &LayerCB { + dst: [ + (center_px[0] + min_x - spread + offset_px[0]) / rw, + (center_px[1] + min_y - spread + offset_px[1]) / rh, + box_w / rw, + box_h / rh, + ], + quad_px: [box_w, box_h], + radius_px: radius, + mode: 12.0, + color: [0.0, 0.0, 0.0, opacity], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + mb: [0.0, spread, 1.0, 0.0], + ..Default::default() + }, + ); + } + + /// Écran incliné (mode 8) : warp bilinéaire inverse dans la bbox du quad projeté. + /// Pas de motion blur sur ce chemin — le tilt est bref, la simplification ne se voit pas. + unsafe fn draw_tilted_screen( + &self, + enc: &metal::RenderCommandEncoderRef, + quad: &crate::regions::TiltedQuad, + s_px: [f32; 2], + center_px: [f32; 2], + cut: [f32; 4], + radius: f32, + y: &metal::Texture, + uv: &metal::Texture, + ) { + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let corners = quad.corners; + // Taille du plan dans son propre repère, avant projection : c'est là que vit le rayon, + // pour qu'il reste constant le long du bord au lieu de s'étirer avec la perspective. + let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let bbox_w = (max_x - min_x).max(1.0); + let bbox_h = (max_y - min_y).max(1.0); + // coins en px LOCAUX à la bbox, pour matcher `i.local` du shader. + let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x, y - min_y] }; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + self.draw_video( + enc, + &LayerCB { + dst: [ + (center_px[0] + min_x) / rw, + (center_px[1] + min_y) / rh, + bbox_w / rw, + bbox_h / rh, + ], + src: cut, + quad_px: [bbox_w, bbox_h], + radius_px: radius * quad.scale, + mode: 8.0, + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], + ..Default::default() + }, + y, + uv, + ); + } + + + /// Annotations : calque le plus haut, ancré sur `screen_dst` — le conteneur que reçoit + /// l'overlay web. Port de `compositor_windows::draw_annotations`. + unsafe fn draw_annotations( + &self, + cmd: &metal::CommandBufferRef, + scene: Option<&Scene>, + t: f32, + screen_dst: [f32; 4], + ) -> Result<()> { + let Some(scene) = scene else { return Ok(()) }; + if scene.annotations.is_empty() { + return Ok(()); + } + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let visible = |a: &crate::scene::SceneAnnotation| { + t >= a.start_sec as f32 && t < a.end_sec as f32 + }; + // UNE seule recopie pour toutes les annotations flou de la frame : leur lecture doit + // voir l'image composée SANS les flous eux-mêmes, sinon deux zones qui se recouvrent + // s'échantillonneraient l'une l'autre selon l'ordre de dessin. + if scene.annotations.iter().any(|a| a.kind == "blur" && visible(a)) { + let blit = cmd.new_blit_command_encoder(); + blit.copy_from_texture( + &self.rt, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { width: rw as u64, height: rh as u64, depth: 1 }, + &self.ann_copy, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + // Seul le mip 0 est rempli ; le GPU dérive le reste. + blit.generate_mipmaps(&self.ann_copy); + blit.end_encoding(); + } + + let enc = self.begin_pass(cmd, &self.rt, None, &self.pipeline_main)?; + // La liste arrive déjà triée par zIndex côté app : l'ordre d'itération EST l'ordre + // de peinture. + for a in &scene.annotations { + if !visible(a) { + continue; + } + let dst = [ + screen_dst[0] + a.x * screen_dst[2], + screen_dst[1] + a.y * screen_dst[3], + a.w * screen_dst[2], + a.h * screen_dst[3], + ]; + let quad_px = [dst[2] * rw, dst[3] * rh]; + if quad_px[0] <= 0.0 || quad_px[1] <= 0.0 { + continue; + } + match a.kind.as_str() { + "figure" => { + let Some(figure) = a.figure.as_ref() else { continue }; + let (segments, half_stroke) = crate::regions::arrow_local_geometry( + &figure.direction, + figure.stroke_width, + quad_px, + ); + self.draw_solid(enc, &LayerCB { + dst, + quad_px, + mode: 9.0, + color: parse_hex(&figure.color).unwrap_or([1.0, 1.0, 1.0, 1.0]), + fx: segments[0], + src_prev: segments[1], + dst_prev: segments[2], + mb: [1.0, half_stroke, 0.0, 0.0], + ..Default::default() + }); + } + "blur" => { + let Some(blur) = a.blur.as_ref() else { continue }; + // Le masque en tracé libre demanderait une liste de points côté GPU : on + // masque la BOÎTE ENGLOBANTE. Choix délibérément asymétrique — ne rien + // dessiner laisserait passer en clair ce que l'utilisateur a désigné comme + // à cacher, et un masque qui ne masque pas donne confiance à tort. + let freehand = blur.shape == "freehand"; + let is_blur = if blur.style == "blur" { 1.0 } else { 0.0 }; + let amount = if is_blur > 0.5 { blur.intensity } else { blur.block_size }; + // Le repli passe par le rectangle, pas l'ovale : un ovale inscrit + // retirerait les coins, donc une partie de ce qui est couvert. + let is_oval = if blur.shape == "oval" && !freehand { 1.0 } else { 0.0 }; + // La teinte n'a de sens qu'en mosaïque : un flou teinté ne ressemble plus + // à un flou. + let tinted = if is_blur > 0.5 { 0.0 } else { 1.0 }; + let tint = if blur.color == "black" { + [0.0, 0.0, 0.0, 1.0] + } else { + [1.0, 1.0, 1.0, 1.0] + }; + enc.set_fragment_texture(2, Some(&self.ann_copy)); + self.draw_solid(enc, &LayerCB { + dst, + quad_px, + mode: 10.0, + color: tint, + fx: [is_blur, amount.max(1.0), is_oval, tinted], + ..Default::default() + }); + } + "image" => { + let Some(src) = a.image_path.as_ref().filter(|s| !s.is_empty()) else { + continue; + }; + let cached = { + let c = self.ann_img_cache.borrow(); + c.get(&a.id).filter(|(_, _, _, len)| *len == src.len()).cloned() + }; + let Some((tex, iw, ih, _)) = cached.or_else(|| { + match self.load_image_texture(src) { + Ok((tex, w, h)) => { + let e = (tex, w, h, src.len()); + self.ann_img_cache.borrow_mut().insert(a.id.clone(), e.clone()); + Some(e) + } + Err(e) => { + eprintln!("[annotation image] {}: {e:#}", a.id); + None + } + } + }) else { + continue; + }; + if iw == 0 || ih == 0 { + continue; + } + let box_aspect = quad_px[0] / quad_px[1]; + let img_aspect = iw as f32 / ih as f32; + let (fit_w, fit_h) = if img_aspect > box_aspect { + (dst[2], dst[3] * (box_aspect / img_aspect)) + } else { + (dst[2] * (img_aspect / box_aspect), dst[3]) + }; + enc.set_fragment_texture(2, Some(&tex)); + self.draw_solid(enc, &LayerCB { + dst: [ + dst[0] + (dst[2] - fit_w) * 0.5, + dst[1] + (dst[3] - fit_h) * 0.5, + fit_w, + fit_h, + ], + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [fit_w * rw, fit_h * rh], + mode: 7.0, + color: [1.0, 1.0, 1.0, 1.0], + fx: [0.0, 0.0, 1.0, 1.0], + ..Default::default() + }); + } + "text" => { + let Some(text) = a.text.as_ref() else { continue }; + let Some(raster) = self.text_raster.as_ref() else { continue }; + if text.content.trim().is_empty() { + continue; + } + let spec = crate::text::TextSpec { + content: text.content.clone(), + color: parse_hex(&text.color).unwrap_or([1.0, 1.0, 1.0, 1.0]), + background: parse_hex(&text.background_color) + .unwrap_or([0.0, 0.0, 0.0, 0.0]), + font_size_px: text.font_size_rel * (screen_dst[3] * rh), + font_family: text.font_family.clone(), + bold: text.font_weight == "bold", + italic: text.font_style == "italic", + underline: text.text_decoration == "underline", + align: text.text_align.clone(), + box_px: [quad_px[0].round() as u32, quad_px[1].round() as u32], + }; + let key = spec.cache_key(); + let cached = { + let c = self.text_cache.borrow(); + c.get(&a.id).filter(|(_, k)| *k == key).map(|(tex, _)| tex.clone()) + }; + let Some(tex) = cached.or_else(|| match raster.rasterize(&self.gpu, &spec) { + Ok(tex) => { + self.text_cache.borrow_mut().insert(a.id.clone(), (tex.clone(), key)); + Some(tex) + } + Err(e) => { + eprintln!("[annotation texte] {}: {e:#}", a.id); + None + } + }) else { + continue; + }; + let anim = crate::text_anim::text_animation_state( + text.animation.as_deref(), + (t - a.start_sec as f32) * 1000.0, + ); + let anim_px = rh / crate::text_anim::ANIMATION_REFERENCE_HEIGHT; + let (mut ax, mut ay, mut aw, mut ah) = ( + dst[0] + anim.translate_x * anim_px / rw, + dst[1] + anim.translate_y * anim_px / rh, + dst[2], + dst[3], + ); + if (anim.scale - 1.0).abs() > 1e-4 { + let (cx, cy) = (ax + aw * 0.5, ay + ah * 0.5); + aw *= anim.scale; + ah *= anim.scale; + ax = cx - aw * 0.5; + ay = cy - ah * 0.5; + } + let reveal = anim.reveal.clamp(0.0, 1.0); + if reveal <= 0.0 { + continue; + } + enc.set_fragment_texture(2, Some(&tex)); + self.draw_solid(enc, &LayerCB { + dst: [ax, ay, aw * reveal, ah], + src: [0.0, 0.0, reveal, 1.0], + quad_px: [aw * reveal * rw, ah * rh], + mode: 11.0, + color: [1.0, 1.0, 1.0, anim.opacity], + ..Default::default() + }); + } + _ => {} + } + } + enc.end_encoding(); + Ok(()) + } + + + /// Soumet sans attendre, et retient le buffer pour `sync`. + fn submit(&self, cmd: &metal::CommandBufferRef) { + cmd.commit(); + *self.last_cmd.borrow_mut() = Some(cmd.to_owned()); + } + + /// Attend la fin de tout ce qui a été soumis. Metal exécute dans l'ordre sur une même + /// file, donc attendre le DERNIER buffer suffit à garantir les précédents. + fn sync(&self) { + if let Some(cmd) = self.last_cmd.borrow().as_ref() { + cmd.wait_until_completed(); + } + } + + /// Ouvre un encodeur sur `target`. `clear` = `None` conserve ce qui s'y trouve. + /// + /// Metal n'a pas d'`OMSetRenderTargets` : changer de cible veut dire terminer + /// l'encodeur et en ouvrir un autre. C'est ce qui remplace la choréographie + /// `OMSetRenderTargets` / `OMSetBlendState` du chemin D3D11. + fn begin_pass<'a>( + &self, + cmd: &'a metal::CommandBufferRef, + target: &metal::Texture, + clear: Option, + pipeline: &metal::RenderPipelineState, + ) -> Result<&'a metal::RenderCommandEncoderRef> { + let desc = metal::RenderPassDescriptor::new(); + let ca = desc + .color_attachments() + .object_at(0) + .ok_or_else(|| anyhow!("RenderPassDescriptor::color_attachments(0) est nul"))?; + ca.set_texture(Some(target)); + match clear { + Some(c) => { + ca.set_load_action(metal::MTLLoadAction::Clear); + ca.set_clear_color(c); + } + None => ca.set_load_action(metal::MTLLoadAction::Load), + } + ca.set_store_action(metal::MTLStoreAction::Store); + let enc = cmd.new_render_command_encoder(&desc); + enc.set_render_pipeline_state(pipeline); + Ok(enc) + } + + /// Sprite de curseur (mode 7). Rend `Err` quand l'art n'est pas chargeable, pour que + /// l'appelant retombe sur le curseur dessiné. + unsafe fn draw_cursor_sprite( + &self, + enc: &metal::RenderCommandEncoderRef, + placement: crate::frame_geometry::CursorPlacement, + size_px: f32, + a: f32, + sprite: &crate::scene::SceneCursorSprite, + clip: [f32; 4], + ) -> Result<()> { + let cached = self.img_cache.borrow().get(sprite.path.as_str()).cloned(); + let (tex, iw, ih) = match cached { + Some(v) => v, + None => { + let loaded = self.load_image_texture(&sprite.path)?; + self.img_cache.borrow_mut().insert(sprite.path.clone(), loaded.clone()); + loaded + } + }; + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let ar = iw as f32 / ih.max(1) as f32; + let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; + let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; + let cb = match placement { + crate::frame_geometry::CursorPlacement::Upright { center } => LayerCB { + dst: crate::frame_geometry::cursor_sprite_dst(center, pw / rw, ph / rh, hotspot), + src: [0.0, 0.0, 1.0, 1.0], + mode: 7.0, + color: [1.0, 1.0, 1.0, a], + fx: clip, + ..Default::default() + }, + crate::frame_geometry::CursorPlacement::Tilted { + plane_pt, quad, center_px, screen_px, .. + } => { + // Le sprite est posé DANS le plan : sa taille devient une fraction du plan et + // ses quatre coins traversent la même projection que la vidéo. La réduction + // due au tilt vient donc de la projection — rien à multiplier à la main. + let (wf, hf) = (pw / screen_px[0], ph / screen_px[1]); + let x0 = plane_pt[0] - hotspot[0] * wf; + let y0 = plane_pt[1] - hotspot[1] * hf; + let corners = [(x0, y0), (x0 + wf, y0), (x0 + wf, y0 + hf), (x0, y0 + hf)] + .map(|(fx, fy)| { + let (px, py) = quad.point_px(fx, fy); + (center_px[0] + px, center_px[1] + py) + }); + let (min_x, max_x) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + // Le quad projeté d'un sprite peut être très fin de biais : une bbox d'un pixel + // de large ferait diverger le warp inverse, donc plancher à 1 px. + let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); + let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + LayerCB { + dst: [min_x / rw, min_y / rh, bw / rw, bh / rh], + quad_px: [bw, bh], + mode: 13.0, + color: [1.0, 1.0, 1.0, a], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + dst_prev: clip, + ..Default::default() + } + } + }; + enc.set_fragment_texture(2, Some(&tex)); + self.draw_solid(enc, &cb); + Ok(()) + } + + /// Curseur thématisé : le sprite de l'état courant, sinon la flèche, sinon rien. + /// + /// Le repli « dot + ring » mathématique (mode 4) du chemin Windows n'est pas porté : + /// l'app résout toujours un jeu de sprites, et l'art intégré couvre les états qu'un + /// thème ne fournit pas. S'il n'y a vraiment aucun sprite, ne rien dessiner est plus + /// honnête qu'un curseur qui ne ressemble à aucun réglage. + unsafe fn draw_cur_themed( + &self, + enc: &metal::RenderCommandEncoderRef, + sprites: &std::collections::HashMap, + cursor_type: Option<&str>, + placement: crate::frame_geometry::CursorPlacement, + size_px: f32, + a: f32, + clip: [f32; 4], + ) { + let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); + if let Some(sprite) = sprite { + if let Err(e) = self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip) { + eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); + } + } + } + + /// Compose la frame : fond, ombre écran, écran, ombre caméra, caméra — puis miroir + /// `Shared` pour la lecture CPU. + /// + /// La géométrie vient de `frame_geometry::plan_frame`, la MÊME fonction que le moteur + /// D3D11 appelle. Ce qui reste ici n'est donc que l'émission des draws ; c'est aussi + /// pourquoi cette moitié se relit en regard de `compositor_windows.rs`, section par + /// section. + /// + /// Pas encore rendu : le tilt 3D (mode 8), les annotations, le curseur, le flou de + /// fond, et le wallpaper image — ce dernier faute de chemin de décodage/upload d'image + /// côté Metal, et il retombe sur la couleur de fond en le disant. + pub unsafe fn compose_frame( + &self, + screen: *const AVFrame, + webcam: *const AVFrame, + frame: f32, + cfg: &Cfg, + ) -> Result<()> { + if Self::pixel_buffer_of(screen).is_none() { + return self.clear_rt(); + } + let (sy, suv) = self.nv12_srvs(screen)?; + // La caméra peut manquer (clip sans webcam) : son absence ne doit pas emporter + // l'écran avec elle. + let webcam_tex = self.nv12_srvs(webcam).ok(); + let (stw, sth) = self.tex_dims(screen); + let (wtw, wth) = self.tex_dims(webcam); + let (scw, sch) = ((*screen).width as f32, (*screen).height as f32); + let (wcw, wch) = if webcam.is_null() { + (1.0, 1.0) + } else { + ((*webcam).width as f32, (*webcam).height as f32) + }; + let u_max = scw / (stw.max(1)) as f32; + let v_max = sch / (sth.max(1)) as f32; + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + + let scene_ref = self.scene.borrow(); + let cursor_ref = self.cursor.borrow(); + let lp = *self.live_params.borrow(); + let g = crate::frame_geometry::plan_frame(&FrameGeometryInput { + render_px: [rw, rh], + screen_tex_px: [stw as f32, sth as f32], + screen_visible_px: [scw, sch], + webcam_visible_px: [wcw, wch], + u_max, + v_max, + frame, + cfg, + live: lp, + scene: scene_ref.as_ref(), + cursor: cursor_ref.as_ref(), + timeline_t_override: *self.timeline_time.borrow(), + }); + + let cmd_buf = self.gpu.context.new_command_buffer(); + let enc = self.begin_pass( + cmd_buf, + &self.rt, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), + &self.pipeline_main, + )?; + // Les deux plans écran restent liés par défaut : les quads de couleur ne les + // échantillonnent pas, mais Metal veut des slots renseignés pour les draws qui, eux, + // le font. + enc.set_fragment_texture(0, Some(&sy)); + enc.set_fragment_texture(1, Some(&suv)); + + // --- fond --- (parité `compositor_windows.rs`, section « fond ») + match scene_ref.as_ref().map(|s| s.background.clone()) { + Some(SceneBackground::Color { color }) => { + let c = parse_hex(&color).unwrap_or(lp.bg_color); + self.draw_solid( + enc, + &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() }, + ); + } + Some(SceneBackground::Gradient { angle_deg, stops }) => { + let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); + let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + let a = angle_deg.to_radians(); + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [c1[0], c1[1], c1[2], c1[3]], + mode: 5.0, + color: c0, + fx: [a.sin(), -a.cos(), 0.0, 0.0], + ..Default::default() + }, + ); + } + Some(SceneBackground::Image { path }) => { + // Repli couleur en cas d'échec, mais LOGGÉ : un fallback silencieux masquerait + // un chemin cassé. + if let Err(e) = self.draw_image_bg(enc, &path, rw / rh) { + eprintln!("[compositor] wallpaper image \"{path}\" : {e:#}"); + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }, + ); + } + } + None => { + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }, + ); + } + } + + // « Blur BG » (parité web `blurredBackgroundLayer`) : floute CE wallpaper qu'on vient + // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un + // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici. + enc.end_encoding(); + if scene_ref.as_ref().map(|s| s.effects.blur).unwrap_or(false) { + self.blur_bg(cmd_buf)?; + } + let enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + enc.set_fragment_texture(0, Some(&sy)); + enc.set_fragment_texture(1, Some(&suv)); + + // --- écran : ombre puis vidéo --- + let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; + // Géométrie du tilt calculée UNE fois : l'ombre et l'écran doivent porter exactement + // le même quadrilatère, sinon l'ombre se décolle dès que l'un des deux change. + let tilt = (!crate::regions::is_identity_rotation(g.zoom_rotation)) + .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], g.zoom_rotation)); + let quad_center_px = [ + (g.s_dst[0] + g.s_dst[2] * 0.5) * rw, + (g.s_dst[1] + g.s_dst[3] * 0.5) * rh, + ]; + if cfg.shadow { + let spread = SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; + let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * g.frame_min_px]; + let opacity = 0.45 * lp.shadow_scale; + // L'ombre suit la silhouette réellement affichée : rect arrondi quand l'écran est + // droit, quadrilatère projeté quand il est penché. Un rect droit derrière un écran + // incliné se lit comme une seconde surface, pas comme son ombre. + match tilt.as_ref() { + None => self.draw_shadow(enc, g.s_dst, s_px, g.s_radius, spread, offset, opacity), + Some(quad) => self.draw_quad_shadow( + enc, + &quad.corners, + quad_center_px, + g.s_radius * quad.scale, + spread, + offset, + opacity, + ), + } + } + let [su0, sv0, su1, sv1] = g.cut; + match tilt.as_ref() { + None => self.draw_video( + enc, + &LayerCB { + dst: g.s_dst, + src: [su0, sv0, su1, sv1], + quad_px: s_px, + radius_px: g.s_radius, + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + src_prev: [su0, sv0, su1, sv1], + dst_prev: g.s_dst_prev, + mb: [g.mb_taps, 1.0, 1.0, 0.0], + ..Default::default() + }, + &sy, + &suv, + ), + Some(quad) => self.draw_tilted_screen( + enc, quad, s_px, quad_center_px, g.cut, g.s_radius, &sy, &suv, + ), + } + + enc.end_encoding(); + + // --- curseur --- (parité `compositor_windows.rs`, section « curseur custom ») + if let Some(track) = cursor_ref.as_ref() { + let plan = crate::frame_geometry::plan_cursor( + &g, + &crate::frame_geometry::CursorPlanInput { + render_px: [rw, rh], + u_max, + v_max, + cfg, + live: lp, + scene: scene_ref.as_ref(), + track, + t: self.cursor_time.borrow().unwrap_or(frame / crate::frame_geometry::FPS), + }, + ); + if let Some(plan) = plan { + let sprites = scene_ref + .as_ref() + .map(|s| s.cursor.cursor_sprites.clone()) + .unwrap_or_default(); + let kind = plan.cursor_type.as_deref(); + if plan.taps <= 1 { + let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + self.draw_cur_themed(e, &sprites, kind, plan.placement, plan.size_px, 1.0, plan.clip); + e.end_encoding(); + } else { + // Flou RÉEL, pas des copies discrètes : les N échantillons s'accumulent dans + // un buffer ISOLÉ parti de zéro, puis sont composités « over » sur la scène. + // Les additionner directement sur le RT ajouterait du blanc à ce qui est + // dessous — sur un fond clair, curseur quasi invisible. + let e = self.begin_pass( + cmd_buf, + &self.accum, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)), + &self.pipeline_add, + )?; + let w = 1.0 / plan.taps as f32; + e.set_blend_color(w, w, w, w); + for k in 0..plan.taps { + let f = k as f32 / (plan.taps - 1) as f32; + self.draw_cur_themed( + e, + &sprites, + kind, + plan.prev_placement.lerp(plan.placement, f), + plan.size_px, + 1.0, + plan.clip, + ); + } + e.end_encoding(); + + let c = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_fs_tex)?; + c.set_fragment_texture(0, Some(&self.accum)); + c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + c.end_encoding(); + } + } + } + + // --- caméra : ombre PiP puis vidéo --- + let enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + if let (true, Some((wy, wuv))) = (lp.has_webcam, webcam_tex.as_ref()) { + let (cu0, cv0, cu1, cv1) = crate::frame_geometry::cover_crop_uv( + [wcw, wch], + [wtw as f32, wth as f32], + g.w_px[0] / g.w_px[1].max(0.0001), + ); + let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) }; + let webcam_is_block = matches!( + g.scene_preset.as_deref(), + Some("dual-frame") | Some("vertical-stack") + ); + if cfg.shadow && !webcam_is_block && g.shape_fade > 0.0 { + self.draw_shadow( + enc, + g.w_dst, + g.w_px, + g.w_radius, + WEBCAM_SHADOW_SPREAD_FRAC * g.frame_min_px, + [0.0, WEBCAM_SHADOW_OFFSET_FRAC * g.frame_min_px], + WEBCAM_SHADOW_OPACITY * g.shape_fade, + ); + } + self.draw_video( + enc, + &LayerCB { + dst: g.w_dst, + src: [u0, cv0, u1, cv1], + quad_px: g.w_px, + radius_px: g.w_radius, + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + src_prev: [u0, cv0, u1, cv1], + dst_prev: g.w_dst_prev, + mb: [g.mb_taps, 1.0, 1.0, 0.0], + ..Default::default() + }, + wy, + wuv, + ); + } + + enc.end_encoding(); + + // --- annotations : calque le plus haut, ancré sur le rect ÉCRAN --- + self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_dst)?; + + // Ni miroir RGBA ni attente ici : le miroir ne sert qu'à `readback_direct` (la + // preview), et l'export ne lit jamais le RGBA — le blit pleine résolution était payé + // à chaque frame pour rien. + self.submit(cmd_buf); + Ok(()) + } + + /// Efface le RT au noir (utilisé quand `screen` est null ou sans buffer). + unsafe fn clear_rt(&self) -> Result<()> { + let cmd_buf = self.gpu.context.new_command_buffer(); + let pass_desc = metal::RenderPassDescriptor::new(); + let ca = pass_desc + .color_attachments() + .object_at(0) + .ok_or_else(|| anyhow!("RenderPassDescriptor::color_attachments(0) est nul"))?; + ca.set_texture(Some(&self.rt)); + ca.set_load_action(metal::MTLLoadAction::Clear); + ca.set_clear_color(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)); + ca.set_store_action(metal::MTLStoreAction::Store); + cmd_buf.new_render_command_encoder(&pass_desc).end_encoding(); + + // Ni miroir RGBA ni attente ici : le miroir ne sert qu'à `readback_direct` (la + // preview), et l'export ne lit jamais le RGBA — le blit pleine résolution était payé + // à chaque frame pour rien. + self.submit(cmd_buf); + Ok(()) + } + + /// Copie `rt` (`Private`) vers `rt_read` (`Shared`) dans le command buffer donné. + fn mirror_rt(&self, cmd_buf: &metal::CommandBufferRef) { + let blit = cmd_buf.new_blit_command_encoder(); + blit.copy_from_texture( + &self.rt, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { + width: self.render_w as u64, + height: self.render_h as u64, + depth: 1, + }, + &self.rt_read, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + blit.end_encoding(); + } + + /// 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é. + 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é")) + } + + /// First-pass engine : la cible est toujours le NV12 interne. L'argument `out_tex` + /// est conservé pour l'API symétrique avec Windows ; le câblage zero-copy vers un + /// `CVPixelBuffer` appartenant à l'encodeur viendra avec le commit « encodeur VT ». + /// Rend le RT composé en NV12 **directement dans le `CVPixelBuffer` de l'encodeur**. + /// + /// `out_tex` est un `CVPixelBufferRef` (celui d'une frame `AV_PIX_FMT_VIDEOTOOLBOX` + /// tirée du pool de l'encodeur) ; nul = cible interne, chemin de lecture CPU. + /// + /// C'est le pendant macOS du zero-copy Windows : au lieu de rendre en interne, relire + /// 1,4 Mo vers le CPU puis laisser VideoToolbox les ré-uploader, on wrappe les deux + /// plans du buffer de l'encodeur en `MTLTexture` via le même `CVMetalTextureCache` que + /// le décodage, et on rend dedans. La frame ne quitte jamais le GPU. + pub unsafe fn rgb_to_nv12(&self, out_tex: *mut std::ffi::c_void, _slice: u32) -> Result<()> { + if out_tex.is_null() { + return self.render_nv12(); + } + let cache = &self.metal_texture_cache; + let y = cache.make_texture_from_pixel_buffer(out_tex, 0, metal::MTLPixelFormat::R8Unorm)?; + let uv = cache.make_texture_from_pixel_buffer(out_tex, 1, metal::MTLPixelFormat::RG8Unorm)?; + + let cmd_buf = self.gpu.context.new_command_buffer(); + for (target, pipeline) in [(&y, &self.pipeline_fs_y), (&uv, &self.pipeline_fs_uv)] { + let enc = self.begin_pass( + cmd_buf, + target, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), + pipeline, + )?; + enc.set_fragment_texture(0, Some(&self.rt)); + enc.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + enc.end_encoding(); + } + // Pas de miroir `Shared`, pas de `getBytes` : c'est tout l'intérêt. On attend + // quand même, parce que `avcodec_send_frame` va lire ce buffer juste après. + self.submit(cmd_buf); + self.sync(); + Ok(()) + } + + pub unsafe fn rgb_to_nv12_scaled( + &self, + _target_w: u32, + _target_h: u32, + _out_tex: *mut std::ffi::c_void, + _slice: u32, + ) -> Result<()> { + self.render_nv12() + } + + /// Convertit le RT RGBA → `nv12_y` (R8) et `nv12_uv` (RG8) via deux passes + /// fullscreen (`ps_y` puis `ps_uv` sur `vs_fs`), puis recopie vers les miroirs + /// `Shared` que `read_nv12_scaled` lit. Miroir Metal de + /// `compositor_windows::render_nv12` — même conversion BT.709 limited. + pub unsafe fn render_nv12(&self) -> Result<()> { + let cmd_buf = self.gpu.context.new_command_buffer(); + + for (target, pipeline) in [ + (&self.nv12_y, &self.pipeline_fs_y), + (&self.nv12_uv, &self.pipeline_fs_uv), + ] { + let pass = metal::RenderPassDescriptor::new(); + let ca = pass + .color_attachments() + .object_at(0) + .ok_or_else(|| anyhow!("RenderPassDescriptor::color_attachments(0) est nul"))?; + ca.set_texture(Some(target)); + ca.set_load_action(metal::MTLLoadAction::Clear); + ca.set_clear_color(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)); + ca.set_store_action(metal::MTLStoreAction::Store); + let enc = cmd_buf.new_render_command_encoder(&pass); + enc.set_render_pipeline_state(pipeline); + enc.set_fragment_texture(0, Some(&self.rt)); + // `vs_fs` est un triangle plein écran généré depuis `[[vertex_id]]`. + enc.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + enc.end_encoding(); + } + + let blit = cmd_buf.new_blit_command_encoder(); + for (src, dst, w, h) in [ + (&self.nv12_y, &self.nv12_read_y, self.render_w, self.render_h), + ( + &self.nv12_uv, + &self.nv12_read_uv, + self.render_w / 2, + self.render_h / 2, + ), + ] { + blit.copy_from_texture( + src, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { + width: w as u64, + height: h as u64, + depth: 1, + }, + dst, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + } + blit.end_encoding(); + + self.submit(cmd_buf); + Ok(()) + } + + /// Lit le RT RGBA vers un `Vec` CPU (preview live). Renvoie `(w, h, RGBA8)`. + pub unsafe fn readback_direct(&self) -> Result<(u32, u32, Vec)> { + // Le miroir `Shared` se fait ICI plutôt qu'à chaque composition : seul ce chemin le + // lit, et il n'est emprunté que par la preview. + let cmd_buf = self.gpu.context.new_command_buffer(); + self.mirror_rt(cmd_buf); + self.submit(cmd_buf); + self.sync(); + let (w, h) = (self.render_w, self.render_h); + let bytes_per_row = (w as usize) * 4; + let mut data = vec![0u8; bytes_per_row * h as usize]; + self.rt_read.get_bytes( + data.as_mut_ptr() as *mut std::ffi::c_void, + bytes_per_row as u64, + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: w as u64, + height: h as u64, + depth: 1, + }, + }, + 0, + ); + Ok((w, h, data)) + } + + /// Variante resize de `readback_direct` — first-pass engine : rend à la taille de + /// rendu puis lit ; le resize GPU viendra avec le commit « pipeline resize ». + pub unsafe fn readback_resized(&self, _target_w: u32, _target_h: u32) -> Result> { + let (_, _, data) = self.readback_direct()?; + Ok(data) + } + + /// Lit le NV12 (Y+UV) vers la mémoire système, dans les plans d'une AVFrame. + /// `pitch_y` / `pitch_uv` sont les strides de destination (`AVFrame::linesize`), + /// que `getBytes` respecte via `bytesPerRow`. + #[allow(clippy::too_many_arguments)] + 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<()> { + // Le moteur rend à `render_w`x`render_h` ; lire au-delà serait hors-texture. + // `render_nv12` a soumis sans attendre ; c'est ici, avant la première lecture CPU, + // que la synchronisation est nécessaire. + self.sync(); + let w = target_w.min(self.render_w); + let h = target_h.min(self.render_h); + if w == 0 || h == 0 { + return Err(anyhow!( + "read_nv12_scaled: cible vide ({target_w}x{target_h})" + )); + } + self.nv12_read_y.get_bytes( + dst_y as *mut std::ffi::c_void, + pitch_y as u64, + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: w as u64, + height: h as u64, + depth: 1, + }, + }, + 0, + ); + self.nv12_read_uv.get_bytes( + dst_uv as *mut std::ffi::c_void, + pitch_uv as u64, + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: (w / 2) as u64, + height: (h / 2) as u64, + depth: 1, + }, + }, + 0, + ); + Ok(()) + } + + /// Vide le cache CoreVideo. À appeler quand la source change de dimensions. + pub fn flush_texture_cache(&self) { + self.metal_texture_cache.flush(); + } + + 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 : il n'y a pas de swapchain côté macOS (la preview passe par + // `readback_direct`, l'export par `render_nv12`). + } +} + + +#[cfg(test)] +mod tests { + + + /// Le pendant macOS de `compositor_windows`'s `every_shader_entry_point_compiles`. + /// + /// `shaders.metal` est compilé À L'EXÉCUTION par `new_library_with_source` : une + /// erreur de syntaxe MSL ne se voit donc jamais au `cargo build`, seulement au + /// premier `Compositor::new` — c'est-à-dire quand un utilisateur ouvre l'éditeur. + /// Ce test la fait remonter au `cargo test`. + #[test] + fn every_shader_entry_point_compiles() { + let Some(device) = metal::Device::system_default() else { + eprintln!("pas de MTLDevice (CI sans GPU) — test sauté"); + return; + }; + let library = device + .new_library_with_source( + include_str!("shaders.metal"), + &metal::CompileOptions::new(), + ) + .expect("shaders.metal doit compiler"); + for name in [ + "vs_main", + "vs_fs", + "ps_main", + "ps_y", + "ps_uv", + "ps_blur", + "ps_tex", + "ps_kawase_down", + "ps_kawase_up", + ] { + library + .get_function(name, None) + .unwrap_or_else(|e| panic!("entry point {name} absent de la library : {e}")); + } + } + + /// Les quatre pipeline states que `new_sized` construit doivent être acceptés par + /// Metal : c'est là que se voient les désaccords entre la signature d'un shader et + /// la pièce jointe couleur qu'on lui donne (format, blend), qui ne sont PAS des + /// erreurs de compilation MSL. + #[test] + fn the_compositor_builds_on_the_system_device() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 640, 360).expect("Compositor::new_sized"); + assert_eq!(comp.render_size(), (640, 360)); + } +} diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor_windows.rs similarity index 61% rename from crates/compositor/src/compositor.rs rename to crates/compositor/src/compositor_windows.rs index 7c7754c86b..9a9c16a887 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -2,6 +2,19 @@ //! NV12 échantillonné depuis les textures décodeur (SRV par plan), effets en HLSL (§7). use crate::config::Cfg; +// La géométrie de composition vit dans `frame_geometry` — voir l'en-tête de ce module +// pour le pourquoi. `pub use` sur les constantes : `pipeline_windows.rs`, `live.rs` et +// `crates/poc-d3d/src/app.rs` les lisent via `crate::compositor::…`, et ce chemin doit +// rester valable. +pub use crate::frame_geometry::{live_params_from_scene, webcam_shape_code, LayerCB, + LiveParams, FIXTURE_FRAMES, HALF_H, HALF_W, OUT_H, OUT_W}; +use crate::frame_geometry::{ + cover_crop_uv, cover_uv_rect, cursor_sprite_dst, decode_data_uri, ease_in_out_cubic, lerp, + lerp4, parse_hex, preset_placements, remap_box, screen_source_rect, timeline, CursorPlacement, + FrameParams, Placement, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, + SCREEN_SHADOW_SPREAD_FRAC, SHADOW_TUNING_REF_PX, WEBCAM_SHADOW_OFFSET_FRAC, + WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC, +}; use crate::cursor::CursorTrack; use crate::scene::{Scene, SceneBackground, SceneCrop, SceneCursorSprite}; use crate::d3d::Gpu; @@ -18,376 +31,20 @@ use windows::Win32::Graphics::Direct3D::{ use windows::Win32::Graphics::Direct3D11::*; use windows::Win32::Graphics::Dxgi::Common::*; -pub const OUT_W: u32 = 1920; -pub const OUT_H: u32 = 1080; - -/// Parse une couleur "#rgb" / "#rrggbb" (sRGB, comme les wallpapers web) → [r,g,b,a] 0..1. -/// Les couleurs plates suivent le même chemin que `bg_color` (pas de linéarisation). -/// Décode une data URL base64 (`data:image/png;base64,AAAA…`) en octets. `None` si ce n'en est -/// pas une — l'appelant retombe alors sur une lecture disque. -/// -/// Écrit à la main plutôt qu'avec une dépendance : c'est le seul usage de base64 du projet, et le -/// décodeur tient en quinze lignes vérifiables. Les caractères hors alphabet (retours à la ligne -/// d'un URI replié, `=` de padding) sont ignorés, ce qui rend la fonction tolérante sans être -/// laxiste : un caractère invalide ne peut pas décaler le flux, il est simplement absent. -fn decode_data_uri(uri: &str) -> Option> { - let rest = uri.strip_prefix("data:")?; - let comma = rest.find(',')?; - if !rest[..comma].contains("base64") { - return None; - } - let payload = &rest[comma + 1..]; - let sextet = |c: u8| -> Option { - match c { - b'A'..=b'Z' => Some((c - b'A') as u32), - b'a'..=b'z' => Some((c - b'a') as u32 + 26), - b'0'..=b'9' => Some((c - b'0') as u32 + 52), - b'+' => Some(62), - b'/' => Some(63), - _ => None, - } - }; - let mut out = Vec::with_capacity(payload.len() / 4 * 3); - let (mut acc, mut bits) = (0u32, 0u32); - for byte in payload.bytes() { - let Some(v) = sextet(byte) else { continue }; - acc = (acc << 6) | v; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((acc >> bits) as u8); - } - } - Some(out) -} -fn parse_hex(s: &str) -> Option<[f32; 4]> { - // Le contrat accepte du CSS, pas seulement de l'hex : la bridge des captions produit du - // `rgba(r, g, b, a)` (l'inspector stocke couleur + opacité séparément, et `captionBackgroundCss` - // les recombine en rgba pour la preview) et les stops de gradient arrivent aussi sous cette - // forme. `transparent` est un cas particulier documenté : alpha 0, pas de plaque. Tout le - // reste tombe sur None → l'appelant applique son fallback (alpha 0 pour un fond, alpha 1 - // pour un texte, etc.) — la même sémantique qu'avant l'ajout du parseur rgba. - let trimmed = s.trim(); - if trimmed.eq_ignore_ascii_case("transparent") { - return Some([0.0, 0.0, 0.0, 0.0]); - } - // CSS Color 4 fait de `rgb()` et `rgba()` des synonymes : les deux acceptent 3 ou 4 - // composantes. On les traite donc par le même chemin plutôt que d'imposer une arité par - // nom — refuser `rgba(0, 0, 0)` ne « signalerait » rien d'utile, ça retomberait sur le - // fallback de l'appelant, c'est-à-dire une plaque invisible : exactement le bug #178. - if let Some(inner) = - strip_color_fn(trimmed, "rgba").or_else(|| strip_color_fn(trimmed, "rgb")) - { - return parse_rgb_components(inner); - } - let h = trimmed.trim_start_matches('#'); - // Un corps hex est ASCII par définition, et les découpes par octet ci-dessous (`h[i..=i]`, - // `h[0..2]`…) paniqueraient au milieu d'un caractère multi-octets qui ferait pile 3 ou 6 - // octets (`éa`, `€€`). On refuse avant de découper. - if !h.is_ascii() { - return None; - } - let (r, g, b) = match h.len() { - 3 => { - let d = |i: usize| u8::from_str_radix(&h[i..=i], 16).ok().map(|v| v * 17); - (d(0)?, d(1)?, d(2)?) - } - 6 => ( - u8::from_str_radix(&h[0..2], 16).ok()?, - u8::from_str_radix(&h[2..4], 16).ok()?, - u8::from_str_radix(&h[4..6], 16).ok()?, - ), - _ => return None, - }; - Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) -} -/// `rgba(0, 0, 0, 0.55)` → `"0, 0, 0, 0.55"` (le contenu entre les parenthèses), None si -/// l'enveloppe n'est pas de la forme `fn(...)`. Tolère les espaces et les tabs, refuse les -/// virgules finales et les arguments vides — le gradient parser a déjà démontré que la couche -/// application produit des chaînes propres, donc rester strict ici évite d'avaler des CSS -/// tordus qu'on ne maîtrise pas. La casse du préfixe est libre (`RGBA(...)` est valide) parce -/// que CSS le permet. -fn strip_color_fn<'a>(s: &'a str, name: &str) -> Option<&'a str> { - // `get` rend None si `name.len()` n'est pas une frontière de caractère : c'est ce qui rend - // le slice `s[..name.len()]` juste en dessous sûr par construction. Un `&s[..n]` direct - // paniquerait au milieu d'un caractère multi-octets (`#ab€cd` coupe dans le `€`), et une - // panique traverserait le pont N-API au lieu de retomber sur le fallback de l'appelant — - // le contraire de ce que ce parseur promet. - let after_name = s.get(name.len()..)?; - if !s[..name.len()].eq_ignore_ascii_case(name) { - return None; - } - let inner = after_name.strip_prefix('(')?.strip_suffix(')')?.trim(); - if inner.is_empty() { - return None; - } - Some(inner) -} -/// `"r, g, b"` ou `"r, g, b, a"` (floats 0..255 pour r/g/b, 0..1 pour a) → `[r, g, b, a]` en -/// 0..1, l'alpha valant 1 (opaque) quand elle est absente. Toute autre arité → None. Tolère -/// les espaces autour des virgules, pas les pourcentages : le gradient parser n'envoie pas de -/// `rgb(50%, …)` et les couches UI qui le font n'arrivent pas ici (les couleurs wallpaper -/// passent par une autre route, cf. `parseWallpaper`). -fn parse_rgb_components(s: &str) -> Option<[f32; 4]> { - let parts: Vec<&str> = s.split(',').map(str::trim).collect(); - let (rgb, alpha) = match parts.as_slice() { - [r, g, b] => ([r, g, b], 1.0), - // L'alpha est déjà sur [0..1] par convention (`rgba(...,0.55)`, pas `rgba(...,55)`). - [r, g, b, a] => ([r, g, b], parse_color_channel(a, 1.0)?), - _ => return None, - }; - Some([ - parse_color_channel(rgb[0], 255.0)?, - parse_color_channel(rgb[1], 255.0)?, - parse_color_channel(rgb[2], 255.0)?, - alpha, - ]) -} -fn parse_color_channel(raw: &str, max: f32) -> Option { - let n: f32 = raw.parse().ok()?; - if !n.is_finite() || n < 0.0 || n > max { - return None; - } - Some(n / max) -} -/// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max` -/// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1), -/// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop. -fn screen_source_rect( - u_max: f32, - v_max: f32, - crop: Option, - zoom: f32, - focus: [f32; 2], -) -> [f32; 4] { - let normalized_crop = crop.and_then(|crop| { - if !crop.x.is_finite() || !crop.y.is_finite() - || !crop.width.is_finite() || !crop.height.is_finite() - { - return None; - } - let x0 = crop.x.clamp(0.0, 1.0); - let y0 = crop.y.clamp(0.0, 1.0); - let x1 = (crop.x + crop.width).clamp(x0, 1.0); - let y1 = (crop.y + crop.height).clamp(y0, 1.0); - (x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1]) - }); - let [x0, y0, x1, y1] = normalized_crop.unwrap_or([0.0, 0.0, 1.0, 1.0]); - let (cu0, cv0, cu1, cv1) = (x0 * u_max, y0 * v_max, x1 * u_max, y1 * v_max); - let (cw, ch) = (cu1 - cu0, cv1 - cv0); - let zoom = if zoom.is_finite() && zoom >= 1.0 { zoom } else { 1.0 }; - let fx = if focus[0].is_finite() { focus[0].clamp(0.0, 1.0) } else { 0.5 }; - let fy = if focus[1].is_finite() { focus[1].clamp(0.0, 1.0) } else { 0.5 }; - let (hu, hv) = (cw / (2.0 * zoom), ch / (2.0 * zoom)); - // `.max(cu0/cv0)` absorbs the tiny float inversion possible at zoom=1. - let su0 = (cu0 + fx * cw - hu).clamp(cu0, (cu1 - 2.0 * hu).max(cu0)); - let sv0 = (cv0 + fy * ch - hv).clamp(cv0, (cv1 - 2.0 * hv).max(cv0)); - [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv] -} -/// Rect DESTINATION de l'écran quand on dessine une coupe source PLUS LARGE que celle qui -/// remplissait la boîte — le cœur du correctif #179. -/// -/// Le zoom natif se jouait entièrement dans la coupe source (`screen_source_rect` rétrécit -/// la coupe autour du focus) pendant que la boîte, elle, ne bougeait pas : le zoom -/// s'arrêtait donc à la frontière paddée au lieu d'atteindre les bords du cadre. La -/// référence fait l'inverse — `applyZoomTransform` (TS) met à l'échelle et translate le -/// CONTENEUR CAMÉRA, masque compris, donc la boîte paddée grandit avec le zoom, sort de -/// l'étage, et le padding s'efface. -/// -/// On rend donc le zoom à la boîte : la coupe dessinée redevient le simple crop -/// (`cut`, zoom 1) et c'est la boîte qui porte le grossissement. `cut_ref` est la coupe -/// d'AVANT (zoom entier, celle qui remplissait `base`) et sert de référence : on reporte -/// `cut` à travers le mapping `cut_ref → base`. -/// -/// C'est ce report qui fait toute la sûreté du correctif. Le mapping image→écran est -/// conservé PAR CONSTRUCTION — même grossissement, même cadrage, même point de focus au -/// même pixel — quel que soit le crop, le clamp de bord ou le `cover`, puisque tout cela -/// est déjà cuit dans les deux coupes. Seule l'ÉTENDUE dessinée grandit, et c'est -/// exactement elle qui déborde le padding. Tout ce qui roule sur ce mapping (curseur, -/// tilt 3D, motion blur) est donc inchangé. -/// -/// Pas de clamp dans le cadre : la boîte doit pouvoir en sortir (« No stage clamping », -/// `frameRenderer.cameraAwareMaskRect`) — le rasterizer coupe ce qui dépasse, comme il le -/// fait déjà pour le fond flouté. -fn remap_box(base: [f32; 4], cut_ref: [f32; 4], cut: [f32; 4]) -> [f32; 4] { - let (rw, rh) = ((cut_ref[2] - cut_ref[0]), (cut_ref[3] - cut_ref[1])); - if !(rw > 1e-6 && rh > 1e-6) { - return base; - } - [ - base[0] + base[2] * (cut[0] - cut_ref[0]) / rw, - base[1] + base[3] * (cut[1] - cut_ref[1]) / rh, - base[2] * (cut[2] - cut[0]) / rw, - base[3] * (cut[3] - cut[1]) / rh, - ] -} -/// Sous-rect SOURCE (en UV de texture) qui remplit une boîte de ratio `box_ar` **sans -/// déformer** l'image : le plus grand rect centré ayant ce ratio, tiré de la frame -/// visible — l'équivalent de `object-fit: cover` côté web. -/// -/// C'est LA primitive qui garantit qu'une couche vidéo n'est jamais étirée. Le -/// contrat est déplacé de l'appelant (« donne-moi un dst au ratio de la source », -/// hypothèse qu'un preset pouvait violer en silence) vers le calcul lui-même -/// (« quel que soit le dst, je choisis la coupe qui l'habille »). -/// -/// * `visible` : dimensions RÉELLES de l'image dans la texture (`AVFrame::width/height`) ; -/// elles peuvent être plus petites que la texture, qui est allouée avec du padding -/// décodeur — d'où la division finale par `tex`. -/// * `tex` : dimensions de la texture, pour normaliser en UV. -/// * `box_ar` : ratio largeur/hauteur de la boîte de destination, en pixels de rendu. -/// -/// Retourne `(u0, v0, u1, v1)`. Quand la boîte a déjà le ratio de la source, la coupe -/// est la frame entière — donc aucun changement de pixel sur les placements qui étaient -/// déjà corrects. -fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f32, f32, f32, f32) { - let (cam_w, cam_h) = (visible[0].max(1.0), visible[1].max(1.0)); - let (tex_w, tex_h) = (tex[0].max(1.0), tex[1].max(1.0)); - let full = [0.0, 0.0, cam_w / tex_w, cam_h / tex_h]; - let [u0, v0, u1, v1] = cover_uv_rect(full, tex, box_ar); - (u0, v0, u1, v1) -} -/// Rétrécit un rect SOURCE déjà exprimé en UV (`[u0, v0, u1, v1]`) autour de son -/// centre pour qu'il porte le ratio `box_ar` une fois rapporté aux pixels de la -/// texture. C'est la forme générale de `object-fit: cover`, et LA primitive qui -/// garantit qu'une couche vidéo n'est jamais étirée. -/// -/// Deux appelants, deux points d'entrée dans le rect : -/// - la **webcam** part de la frame visible entière (`cover_crop_uv`) ; -/// - l'**écran** part du rect déjà réduit par le crop utilisateur ET le zoom, -/// et n'applique ce cover que dans les layouts qui le demandent -/// (`Scene.layout.screen_cover` — les blocs side-by-side / top-bottom, où le -/// web fait exactement la même chose via `screenCover`). -/// -/// Rogner APRÈS le crop et le zoom est ce qui rend l'opération composable : le -/// crop décide quoi montrer, le zoom où regarder, le cover comment habiller la -/// boîte. Chacun réduit le rect précédent, jamais ne le déforme. -/// -/// Quand le rect a déjà le ratio de la boîte, il est renvoyé inchangé — donc -/// aucun placement déjà correct ne bouge. -fn cover_uv_rect(uv: [f32; 4], tex: [f32; 2], box_ar: f32) -> [f32; 4] { - let (tex_w, tex_h) = (tex[0].max(1.0), tex[1].max(1.0)); - let (w_uv, h_uv) = ((uv[2] - uv[0]).max(1e-6), (uv[3] - uv[1]).max(1e-6)); - // ratio du rect courant, en PIXELS (les UV sont anisotropes dès que la - // texture n'est pas carrée — d'où le passage par `tex`). - let (w_px, h_px) = (w_uv * tex_w, h_uv * tex_h); - let cur_ar = w_px / h_px; - let box_ar = if box_ar.is_finite() && box_ar > 0.0 { box_ar } else { cur_ar }; - let (new_w_px, new_h_px) = if box_ar >= cur_ar { - (w_px, w_px / box_ar) // boîte plus large → pleine largeur, on rogne en hauteur - } else { - (h_px * box_ar, h_px) // boîte plus haute → pleine hauteur, on rogne en largeur - }; - let (new_w, new_h) = (new_w_px / tex_w, new_h_px / tex_h); - let (cx, cy) = (uv[0] + w_uv * 0.5, uv[1] + h_uv * 0.5); - [cx - new_w * 0.5, cy - new_h * 0.5, cx + new_w * 0.5, cy + new_h * 0.5] -} -/// Constant buffer d'un calque (doit matcher `cbuffer Layer` du HLSL, 64 octets). -#[repr(C)] -#[derive(Clone, Copy, Default)] -pub struct LayerCB { - pub dst: [f32; 4], - pub src: [f32; 4], - pub quad_px: [f32; 2], - pub radius_px: f32, - pub mode: f32, - pub color: [f32; 4], - pub fx: [f32; 4], - pub src_prev: [f32; 4], - pub dst_prev: [f32; 4], - pub mb: [f32; 4], // mb[0] = nombre de taps de motion blur -} -/// Valeurs continues pilotées par l'inspector (celles qui étaient codées en dur dans -/// `compose_frame`). Le défaut reproduit le rendu actuel → bench/export inchangés. -/// Les booléens/taps (fond flouté, ombre on/off, coins on/off, motion blur) restent -/// portés par le `Cfg` que le thread live reconstruit depuis les switches. -#[derive(Clone, Copy)] -pub struct LiveParams { - pub bg_color: [f32; 4], // fond plat (mode couleur) quand non flouté - pub shadow_scale: f32, // multiplie l'opacité des ombres (1 = défaut, 0 = off) - pub radius_scale: f32, // multiplie le rayon des coins (1 = défaut, 0 = carré) - pub padding: f32, // 0..1 : inset supplémentaire du screen (0 = défaut fixture) - pub webcam_size_scale: f32, // multiplie la taille de la webcam (1 = défaut) - pub webcam_mirror: bool, // miroir horizontal de la webcam - pub webcam_shape: u32, // 0=rect, 1=circle, 2=square, 3=rounded (défaut) - pub cursor_size_scale: f32, // multiplie la taille du curseur (1 = défaut) - pub cursor_bounce_scale: f32, // multiplie l'amplitude du click-bounce (1 = défaut, 0 = off) - /// 0..1 : flou de mouvement DU CURSEUR (indépendant du motion blur écran/`cfg.mblur_n`). - /// Approximé par le même mécanisme de traînée fantôme (taps décalés le long de la - /// vélocité), pas par un flou gaussien variable comme le canvas web — plus simple à - /// réutiliser côté GPU, effet de streak équivalent. - pub cursor_motion_blur: f32, - /// False when the "webcam" decoder is actually just the screen video again (the TS side - /// falls `webcamPath` back to the screen asset's own path when a clip has no real camera, - /// purely so the decoder pipeline has something valid to open) — drawing the PiP box in - /// that case duplicates the screen video into its own corner. Live-only: derived in - /// `live.rs` by comparing the active clip's screen/webcam paths; defaults `true` (draw) - /// so fixture/bench renders and any caller that never sets it keep their old behavior. - pub has_webcam: bool, -} -impl Default for LiveParams { - fn default() -> Self { - Self { - bg_color: [0.10, 0.11, 0.14, 1.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: true, - } - } -} -/// "rectangle"|"circle"|"square"|"rounded" -> code webcam_shape (0/1/2/3). Partagé entre le -/// live (`live.rs::set_param_str`) et l'export (construit `LiveParams` depuis la scène) — une -/// seule table de vérité pour ce mapping. -pub fn webcam_shape_code(shape: &str) -> u32 { - match shape { - "rectangle" => 0, - "circle" => 1, - "square" => 2, - _ => 3, // "rounded" (défaut) - } -} -/// Construit les `LiveParams` équivalents à ce que l'inspector pousse en live, mais depuis la -/// scène de l'app — l'export est un rendu one-shot sans historique de sliders, donc il doit lire -/// directement la config déjà posée dans la scène plutôt que dupliquer un mécanisme d'inspector. -/// Unités identiques à `RightPanes.tsx` (mêmes conversions, pas de re-normalisation) : voir -/// `sceneDescription.ts` pour la correspondance settings -> champs de scène. -pub fn live_params_from_scene(s: &crate::scene::Scene) -> LiveParams { - LiveParams { - shadow_scale: s.effects.shadow, - // `radius_scale` reste le multiplicateur du chemin INSPECTOR (bench/GUI standalone) ; le - // rayon écran d'une scène vient désormais de `effects.roundness_frac`, lu directement - // dans `compose_frame`. Le faire transiter ici obligeait à le normaliser par un rayon de - // fixture (`p.screen.radius`, 24 px) pour ressortir la valeur de départ — un aller-retour - // qui ne servait qu'à faire passer des pixels pour un ratio. - 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() - } -} + pub struct Compositor { dev: ID3D11Device, @@ -493,6 +150,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 @@ -509,261 +170,16 @@ struct ResizeTarget { nv12_rtv_uv: ID3D11RenderTargetView, } -pub const HALF_W: u32 = OUT_W / 2; -pub const HALF_H: u32 = OUT_H / 2; - -pub const FIXTURE_FRAMES: u32 = 360; -const FPS: f32 = 60.0; - -/// Longueurs de style exprimées en FRACTION du petit côté du cadre, et non en pixels. -/// -/// Elles étaient écrites en px bruts au point d'appel, ce qui voulait dire « px du render -/// target » — donc une proportion DIFFÉRENTE selon la taille de rendu : 40 px, c'est 3,7 % d'un -/// cadre 1080 mais 1,9 % d'un 2160. L'ombre était donc deux fois plus douce en preview qu'à -/// l'export, et un export 4K la recevait deux fois plus faible qu'un 1080p — même famille de bug -/// que les rayons venus de l'app, mais née à l'intérieur du natif. Les valeurs ci-dessous sont -/// les anciennes constantes rapportées au cadre 1080 contre lequel elles avaient été réglées : -/// le rendu à cette résolution est donc inchangé, et devient enfin identique partout ailleurs. -const SHADOW_TUNING_REF_PX: f32 = 1080.0; -const SCREEN_SHADOW_SPREAD_FRAC: f32 = 40.0 / SHADOW_TUNING_REF_PX; -const SCREEN_SHADOW_OFFSET_FRAC: f32 = 16.0 / SHADOW_TUNING_REF_PX; -const WEBCAM_SHADOW_SPREAD_FRAC: f32 = 32.0 / SHADOW_TUNING_REF_PX; -const WEBCAM_SHADOW_OFFSET_FRAC: f32 = 12.0 / SHADOW_TUNING_REF_PX; -/// Opacité FIXE de l'ombre portée de la caméra (layout PiP uniquement). Contrairement à -/// l'ombre de l'écran — dont l'opacité est pilotée par le slider Shadow (`shadow_scale`) — -/// l'ombre de la caméra est une ombre légère NON paramétrable : même valeur quelle que soit -/// la position du slider. Parité avec le preset PiP côté web (`compositeLayout.ts`, -/// `rgba(0,0,0,0.35)`), dont l'ombre est elle aussi un forfait fixe et PiP-only. -const WEBCAM_SHADOW_OPACITY: f32 = 0.35; -/// Taille de base du curseur, même convention (34 px réglés contre un cadre 1080). -const CURSOR_BASE_SIZE_FRAC: f32 = 34.0 / SHADOW_TUNING_REF_PX; - -/// Rect [x,y,w,h] normalisé d'un sprite de curseur de taille `w`×`h` dont le pivot `hotspot` -/// (fraction 0..1 de l'image) doit tomber exactement sur `center`. -/// -/// L'invariant est que `center` reste sur le pixel désigné QUELLE QUE SOIT la taille : le -/// décalage grandit avec le sprite, donc il doit être une fraction de `w`/`h` et pas une -/// constante. Un pivot centré en dur (0.5) laissait la pointe dériver de plus en plus loin de -/// la zone visée à mesure qu'on agrandissait le curseur. -fn cursor_sprite_dst(center: [f32; 2], w: f32, h: f32, hotspot: [f32; 2]) -> [f32; 4] { - [center[0] - w * hotspot[0], center[1] - h * hotspot[1], w, h] -} -/// Où poser le curseur, et dans quel repère. -/// -/// Le curseur remplace un pointeur qui faisait partie de l'image capturée, donc il vit SUR la -/// surface de l'écran, pas dans un calque au-dessus. Quand cet écran est incliné en 3D, ce n'est -/// donc pas seulement sa position qu'il faut projeter mais son sprite entier : autrement il se -/// lit comme un autocollant plat posé sur une scène en perspective. -#[derive(Clone, Copy)] -enum CursorPlacement { - /// Écran droit : centre en coordonnées sortie 0..1. - Upright { center: [f32; 2] }, - /// Écran incliné : position 0..1 DANS le plan, plus de quoi projeter les coins du sprite. - Tilted { - /// Position du pivot dans le plan (0..1 depuis son coin haut-gauche). - plane_pt: [f32; 2], - quad: crate::regions::TiltedQuad, - /// Centre du plan en px sortie — `quad.corners` y est relatif. - center_px: [f32; 2], - /// Taille du rect d'écran NON incliné en px : l'unité dans laquelle la taille du - /// curseur est exprimée, et donc ce qui la convertit en fraction du plan. - screen_px: [f32; 2], - /// Taille de la cible de rendu en px, pour repasser des px aux 0..1 de la sortie. - render_px: [f32; 2], - }, -} -impl CursorPlacement { - /// Interpolation entre deux placements, pour les copies de la traînée de flou. Sur un plan - /// incliné on interpole DANS le plan : la traînée suit alors la surface au lieu de couper - /// droit à travers la perspective. - fn lerp(self, other: CursorPlacement, f: f32) -> CursorPlacement { - match (self, other) { - ( - CursorPlacement::Tilted { plane_pt: a, quad, center_px, screen_px, render_px }, - CursorPlacement::Tilted { plane_pt: b, .. }, - ) => CursorPlacement::Tilted { - plane_pt: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], - quad, - center_px, - screen_px, - render_px, - }, - (a, b) => { - let (p, q) = (a.upright_center(), b.upright_center()); - CursorPlacement::Upright { - center: [p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f], - } - } - } - } - /// Le centre en coordonnées sortie, quel que soit le repère — ce dont ont besoin le curseur - /// math de secours et le calcul de vélocité. - fn upright_center(self) -> [f32; 2] { - match self { - CursorPlacement::Upright { center } => center, - CursorPlacement::Tilted { plane_pt, quad, center_px, render_px, .. } => { - let (px, py) = quad.point_px(plane_pt[0], plane_pt[1]); - [(center_px[0] + px) / render_px[0], (center_px[1] + py) / render_px[1]] - } - } - } -} -fn ease_in_out_cubic(x: f32) -> f32 { - let x = x.clamp(0.0, 1.0); - if x < 0.5 { - 4.0 * x * x * x - } else { - 1.0 - (-2.0 * x + 2.0).powi(3) / 2.0 - } -} -fn lerp(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t -} -fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] { - [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)] -} -/// Un calque vidéo animé (rect sortie, taille px, rayon) — screen ou webcam. -#[derive(Clone, Copy)] -struct Placement { - dst: [f32; 4], - radius: f32, -} -/// Paramètres d'une frame : dérivés du temps par la timeline (§8). -#[derive(Clone, Copy)] -struct FrameParams { - zoom: f32, - focus: [f32; 2], - screen: Placement, - webcam: Placement, // dst carré (w en px via OUT_W) -} -/// Timeline figée de la fixture (6 s) : zoom 1.0→1.8→1.0, layout A(PIP)↔B(côte à côte). -/// `frame` fractionnaire pour permettre le supersampling temporel (flou de mouvement). -/// Gaté par `cfg` : zoom et layout ne bougent que si activés. -fn timeline(frame: f32, cfg: &Cfg) -> FrameParams { - let t = frame / FPS; // secondes - - // zoom : montée [0,3s] puis descente [3s,6s], easeInOutCubic - let zoom = if cfg.zoom { - let zt = if t < 3.0 { ease_in_out_cubic(t / 3.0) } else { ease_in_out_cubic((6.0 - t) / 3.0) }; - 1.0 + 0.8 * zt - } else { - 1.0 - }; - - // layout A = PIP bas-droite ; B = côte à côte. Transitions A→B [2,2.5]s, B→A [4,4.5]s. - let lf = if !cfg.layout_anim { - 0.0 - } else if t < 2.0 { - 0.0 - } else if t < 2.5 { - ease_in_out_cubic((t - 2.0) / 0.5) - } else if t < 4.0 { - 1.0 - } else if t < 4.5 { - 1.0 - ease_in_out_cubic((t - 4.0) / 0.5) - } else { - 0.0 - }; - - // Layout A (PIP) - let a_screen = Placement { dst: [0.05, 0.05, 0.90, 0.90], radius: 24.0 }; - let a_side = 320.0_f32; - let a_webcam = Placement { - dst: [ - (OUT_W as f32 - 40.0 - a_side) / OUT_W as f32, - (OUT_H as f32 - 40.0 - a_side) / OUT_H as f32, - a_side / OUT_W as f32, - a_side / OUT_H as f32, - ], - radius: 40.0, - }; - // Layout B (côte à côte) : screen à gauche (16:9), webcam carré à droite - let b_screen = Placement { dst: [0.035, 0.22, 0.60, 0.5625], radius: 20.0 }; - let b_side = 520.0_f32; - let b_webcam = Placement { - dst: [ - 0.70, - (OUT_H as f32 - b_side) * 0.5 / OUT_H as f32, - b_side / OUT_W as f32, - b_side / OUT_H as f32, - ], - radius: 40.0, - }; - - FrameParams { - zoom, - focus: [0.5, 0.32], - screen: Placement { dst: lerp4(a_screen.dst, b_screen.dst, lf), radius: lerp(a_screen.radius, b_screen.radius, lf) }, - webcam: Placement { dst: lerp4(a_webcam.dst, b_webcam.dst, lf), radius: lerp(a_webcam.radius, b_webcam.radius, lf) }, - } -} -/// Placements statiques screen+webcam pour un preset de layout de l'app (contrat de scène) — -/// remplace le planning A↔B fixture de `timeline()`. Zoom = 1 (les zoom regions viennent ensuite). -/// La taille/forme/miroir webcam restent appliqués par-dessus via `LiveParams`. -fn preset_placements(preset: &str) -> FrameParams { - // plein cadre : le padding l'insère ensuite (padding 0 → bord à bord). - let full_screen = Placement { dst: [0.0, 0.0, 1.0, 1.0], radius: 24.0 }; - // PiP bas-droite (≈ layout A fixture). - let a_side = 320.0_f32; - let pip_webcam = Placement { - dst: [ - (OUT_W as f32 - 40.0 - a_side) / OUT_W as f32, - (OUT_H as f32 - 40.0 - a_side) / OUT_H as f32, - a_side / OUT_W as f32, - a_side / OUT_H as f32, - ], - radius: 40.0, - }; - // webcam hors écran (no-webcam) : quad de taille nulle, jamais visible. - let off_webcam = Placement { dst: [2.0, 2.0, 0.0, 0.0], radius: 0.0 }; - - let (screen, webcam) = match preset { - "dual-frame" => { - // côte à côte : screen 16:9 à gauche, webcam carré à droite (≈ layout B fixture). - let b_side = 520.0_f32; - ( - Placement { dst: [0.035, 0.22, 0.60, 0.5625], radius: 20.0 }, - Placement { - dst: [ - 0.70, - (OUT_H as f32 - b_side) * 0.5 / OUT_H as f32, - b_side / OUT_W as f32, - b_side / OUT_H as f32, - ], - radius: 40.0, - }, - ) - } - "vertical-stack" => { - // haut/bas : screen en haut, webcam carré centré en bas. - let w_side = 360.0_f32; - ( - Placement { dst: [0.13, 0.04, 0.74, 0.52], radius: 20.0 }, - Placement { - dst: [ - 0.5 - (w_side * 0.5) / OUT_W as f32, - 0.60, - w_side / OUT_W as f32, - w_side / OUT_H as f32, - ], - radius: 40.0, - }, - ) - } - "no-webcam" => (full_screen, off_webcam), - _ => (full_screen, pip_webcam), // "picture-in-picture" (défaut) - }; - FrameParams { zoom: 1.0, focus: [0.5, 0.5], screen, webcam } -} + unsafe fn compile(src: &[u8], entry: &[u8], target: &[u8]) -> Result { let mut code: Option = None; @@ -1143,6 +559,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), }) } @@ -1675,358 +1092,30 @@ impl Compositor { let u_max = scw / stw as f32; let v_max = sch / sth as f32; - // Scène de l'app présente → placements du layout preset (ou, mieux, le rect résolu par - // l'app dans `layout.webcam_rect`) ; sinon planning fixture (bench). let scene_ref = self.scene.borrow(); - let scene_preset: Option = - scene_ref.as_ref().map(|s| s.layout.preset.clone()); - // Webcam rect résolu par l'app (= `computeCompositeLayout`, source de vérité unique - // entre preview et natif) : quand il est présent ET que la scène est posée, on l'utilise - // COMME placement de base. Sinon, fallback sur `preset_placements` historique (PiP - // codé en dur à 320 px + 40 px de marge — l'arrangement qui dérivait de la preview). - let app_webcam_rect: Option<[f32; 4]> = scene_ref - .as_ref() - .and_then(|s| s.layout.webcam_rect) - .map(|r| [r.x, r.y, r.width, r.height]); - // Idem pour l'écran. Les deux rects viennent du MÊME appel `computeCompositeLayout`, donc - // les consommer ensemble est la seule façon de garder le bloc écran+caméra cohérent : - // n'en prendre qu'un revenait à mélanger la géométrie de l'app et un placement fixture. - let app_screen_rect: Option<[f32; 4]> = scene_ref - .as_ref() - .and_then(|s| s.layout.screen_rect) - .map(|r| [r.x, r.y, r.width, r.height]); - let (mut p, mut pp) = match &scene_preset { - Some(preset) => { - // Chaque rect résolu par l'app remplace INDÉPENDAMMENT sa contrepartie du - // preset ; sinon celle du preset reste (le padding slider l'insèrera ensuite - // dans `scale_frame`). - // - // Avant, ce match portait sur `app_webcam_rect` et le rect ÉCRAN n'était donc - // honoré que si un rect webcam arrivait aussi. Un layout sans caméra gardait - // l'écran plein cadre du preset — pendant que `fit_screen` (plus bas) coupait - // quand même son fit au ratio du crop, puisqu'un `app_screen_rect` était bien - // présent. Résultat : un clip recadré sans caméra était étiré, et aucune des - // deux voies ne le rattrapait. Coupler l'écran à la présence de la caméra - // n'avait aucune raison d'être — ce sont deux calques indépendants. - let mut fp = preset_placements(preset); - if let Some(wr) = app_webcam_rect { - fp.webcam.dst = wr; - } - if let Some(sr) = app_screen_rect { - fp.screen.dst = sr; - } - (fp, fp) // layout statique → vélocité nulle - } - None => (timeline(frame, cfg), timeline(frame - 1.0, cfg)), - }; - let lp = *self.live_params.borrow(); - // Motion blur écran : quand la scène (contrat de l'app) est posée, c'est elle qui pilote - // (parité inspector : 1.0 + motion_blur*15 taps), sinon on retombe sur `cfg.mblur_n` - // (le bench fixture continue d'utiliser ses taps explicites). - let mb_taps = scene_ref - .as_ref() - .map(|s| 1.0 + s.effects.motion_blur.clamp(0.0, 1.0) * 15.0) - .unwrap_or(cfg.mblur_n as f32); - - // Zoom regions + Full Camera : filtrées en amont pour le clip actif et échantillonnées - // dans le même référentiel source que le PTS du décodeur écran. - let empty_zoom: Vec = Vec::new(); - let empty_cam: Vec = Vec::new(); - let zoom_regions = scene_ref.as_ref().map(|s| &s.zoom_regions).unwrap_or(&empty_zoom); - let cam_regions = - scene_ref.as_ref().map(|s| &s.camera_fullscreen_regions).unwrap_or(&empty_cam); - let webcam_reactive = scene_ref.as_ref().map(|s| s.layout.webcam_reactive_zoom).unwrap_or(false); - let source_t = self.timeline_t_override.borrow().unwrap_or(frame / FPS); - let source_t_prev = source_t - 1.0 / FPS; - // le focus "auto" (suivi curseur) réutilise la même piste que le rendu du curseur. let cursor_ref = self.cursor.borrow(); - let cursor_for_zoom = cursor_ref.as_ref(); - // La rotation 3D (mode 8, pas de motion blur dans ce chemin — cf. le commentaire au - // point d'appel) n'est calculée QUE pour la frame courante ; `pp` ne sert qu'au zoom - // écran normal (vélocité pour le motion blur du chemin non-tilté). - let mut zoom_rotation = [0.0f32; 3]; - if !zoom_regions.is_empty() { - let zs = crate::regions::zoom_state_at(zoom_regions, source_t, cursor_for_zoom); - p.zoom = zs.scale; - p.focus = zs.focus; - zoom_rotation = zs.rotation; - let zs_p = crate::regions::zoom_state_at(zoom_regions, source_t_prev, cursor_for_zoom); - pp.zoom = zs_p.scale; - pp.focus = zs_p.focus; - } - // Full Camera ignore le rétrécissement réactif de la webcam (design web : mélanger - // "rétrécit pour le zoom" et "grandit en plein cadre" dans la même frame n'a pas de sens). - let cam_progress = crate::regions::camera_fullscreen_progress_at(cam_regions, source_t); - let cam_progress_prev = - crate::regions::camera_fullscreen_progress_at(cam_regions, source_t_prev); - // rétrécissement réactif : la webcam rétrécit pendant un zoom actif (1/zoom, plancher - // 0.35 — parité `reactiveWebcamScale`, TS). Ignoré pendant Full Camera (voir ci-dessus). - let reactive_scale = |zoom: f32, progress: f32| -> f32 { - if webcam_reactive && progress <= 0.0 && zoom.is_finite() && zoom > 0.0 { - (1.0 / zoom).clamp(0.35, 1.0) - } else { - 1.0 - } - }; - // `lp.webcam_size_scale` vient de `scene.layout.webcamSize` (voir `live_params_from_scene`) - // — le MÊME nombre que le fraction webcamSizePreset déjà pris en compte côté app pour - // calculer `wr` (`computeCompositeLayout`, TS). Quand l'app fournit un `webcam_rect` - // explicite, la taille y est donc déjà cuite : réappliquer `lp.webcam_size_scale` ici - // double-échelonnerait la boîte (ex. un preset 34% → webcam rendue à ~34%×34% ≈ 12% au - // lieu de 34%, la webcam apparaissant bien plus petite que ce que montre l'aperçu web). - // Seul `reactive_scale` (rétrécissement pendant un zoom, une valeur ANIMÉE par frame que - // le rect statique de l'app ne capture pas) doit encore s'appliquer dans ce cas. - let base_size_scale = if app_webcam_rect.is_some() { 1.0 } else { lp.webcam_size_scale }; - let webcam_size_scale = base_size_scale * reactive_scale(p.zoom, cam_progress); - let webcam_size_scale_prev = base_size_scale * reactive_scale(pp.zoom, cam_progress_prev); - - // padding : échelle globale du layout autour du centre du cadre (parité web frameRenderer : - // paddingScale = 1 - padding*0.4 → padding 0 = plein cadre). S'applique à TOUS les presets : - // côté web, side-by-side et top/bottom soudent écran+caméra en un bloc unique et c'est ce - // bloc que le padding rétrécit (cf. `compositeLayout.ts`, branche `block`). Vertical-stack - // en était exempté tant qu'il était full-bleed ; il ne l'est plus. - let padding_scale = 1.0 - lp.padding * 0.4; - let scale_frame = |dst: [f32; 4], s: f32| -> [f32; 4] { - [0.5 + (dst[0] - 0.5) * s, 0.5 + (dst[1] - 0.5) * s, dst[2] * s, dst[3] * s] - }; - // webcam : ancrée à son coin bas-droite (grandit vers le haut-gauche, pas depuis le centre). - let scale_corner_br = |dst: [f32; 4], s: f32| -> [f32; 4] { - let (brx, bry) = (dst[0] + dst[2], dst[1] + dst[3]); - let (nw, nh) = (dst[2] * s, dst[3] * s); - [brx - nw, bry - nh, nw, nh] - }; - // parité web (compositeLayout) : rectangle/rounded gardent le ratio natif de la webcam ; - // square/circle forcent un carré (side = min). Le placement de base est carré → on ajuste - // ici, en gardant le coin bas-droite fixe (cohérent avec le size-scale). - let is_square_shape = matches!(lp.webcam_shape, 1 | 2); // circle | square - let cam_ar = if is_square_shape { 1.0 } else { (wcw / wch).max(0.01) }; - let fit_cam_aspect = |dst: [f32; 4]| -> [f32; 4] { - let s = (dst[2] * self.rw()).min(dst[3] * self.rh()); // côté carré de base (px) - let (pw, ph) = if cam_ar >= 1.0 { (s, s / cam_ar) } else { (s * cam_ar, s) }; - let (nw, nh) = (pw / self.rw(), ph / self.rh()); - let (brx, bry) = (dst[0] + dst[2], dst[1] + dst[3]); - [brx - nw, bry - nh, nw, nh] - }; - // Variantes ancrées au CENTRE (au lieu du coin bas-droite) de `dst`, pour le cas où - // `dst` vient de `app_webcam_rect` : ce rect est déjà la position que l'utilisateur a - // choisie/déplacée (résolue côté app via `computeCompositeLayout`, même convention - // centre-fraction que `cx`/`cy` dans `compositeLayout.ts`) — l'ancrer au coin bas-droite - // comme le fait `fit_cam_aspect` (pensé pour le placement par DÉFAUT, ancré à ce coin - // avec une marge fixe) réancre silencieusement la webcam glissée n'importe où d'autre à - // ce coin, ignorant la position réelle choisie par l'utilisateur — le bug rapporté - // (webcam glissée au coin bas-gauche, DOM/JSON envoyé au natif confirmant une position - // flush, mais rendu natif visiblement décalé). Le centre est le point fixe qui a un sens - // pour un rect DÉJÀ positionné par l'app ; le coin bas-droite n'a de sens que pour le - // placement par défaut, qui grandit depuis ce coin faute de position explicite. - let scale_center = |dst: [f32; 4], s: f32| -> [f32; 4] { - let (cx, cy) = (dst[0] + dst[2] * 0.5, dst[1] + dst[3] * 0.5); - let (nw, nh) = (dst[2] * s, dst[3] * s); - [cx - nw * 0.5, cy - nh * 0.5, nw, nh] - }; - // Le ratio de sortie réel (peut différer du canvas interne 16:9 fixe) et le facteur - // d'étirement non uniforme que `blit_resized` appliquera en fin de pipeline — nécessaires - // ici (avant `undistort`, plus bas) pour que le fit ci-dessous cible le ratio de boîte tel - // qu'il apparaîtra APRÈS cet étirement, pas tel qu'il est dans l'espace canvas pré-étirement - // (sinon le fit et l'undistort composent deux corrections indépendantes et sur-rétrécissent - // le contenu — cf. rapport utilisateur : crop 9:16 + sortie 9:16 + padding 0% laissait - // quand même une grosse marge, alors que le crop correspond déjà exactement au cadre). - // Le crop de l'utilisateur (dialogue "Edit clip") a son PROPRE ratio (ex. une bande - // verticale 9:16 recadrée dans une source 16:9) — le zoom appliqué ensuite (§ - // `screen_source_rect`) le préserve (mêmes facteurs sur les deux axes), donc c'est bien - // le ratio du CROP qui doit dimensionner le quad de destination, pas celui (fixe, issu - // du preset de layout) de `p.screen.dst`. Sans ça, le rect recadré (dont le ratio propre - // diffère de la boîte du preset) se retrouve étiré pour remplir cette boîte — parité web - // cassée : `computeCompositeLayout`/`centerRectInBounds` (TS) contiennent déjà le crop - // dans sa boîte en respectant son ratio, le natif ne le faisait pas (rapport utilisateur). - let active_crop = scene_ref.as_ref().and_then(|scene| { - scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() - }); - let crop_aspect = match active_crop { - Some(c) if c.width > 0.0001 && c.height > 0.0001 => { - (c.width * scw) / (c.height * sch).max(0.0001) - } - _ => scw / sch.max(0.0001), - }; - // Contain (parité `centerRectInBounds`) : rétrécit `dst` (centré) pour que son ratio - // devienne `aspect`, sans jamais dépasser sa boîte d'origine — mais la boîte de référence - // doit être mesurée telle qu'elle apparaîtra APRÈS l'étirement de sortie (`dst` * ratio de - // sortie), pas dans l'espace canvas 16:9 pré-étirement : sinon le fit cible le mauvais - // ratio de boîte dès que la sortie n'est pas 16:9. `undistort` (plus bas) annule ensuite - // exactement ce même facteur, donc convertir le résultat en fraction canvas se fait par - // `/ uniform_stretch` (propriété de `undistort` : le ratio final ne dépend que de la - // taille de `dst` en PIXELS CANVAS, jamais du ratio de sortie choisi). - let fit_dst_to_aspect = |dst: [f32; 4], aspect: f32| -> [f32; 4] { - let box_w_px = dst[2] * self.rw(); - let box_h_px = dst[3] * self.rh(); - let box_ar = box_w_px / box_h_px.max(0.0001); - let (nw_px, nh_px) = if aspect > box_ar { - (box_w_px, box_w_px / aspect.max(0.0001)) - } else { - (box_h_px * aspect, box_h_px) - }; - let (nw, nh) = (nw_px / self.rw(), nh_px / self.rh()); - let (cx, cy) = (dst[0] + dst[2] * 0.5, dst[1] + dst[3] * 0.5); - [cx - nw * 0.5, cy - nh * 0.5, nw, nh] - }; - // Quand l'app a résolu la boîte écran, elle a DÉJÀ appliqué le padding (le rect est - // calculé contre `maxContentSize`) et l'a DÉJÀ mise au ratio du crop - // (`computeCompositeLayout` reçoit la taille de la source recadrée) : rejouer - // `scale_frame` + `fit_dst_to_aspect` par-dessus appliquerait le padding deux fois et - // re-contiendrait une boîte déjà au bon ratio. Même raisonnement que pour la webcam. - let fit_screen = |dst: [f32; 4]| { - if app_screen_rect.is_some() { - dst - } else { - fit_dst_to_aspect(scale_frame(dst, padding_scale), crop_aspect) - } - }; - // Issue #179 : le zoom se jouait entièrement dans la coupe source, donc la boîte - // écran restait au rect paddé et le zoom butait sur cette frontière au lieu - // d'atteindre les bords du cadre. On rend le zoom à la BOÎTE (cf. `remap_box`) : - // la coupe dessinée redevient le crop nu, la boîte porte le grossissement et - // déborde le padding — c'est la géométrie de `applyZoomTransform` (TS). - let s_base = fit_screen(p.screen.dst); - let s_base_prev = fit_screen(pp.screen.dst); - // Layouts "bloc" (side-by-side / top-bottom) : la boîte écran est un SLOT au ratio - // arbitraire, et le web y fait tenir l'image en `cover` (`computeCompositeLayout` - // renvoie `screenCover: true`, honoré par `frameRenderer`). Le natif l'ignorait, donc - // il étirait la source pour remplir le slot — visible dès que le clip est recadré, - // puisque le crop éloigne encore le ratio de la source de celui du slot. - // - // Le cover s'applique APRÈS le crop et le zoom, sur leur rect résultant : le crop - // décide quoi montrer, le zoom où regarder, le cover comment habiller la boîte. Son - // ratio de boîte se lit sur `s_base` : `remap_box` met les deux axes à la même - // échelle, donc la boîte finale a le même ratio et le cover ne dépend pas d'elle - // (ce qui casserait la circularité coupe → boîte → coupe). - let cover_box_ar = scene_ref.as_ref().and_then(|s| { - s.layout - .screen_cover - .then_some((s_base[2] * self.rw()) / (s_base[3] * self.rh()).max(0.0001)) + let lp = *self.live_params.borrow(); + // Toute la géométrie vit dans `frame_geometry::plan_frame` — 353 lignes sans un + // appel GPU, partagées avec le backend Metal. Ce qui suit ce destructure est + // inchangé, à l'octet près. + let crate::frame_geometry::FrameGeometry { + scene_preset, mb_taps, source_t, zoom_rotation, padding_scale, cut, s_dst, + s_dst_prev, s_radius, frame_min_px, w_dst, w_dst_prev, w_px, w_radius, shape_fade, + } = crate::frame_geometry::plan_frame(&crate::frame_geometry::FrameGeometryInput { + render_px: [self.rw(), self.rh()], + screen_tex_px: [stw as f32, sth as f32], + screen_visible_px: [scw, sch], + webcam_visible_px: [wcw, wch], + u_max, + v_max, + frame, + cfg, + live: lp, + scene: scene_ref.as_ref(), + cursor: cursor_ref.as_ref(), + timeline_t_override: *self.timeline_t_override.borrow(), }); - let cover = |uv: [f32; 4]| -> [f32; 4] { - match cover_box_ar { - Some(ar) => cover_uv_rect(uv, [stw as f32, sth as f32], ar), - None => uv, - } - }; - // La coupe RÉFÉRENCE (zoom entier) est celle qui remplissait la boîte paddée avant - // ce correctif ; la coupe DESSINÉE ne porte plus que le crop. `remap_box` reporte la - // seconde à travers le mapping de la première, ce qui conserve le cadrage exact. - // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. - let cut_ref = cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); - let cut_ref_prev = cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); - let cut = cover(screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus)); - let s_dst = remap_box(s_base, cut_ref, cut); - let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); - // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée - // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) - // — SAUF quand l'app a résolu un placement explicite (`app_webcam_rect`, drag-to-reposition - // compris). Ce rect est déjà exprimé en fraction du VRAI output (calculé côté web par - // `computeCompositeLayout` avec les vraies dimensions de sortie), position ET aspect déjà - // corrects — `fit_cam_aspect`/`scale_corner_br` (chemin preset par défaut) sont donc - // doublement inadaptés ici : ils réancrent au coin bas-droite (ignorant la position - // choisie par l'utilisateur) ET recalculent l'aspect en pixels du canvas fixe 16:9 - // (`OUT_W`×`OUT_H`), une référence différente du vrai output dès que la sortie n'est pas - // 16:9 (rapport utilisateur : webcam glissée au coin bas-gauche en 9:16, JSON envoyé au - // natif confirmant une position flush, mais rendu native visiblement décalé ET trop - // petit). On garde seulement `scale_center` (zoom réactif, préserve position+aspect) puis - // on pré-compense par `inverse_undistort` pour annuler le `undistort()` générique - // appliqué plus bas à tous les calques (écran compris) — sans quoi ce rect déjà correct - // se ferait déformer une seconde fois par cet undistort partagé. - let mut w_dst = if app_webcam_rect.is_some() { - scale_center(p.webcam.dst, webcam_size_scale) - } else { - fit_cam_aspect(scale_corner_br(p.webcam.dst, webcam_size_scale)) - }; - let mut w_dst_prev = if app_webcam_rect.is_some() { - scale_center(pp.webcam.dst, webcam_size_scale_prev) - } else { - fit_cam_aspect(scale_corner_br(pp.webcam.dst, webcam_size_scale_prev)) - }; - // Full Camera : la caméra PREND le cadre — parité `computeCameraFullscreenRect` (TS). - // La cible est exactement [0,0,1,1] : pas de marge, pas de padding, pas d'arrondi, et - // plus rien de la composition (fond, écran, ombre) derrière. Le rect change de ratio en - // chemin, mais `cover_crop_uv` (plus bas) dérive la coupe source du ratio RÉEL de la - // boîte à chaque frame : la caméra n'est donc jamais étirée pendant l'animation. - let fullscreen_dst = |dst: [f32; 4], progress: f32| -> [f32; 4] { - if progress <= 0.0 { - return dst; - } - let lerp = |a: f32, b: f32| a + (b - a) * progress; - [lerp(dst[0], 0.0), lerp(dst[1], 0.0), lerp(dst[2], 1.0), lerp(dst[3], 1.0)] - }; - // Petit côté de la boîte caméra AVANT que Full Camera ne la fasse grandir. C'est la - // référence du rayon de coin : le zoom réactif est déjà dedans (il rétrécit la boîte, - // donc l'arrondi suit tout seul — parité `borderRadius * reactiveFactor` côté TS), alors - // que Full Camera ne fait pas grossir l'arrondi, il le DISSOUT (cf. `shape_fade`). - let w_nominal_min = (w_dst[2] * self.rw()).min(w_dst[3] * self.rh()); - w_dst = fullscreen_dst(w_dst, cam_progress); - w_dst_prev = fullscreen_dst(w_dst_prev, cam_progress_prev); - - // Contre-étirement "fit" : le canvas interne compose TOUJOURS en OUT_W×OUT_H (16:9), - // puis `blit_resized` étire tout, de façon non uniforme si besoin, vers la résolution - // de sortie demandée — voulu pour que le FOND (dessiné plus bas en dst=[0,0,1,1]) - // remplisse tout le cadre quel que soit le ratio choisi. Mais l'écran et la webcam ne - // doivent PAS être déformés par cet étirement : on rétrécit ici leur rect de - // destination (centré, dans cet espace 16:9 PRÉ-étirement) par l'inverse du plus fort - // des deux facteurs d'étirement, pour qu'après l'étirement final leur ratio d'origine - // reste préservé (letterboxé/pillarboxé sur le fond, qui lui reste plein cadre) — mode - // "fit"/contain. Si l'utilisateur veut un rendu "fill" (remplir sans bandes), il ajuste - // le crop lui-même ; le natif ne fait plus ce choix à sa place en étirant l'image. - // Le dessin du coin (SDF, shaders.hlsl) compare le rayon à `quad_px`, exprimé en px du - // RENDER TARGET : c'est donc dans cet espace-là qu'il faut le lui donner. - // - // Toutes les longueurs de la scène sont des FRACTIONS ; on les multiplie ici par ce - // qu'elles mesurent, dans l'espace du render target. C'est ce qui rend preview et export - // identiques : « un pixel » n'y désigne pas la même chose (la preview rastérise dans un - // cadre contain-fitté plus petit, cf. `preview_render_size`), alors qu'une fraction, si. - // `frame_min_px` est la référence des quantités relatives au CADRE ; un rayon de coin, - // lui, se mesure contre sa propre boîte — il doit rester en place quand on redimensionne - // la boîte, pas suivre le cadre. - let frame_min_px = self.rw().min(self.rh()); - let s_min_px = (s_dst[2] * self.rw()).min(s_dst[3] * self.rh()); - let app_screen_radius_frac = scene_ref.as_ref().and_then(|s| s.layout.screen_radius_frac); - let scene_roundness_frac = scene_ref.as_ref().map(|s| s.effects.roundness_frac); - // Le rayon suit la boîte : quand le zoom l'agrandit (issue #179), les coins grandissent - // avec elle puis sortent du cadre — comme le masque de la référence, qui porte le même - // `br: maskBorderRadius * camS` et quitte l'étage au même moment. - let s_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { - (false, _, _) => 0.0, - // Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra). - (true, Some(f), _) => f * s_min_px, - // Scène sans rayon imposé : slider Roundness, relatif au cadre. - (true, None, Some(f)) => f * frame_min_px, - // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. - (true, None, None) => p.screen.radius * lp.radius_scale, - }; - let w_px = [w_dst[2] * self.rw(), w_dst[3] * self.rh()]; - // Rayon caméra. Le slider Roundness ne s'y applique jamais (il ne vaut que pour l'ÉCRAN). - // Quand l'app le résout (`computeCompositeLayout`, source unique), on le prend : c'est la - // seule façon que les deux moitiés d'un layout en bloc soient encadrées à l'identique, - // l'écran consommant déjà `screen_radius_frac` du même calcul. La table ci-dessous en - // était une SECONDE, indépendante — fraction différente (0.12 vs 0.06 côté web) et sans - // bornes — donc écran et caméra ne pouvaient pas s'accorder. - let app_webcam_radius_frac = scene_ref.as_ref().and_then(|s| s.layout.webcam_radius_frac); - // Full Camera dissout la forme en même temps qu'elle prend le cadre : le rayon fond - // vers 0 avec `cam_progress`, donc le cercle devient un rect à coins de plus en plus - // francs puis un plein cadre net — aucun masque ne survit au plein écran (parité - // `computeCameraFullscreenRect`, qui ramène `maskShape` à "rectangle" et lerpe le - // rayon vers 0 pour exactement la même raison). - let shape_fade = (1.0 - cam_progress).clamp(0.0, 1.0); - let w_radius = shape_fade - * w_nominal_min - * match app_webcam_radius_frac { - Some(f) => f, - // Fallback (payload sans fraction, fixture/bench) : l'ancienne table, keyée sur la - // forme. Rectangle ET square n'ont qu'un léger arrondi (0.12) et ne diffèrent que - // par le ratio ; rounded est nettement plus arrondi (0.3) ; circle = demi-côté. - None => match lp.webcam_shape { - 1 => 0.5, - 3 => 0.3, - _ => 0.12, - }, - }; self.begin([0.0, 0.0, 0.0, 1.0]); @@ -3045,15 +2134,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 { @@ -3073,12 +2179,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(()) } @@ -3203,27 +2370,6 @@ impl Compositor { mod tests { use super::*; - /// Le pivot doit rester collé à `center` quand le sprite grandit — c'est exactement ce qui - /// était cassé (ancrage centré en dur : la pointe s'éloignait proportionnellement à la - /// taille). On dessine la même flèche à deux tailles et on vérifie que le point désigné - /// ne bouge pas. - #[test] - fn sprite_hotspot_stays_on_target_at_any_size() { - let center = [0.4, 0.6]; - let hotspot = [0.119, 0.0874]; // flèche intégrée : la pointe, près du coin haut-gauche - - for (w, h) in [(0.02, 0.04), (0.08, 0.16)] { - let dst = cursor_sprite_dst(center, w, h, hotspot); - let pivot = [dst[0] + dst[2] * hotspot[0], dst[1] + dst[3] * hotspot[1]]; - assert!((pivot[0] - center[0]).abs() < 1e-6, "x drifted at {w}x{h}: {pivot:?}"); - assert!((pivot[1] - center[1]).abs() < 1e-6, "y drifted at {w}x{h}: {pivot:?}"); - assert_eq!([dst[2], dst[3]], [w, h], "taille altérée"); - } - - // Et un pivot centré reste bien l'ancien comportement, pour les sprites qui le veulent - // (viseur, I-beam, poignées de redimensionnement). - assert_eq!(cursor_sprite_dst([0.5, 0.5], 0.2, 0.2, [0.5, 0.5]), [0.4, 0.4, 0.2, 0.2]); - } /// Le HLSL est compilé au démarrage du compositeur : jusqu'ici une faute dedans ne se voyait /// qu'à l'exécution, donc après un rebuild du natif ET un relancement de l'app. `D3DCompile` @@ -3249,323 +2395,4 @@ mod tests { } } - fn assert_rect(actual: [f32; 4], expected: [f32; 4]) { - for (actual, expected) in actual.into_iter().zip(expected) { - assert!((actual - expected).abs() < 1e-6, "actual={actual}, expected={expected}"); - } - } - - #[test] - fn decodes_a_base64_data_uri() { - // "Hi!" -> SGkh - assert_eq!(decode_data_uri("data:image/png;base64,SGkh").unwrap(), b"Hi!".to_vec()); - } - - /// L'inspector stocke les couleurs de caption comme `couleur_hex` + `opacité` puis la - /// bridge JS recombine en `rgba(r, g, b, a)` pour la preview. Le natif doit rendre la même - /// plaque (couleur et opacité) — sinon le calque disparaît silencieusement et la caption - /// n'apparaît qu'en texte brut dans l'export. C'était exactement le bug de l'issue #178. - #[test] - fn parse_hex_understands_rgba_caption_backgrounds() { - let parsed = parse_hex("rgba(0, 0, 0, 0.55)").expect("rgba doit parser"); - assert!((parsed[3] - 0.55).abs() < 1e-6, "alpha 0.55 transmise, pas tombée à 0"); - assert_eq!([parsed[0], parsed[1], parsed[2]], [0.0, 0.0, 0.0]); - } - - /// `rgb(...)` sans alpha est sémantiquement `rgba(..., 1)` — il faut le supporter pour - /// qu'un inspector qui n'expose pas d'opacité n'écrive pas un fond invisible. - #[test] - fn parse_hex_treats_rgb_as_opaque() { - let parsed = parse_hex("rgb(255, 128, 0)").expect("rgb doit parser"); - assert_eq!(parsed, [1.0, 128.0 / 255.0, 0.0, 1.0]); - } - - /// Le cas "transparent" est documenté dans le code d'appel : on garde la sémantique - /// historique (alpha 0) — la plaque est sautée côté rastérisation, ce qui est exactement ce - /// que veut le CSS. Le nouveau parseur ne doit pas le casser. - #[test] - fn parse_hex_keeps_transparent_at_alpha_zero() { - assert_eq!(parse_hex("transparent"), Some([0.0, 0.0, 0.0, 0.0])); - // La casse ne doit pas non plus casser : CSS autorise `TRANSPARENT` en théorie, et - // refuse une chaîne qui ressemble à un rgba mal formé. - assert_eq!(parse_hex("Transparent"), Some([0.0, 0.0, 0.0, 0.0])); - assert_eq!(parse_hex("rgba(0, 0, 0, 0)"), Some([0.0, 0.0, 0.0, 0.0])); - } - - /// Le contrat historique `#rrggbb` / `rrggbb` ne doit pas régresser : les annotations - /// normales (saisies via `ColorField`) ne passent que par ce chemin, et leurs snapshots - /// ne pardonneraient pas un changement d'alpha implicite. - #[test] - fn parse_hex_still_understands_hex_colours() { - assert_eq!(parse_hex("#fff"), Some([1.0, 1.0, 1.0, 1.0])); - assert_eq!(parse_hex("#000000"), Some([0.0, 0.0, 0.0, 1.0])); - assert_eq!( - parse_hex("ff8800"), - Some([1.0, 136.0 / 255.0, 0.0, 1.0]) - ); - } - - /// Hors-format (channel > 255, chaîne vide, named color) → None → l'appelant retombe sur - /// son fallback. C'est la même politique qu'avant l'ajout du parseur rgba, on la garde - /// explicite pour qu'elle ne dérive pas. - #[test] - fn parse_hex_rejects_malformed_colours() { - assert_eq!(parse_hex(""), None); - assert_eq!(parse_hex("not-a-color"), None); - assert_eq!(parse_hex("rgba(256, 0, 0, 1)"), None); // canal >255 - assert_eq!(parse_hex("rgba(0, 0, 0, 1.5)"), None); // alpha >1 - assert_eq!(parse_hex("rgba(0, 0, 0, 0.5, 1)"), None); // 5 composantes - assert_eq!(parse_hex("rgb(0, 0)"), None); // 2 composantes - } - - /// CSS Color 4 : `rgb()` et `rgba()` sont synonymes, les deux prennent 3 ou 4 composantes. - /// Une couleur bien formée ne doit pas finir sur le fallback de l'appelant — pour un fond - /// c'est alpha 0, donc une plaque invisible, soit très exactement le symptôme de #178. - #[test] - fn parse_hex_accepts_both_arities_on_both_names() { - assert_eq!(parse_hex("rgba(0, 0, 0)"), Some([0.0, 0.0, 0.0, 1.0])); - assert_eq!(parse_hex("rgb(0, 0, 0, 0.5)"), Some([0.0, 0.0, 0.0, 0.5])); - } - - /// Une couleur non-ASCII doit être refusée, pas paniquer : `strip_color_fn` découpait - /// `s[..3]` / `s[..4]` sans vérifier la frontière de caractère, donc `#ab€cd` (le `€` occupe - /// les octets 3..6) tuait le process au lieu de retomber sur le fallback. `parseWallpaper` - /// laisse passer n'importe quelle chaîne préfixée `#` jusqu'ici, une panique côté natif - /// traverserait le pont N-API et emporterait l'export. - #[test] - fn parse_hex_refuses_non_ascii_without_panicking() { - assert_eq!(parse_hex("#ab€cd"), None); - assert_eq!(parse_hex("rg€(0, 0, 0)"), None); - assert_eq!(parse_hex("é"), None); - assert_eq!(parse_hex("🎨🎨"), None); - // Le chemin hex découpe par octet sur les longueurs 3 et 6 : `éa` fait 3 octets et - // `€€` en fait 6, donc les deux tombaient pile sur une découpe intra-caractère. - assert_eq!(parse_hex("éa"), None); - assert_eq!(parse_hex("€€"), None); - } - - #[test] - fn ignores_padding_and_line_breaks_inside_the_payload() { - // Un URI replié ou paddé doit décoder à l'identique : les caractères hors alphabet sont - // sautés, donc ils ne peuvent pas décaler le flux. - let folded = "data:image/png;base64,SGkh -=="; - assert_eq!(decode_data_uri(folded).unwrap(), b"Hi!".to_vec()); - } - - #[test] - fn a_plain_path_is_not_a_data_uri() { - // Le repli lecture-disque des wallpapers en dépend. - assert!(decode_data_uri("/wallpapers/x.jpg").is_none()); - assert!(decode_data_uri("C:/img/y.png").is_none()); - } - - #[test] - fn a_non_base64_data_uri_is_refused() { - // `data:image/svg+xml,` n'est pas du base64 : mieux vaut échouer que décoder du - // texte comme des octets. - assert!(decode_data_uri("data:image/svg+xml,").is_none()); - } - - #[test] - fn crop_maps_visible_frame_fractions_to_texture_uvs() { - let crop = SceneCrop { x: 0.25, y: 0.1, width: 0.5, height: 0.6 }; - assert_rect(screen_source_rect(0.8, 0.9, None, 1.0, [0.2, 0.7]), [0.0, 0.0, 0.8, 0.9]); - assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 1.0, [0.5, 0.5]), [0.2, 0.09, 0.6, 0.63]); - } - - #[test] - fn zoom_focus_is_applied_inside_the_crop() { - let crop = SceneCrop { x: 0.25, y: 0.1, width: 0.5, height: 0.6 }; - assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [0.5, 0.5]), [0.3, 0.225, 0.5, 0.495]); - assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [1.0, 1.0]), [0.4, 0.36, 0.6, 0.63]); - } - - // --- le zoom rendu à la boîte (issue #179) ------------------------------ - // Le zoom déplace et agrandit la boîte au lieu de rétrécir la coupe. Deux choses à - // figer, et elles tirent en sens inverse : la boîte DOIT déborder le padding (l'issue), - // et le mapping image→écran ne doit PAS bouger (tout le reste du compositeur en - // dépend). Une version antérieure de ce correctif protégeait si bien le second qu'elle - // annulait le premier dès que le focus n'était pas centré — d'où le balayage sur des - // focus décentrés dans les deux tests. - - /// Boîte paddée (padding 50 % → `scale_frame` 0.8) dans une sortie carrée : le cas - /// plein cadre de l'issue. - const PADDED: [f32; 4] = [0.1, 0.1, 0.8, 0.8]; - - /// Les zooms d'un preset (`ZOOM_DEPTH_SCALES`, TS) et des focus réalistes — dont des - /// focus très décentrés, que le suivi de curseur produit en permanence. - const ZOOMS: [f32; 6] = [1.0, 1.25, 1.5, 1.8, 2.2, 3.5]; - const FOCUSES: [[f32; 2]; 6] = [ - [0.5, 0.5], - [0.3, 0.5], - [0.5, 0.8], - [0.15, 0.9], - [0.85, 0.2], - [0.0, 1.0], - ]; - - /// Le couple (boîte, coupe) réellement envoyé au GPU. `u_max`/`v_max` à 1 et pas de - /// crop : la coupe est donc directement en fractions d'image. - fn drawn(base: [f32; 4], zoom: f32, focus: [f32; 2]) -> ([f32; 4], [f32; 4]) { - let cut_ref = screen_source_rect(1.0, 1.0, None, zoom, focus); - let cut = screen_source_rect(1.0, 1.0, None, 1.0, focus); - (remap_box(base, cut_ref, cut), cut) - } - - /// Où un point de l'image atterrit à l'écran, en fraction du CADRE. - fn on_screen(base: [f32; 4], zoom: f32, focus: [f32; 2], point: [f32; 2]) -> [f32; 2] { - let (dst, src) = drawn(base, zoom, focus); - let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0); - [ - at(point[0], src[0], src[2], dst[0], dst[2]), - at(point[1], src[1], src[3], dst[1], dst[3]), - ] - } - - /// Le mapping d'avant : la coupe zoomée remplissait la boîte paddée, sans la bouger. - fn on_screen_before(base: [f32; 4], zoom: f32, focus: [f32; 2], point: [f32; 2]) -> [f32; 2] { - let src = screen_source_rect(1.0, 1.0, None, zoom, focus); - let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0); - [ - at(point[0], src[0], src[2], base[0], base[2]), - at(point[1], src[1], src[3], base[1], base[3]), - ] - } - - /// L'invariant : rendre le zoom à la boîte ne déplace AUCUN point de l'image — même - /// grossissement, même cadrage. Seule l'étendue dessinée change. - #[test] - fn handing_the_zoom_to_the_box_moves_no_pixel() { - for &zoom in &ZOOMS { - for &focus in &FOCUSES { - for &point in &[[0.5, 0.5], [0.0, 0.0], [1.0, 1.0], [0.25, 0.75]] { - let (was, now) = ( - on_screen_before(PADDED, zoom, focus, point), - on_screen(PADDED, zoom, focus, point), - ); - assert!( - (was[0] - now[0]).abs() < 1e-4 && (was[1] - now[1]).abs() < 1e-4, - "point {point:?} déplacé (zoom {zoom}, focus {focus:?}) : {was:?} → {now:?}" - ); - } - } - } - } - - /// Ce que l'issue demande, et la régression que le testeur a vue : dès qu'on zoome, la - /// boîte doit déborder le rect paddé — y compris (surtout) avec un focus décentré. - #[test] - fn any_zoom_overflows_the_padding() { - for &zoom in &ZOOMS { - for &focus in &FOCUSES { - let (dst, _) = drawn(PADDED, zoom, focus); - let grew = dst[2] / PADDED[2]; - assert!( - (grew - zoom).abs() < 1e-4, - "la boîte n'a pas pris le zoom (zoom {zoom}, focus {focus:?}) : ×{grew}" - ); - if zoom > 1.0 { - // Elle dépasse le rect paddé d'au moins un bord, donc mange du padding. - assert!( - dst[0] < PADDED[0] - 1e-6 || dst[0] + dst[2] > PADDED[0] + PADDED[2] + 1e-6, - "boîte encore dans le padding (zoom {zoom}, focus {focus:?}) : {dst:?}" - ); - } - } - } - // Focus centré : le padding disparaît des QUATRE côtés dès que le zoom suffit à - // couvrir le cadre (ici 1/0.8 = 1.25). - let (dst, _) = drawn(PADDED, 1.25, [0.5, 0.5]); - assert_rect(dst, [0.0, 0.0, 1.0, 1.0]); - // Sans padding il n'y a rien à déborder, mais la boîte porte quand même le zoom. - let (dst, _) = drawn([0.0, 0.0, 1.0, 1.0], 2.0, [0.5, 0.5]); - assert_rect(dst, [-0.5, -0.5, 2.0, 2.0]); - } - - // --- cover_crop_uv : la caméra n'est jamais étirée -------------------- - // Le ratio de la coupe source, ramené en pixels d'image, doit TOUJOURS égaler - // celui de la boîte : c'est la définition de « pas de déformation ». - - /// Ratio largeur/hauteur de la coupe, exprimé en pixels de l'image source. - fn crop_aspect(uv: (f32, f32, f32, f32), tex: [f32; 2]) -> f32 { - ((uv.2 - uv.0) * tex[0]) / ((uv.3 - uv.1) * tex[1]) - } - - /// L'invariant, balayé sur des boîtes très diverses — dont le slot en colonne - /// du preset side-by-side, qui est précisément le cas qui étirait la caméra. - #[test] - fn cover_crop_never_distorts_whatever_the_destination_box() { - let tex = [1024.0, 1024.0]; - for &cam in &[[1280.0, 720.0], [960.0, 720.0], [640.0, 480.0]] { - for &box_ar in &[0.35, 0.5, 0.75, 1.0, 16.0 / 9.0, 2.4] { - let uv = cover_crop_uv(cam, tex, box_ar); - let got = crop_aspect(uv, tex); - assert!( - (got - box_ar).abs() < 1e-3, - "cam {cam:?} boite {box_ar} → coupe de ratio {got}, attendu {box_ar}", - ); - } - } - } - - /// La coupe reste DANS l'image visible et centrée — on ne va jamais chercher - /// le padding décodeur au-delà de `visible`, qui contient des pixels indéfinis. - #[test] - fn cover_crop_stays_inside_the_visible_frame_and_is_centred() { - let (cam, tex) = ([1280.0, 720.0], [2048.0, 1024.0]); - for &box_ar in &[0.35, 1.0, 2.4] { - let (u0, v0, u1, v1) = cover_crop_uv(cam, tex, box_ar); - assert!(u0 >= 0.0 && v0 >= 0.0, "coupe hors image: {u0},{v0}"); - assert!(u1 <= cam[0] / tex[0] + 1e-6, "u1 {u1} deborde la largeur visible"); - assert!(v1 <= cam[1] / tex[1] + 1e-6, "v1 {v1} deborde la hauteur visible"); - let (mx, my) = (u0 + u1, v0 + v1); - assert!((mx - cam[0] / tex[0]).abs() < 1e-6, "pas centre en x"); - assert!((my - cam[1] / tex[1]).abs() < 1e-6, "pas centre en y"); - } - } - - /// L'écran en layout bloc : le cover s'applique au rect DÉJÀ réduit par le crop - /// et le zoom. Quel que soit ce rect de départ, ce qui atterrit dans la boîte a - /// le ratio de la boîte — c'est ce qui empêche l'étirement. - #[test] - fn cover_uv_rect_gives_the_box_aspect_whatever_the_crop_and_zoom_left() { - let tex = [2048.0, 1024.0]; - // rects source plausibles : plein cadre, bande verticale (crop portrait), zoom serré - for &uv in &[ - [0.0, 0.0, 0.9375, 0.7031], - [0.41, 0.04, 0.55, 0.67], - [0.30, 0.20, 0.55, 0.45], - ] { - for &box_ar in &[0.4, 0.75, 1.0, 1.9, 3.2] { - let out = cover_uv_rect(uv, tex, box_ar); - let got = ((out[2] - out[0]) * tex[0]) / ((out[3] - out[1]) * tex[1]); - assert!( - (got - box_ar).abs() / box_ar < 1e-3, - "uv {uv:?} boite {box_ar} -> ratio {got}", - ); - // le cover RÉDUIT : il ne va jamais chercher des pixels hors du rect source - assert!(out[0] >= uv[0] - 1e-6 && out[1] >= uv[1] - 1e-6, "deborde en haut/gauche"); - assert!(out[2] <= uv[2] + 1e-6 && out[3] <= uv[3] + 1e-6, "deborde en bas/droite"); - } - } - } - - /// Propriété de sûreté : quand la boîte a DÉJÀ le ratio de la source (tous les - /// placements qui étaient corrects — PiP par défaut, vertical-stack, et le - /// center-crop carré de square/circle), la coupe est la frame entière. Le - /// correctif ne peut donc pas déplacer un pixel de ces cas-là. - #[test] - fn cover_crop_is_the_whole_frame_when_the_box_already_matches() { - let (cam, tex) = ([1280.0, 720.0], [2048.0, 1024.0]); - let uv = cover_crop_uv(cam, tex, cam[0] / cam[1]); - assert!((uv.0).abs() < 1e-6 && (uv.1).abs() < 1e-6); - assert!((uv.2 - cam[0] / tex[0]).abs() < 1e-6); - assert!((uv.3 - cam[1] / tex[1]).abs() < 1e-6); - // et une boîte carrée sur une source 4:3 redonne bien le center-crop carré - // que l'ancien branchement `is_square_shape` codait à la main. - let (su0, _, su1, _) = cover_crop_uv([960.0, 720.0], tex, 1.0); - assert!((su0 - (960.0 - 720.0) * 0.5 / tex[0]).abs() < 1e-6); - assert!((su1 - (960.0 + 720.0) * 0.5 / tex[0]).abs() < 1e-6); - } } 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..d8e5629196 --- /dev/null +++ b/crates/compositor/src/d3d_macos.rs @@ -0,0 +1,161 @@ +//! 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). + /// + /// `_debug` est le pendant du flag de couche de debug D3D11 ; Metal a l'équivalent + /// via la variable d'environnement `METAL_DEVICE_WRAPPER_TYPE`, donc rien à faire + /// ici. Le paramètre reste pour que les call-sites (`compositor-view-napi`) soient + /// littéralement les mêmes des deux côtés. + pub fn create_auto(_debug: bool) -> Result { + create_backend(Backend::Hardware) + } + + /// Création hardware-strict (utilisée par les tests et les goldens). + pub fn create(_debug: bool) -> Result { + create_backend(Backend::Hardware) + } + + /// Le backend de cette machine, mis en cache. `d3d_windows` l'expose comme + /// fonction ASSOCIÉE (`Gpu::probe()`) et `compositor-view-napi` l'appelle ainsi ; + /// la version macOS n'avait qu'une fonction libre `probe()`, donc l'addon ne + /// compilait pas. + pub fn probe() -> Option { + probe() + } +} + +/// 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/ffi.rs b/crates/compositor/src/ffi.rs index 8ca4619da6..6e2b530adf 100644 --- a/crates/compositor/src/ffi.rs +++ b/crates/compositor/src/ffi.rs @@ -1,3 +1,75 @@ //! Bindings libav* bruts, générés par bindgen sur les headers ffmpeg 8.x (voir build.rs). #![allow(non_upper_case_globals, non_camel_case_types, non_snake_case, dead_code)] include!(concat!(env!("OUT_DIR"), "/ffi.rs")); + +// --------------------------------------------------------------------------- +// Ce que bindgen ne peut PAS générer +// --------------------------------------------------------------------------- +// +// Deux catégories, et elles vivent ici parce qu'elles ne dépendent d'aucune +// plateforme — `pipeline_windows.rs`, `pipeline_macos.rs` et `audio.rs` en ont +// tous besoin : +// +// 1. Les macros. `AVERROR(EAGAIN)`, `AVERROR_EOF` et `AVSEEK_FLAG_BACKWARD` sont +// des `#define`, donc invisibles à bindgen ; leurs valeurs sont figées par +// l'ABI de libavutil. +// 2. Les accesseurs de `shim.c`. `AVFormatContext` n'est atteint que par pointeur +// dans les headers, donc bindgen le rend opaque et ses champs (`streams`, `pb`) +// sont inatteignables depuis Rust. + +/// `AVERROR(EAGAIN)` — « pas encore de sortie, redonne-moi une entrée ». +/// +/// **Cette valeur dépend de la plateforme.** `AVERROR(e)` vaut `-e`, et `EAGAIN` vaut +/// 11 sur Windows et Linux mais **35** sur macOS et les BSD. Une constante écrite en dur +/// à -11 ne fait pas planter macOS : elle fait juste que `avcodec_receive_frame` ne +/// reconnaît jamais son « redonne-moi un paquet », traite -35 comme fatal, et ne décode +/// pas une seule frame. `sn_averror_eagain()` (shim.c) est la valeur de référence, et le +/// test plus bas confronte les deux à chaque `cargo test`. +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub const AVERROR_EAGAIN: i32 = -35; +#[cfg(not(any(target_os = "macos", target_os = "ios")))] +pub const AVERROR_EAGAIN: i32 = -11; +/// `AVERROR_EOF` = `-MKTAG('E','O','F',' ')`. +pub const AVERROR_EOF: i32 = -541478725; +/// `AVSEEK_FLAG_BACKWARD` — chercher la keyframe <= ts. +pub const AVSEEK_FLAG_BACKWARD: i32 = 1; + +extern "C" { + /// `s->streams[i]` (cf. `shim.c`). + pub fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; + /// `s->nb_streams` (cf. `shim.c`). + pub fn sn_fmt_nb_streams(s: *mut AVFormatContext) -> u32; + /// `s->pb` (cf. `shim.c`). + pub fn sn_fmt_get_pb(s: *mut AVFormatContext) -> *mut AVIOContext; + /// `s->pb = p` (cf. `shim.c`). + pub fn sn_fmt_set_pb(s: *mut AVFormatContext, p: *mut AVIOContext); + /// `AVERROR(EAGAIN)` tel que le voit le compilateur de la cible (cf. `shim.c`). + pub fn sn_averror_eagain() -> i32; + /// `AVERROR_EOF` tel que le voit le compilateur de la cible (cf. `shim.c`). + pub fn sn_averror_eof() -> i32; +} + +/// Transforme un code de retour libav* négatif en `Err` porteur du contexte d'appel. +/// +/// Le message reprend le code brut plutôt que `av_strerror` : sur les erreurs de +/// device/hwaccel, `av_strerror` rend « Generic error in an external library », qui +/// ne distingue pas deux causes très différentes, alors que le code numérique se +/// recherche directement dans les sources ffmpeg. +pub fn averr(ret: i32, ctx: &str) -> anyhow::Result<()> { + if ret < 0 { + anyhow::bail!("{ctx} a échoué (ret={ret})"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + /// Les constantes Rust doivent valoir EXACTEMENT ce que les macros ffmpeg valent + /// sur cette cible. C'est le test qui aurait attrapé le -11 en dur sur macOS avant + /// qu'il ne se manifeste comme « la preview reste noire ». + #[test] + fn averror_constants_match_the_ffmpeg_macros() { + assert_eq!(super::AVERROR_EAGAIN, unsafe { super::sn_averror_eagain() }); + assert_eq!(super::AVERROR_EOF, unsafe { super::sn_averror_eof() }); + } +} diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs new file mode 100644 index 0000000000..b77ea0b077 --- /dev/null +++ b/crates/compositor/src/frame_geometry.rs @@ -0,0 +1,1666 @@ +//! La géométrie de composition, sans backend. +//! +//! Tout ce qui décide OÙ va un calque et de quoi il a l'air — placements de preset, +//! coupe source, cover-fit, rayons, ombres, timeline de la fixture, parsing des +//! couleurs CSS — par opposition à ce qui l'envoie au GPU. Rien ici ne connaît +//! D3D11 ni Metal, et le module est donc compilé sur les deux plateformes +//! (`pub mod frame_geometry;` sans `cfg`, comme `regions.rs` juste à côté). +//! +//! # Pourquoi ce module existe +//! +//! Ce code vivait dans `compositor_windows.rs`. Le port macOS a besoin des mêmes +//! placements au pixel près — c'est la propriété « iso-render » que le projet +//! mesure — et la seule façon de garantir que deux backends s'accordent est qu'ils +//! lisent la même fonction, pas qu'ils entretiennent deux copies qui doivent rester +//! d'accord. C'est le même raisonnement que `timeline_walk.rs`. +//! +//! Effet de bord immédiat : cette géométrie et ses tests, qui n'avaient jamais été +//! exécutés ailleurs que sur Windows, tournent maintenant aussi dans le job macOS. + +// Sur macOS, la moitié de ce module est encore sans consommateur : le moteur Metal +// n'a pas de `compose_frame` en couches, donc rien n'appelle encore `screen_source_rect`, +// `cover_uv_rect`, les fractions d'ombre ou `CursorPlacement`. Ce n'est PAS du code mort — +// c'est du code que le port n'a pas encore atteint, et il est exercé par ses tests sur les +// deux plateformes. Le `allow` saute quand le pilotage des couches arrive côté Metal. +#![allow(dead_code)] + +use crate::config::Cfg; +use crate::scene::{Scene, SceneCrop}; + +/// Constant buffer d'un calque : **128 octets**, un par draw. +/// +/// C'est le contrat partagé par les trois côtés — `cbuffer Layer` dans `shaders.hlsl`, +/// `struct Layer` dans `shaders.metal`, et ce struct. Les trois doivent s'accorder champ +/// pour champ ET octet pour octet : un décalage ne produit pas d'erreur, il produit un +/// shader qui lit `color` là où on a écrit `fx`. +/// +/// `align(16)` vient de la version macOS ; sous `repr(C)` seul, les offsets sont déjà +/// 0/16/32/40/44/48/64/80/96/112 des deux côtés — l'alignement Rust ne change que +/// l'adresse du struct, pas son contenu, et Windows le `copy_nonoverlapping` dans un +/// constant buffer mappé où l'alignement source est sans effet. Les deux formes étaient +/// donc compatibles ; les unifier évite qu'elles cessent de l'être. +/// +/// (Le commentaire d'origine annonçait « 64 octets ». Il n'a jamais été juste : dix champs, +/// trente-deux `f32`.) +#[repr(C, align(16))] +#[derive(Clone, Copy, Default)] +pub struct LayerCB { + pub dst: [f32; 4], + pub src: [f32; 4], + pub quad_px: [f32; 2], + pub radius_px: f32, + pub mode: f32, + pub color: [f32; 4], + pub fx: [f32; 4], + pub src_prev: [f32; 4], + pub dst_prev: [f32; 4], + pub mb: [f32; 4], // mb[0] = nombre de taps de motion blur +} + +pub const OUT_W: u32 = 1920; +pub const OUT_H: u32 = 1080; +/// Parse une couleur "#rgb" / "#rrggbb" (sRGB, comme les wallpapers web) → [r,g,b,a] 0..1. +/// Les couleurs plates suivent le même chemin que `bg_color` (pas de linéarisation). +/// Décode une data URL base64 (`data:image/png;base64,AAAA…`) en octets. `None` si ce n'en est +/// pas une — l'appelant retombe alors sur une lecture disque. +/// +/// Écrit à la main plutôt qu'avec une dépendance : c'est le seul usage de base64 du projet, et le +/// décodeur tient en quinze lignes vérifiables. Les caractères hors alphabet (retours à la ligne +/// d'un URI replié, `=` de padding) sont ignorés, ce qui rend la fonction tolérante sans être +/// laxiste : un caractère invalide ne peut pas décaler le flux, il est simplement absent. +pub(crate) fn decode_data_uri(uri: &str) -> Option> { + let rest = uri.strip_prefix("data:")?; + let comma = rest.find(',')?; + if !rest[..comma].contains("base64") { + return None; + } + let payload = &rest[comma + 1..]; + let sextet = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some((c - b'A') as u32), + b'a'..=b'z' => Some((c - b'a') as u32 + 26), + b'0'..=b'9' => Some((c - b'0') as u32 + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + }; + let mut out = Vec::with_capacity(payload.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + for byte in payload.bytes() { + let Some(v) = sextet(byte) else { continue }; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + Some(out) +} +pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { + // Le contrat accepte du CSS, pas seulement de l'hex : la bridge des captions produit du + // `rgba(r, g, b, a)` (l'inspector stocke couleur + opacité séparément, et `captionBackgroundCss` + // les recombine en rgba pour la preview) et les stops de gradient arrivent aussi sous cette + // forme. `transparent` est un cas particulier documenté : alpha 0, pas de plaque. Tout le + // reste tombe sur None → l'appelant applique son fallback (alpha 0 pour un fond, alpha 1 + // pour un texte, etc.) — la même sémantique qu'avant l'ajout du parseur rgba. + let trimmed = s.trim(); + if trimmed.eq_ignore_ascii_case("transparent") { + return Some([0.0, 0.0, 0.0, 0.0]); + } + // CSS Color 4 fait de `rgb()` et `rgba()` des synonymes : les deux acceptent 3 ou 4 + // composantes. On les traite donc par le même chemin plutôt que d'imposer une arité par + // nom — refuser `rgba(0, 0, 0)` ne « signalerait » rien d'utile, ça retomberait sur le + // fallback de l'appelant, c'est-à-dire une plaque invisible : exactement le bug #178. + if let Some(inner) = + strip_color_fn(trimmed, "rgba").or_else(|| strip_color_fn(trimmed, "rgb")) + { + return parse_rgb_components(inner); + } + let h = trimmed.trim_start_matches('#'); + // Un corps hex est ASCII par définition, et les découpes par octet ci-dessous (`h[i..=i]`, + // `h[0..2]`…) paniqueraient au milieu d'un caractère multi-octets qui ferait pile 3 ou 6 + // octets (`éa`, `€€`). On refuse avant de découper. + if !h.is_ascii() { + return None; + } + let (r, g, b) = match h.len() { + 3 => { + let d = |i: usize| u8::from_str_radix(&h[i..=i], 16).ok().map(|v| v * 17); + (d(0)?, d(1)?, d(2)?) + } + 6 => ( + u8::from_str_radix(&h[0..2], 16).ok()?, + u8::from_str_radix(&h[2..4], 16).ok()?, + u8::from_str_radix(&h[4..6], 16).ok()?, + ), + _ => return None, + }; + Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) +} +/// `rgba(0, 0, 0, 0.55)` → `"0, 0, 0, 0.55"` (le contenu entre les parenthèses), None si +/// l'enveloppe n'est pas de la forme `fn(...)`. Tolère les espaces et les tabs, refuse les +/// virgules finales et les arguments vides — le gradient parser a déjà démontré que la couche +/// application produit des chaînes propres, donc rester strict ici évite d'avaler des CSS +/// tordus qu'on ne maîtrise pas. La casse du préfixe est libre (`RGBA(...)` est valide) parce +/// que CSS le permet. +pub(crate) fn strip_color_fn<'a>(s: &'a str, name: &str) -> Option<&'a str> { + // `get` rend None si `name.len()` n'est pas une frontière de caractère : c'est ce qui rend + // le slice `s[..name.len()]` juste en dessous sûr par construction. Un `&s[..n]` direct + // paniquerait au milieu d'un caractère multi-octets (`#ab€cd` coupe dans le `€`), et une + // panique traverserait le pont N-API au lieu de retomber sur le fallback de l'appelant — + // le contraire de ce que ce parseur promet. + let after_name = s.get(name.len()..)?; + if !s[..name.len()].eq_ignore_ascii_case(name) { + return None; + } + let inner = after_name.strip_prefix('(')?.strip_suffix(')')?.trim(); + if inner.is_empty() { + return None; + } + Some(inner) +} +/// `"r, g, b"` ou `"r, g, b, a"` (floats 0..255 pour r/g/b, 0..1 pour a) → `[r, g, b, a]` en +/// 0..1, l'alpha valant 1 (opaque) quand elle est absente. Toute autre arité → None. Tolère +/// les espaces autour des virgules, pas les pourcentages : le gradient parser n'envoie pas de +/// `rgb(50%, …)` et les couches UI qui le font n'arrivent pas ici (les couleurs wallpaper +/// passent par une autre route, cf. `parseWallpaper`). +pub(crate) fn parse_rgb_components(s: &str) -> Option<[f32; 4]> { + let parts: Vec<&str> = s.split(',').map(str::trim).collect(); + let (rgb, alpha) = match parts.as_slice() { + [r, g, b] => ([r, g, b], 1.0), + // L'alpha est déjà sur [0..1] par convention (`rgba(...,0.55)`, pas `rgba(...,55)`). + [r, g, b, a] => ([r, g, b], parse_color_channel(a, 1.0)?), + _ => return None, + }; + Some([ + parse_color_channel(rgb[0], 255.0)?, + parse_color_channel(rgb[1], 255.0)?, + parse_color_channel(rgb[2], 255.0)?, + alpha, + ]) +} +pub(crate) fn parse_color_channel(raw: &str, max: f32) -> Option { + let n: f32 = raw.parse().ok()?; + if !n.is_finite() || n < 0.0 || n > max { + return None; + } + Some(n / max) +} +/// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max` +/// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1), +/// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop. +pub(crate) fn screen_source_rect( + u_max: f32, + v_max: f32, + crop: Option, + zoom: f32, + focus: [f32; 2], +) -> [f32; 4] { + let normalized_crop = crop.and_then(|crop| { + if !crop.x.is_finite() || !crop.y.is_finite() + || !crop.width.is_finite() || !crop.height.is_finite() + { + return None; + } + let x0 = crop.x.clamp(0.0, 1.0); + let y0 = crop.y.clamp(0.0, 1.0); + let x1 = (crop.x + crop.width).clamp(x0, 1.0); + let y1 = (crop.y + crop.height).clamp(y0, 1.0); + (x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1]) + }); + let [x0, y0, x1, y1] = normalized_crop.unwrap_or([0.0, 0.0, 1.0, 1.0]); + let (cu0, cv0, cu1, cv1) = (x0 * u_max, y0 * v_max, x1 * u_max, y1 * v_max); + let (cw, ch) = (cu1 - cu0, cv1 - cv0); + let zoom = if zoom.is_finite() && zoom >= 1.0 { zoom } else { 1.0 }; + let fx = if focus[0].is_finite() { focus[0].clamp(0.0, 1.0) } else { 0.5 }; + let fy = if focus[1].is_finite() { focus[1].clamp(0.0, 1.0) } else { 0.5 }; + let (hu, hv) = (cw / (2.0 * zoom), ch / (2.0 * zoom)); + // `.max(cu0/cv0)` absorbs the tiny float inversion possible at zoom=1. + let su0 = (cu0 + fx * cw - hu).clamp(cu0, (cu1 - 2.0 * hu).max(cu0)); + let sv0 = (cv0 + fy * ch - hv).clamp(cv0, (cv1 - 2.0 * hv).max(cv0)); + [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv] +} +/// Rect DESTINATION de l'écran quand on dessine une coupe source PLUS LARGE que celle qui +/// remplissait la boîte — le cœur du correctif #179. +/// +/// Le zoom natif se jouait entièrement dans la coupe source (`screen_source_rect` rétrécit +/// la coupe autour du focus) pendant que la boîte, elle, ne bougeait pas : le zoom +/// s'arrêtait donc à la frontière paddée au lieu d'atteindre les bords du cadre. La +/// référence fait l'inverse — `applyZoomTransform` (TS) met à l'échelle et translate le +/// CONTENEUR CAMÉRA, masque compris, donc la boîte paddée grandit avec le zoom, sort de +/// l'étage, et le padding s'efface. +/// +/// On rend donc le zoom à la boîte : la coupe dessinée redevient le simple crop +/// (`cut`, zoom 1) et c'est la boîte qui porte le grossissement. `cut_ref` est la coupe +/// d'AVANT (zoom entier, celle qui remplissait `base`) et sert de référence : on reporte +/// `cut` à travers le mapping `cut_ref → base`. +/// +/// C'est ce report qui fait toute la sûreté du correctif. Le mapping image→écran est +/// conservé PAR CONSTRUCTION — même grossissement, même cadrage, même point de focus au +/// même pixel — quel que soit le crop, le clamp de bord ou le `cover`, puisque tout cela +/// est déjà cuit dans les deux coupes. Seule l'ÉTENDUE dessinée grandit, et c'est +/// exactement elle qui déborde le padding. Tout ce qui roule sur ce mapping (curseur, +/// tilt 3D, motion blur) est donc inchangé. +/// +/// Pas de clamp dans le cadre : la boîte doit pouvoir en sortir (« No stage clamping », +/// `frameRenderer.cameraAwareMaskRect`) — le rasterizer coupe ce qui dépasse, comme il le +/// fait déjà pour le fond flouté. +pub(crate) fn remap_box(base: [f32; 4], cut_ref: [f32; 4], cut: [f32; 4]) -> [f32; 4] { + let (rw, rh) = ((cut_ref[2] - cut_ref[0]), (cut_ref[3] - cut_ref[1])); + if !(rw > 1e-6 && rh > 1e-6) { + return base; + } + [ + base[0] + base[2] * (cut[0] - cut_ref[0]) / rw, + base[1] + base[3] * (cut[1] - cut_ref[1]) / rh, + base[2] * (cut[2] - cut[0]) / rw, + base[3] * (cut[3] - cut[1]) / rh, + ] +} +/// Sous-rect SOURCE (en UV de texture) qui remplit une boîte de ratio `box_ar` **sans +/// déformer** l'image : le plus grand rect centré ayant ce ratio, tiré de la frame +/// visible — l'équivalent de `object-fit: cover` côté web. +/// +/// C'est LA primitive qui garantit qu'une couche vidéo n'est jamais étirée. Le +/// contrat est déplacé de l'appelant (« donne-moi un dst au ratio de la source », +/// hypothèse qu'un preset pouvait violer en silence) vers le calcul lui-même +/// (« quel que soit le dst, je choisis la coupe qui l'habille »). +/// +/// * `visible` : dimensions RÉELLES de l'image dans la texture (`AVFrame::width/height`) ; +/// elles peuvent être plus petites que la texture, qui est allouée avec du padding +/// décodeur — d'où la division finale par `tex`. +/// * `tex` : dimensions de la texture, pour normaliser en UV. +/// * `box_ar` : ratio largeur/hauteur de la boîte de destination, en pixels de rendu. +/// +/// Retourne `(u0, v0, u1, v1)`. Quand la boîte a déjà le ratio de la source, la coupe +/// est la frame entière — donc aucun changement de pixel sur les placements qui étaient +/// déjà corrects. +pub(crate) fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f32, f32, f32, f32) { + let (cam_w, cam_h) = (visible[0].max(1.0), visible[1].max(1.0)); + let (tex_w, tex_h) = (tex[0].max(1.0), tex[1].max(1.0)); + let full = [0.0, 0.0, cam_w / tex_w, cam_h / tex_h]; + let [u0, v0, u1, v1] = cover_uv_rect(full, tex, box_ar); + (u0, v0, u1, v1) +} +/// Rétrécit un rect SOURCE déjà exprimé en UV (`[u0, v0, u1, v1]`) autour de son +/// centre pour qu'il porte le ratio `box_ar` une fois rapporté aux pixels de la +/// texture. C'est la forme générale de `object-fit: cover`, et LA primitive qui +/// garantit qu'une couche vidéo n'est jamais étirée. +/// +/// Deux appelants, deux points d'entrée dans le rect : +/// - la **webcam** part de la frame visible entière (`cover_crop_uv`) ; +/// - l'**écran** part du rect déjà réduit par le crop utilisateur ET le zoom, +/// et n'applique ce cover que dans les layouts qui le demandent +/// (`Scene.layout.screen_cover` — les blocs side-by-side / top-bottom, où le +/// web fait exactement la même chose via `screenCover`). +/// +/// Rogner APRÈS le crop et le zoom est ce qui rend l'opération composable : le +/// crop décide quoi montrer, le zoom où regarder, le cover comment habiller la +/// boîte. Chacun réduit le rect précédent, jamais ne le déforme. +/// +/// Quand le rect a déjà le ratio de la boîte, il est renvoyé inchangé — donc +/// aucun placement déjà correct ne bouge. +pub(crate) fn cover_uv_rect(uv: [f32; 4], tex: [f32; 2], box_ar: f32) -> [f32; 4] { + let (tex_w, tex_h) = (tex[0].max(1.0), tex[1].max(1.0)); + let (w_uv, h_uv) = ((uv[2] - uv[0]).max(1e-6), (uv[3] - uv[1]).max(1e-6)); + // ratio du rect courant, en PIXELS (les UV sont anisotropes dès que la + // texture n'est pas carrée — d'où le passage par `tex`). + let (w_px, h_px) = (w_uv * tex_w, h_uv * tex_h); + let cur_ar = w_px / h_px; + let box_ar = if box_ar.is_finite() && box_ar > 0.0 { box_ar } else { cur_ar }; + let (new_w_px, new_h_px) = if box_ar >= cur_ar { + (w_px, w_px / box_ar) // boîte plus large → pleine largeur, on rogne en hauteur + } else { + (h_px * box_ar, h_px) // boîte plus haute → pleine hauteur, on rogne en largeur + }; + let (new_w, new_h) = (new_w_px / tex_w, new_h_px / tex_h); + let (cx, cy) = (uv[0] + w_uv * 0.5, uv[1] + h_uv * 0.5); + [cx - new_w * 0.5, cy - new_h * 0.5, cx + new_w * 0.5, cy + new_h * 0.5] +} +pub const HALF_W: u32 = OUT_W / 2; +pub const HALF_H: u32 = OUT_H / 2; +pub const FIXTURE_FRAMES: u32 = 360; +pub(crate) const FPS: f32 = 60.0; +/// Longueurs de style exprimées en FRACTION du petit côté du cadre, et non en pixels. +/// +/// Elles étaient écrites en px bruts au point d'appel, ce qui voulait dire « px du render +/// target » — donc une proportion DIFFÉRENTE selon la taille de rendu : 40 px, c'est 3,7 % d'un +/// cadre 1080 mais 1,9 % d'un 2160. L'ombre était donc deux fois plus douce en preview qu'à +/// l'export, et un export 4K la recevait deux fois plus faible qu'un 1080p — même famille de bug +/// que les rayons venus de l'app, mais née à l'intérieur du natif. Les valeurs ci-dessous sont +/// les anciennes constantes rapportées au cadre 1080 contre lequel elles avaient été réglées : +/// le rendu à cette résolution est donc inchangé, et devient enfin identique partout ailleurs. +pub(crate) const SHADOW_TUNING_REF_PX: f32 = 1080.0; +pub(crate) const SCREEN_SHADOW_SPREAD_FRAC: f32 = 40.0 / SHADOW_TUNING_REF_PX; +pub(crate) const SCREEN_SHADOW_OFFSET_FRAC: f32 = 16.0 / SHADOW_TUNING_REF_PX; +pub(crate) const WEBCAM_SHADOW_SPREAD_FRAC: f32 = 32.0 / SHADOW_TUNING_REF_PX; +pub(crate) const WEBCAM_SHADOW_OFFSET_FRAC: f32 = 12.0 / SHADOW_TUNING_REF_PX; +/// Opacité FIXE de l'ombre portée de la caméra (layout PiP uniquement). Contrairement à +/// l'ombre de l'écran — dont l'opacité est pilotée par le slider Shadow (`shadow_scale`) — +/// l'ombre de la caméra est une ombre légère NON paramétrable : même valeur quelle que soit +/// la position du slider. Parité avec le preset PiP côté web (`compositeLayout.ts`, +/// `rgba(0,0,0,0.35)`), dont l'ombre est elle aussi un forfait fixe et PiP-only. +pub(crate) const WEBCAM_SHADOW_OPACITY: f32 = 0.35; +/// Taille de base du curseur, même convention (34 px réglés contre un cadre 1080). +pub(crate) const CURSOR_BASE_SIZE_FRAC: f32 = 34.0 / SHADOW_TUNING_REF_PX; +/// Rect [x,y,w,h] normalisé d'un sprite de curseur de taille `w`×`h` dont le pivot `hotspot` +/// (fraction 0..1 de l'image) doit tomber exactement sur `center`. +/// +/// L'invariant est que `center` reste sur le pixel désigné QUELLE QUE SOIT la taille : le +/// décalage grandit avec le sprite, donc il doit être une fraction de `w`/`h` et pas une +/// constante. Un pivot centré en dur (0.5) laissait la pointe dériver de plus en plus loin de +/// la zone visée à mesure qu'on agrandissait le curseur. +pub(crate) fn cursor_sprite_dst(center: [f32; 2], w: f32, h: f32, hotspot: [f32; 2]) -> [f32; 4] { + [center[0] - w * hotspot[0], center[1] - h * hotspot[1], w, h] +} +/// Où poser le curseur, et dans quel repère. +/// +/// Le curseur remplace un pointeur qui faisait partie de l'image capturée, donc il vit SUR la +/// surface de l'écran, pas dans un calque au-dessus. Quand cet écran est incliné en 3D, ce n'est +/// donc pas seulement sa position qu'il faut projeter mais son sprite entier : autrement il se +/// lit comme un autocollant plat posé sur une scène en perspective. +#[derive(Clone, Copy)] +pub(crate) enum CursorPlacement { + /// Écran droit : centre en coordonnées sortie 0..1. + Upright { center: [f32; 2] }, + /// Écran incliné : position 0..1 DANS le plan, plus de quoi projeter les coins du sprite. + Tilted { + /// Position du pivot dans le plan (0..1 depuis son coin haut-gauche). + plane_pt: [f32; 2], + quad: crate::regions::TiltedQuad, + /// Centre du plan en px sortie — `quad.corners` y est relatif. + center_px: [f32; 2], + /// Taille du rect d'écran NON incliné en px : l'unité dans laquelle la taille du + /// curseur est exprimée, et donc ce qui la convertit en fraction du plan. + screen_px: [f32; 2], + /// Taille de la cible de rendu en px, pour repasser des px aux 0..1 de la sortie. + render_px: [f32; 2], + }, +} +impl CursorPlacement { + /// Interpolation entre deux placements, pour les copies de la traînée de flou. Sur un plan + /// incliné on interpole DANS le plan : la traînée suit alors la surface au lieu de couper + /// droit à travers la perspective. + pub(crate) fn lerp(self, other: CursorPlacement, f: f32) -> CursorPlacement { + match (self, other) { + ( + CursorPlacement::Tilted { plane_pt: a, quad, center_px, screen_px, render_px }, + CursorPlacement::Tilted { plane_pt: b, .. }, + ) => CursorPlacement::Tilted { + plane_pt: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], + quad, + center_px, + screen_px, + render_px, + }, + (a, b) => { + let (p, q) = (a.upright_center(), b.upright_center()); + CursorPlacement::Upright { + center: [p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f], + } + } + } + } + + /// Le centre en coordonnées sortie, quel que soit le repère — ce dont ont besoin le curseur + /// math de secours et le calcul de vélocité. + pub(crate) fn upright_center(self) -> [f32; 2] { + match self { + CursorPlacement::Upright { center } => center, + CursorPlacement::Tilted { plane_pt, quad, center_px, render_px, .. } => { + let (px, py) = quad.point_px(plane_pt[0], plane_pt[1]); + [(center_px[0] + px) / render_px[0], (center_px[1] + py) / render_px[1]] + } + } + } +} +pub(crate) fn ease_in_out_cubic(x: f32) -> f32 { + let x = x.clamp(0.0, 1.0); + if x < 0.5 { + 4.0 * x * x * x + } else { + 1.0 - (-2.0 * x + 2.0).powi(3) / 2.0 + } +} +pub(crate) fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} +pub(crate) fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] { + [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)] +} +/// Un calque vidéo animé (rect sortie, taille px, rayon) — screen ou webcam. +#[derive(Clone, Copy)] +pub(crate) struct Placement { + pub(crate) dst: [f32; 4], + pub(crate) radius: f32, +} +/// Paramètres d'une frame : dérivés du temps par la timeline (§8). +#[derive(Clone, Copy)] +pub(crate) struct FrameParams { + pub(crate) zoom: f32, + pub(crate) focus: [f32; 2], + pub(crate) screen: Placement, + pub(crate) webcam: Placement, // dst carré (w en px via OUT_W) +} +/// Timeline figée de la fixture (6 s) : zoom 1.0→1.8→1.0, layout A(PIP)↔B(côte à côte). +/// `frame` fractionnaire pour permettre le supersampling temporel (flou de mouvement). +/// Gaté par `cfg` : zoom et layout ne bougent que si activés. +pub(crate) fn timeline(frame: f32, cfg: &Cfg) -> FrameParams { + let t = frame / FPS; // secondes + + // zoom : montée [0,3s] puis descente [3s,6s], easeInOutCubic + let zoom = if cfg.zoom { + let zt = if t < 3.0 { ease_in_out_cubic(t / 3.0) } else { ease_in_out_cubic((6.0 - t) / 3.0) }; + 1.0 + 0.8 * zt + } else { + 1.0 + }; + + // layout A = PIP bas-droite ; B = côte à côte. Transitions A→B [2,2.5]s, B→A [4,4.5]s. + let lf = if !cfg.layout_anim { + 0.0 + } else if t < 2.0 { + 0.0 + } else if t < 2.5 { + ease_in_out_cubic((t - 2.0) / 0.5) + } else if t < 4.0 { + 1.0 + } else if t < 4.5 { + 1.0 - ease_in_out_cubic((t - 4.0) / 0.5) + } else { + 0.0 + }; + + // Layout A (PIP) + let a_screen = Placement { dst: [0.05, 0.05, 0.90, 0.90], radius: 24.0 }; + let a_side = 320.0_f32; + let a_webcam = Placement { + dst: [ + (OUT_W as f32 - 40.0 - a_side) / OUT_W as f32, + (OUT_H as f32 - 40.0 - a_side) / OUT_H as f32, + a_side / OUT_W as f32, + a_side / OUT_H as f32, + ], + radius: 40.0, + }; + // Layout B (côte à côte) : screen à gauche (16:9), webcam carré à droite + let b_screen = Placement { dst: [0.035, 0.22, 0.60, 0.5625], radius: 20.0 }; + let b_side = 520.0_f32; + let b_webcam = Placement { + dst: [ + 0.70, + (OUT_H as f32 - b_side) * 0.5 / OUT_H as f32, + b_side / OUT_W as f32, + b_side / OUT_H as f32, + ], + radius: 40.0, + }; + + FrameParams { + zoom, + focus: [0.5, 0.32], + screen: Placement { dst: lerp4(a_screen.dst, b_screen.dst, lf), radius: lerp(a_screen.radius, b_screen.radius, lf) }, + webcam: Placement { dst: lerp4(a_webcam.dst, b_webcam.dst, lf), radius: lerp(a_webcam.radius, b_webcam.radius, lf) }, + } +} +/// Placements statiques screen+webcam pour un preset de layout de l'app (contrat de scène) — +/// remplace le planning A↔B fixture de `timeline()`. Zoom = 1 (les zoom regions viennent ensuite). +/// La taille/forme/miroir webcam restent appliqués par-dessus via `LiveParams`. +pub(crate) fn preset_placements(preset: &str) -> FrameParams { + // plein cadre : le padding l'insère ensuite (padding 0 → bord à bord). + let full_screen = Placement { dst: [0.0, 0.0, 1.0, 1.0], radius: 24.0 }; + // PiP bas-droite (≈ layout A fixture). + let a_side = 320.0_f32; + let pip_webcam = Placement { + dst: [ + (OUT_W as f32 - 40.0 - a_side) / OUT_W as f32, + (OUT_H as f32 - 40.0 - a_side) / OUT_H as f32, + a_side / OUT_W as f32, + a_side / OUT_H as f32, + ], + radius: 40.0, + }; + // webcam hors écran (no-webcam) : quad de taille nulle, jamais visible. + let off_webcam = Placement { dst: [2.0, 2.0, 0.0, 0.0], radius: 0.0 }; + + let (screen, webcam) = match preset { + "dual-frame" => { + // côte à côte : screen 16:9 à gauche, webcam carré à droite (≈ layout B fixture). + let b_side = 520.0_f32; + ( + Placement { dst: [0.035, 0.22, 0.60, 0.5625], radius: 20.0 }, + Placement { + dst: [ + 0.70, + (OUT_H as f32 - b_side) * 0.5 / OUT_H as f32, + b_side / OUT_W as f32, + b_side / OUT_H as f32, + ], + radius: 40.0, + }, + ) + } + "vertical-stack" => { + // haut/bas : screen en haut, webcam carré centré en bas. + let w_side = 360.0_f32; + ( + Placement { dst: [0.13, 0.04, 0.74, 0.52], radius: 20.0 }, + Placement { + dst: [ + 0.5 - (w_side * 0.5) / OUT_W as f32, + 0.60, + w_side / OUT_W as f32, + w_side / OUT_H as f32, + ], + radius: 40.0, + }, + ) + } + "no-webcam" => (full_screen, off_webcam), + _ => (full_screen, pip_webcam), // "picture-in-picture" (défaut) + }; + + FrameParams { zoom: 1.0, focus: [0.5, 0.5], screen, webcam } +} + +// Les quatre items qui suivent existaient en DOUBLE, un exemplaire par backend, et les +// commentaires macOS affirmaient « mêmes champs et même layout » puis « mêmes formules ». +// Les deux affirmations étaient fausses sur trois valeurs : +// +// bg_color défaut windows [0.10, 0.11, 0.14, 1.0] macos [0, 0, 0, 0] +// has_webcam défaut windows true macos false +// webcam_shape_code(_) windows 3 ("rounded") macos 0 ("rectangle") +// +// La troisième est celle qui mord : `live_params_from_scene` l'appelle, et `webcam_shape` +// vaut "rounded" par défaut côté app — donc la même scène décrivait une caméra arrondie +// sur Windows et rectangulaire sur macOS. Les valeurs Windows font foi : c'est le backend +// qui rend en production aujourd'hui. + +/// Valeurs continues pilotées par l'inspector (celles qui étaient codées en dur dans +/// `compose_frame`). Le défaut reproduit le rendu actuel → bench/export inchangés. +/// Les booléens/taps (fond flouté, ombre on/off, coins on/off, motion blur) restent +/// portés par le `Cfg` que le thread live reconstruit depuis les switches. +#[derive(Clone, Copy)] +pub struct LiveParams { + pub bg_color: [f32; 4], // fond plat (mode couleur) quand non flouté + pub shadow_scale: f32, // multiplie l'opacité des ombres (1 = défaut, 0 = off) + pub radius_scale: f32, // multiplie le rayon des coins (1 = défaut, 0 = carré) + pub padding: f32, // 0..1 : inset supplémentaire du screen (0 = défaut fixture) + pub webcam_size_scale: f32, // multiplie la taille de la webcam (1 = défaut) + pub webcam_mirror: bool, // miroir horizontal de la webcam + pub webcam_shape: u32, // 0=rect, 1=circle, 2=square, 3=rounded (défaut) + pub cursor_size_scale: f32, // multiplie la taille du curseur (1 = défaut) + pub cursor_bounce_scale: f32, // multiplie l'amplitude du click-bounce (1 = défaut, 0 = off) + /// 0..1 : flou de mouvement DU CURSEUR (indépendant du motion blur écran/`cfg.mblur_n`). + /// Approximé par le même mécanisme de traînée fantôme (taps décalés le long de la + /// vélocité), pas par un flou gaussien variable comme le canvas web — plus simple à + /// réutiliser côté GPU, effet de streak équivalent. + pub cursor_motion_blur: f32, + /// False when the "webcam" decoder is actually just the screen video again (the TS side + /// falls `webcamPath` back to the screen asset's own path when a clip has no real camera, + /// purely so the decoder pipeline has something valid to open) — drawing the PiP box in + /// that case duplicates the screen video into its own corner. Live-only: derived in + /// `live.rs` by comparing the active clip's screen/webcam paths; defaults `true` (draw) + /// so fixture/bench renders and any caller that never sets it keep their old behavior. + pub has_webcam: bool, +} + +impl Default for LiveParams { + fn default() -> Self { + Self { + bg_color: [0.10, 0.11, 0.14, 1.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: true, + } + } +} + +/// "rectangle"|"circle"|"square"|"rounded" -> code webcam_shape (0/1/2/3). Partagé entre le +/// live (`live.rs::set_param_str`) et l'export (construit `LiveParams` depuis la scène) — une +/// seule table de vérité pour ce mapping. +pub fn webcam_shape_code(shape: &str) -> u32 { + match shape { + "rectangle" => 0, + "circle" => 1, + "square" => 2, + _ => 3, // "rounded" (défaut) + } +} + +/// Construit les `LiveParams` équivalents à ce que l'inspector pousse en live, mais depuis la +/// scène de l'app — l'export est un rendu one-shot sans historique de sliders, donc il doit lire +/// directement la config déjà posée dans la scène plutôt que dupliquer un mécanisme d'inspector. +/// Unités identiques à `RightPanes.tsx` (mêmes conversions, pas de re-normalisation) : voir +/// `sceneDescription.ts` pour la correspondance settings -> champs de scène. +pub fn live_params_from_scene(s: &crate::scene::Scene) -> LiveParams { + LiveParams { + shadow_scale: s.effects.shadow, + // `radius_scale` reste le multiplicateur du chemin INSPECTOR (bench/GUI standalone) ; le + // rayon écran d'une scène vient désormais de `effects.roundness_frac`, lu directement + // dans `compose_frame`. Le faire transiter ici obligeait à le normaliser par un rayon de + // fixture (`p.screen.radius`, 24 px) pour ressortir la valeur de départ — un aller-retour + // qui ne servait qu'à faire passer des pixels pour un ratio. + 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() + } +} + +/// Ce que `plan_frame` a besoin de savoir. Rien ici n'est un objet backend : ce sont des +/// dimensions, la scène, et les réglages live. C'est ce qui rend la fonction partageable. +pub struct FrameGeometryInput<'a> { + /// Taille de la cible de rendu en px (`Compositor::rw()`/`rh()` côté Windows, + /// `render_w`/`render_h` côté macOS). + pub render_px: [f32; 2], + /// Dimensions de la TEXTURE écran. Sur D3D11VA elles sont alignées macrobloc + /// (1080 → 1088) ; sur CoreVideo elles sont nominales. L'écart est voulu et c'est + /// exactement pourquoi `u_max`/`v_max` existent — ne jamais supposer texture == visible. + pub screen_tex_px: [f32; 2], + pub screen_visible_px: [f32; 2], + pub webcam_visible_px: [f32; 2], + /// Fraction utile de la texture écran : `visible / texture`. + pub u_max: f32, + pub v_max: f32, + pub frame: f32, + pub cfg: &'a Cfg, + pub live: LiveParams, + pub scene: Option<&'a Scene>, + pub cursor: Option<&'a crate::cursor::CursorTrack>, + pub timeline_t_override: Option, +} + +/// Les 15 valeurs que la moitié « dessin » consomme. Sur les 75 locaux que le calcul +/// produit, 60 meurent avant le premier draw — ce sont ceux-là, et seulement ceux-là, +/// qui traversent. +pub struct FrameGeometry { + pub scene_preset: Option, + pub mb_taps: f32, + pub source_t: f32, + pub zoom_rotation: [f32; 3], + pub padding_scale: f32, + /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). + pub cut: [f32; 4], + pub s_dst: [f32; 4], + pub s_dst_prev: [f32; 4], + pub s_radius: f32, + pub frame_min_px: f32, + pub w_dst: [f32; 4], + pub w_dst_prev: [f32; 4], + pub w_px: [f32; 2], + pub w_radius: f32, + pub shape_fade: f32, +} + +/// Où va chaque calque, pour une frame — sans toucher au GPU. +/// +/// C'est la première moitié de `compose_frame`, mot pour mot : 353 lignes qui ne +/// contenaient pas un seul appel D3D11. Les deux backends doivent produire ces +/// placements au pixel près (la propriété « iso-render » que le projet mesure), et la +/// seule façon fiable d'y arriver est qu'ils appellent la même fonction. +pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { + let (rw, rh) = (input.render_px[0], input.render_px[1]); + let (stw, sth) = (input.screen_tex_px[0], input.screen_tex_px[1]); + let (scw, sch) = (input.screen_visible_px[0], input.screen_visible_px[1]); + let (wcw, wch) = (input.webcam_visible_px[0], input.webcam_visible_px[1]); + let (u_max, v_max) = (input.u_max, input.v_max); + let (frame, cfg) = (input.frame, input.cfg); + let lp = input.live; + let scene = input.scene; + let cursor = input.cursor; + + // Scène de l'app présente → placements du layout preset (ou, mieux, le rect résolu par + // l'app dans `layout.webcam_rect`) ; sinon planning fixture (bench). + let scene_preset: Option = + scene.map(|s| s.layout.preset.clone()); + // Webcam rect résolu par l'app (= `computeCompositeLayout`, source de vérité unique + // entre preview et natif) : quand il est présent ET que la scène est posée, on l'utilise + // COMME placement de base. Sinon, fallback sur `preset_placements` historique (PiP + // codé en dur à 320 px + 40 px de marge — l'arrangement qui dérivait de la preview). + let app_webcam_rect: Option<[f32; 4]> = scene + .and_then(|s| s.layout.webcam_rect) + .map(|r| [r.x, r.y, r.width, r.height]); + // Idem pour l'écran. Les deux rects viennent du MÊME appel `computeCompositeLayout`, donc + // les consommer ensemble est la seule façon de garder le bloc écran+caméra cohérent : + // n'en prendre qu'un revenait à mélanger la géométrie de l'app et un placement fixture. + let app_screen_rect: Option<[f32; 4]> = scene + .and_then(|s| s.layout.screen_rect) + .map(|r| [r.x, r.y, r.width, r.height]); + let (mut p, mut pp) = match &scene_preset { + Some(preset) => { + // Chaque rect résolu par l'app remplace INDÉPENDAMMENT sa contrepartie du + // preset ; sinon celle du preset reste (le padding slider l'insèrera ensuite + // dans `scale_frame`). + // + // Avant, ce match portait sur `app_webcam_rect` et le rect ÉCRAN n'était donc + // honoré que si un rect webcam arrivait aussi. Un layout sans caméra gardait + // l'écran plein cadre du preset — pendant que `fit_screen` (plus bas) coupait + // quand même son fit au ratio du crop, puisqu'un `app_screen_rect` était bien + // présent. Résultat : un clip recadré sans caméra était étiré, et aucune des + // deux voies ne le rattrapait. Coupler l'écran à la présence de la caméra + // n'avait aucune raison d'être — ce sont deux calques indépendants. + let mut fp = preset_placements(preset); + if let Some(wr) = app_webcam_rect { + fp.webcam.dst = wr; + } + if let Some(sr) = app_screen_rect { + fp.screen.dst = sr; + } + (fp, fp) // layout statique → vélocité nulle + } + None => (timeline(frame, cfg), timeline(frame - 1.0, cfg)), + }; + // Motion blur écran : quand la scène (contrat de l'app) est posée, c'est elle qui pilote + // (parité inspector : 1.0 + motion_blur*15 taps), sinon on retombe sur `cfg.mblur_n` + // (le bench fixture continue d'utiliser ses taps explicites). + let mb_taps = scene + .map(|s| 1.0 + s.effects.motion_blur.clamp(0.0, 1.0) * 15.0) + .unwrap_or(cfg.mblur_n as f32); + + // Zoom regions + Full Camera : filtrées en amont pour le clip actif et échantillonnées + // dans le même référentiel source que le PTS du décodeur écran. + let empty_zoom: Vec = Vec::new(); + let empty_cam: Vec = Vec::new(); + let zoom_regions = scene.map(|s| &s.zoom_regions).unwrap_or(&empty_zoom); + let cam_regions = + scene.map(|s| &s.camera_fullscreen_regions).unwrap_or(&empty_cam); + let webcam_reactive = scene.map(|s| s.layout.webcam_reactive_zoom).unwrap_or(false); + let source_t = input.timeline_t_override.unwrap_or(frame / FPS); + let source_t_prev = source_t - 1.0 / FPS; + // le focus "auto" (suivi curseur) réutilise la même piste que le rendu du curseur. + let cursor_for_zoom = cursor; + // La rotation 3D (mode 8, pas de motion blur dans ce chemin — cf. le commentaire au + // point d'appel) n'est calculée QUE pour la frame courante ; `pp` ne sert qu'au zoom + // écran normal (vélocité pour le motion blur du chemin non-tilté). + let mut zoom_rotation = [0.0f32; 3]; + if !zoom_regions.is_empty() { + let zs = crate::regions::zoom_state_at(zoom_regions, source_t, cursor_for_zoom); + p.zoom = zs.scale; + p.focus = zs.focus; + zoom_rotation = zs.rotation; + let zs_p = crate::regions::zoom_state_at(zoom_regions, source_t_prev, cursor_for_zoom); + pp.zoom = zs_p.scale; + pp.focus = zs_p.focus; + } + // Full Camera ignore le rétrécissement réactif de la webcam (design web : mélanger + // "rétrécit pour le zoom" et "grandit en plein cadre" dans la même frame n'a pas de sens). + let cam_progress = crate::regions::camera_fullscreen_progress_at(cam_regions, source_t); + let cam_progress_prev = + crate::regions::camera_fullscreen_progress_at(cam_regions, source_t_prev); + // rétrécissement réactif : la webcam rétrécit pendant un zoom actif (1/zoom, plancher + // 0.35 — parité `reactiveWebcamScale`, TS). Ignoré pendant Full Camera (voir ci-dessus). + let reactive_scale = |zoom: f32, progress: f32| -> f32 { + if webcam_reactive && progress <= 0.0 && zoom.is_finite() && zoom > 0.0 { + (1.0 / zoom).clamp(0.35, 1.0) + } else { + 1.0 + } + }; + // `lp.webcam_size_scale` vient de `scene.layout.webcamSize` (voir `live_params_from_scene`) + // — le MÊME nombre que le fraction webcamSizePreset déjà pris en compte côté app pour + // calculer `wr` (`computeCompositeLayout`, TS). Quand l'app fournit un `webcam_rect` + // explicite, la taille y est donc déjà cuite : réappliquer `lp.webcam_size_scale` ici + // double-échelonnerait la boîte (ex. un preset 34% → webcam rendue à ~34%×34% ≈ 12% au + // lieu de 34%, la webcam apparaissant bien plus petite que ce que montre l'aperçu web). + // Seul `reactive_scale` (rétrécissement pendant un zoom, une valeur ANIMÉE par frame que + // le rect statique de l'app ne capture pas) doit encore s'appliquer dans ce cas. + let base_size_scale = if app_webcam_rect.is_some() { 1.0 } else { lp.webcam_size_scale }; + let webcam_size_scale = base_size_scale * reactive_scale(p.zoom, cam_progress); + let webcam_size_scale_prev = base_size_scale * reactive_scale(pp.zoom, cam_progress_prev); + + // padding : échelle globale du layout autour du centre du cadre (parité web frameRenderer : + // paddingScale = 1 - padding*0.4 → padding 0 = plein cadre). S'applique à TOUS les presets : + // côté web, side-by-side et top/bottom soudent écran+caméra en un bloc unique et c'est ce + // bloc que le padding rétrécit (cf. `compositeLayout.ts`, branche `block`). Vertical-stack + // en était exempté tant qu'il était full-bleed ; il ne l'est plus. + let padding_scale = 1.0 - lp.padding * 0.4; + let scale_frame = |dst: [f32; 4], s: f32| -> [f32; 4] { + [0.5 + (dst[0] - 0.5) * s, 0.5 + (dst[1] - 0.5) * s, dst[2] * s, dst[3] * s] + }; + // webcam : ancrée à son coin bas-droite (grandit vers le haut-gauche, pas depuis le centre). + let scale_corner_br = |dst: [f32; 4], s: f32| -> [f32; 4] { + let (brx, bry) = (dst[0] + dst[2], dst[1] + dst[3]); + let (nw, nh) = (dst[2] * s, dst[3] * s); + [brx - nw, bry - nh, nw, nh] + }; + // parité web (compositeLayout) : rectangle/rounded gardent le ratio natif de la webcam ; + // square/circle forcent un carré (side = min). Le placement de base est carré → on ajuste + // ici, en gardant le coin bas-droite fixe (cohérent avec le size-scale). + let is_square_shape = matches!(lp.webcam_shape, 1 | 2); // circle | square + let cam_ar = if is_square_shape { 1.0 } else { (wcw / wch).max(0.01) }; + let fit_cam_aspect = |dst: [f32; 4]| -> [f32; 4] { + let s = (dst[2] * rw).min(dst[3] * rh); // côté carré de base (px) + let (pw, ph) = if cam_ar >= 1.0 { (s, s / cam_ar) } else { (s * cam_ar, s) }; + let (nw, nh) = (pw / rw, ph / rh); + let (brx, bry) = (dst[0] + dst[2], dst[1] + dst[3]); + [brx - nw, bry - nh, nw, nh] + }; + // Variantes ancrées au CENTRE (au lieu du coin bas-droite) de `dst`, pour le cas où + // `dst` vient de `app_webcam_rect` : ce rect est déjà la position que l'utilisateur a + // choisie/déplacée (résolue côté app via `computeCompositeLayout`, même convention + // centre-fraction que `cx`/`cy` dans `compositeLayout.ts`) — l'ancrer au coin bas-droite + // comme le fait `fit_cam_aspect` (pensé pour le placement par DÉFAUT, ancré à ce coin + // avec une marge fixe) réancre silencieusement la webcam glissée n'importe où d'autre à + // ce coin, ignorant la position réelle choisie par l'utilisateur — le bug rapporté + // (webcam glissée au coin bas-gauche, DOM/JSON envoyé au natif confirmant une position + // flush, mais rendu natif visiblement décalé). Le centre est le point fixe qui a un sens + // pour un rect DÉJÀ positionné par l'app ; le coin bas-droite n'a de sens que pour le + // placement par défaut, qui grandit depuis ce coin faute de position explicite. + let scale_center = |dst: [f32; 4], s: f32| -> [f32; 4] { + let (cx, cy) = (dst[0] + dst[2] * 0.5, dst[1] + dst[3] * 0.5); + let (nw, nh) = (dst[2] * s, dst[3] * s); + [cx - nw * 0.5, cy - nh * 0.5, nw, nh] + }; + // Le ratio de sortie réel (peut différer du canvas interne 16:9 fixe) et le facteur + // d'étirement non uniforme que `blit_resized` appliquera en fin de pipeline — nécessaires + // ici (avant `undistort`, plus bas) pour que le fit ci-dessous cible le ratio de boîte tel + // qu'il apparaîtra APRÈS cet étirement, pas tel qu'il est dans l'espace canvas pré-étirement + // (sinon le fit et l'undistort composent deux corrections indépendantes et sur-rétrécissent + // le contenu — cf. rapport utilisateur : crop 9:16 + sortie 9:16 + padding 0% laissait + // quand même une grosse marge, alors que le crop correspond déjà exactement au cadre). + // Le crop de l'utilisateur (dialogue "Edit clip") a son PROPRE ratio (ex. une bande + // verticale 9:16 recadrée dans une source 16:9) — le zoom appliqué ensuite (§ + // `screen_source_rect`) le préserve (mêmes facteurs sur les deux axes), donc c'est bien + // le ratio du CROP qui doit dimensionner le quad de destination, pas celui (fixe, issu + // du preset de layout) de `p.screen.dst`. Sans ça, le rect recadré (dont le ratio propre + // diffère de la boîte du preset) se retrouve étiré pour remplir cette boîte — parité web + // cassée : `computeCompositeLayout`/`centerRectInBounds` (TS) contiennent déjà le crop + // dans sa boîte en respectant son ratio, le natif ne le faisait pas (rapport utilisateur). + let active_crop = scene.and_then(|scene| { + scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() + }); + let crop_aspect = match active_crop { + Some(c) if c.width > 0.0001 && c.height > 0.0001 => { + (c.width * scw) / (c.height * sch).max(0.0001) + } + _ => scw / sch.max(0.0001), + }; + // Contain (parité `centerRectInBounds`) : rétrécit `dst` (centré) pour que son ratio + // devienne `aspect`, sans jamais dépasser sa boîte d'origine — mais la boîte de référence + // doit être mesurée telle qu'elle apparaîtra APRÈS l'étirement de sortie (`dst` * ratio de + // sortie), pas dans l'espace canvas 16:9 pré-étirement : sinon le fit cible le mauvais + // ratio de boîte dès que la sortie n'est pas 16:9. `undistort` (plus bas) annule ensuite + // exactement ce même facteur, donc convertir le résultat en fraction canvas se fait par + // `/ uniform_stretch` (propriété de `undistort` : le ratio final ne dépend que de la + // taille de `dst` en PIXELS CANVAS, jamais du ratio de sortie choisi). + let fit_dst_to_aspect = |dst: [f32; 4], aspect: f32| -> [f32; 4] { + let box_w_px = dst[2] * rw; + let box_h_px = dst[3] * rh; + let box_ar = box_w_px / box_h_px.max(0.0001); + let (nw_px, nh_px) = if aspect > box_ar { + (box_w_px, box_w_px / aspect.max(0.0001)) + } else { + (box_h_px * aspect, box_h_px) + }; + let (nw, nh) = (nw_px / rw, nh_px / rh); + let (cx, cy) = (dst[0] + dst[2] * 0.5, dst[1] + dst[3] * 0.5); + [cx - nw * 0.5, cy - nh * 0.5, nw, nh] + }; + // Quand l'app a résolu la boîte écran, elle a DÉJÀ appliqué le padding (le rect est + // calculé contre `maxContentSize`) et l'a DÉJÀ mise au ratio du crop + // (`computeCompositeLayout` reçoit la taille de la source recadrée) : rejouer + // `scale_frame` + `fit_dst_to_aspect` par-dessus appliquerait le padding deux fois et + // re-contiendrait une boîte déjà au bon ratio. Même raisonnement que pour la webcam. + let fit_screen = |dst: [f32; 4]| { + if app_screen_rect.is_some() { + dst + } else { + fit_dst_to_aspect(scale_frame(dst, padding_scale), crop_aspect) + } + }; + // Issue #179 : le zoom se jouait entièrement dans la coupe source, donc la boîte + // écran restait au rect paddé et le zoom butait sur cette frontière au lieu + // d'atteindre les bords du cadre. On rend le zoom à la BOÎTE (cf. `remap_box`) : + // la coupe dessinée redevient le crop nu, la boîte porte le grossissement et + // déborde le padding — c'est la géométrie de `applyZoomTransform` (TS). + let s_base = fit_screen(p.screen.dst); + let s_base_prev = fit_screen(pp.screen.dst); + // Layouts "bloc" (side-by-side / top-bottom) : la boîte écran est un SLOT au ratio + // arbitraire, et le web y fait tenir l'image en `cover` (`computeCompositeLayout` + // renvoie `screenCover: true`, honoré par `frameRenderer`). Le natif l'ignorait, donc + // il étirait la source pour remplir le slot — visible dès que le clip est recadré, + // puisque le crop éloigne encore le ratio de la source de celui du slot. + // + // Le cover s'applique APRÈS le crop et le zoom, sur leur rect résultant : le crop + // décide quoi montrer, le zoom où regarder, le cover comment habiller la boîte. Son + // ratio de boîte se lit sur `s_base` : `remap_box` met les deux axes à la même + // échelle, donc la boîte finale a le même ratio et le cover ne dépend pas d'elle + // (ce qui casserait la circularité coupe → boîte → coupe). + let cover_box_ar = scene.and_then(|s| { + s.layout + .screen_cover + .then_some((s_base[2] * rw) / (s_base[3] * rh).max(0.0001)) + }); + let cover = |uv: [f32; 4]| -> [f32; 4] { + match cover_box_ar { + Some(ar) => cover_uv_rect(uv, [stw as f32, sth as f32], ar), + None => uv, + } + }; + // La coupe RÉFÉRENCE (zoom entier) est celle qui remplissait la boîte paddée avant + // ce correctif ; la coupe DESSINÉE ne porte plus que le crop. `remap_box` reporte la + // seconde à travers le mapping de la première, ce qui conserve le cadrage exact. + // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. + let cut_ref = cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); + let cut_ref_prev = cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); + let cut = cover(screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus)); + let s_dst = remap_box(s_base, cut_ref, cut); + let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); + // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée + // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) + // — SAUF quand l'app a résolu un placement explicite (`app_webcam_rect`, drag-to-reposition + // compris). Ce rect est déjà exprimé en fraction du VRAI output (calculé côté web par + // `computeCompositeLayout` avec les vraies dimensions de sortie), position ET aspect déjà + // corrects — `fit_cam_aspect`/`scale_corner_br` (chemin preset par défaut) sont donc + // doublement inadaptés ici : ils réancrent au coin bas-droite (ignorant la position + // choisie par l'utilisateur) ET recalculent l'aspect en pixels du canvas fixe 16:9 + // (`OUT_W`×`OUT_H`), une référence différente du vrai output dès que la sortie n'est pas + // 16:9 (rapport utilisateur : webcam glissée au coin bas-gauche en 9:16, JSON envoyé au + // natif confirmant une position flush, mais rendu native visiblement décalé ET trop + // petit). On garde seulement `scale_center` (zoom réactif, préserve position+aspect) puis + // on pré-compense par `inverse_undistort` pour annuler le `undistort()` générique + // appliqué plus bas à tous les calques (écran compris) — sans quoi ce rect déjà correct + // se ferait déformer une seconde fois par cet undistort partagé. + let mut w_dst = if app_webcam_rect.is_some() { + scale_center(p.webcam.dst, webcam_size_scale) + } else { + fit_cam_aspect(scale_corner_br(p.webcam.dst, webcam_size_scale)) + }; + let mut w_dst_prev = if app_webcam_rect.is_some() { + scale_center(pp.webcam.dst, webcam_size_scale_prev) + } else { + fit_cam_aspect(scale_corner_br(pp.webcam.dst, webcam_size_scale_prev)) + }; + + // Full Camera : la caméra PREND le cadre — parité `computeCameraFullscreenRect` (TS). + // La cible est exactement [0,0,1,1] : pas de marge, pas de padding, pas d'arrondi, et + // plus rien de la composition (fond, écran, ombre) derrière. Le rect change de ratio en + // chemin, mais `cover_crop_uv` (plus bas) dérive la coupe source du ratio RÉEL de la + // boîte à chaque frame : la caméra n'est donc jamais étirée pendant l'animation. + let fullscreen_dst = |dst: [f32; 4], progress: f32| -> [f32; 4] { + if progress <= 0.0 { + return dst; + } + let lerp = |a: f32, b: f32| a + (b - a) * progress; + [lerp(dst[0], 0.0), lerp(dst[1], 0.0), lerp(dst[2], 1.0), lerp(dst[3], 1.0)] + }; + // Petit côté de la boîte caméra AVANT que Full Camera ne la fasse grandir. C'est la + // référence du rayon de coin : le zoom réactif est déjà dedans (il rétrécit la boîte, + // donc l'arrondi suit tout seul — parité `borderRadius * reactiveFactor` côté TS), alors + // que Full Camera ne fait pas grossir l'arrondi, il le DISSOUT (cf. `shape_fade`). + let w_nominal_min = (w_dst[2] * rw).min(w_dst[3] * rh); + w_dst = fullscreen_dst(w_dst, cam_progress); + w_dst_prev = fullscreen_dst(w_dst_prev, cam_progress_prev); + + // Contre-étirement "fit" : le canvas interne compose TOUJOURS en OUT_W×OUT_H (16:9), + // puis `blit_resized` étire tout, de façon non uniforme si besoin, vers la résolution + // de sortie demandée — voulu pour que le FOND (dessiné plus bas en dst=[0,0,1,1]) + // remplisse tout le cadre quel que soit le ratio choisi. Mais l'écran et la webcam ne + // doivent PAS être déformés par cet étirement : on rétrécit ici leur rect de + // destination (centré, dans cet espace 16:9 PRÉ-étirement) par l'inverse du plus fort + // des deux facteurs d'étirement, pour qu'après l'étirement final leur ratio d'origine + // reste préservé (letterboxé/pillarboxé sur le fond, qui lui reste plein cadre) — mode + // "fit"/contain. Si l'utilisateur veut un rendu "fill" (remplir sans bandes), il ajuste + // le crop lui-même ; le natif ne fait plus ce choix à sa place en étirant l'image. + // Le dessin du coin (SDF, shaders.hlsl) compare le rayon à `quad_px`, exprimé en px du + // RENDER TARGET : c'est donc dans cet espace-là qu'il faut le lui donner. + // + // Toutes les longueurs de la scène sont des FRACTIONS ; on les multiplie ici par ce + // qu'elles mesurent, dans l'espace du render target. C'est ce qui rend preview et export + // identiques : « un pixel » n'y désigne pas la même chose (la preview rastérise dans un + // cadre contain-fitté plus petit, cf. `preview_render_size`), alors qu'une fraction, si. + // `frame_min_px` est la référence des quantités relatives au CADRE ; un rayon de coin, + // lui, se mesure contre sa propre boîte — il doit rester en place quand on redimensionne + // la boîte, pas suivre le cadre. + let frame_min_px = rw.min(rh); + let s_min_px = (s_dst[2] * rw).min(s_dst[3] * rh); + let app_screen_radius_frac = scene.and_then(|s| s.layout.screen_radius_frac); + let scene_roundness_frac = scene.map(|s| s.effects.roundness_frac); + // Le rayon suit la boîte : quand le zoom l'agrandit (issue #179), les coins grandissent + // avec elle puis sortent du cadre — comme le masque de la référence, qui porte le même + // `br: maskBorderRadius * camS` et quitte l'étage au même moment. + let s_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { + (false, _, _) => 0.0, + // Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra). + (true, Some(f), _) => f * s_min_px, + // Scène sans rayon imposé : slider Roundness, relatif au cadre. + (true, None, Some(f)) => f * frame_min_px, + // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. + (true, None, None) => p.screen.radius * lp.radius_scale, + }; + let w_px = [w_dst[2] * rw, w_dst[3] * rh]; + // Rayon caméra. Le slider Roundness ne s'y applique jamais (il ne vaut que pour l'ÉCRAN). + // Quand l'app le résout (`computeCompositeLayout`, source unique), on le prend : c'est la + // seule façon que les deux moitiés d'un layout en bloc soient encadrées à l'identique, + // l'écran consommant déjà `screen_radius_frac` du même calcul. La table ci-dessous en + // était une SECONDE, indépendante — fraction différente (0.12 vs 0.06 côté web) et sans + // bornes — donc écran et caméra ne pouvaient pas s'accorder. + let app_webcam_radius_frac = scene.and_then(|s| s.layout.webcam_radius_frac); + // Full Camera dissout la forme en même temps qu'elle prend le cadre : le rayon fond + // vers 0 avec `cam_progress`, donc le cercle devient un rect à coins de plus en plus + // francs puis un plein cadre net — aucun masque ne survit au plein écran (parité + // `computeCameraFullscreenRect`, qui ramène `maskShape` à "rectangle" et lerpe le + // rayon vers 0 pour exactement la même raison). + let shape_fade = (1.0 - cam_progress).clamp(0.0, 1.0); + let w_radius = shape_fade + * w_nominal_min + * match app_webcam_radius_frac { + Some(f) => f, + // Fallback (payload sans fraction, fixture/bench) : l'ancienne table, keyée sur la + // forme. Rectangle ET square n'ont qu'un léger arrondi (0.12) et ne diffèrent que + // par le ratio ; rounded est nettement plus arrondi (0.3) ; circle = demi-côté. + None => match lp.webcam_shape { + 1 => 0.5, + 3 => 0.3, + _ => 0.12, + }, + }; + + FrameGeometry { + scene_preset, + mb_taps, + source_t, + zoom_rotation, + padding_scale, + cut, + s_dst, + s_dst_prev, + s_radius, + frame_min_px, + w_dst, + w_dst_prev, + w_px, + w_radius, + shape_fade, + } +} + +/// Le curseur, prêt à dessiner : où, à quelle taille, avec quelle traînée. +/// +/// Extrait de la moitié « dessin » de `compose_frame` pour la même raison que +/// `plan_frame` : deux backends qui doivent poser le curseur au pixel près ne peuvent pas +/// entretenir deux copies de ce mapping. Le placement dépend de la coupe source, du zoom, +/// du padding et de l'inclinaison — autant d'endroits où deux implémentations dérivent. +pub struct CursorPlan { + pub placement: CursorPlacement, + /// Placement à `t - trail_frames/FPS`, pour la traînée. `placement` quand il n'y en a pas. + pub prev_placement: CursorPlacement, + /// Côté du sprite en px de sortie (bounce et padding déjà appliqués). + pub size_px: f32, + /// Nombre d'échantillons de la traînée. 1 = curseur net, pas d'accumulation. + pub taps: u32, + /// Rect de clip « Clip to canvas » (mode 4/7 du shader lit `fx`). + pub clip: [f32; 4], + /// État du curseur à cet instant (`arrow`, `pointer`, …) pour choisir le sprite. + pub cursor_type: Option, +} + +/// Ce que `plan_cursor` doit savoir en plus de `FrameGeometry`. +pub struct CursorPlanInput<'a> { + pub render_px: [f32; 2], + pub u_max: f32, + pub v_max: f32, + pub cfg: &'a Cfg, + pub live: LiveParams, + pub scene: Option<&'a Scene>, + pub track: &'a crate::cursor::CursorTrack, + /// Temps curseur, déjà résolu (`cursor_t_override` ou `frame / FPS`). + pub t: f32, +} + +/// `None` = rien à dessiner cette frame : curseur masqué, ou pointeur hors du rect source +/// courant (zoom serré, hors écran) — un état normal en lecture, pas une erreur. +pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option { + let (rw, rh) = (input.render_px[0], input.render_px[1]); + let show = input.scene.map(|s| s.cursor.show).unwrap_or(input.cfg.cursor); + if !show { + return None; + } + let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; + let tilt = (!crate::regions::is_identity_rotation(g.zoom_rotation)) + .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], g.zoom_rotation)); + let quad_center_px = [ + (g.s_dst[0] + g.s_dst[2] * 0.5) * rw, + (g.s_dst[1] + g.s_dst[3] * 0.5) * rh, + ]; + let cursor_bounds: [f32; 4] = match tilt.as_ref() { + None => g.s_dst, + Some(quad) => { + let (hx, hy) = quad.half_extents_px(); + [ + (quad_center_px[0] - hx) / rw, + (quad_center_px[1] - hy) / rh, + 2.0 * hx / rw, + 2.0 * hy / rh, + ] + } + }; + let clip = match input.scene { + Some(s) if s.cursor.clip_to_bounds => cursor_bounds, + _ => [-1.0, -1.0, 3.0, 3.0], + }; + + let [su0, sv0, su1, sv1] = g.cut; + let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); + let place = |cxy: Option<(f32, f32)>, dst: [f32; 4]| -> Option { + cxy.and_then(|(cx2, cy2)| { + let fx = (cx2 * input.u_max - su0) / (2.0 * hu); + let fy = (cy2 * input.v_max - sv0) / (2.0 * hv); + if !(0.0..=1.0).contains(&fx) || !(0.0..=1.0).contains(&fy) { + return None; + } + Some(match tilt.as_ref() { + Some(&quad) => CursorPlacement::Tilted { + plane_pt: [fx, fy], + quad, + center_px: quad_center_px, + screen_px: s_px, + render_px: [rw, rh], + }, + None => CursorPlacement::Upright { + center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]], + }, + }) + }) + }; + let placement = place(input.track.at(input.t), g.s_dst)?; + + let lp = input.live; + let bounce = 1.0 + (input.track.bounce(input.t) - 1.0) * lp.cursor_bounce_scale; + let size_px = + CURSOR_BASE_SIZE_FRAC * g.frame_min_px * lp.cursor_size_scale * bounce * g.padding_scale; + + let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0); + let has_scene = input.scene.is_some(); + let trail_frames = if has_scene { 1.0 + blur01 * 7.0 } else { 1.0 }; + let taps = if has_scene { + (1.0 + blur01 * 10.0).round() as u32 + } else { + input.cfg.mblur_n + }; + let prev_placement = if taps <= 1 { + placement + } else { + place(input.track.at(input.t - trail_frames / FPS), g.s_dst_prev).unwrap_or(placement) + }; + + Some(CursorPlan { + placement, + prev_placement, + size_px, + taps, + clip, + cursor_type: input.track.type_at(input.t).map(str::to_string), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// La scène de référence du golden : un cas qui exerce le padding, le crop, le zoom, + /// une caméra PiP décalée, un rayon et une inclinaison nulle. + fn golden_scene() -> Scene { + Scene::from_json( + r##"{ + "clips":[{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":true}], + "layout":{"preset":"picture-in-picture","webcamSize":0.44,"webcamShape":"circle","webcamMirror":false, + "webcamPosition":{"cx":0.8577,"cy":0.8159},"webcamReactiveZoom":false}, + "effects":{"padding":0.51,"blur":false,"shadow":0.35,"roundnessFrac":0.0255,"motionBlur":0.35}, + "background":{"kind":"color","color":"#1e1e2e"}, + "zoomRegions":[], + "cursor":{"show":true,"size":7.76,"smoothing":0,"motionBlur":0.35,"clickBounce":1,"clipToBounds":false,"theme":"default"}, + "cropByClip":[{"x":0,"y":0,"width":0.61,"height":0.61}], + "output":{"width":1170,"height":658,"fps":60} + }"##, + ) + .expect("golden scene") + } + + fn golden_input(scene: &Scene, cfg: &Cfg) -> FrameGeometryInput<'static> { + // SAFETY-free: on fuit volontairement les deux références pour obtenir un + // `'static` dans le test — la scène et le cfg vivent jusqu'à la fin du process. + let scene: &'static Scene = Box::leak(Box::new(scene.clone())); + let cfg: &'static Cfg = Box::leak(Box::new(cfg.clone())); + FrameGeometryInput { + render_px: [1170.0, 658.0], + screen_tex_px: [1920.0, 1088.0], + screen_visible_px: [1920.0, 1080.0], + webcam_visible_px: [1280.0, 720.0], + u_max: 1920.0 / 1920.0, + v_max: 1080.0 / 1088.0, + frame: 90.0, + cfg, + live: live_params_from_scene(scene), + scene: Some(scene), + cursor: None, + timeline_t_override: Some(1.5), + } + } + + /// **Le golden iso-render.** + /// + /// Les deux backends ne peuvent pas tourner sur la même machine, donc « iso avec + /// D3D » ne peut pas être mesuré en comparant deux images rendues. Ce qui PEUT l'être, + /// et qui est la couche où la divergence s'est effectivement produite, c'est la + /// géométrie : `plan_frame` est le MÊME code des deux côtés, et ce test épingle ses 15 + /// sorties au bit près. Il tourne dans le job macOS ET dans le job Windows, donc si un + /// jour les deux plateformes calculent des placements différents, l'un des deux vire au + /// rouge — ce qui est exactement la garantie qu'on cherche. + /// + /// Ce que ce test ne couvre PAS, et qu'il ne faut pas lui faire dire : la rastérisation. + /// D3D11 et Metal ne rendront jamais bit-à-bit identique (la PR #162 a mesuré 93-95 % + /// de canaux identiques, écart max 3/255, entre deux backends sur la MÊME machine). + /// La parité des shaders est tenue séparément, par le fait que `shaders.metal` et + /// `shaders.hlsl` ont été diffés ligne à ligne sur les 14 modes. + #[test] + fn plan_frame_is_pinned_bit_for_bit() { + let scene = golden_scene(); + let cfg = crate::config::all().pop().expect("au moins une config"); + let g = plan_frame(&golden_input(&scene, &cfg)); + let got = [ + g.s_dst[0], g.s_dst[1], g.s_dst[2], g.s_dst[3], + g.w_dst[0], g.w_dst[1], g.w_dst[2], g.w_dst[3], + g.cut[0], g.cut[1], g.cut[2], g.cut[3], + g.s_radius, g.w_radius, g.w_px[0], g.w_px[1], + g.frame_min_px, g.padding_scale, g.shape_fade, g.mb_taps, g.source_t, + ]; + // Valeurs mesurées, pas devinées : toute dérive est une divergence à expliquer, + // pas un seuil à relâcher. + // Mesuré sur ce code, pas deviné : toute dérive est une divergence à expliquer, + // pas un seuil à relâcher. Ordre : s_dst[4], w_dst[4], cut[4], s_radius, w_radius, + // w_px[2], frame_min_px, padding_scale, shape_fade, mb_taps, source_t. + let want: [f32; 21] = [ + 0.10207555, 0.102, 0.7958489, 0.796, + 0.9058473, 0.8325926, 0.0733194, 0.13037036, + 0.0, 0.0, 0.61, 0.6055147, + 16.779, 42.89185, 85.7837, 85.7837, + 658.0, 0.796, 1.0, 6.25, 1.5, + ]; + for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() { + assert_eq!( + a.to_bits(), + b.to_bits(), + "sortie #{i} de plan_frame : {a} != {b}", + ); + } + } + + /// Le contrat cross-backend, verrouillé octet par octet. Un shader qui lit un champ + /// décalé ne lève rien : il rend faux, en silence. + #[test] + fn layer_cb_matches_the_shader_constant_buffer() { + use std::mem::{align_of, offset_of, size_of}; + assert_eq!(size_of::(), 128); + assert_eq!(align_of::(), 16); + for (name, got, want) in [ + ("dst", offset_of!(LayerCB, dst), 0), + ("src", offset_of!(LayerCB, src), 16), + ("quad_px", offset_of!(LayerCB, quad_px), 32), + ("radius_px", offset_of!(LayerCB, radius_px), 40), + ("mode", offset_of!(LayerCB, mode), 44), + ("color", offset_of!(LayerCB, color), 48), + ("fx", offset_of!(LayerCB, fx), 64), + ("src_prev", offset_of!(LayerCB, src_prev), 80), + ("dst_prev", offset_of!(LayerCB, dst_prev), 96), + ("mb", offset_of!(LayerCB, mb), 112), + ] { + assert_eq!(got, want, "offset de `{name}`"); + } + } + + /// Le pivot doit rester collé à `center` quand le sprite grandit — c'est exactement ce qui + /// était cassé (ancrage centré en dur : la pointe s'éloignait proportionnellement à la + /// taille). On dessine la même flèche à deux tailles et on vérifie que le point désigné + /// ne bouge pas. + #[test] + fn sprite_hotspot_stays_on_target_at_any_size() { + let center = [0.4, 0.6]; + let hotspot = [0.119, 0.0874]; // flèche intégrée : la pointe, près du coin haut-gauche + + for (w, h) in [(0.02, 0.04), (0.08, 0.16)] { + let dst = cursor_sprite_dst(center, w, h, hotspot); + let pivot = [dst[0] + dst[2] * hotspot[0], dst[1] + dst[3] * hotspot[1]]; + assert!((pivot[0] - center[0]).abs() < 1e-6, "x drifted at {w}x{h}: {pivot:?}"); + assert!((pivot[1] - center[1]).abs() < 1e-6, "y drifted at {w}x{h}: {pivot:?}"); + assert_eq!([dst[2], dst[3]], [w, h], "taille altérée"); + } + + // Et un pivot centré reste bien l'ancien comportement, pour les sprites qui le veulent + // (viseur, I-beam, poignées de redimensionnement). + assert_eq!(cursor_sprite_dst([0.5, 0.5], 0.2, 0.2, [0.5, 0.5]), [0.4, 0.4, 0.2, 0.2]); + } + fn assert_rect(actual: [f32; 4], expected: [f32; 4]) { + for (actual, expected) in actual.into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-6, "actual={actual}, expected={expected}"); + } + } + + #[test] + fn decodes_a_base64_data_uri() { + // "Hi!" -> SGkh + assert_eq!(decode_data_uri("data:image/png;base64,SGkh").unwrap(), b"Hi!".to_vec()); + } + + /// L'inspector stocke les couleurs de caption comme `couleur_hex` + `opacité` puis la + /// bridge JS recombine en `rgba(r, g, b, a)` pour la preview. Le natif doit rendre la même + /// plaque (couleur et opacité) — sinon le calque disparaît silencieusement et la caption + /// n'apparaît qu'en texte brut dans l'export. C'était exactement le bug de l'issue #178. + #[test] + fn parse_hex_understands_rgba_caption_backgrounds() { + let parsed = parse_hex("rgba(0, 0, 0, 0.55)").expect("rgba doit parser"); + assert!((parsed[3] - 0.55).abs() < 1e-6, "alpha 0.55 transmise, pas tombée à 0"); + assert_eq!([parsed[0], parsed[1], parsed[2]], [0.0, 0.0, 0.0]); + } + + /// `rgb(...)` sans alpha est sémantiquement `rgba(..., 1)` — il faut le supporter pour + /// qu'un inspector qui n'expose pas d'opacité n'écrive pas un fond invisible. + #[test] + fn parse_hex_treats_rgb_as_opaque() { + let parsed = parse_hex("rgb(255, 128, 0)").expect("rgb doit parser"); + assert_eq!(parsed, [1.0, 128.0 / 255.0, 0.0, 1.0]); + } + + /// Le cas "transparent" est documenté dans le code d'appel : on garde la sémantique + /// historique (alpha 0) — la plaque est sautée côté rastérisation, ce qui est exactement ce + /// que veut le CSS. Le nouveau parseur ne doit pas le casser. + #[test] + fn parse_hex_keeps_transparent_at_alpha_zero() { + assert_eq!(parse_hex("transparent"), Some([0.0, 0.0, 0.0, 0.0])); + // La casse ne doit pas non plus casser : CSS autorise `TRANSPARENT` en théorie, et + // refuse une chaîne qui ressemble à un rgba mal formé. + assert_eq!(parse_hex("Transparent"), Some([0.0, 0.0, 0.0, 0.0])); + assert_eq!(parse_hex("rgba(0, 0, 0, 0)"), Some([0.0, 0.0, 0.0, 0.0])); + } + + /// Le contrat historique `#rrggbb` / `rrggbb` ne doit pas régresser : les annotations + /// normales (saisies via `ColorField`) ne passent que par ce chemin, et leurs snapshots + /// ne pardonneraient pas un changement d'alpha implicite. + #[test] + fn parse_hex_still_understands_hex_colours() { + assert_eq!(parse_hex("#fff"), Some([1.0, 1.0, 1.0, 1.0])); + assert_eq!(parse_hex("#000000"), Some([0.0, 0.0, 0.0, 1.0])); + assert_eq!( + parse_hex("ff8800"), + Some([1.0, 136.0 / 255.0, 0.0, 1.0]) + ); + } + + /// Hors-format (channel > 255, chaîne vide, named color) → None → l'appelant retombe sur + /// son fallback. C'est la même politique qu'avant l'ajout du parseur rgba, on la garde + /// explicite pour qu'elle ne dérive pas. + #[test] + fn parse_hex_rejects_malformed_colours() { + assert_eq!(parse_hex(""), None); + assert_eq!(parse_hex("not-a-color"), None); + assert_eq!(parse_hex("rgba(256, 0, 0, 1)"), None); // canal >255 + assert_eq!(parse_hex("rgba(0, 0, 0, 1.5)"), None); // alpha >1 + assert_eq!(parse_hex("rgba(0, 0, 0, 0.5, 1)"), None); // 5 composantes + assert_eq!(parse_hex("rgb(0, 0)"), None); // 2 composantes + } + + /// CSS Color 4 : `rgb()` et `rgba()` sont synonymes, les deux prennent 3 ou 4 composantes. + /// Une couleur bien formée ne doit pas finir sur le fallback de l'appelant — pour un fond + /// c'est alpha 0, donc une plaque invisible, soit très exactement le symptôme de #178. + #[test] + fn parse_hex_accepts_both_arities_on_both_names() { + assert_eq!(parse_hex("rgba(0, 0, 0)"), Some([0.0, 0.0, 0.0, 1.0])); + assert_eq!(parse_hex("rgb(0, 0, 0, 0.5)"), Some([0.0, 0.0, 0.0, 0.5])); + } + + /// Une couleur non-ASCII doit être refusée, pas paniquer : `strip_color_fn` découpait + /// `s[..3]` / `s[..4]` sans vérifier la frontière de caractère, donc `#ab€cd` (le `€` occupe + /// les octets 3..6) tuait le process au lieu de retomber sur le fallback. `parseWallpaper` + /// laisse passer n'importe quelle chaîne préfixée `#` jusqu'ici, une panique côté natif + /// traverserait le pont N-API et emporterait l'export. + #[test] + fn parse_hex_refuses_non_ascii_without_panicking() { + assert_eq!(parse_hex("#ab€cd"), None); + assert_eq!(parse_hex("rg€(0, 0, 0)"), None); + assert_eq!(parse_hex("é"), None); + assert_eq!(parse_hex("🎨🎨"), None); + // Le chemin hex découpe par octet sur les longueurs 3 et 6 : `éa` fait 3 octets et + // `€€` en fait 6, donc les deux tombaient pile sur une découpe intra-caractère. + assert_eq!(parse_hex("éa"), None); + assert_eq!(parse_hex("€€"), None); + } + + #[test] + fn ignores_padding_and_line_breaks_inside_the_payload() { + // Un URI replié ou paddé doit décoder à l'identique : les caractères hors alphabet sont + // sautés, donc ils ne peuvent pas décaler le flux. + let folded = "data:image/png;base64,SGkh +=="; + assert_eq!(decode_data_uri(folded).unwrap(), b"Hi!".to_vec()); + } + + #[test] + fn a_plain_path_is_not_a_data_uri() { + // Le repli lecture-disque des wallpapers en dépend. + assert!(decode_data_uri("/wallpapers/x.jpg").is_none()); + assert!(decode_data_uri("C:/img/y.png").is_none()); + } + + #[test] + fn a_non_base64_data_uri_is_refused() { + // `data:image/svg+xml,` n'est pas du base64 : mieux vaut échouer que décoder du + // texte comme des octets. + assert!(decode_data_uri("data:image/svg+xml,").is_none()); + } + + #[test] + fn crop_maps_visible_frame_fractions_to_texture_uvs() { + let crop = SceneCrop { x: 0.25, y: 0.1, width: 0.5, height: 0.6 }; + assert_rect(screen_source_rect(0.8, 0.9, None, 1.0, [0.2, 0.7]), [0.0, 0.0, 0.8, 0.9]); + assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 1.0, [0.5, 0.5]), [0.2, 0.09, 0.6, 0.63]); + } + + #[test] + fn zoom_focus_is_applied_inside_the_crop() { + let crop = SceneCrop { x: 0.25, y: 0.1, width: 0.5, height: 0.6 }; + assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [0.5, 0.5]), [0.3, 0.225, 0.5, 0.495]); + assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [1.0, 1.0]), [0.4, 0.36, 0.6, 0.63]); + } + + // --- le zoom rendu à la boîte (issue #179) ------------------------------ + // Le zoom déplace et agrandit la boîte au lieu de rétrécir la coupe. Deux choses à + // figer, et elles tirent en sens inverse : la boîte DOIT déborder le padding (l'issue), + // et le mapping image→écran ne doit PAS bouger (tout le reste du compositeur en + // dépend). Une version antérieure de ce correctif protégeait si bien le second qu'elle + // annulait le premier dès que le focus n'était pas centré — d'où le balayage sur des + // focus décentrés dans les deux tests. + + /// Boîte paddée (padding 50 % → `scale_frame` 0.8) dans une sortie carrée : le cas + /// plein cadre de l'issue. + const PADDED: [f32; 4] = [0.1, 0.1, 0.8, 0.8]; + + /// Les zooms d'un preset (`ZOOM_DEPTH_SCALES`, TS) et des focus réalistes — dont des + /// focus très décentrés, que le suivi de curseur produit en permanence. + const ZOOMS: [f32; 6] = [1.0, 1.25, 1.5, 1.8, 2.2, 3.5]; + const FOCUSES: [[f32; 2]; 6] = [ + [0.5, 0.5], + [0.3, 0.5], + [0.5, 0.8], + [0.15, 0.9], + [0.85, 0.2], + [0.0, 1.0], + ]; + + /// Le couple (boîte, coupe) réellement envoyé au GPU. `u_max`/`v_max` à 1 et pas de + /// crop : la coupe est donc directement en fractions d'image. + fn drawn(base: [f32; 4], zoom: f32, focus: [f32; 2]) -> ([f32; 4], [f32; 4]) { + let cut_ref = screen_source_rect(1.0, 1.0, None, zoom, focus); + let cut = screen_source_rect(1.0, 1.0, None, 1.0, focus); + (remap_box(base, cut_ref, cut), cut) + } + + /// Où un point de l'image atterrit à l'écran, en fraction du CADRE. + fn on_screen(base: [f32; 4], zoom: f32, focus: [f32; 2], point: [f32; 2]) -> [f32; 2] { + let (dst, src) = drawn(base, zoom, focus); + let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0); + [ + at(point[0], src[0], src[2], dst[0], dst[2]), + at(point[1], src[1], src[3], dst[1], dst[3]), + ] + } + + /// Le mapping d'avant : la coupe zoomée remplissait la boîte paddée, sans la bouger. + fn on_screen_before(base: [f32; 4], zoom: f32, focus: [f32; 2], point: [f32; 2]) -> [f32; 2] { + let src = screen_source_rect(1.0, 1.0, None, zoom, focus); + let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0); + [ + at(point[0], src[0], src[2], base[0], base[2]), + at(point[1], src[1], src[3], base[1], base[3]), + ] + } + + /// L'invariant : rendre le zoom à la boîte ne déplace AUCUN point de l'image — même + /// grossissement, même cadrage. Seule l'étendue dessinée change. + #[test] + fn handing_the_zoom_to_the_box_moves_no_pixel() { + for &zoom in &ZOOMS { + for &focus in &FOCUSES { + for &point in &[[0.5, 0.5], [0.0, 0.0], [1.0, 1.0], [0.25, 0.75]] { + let (was, now) = ( + on_screen_before(PADDED, zoom, focus, point), + on_screen(PADDED, zoom, focus, point), + ); + assert!( + (was[0] - now[0]).abs() < 1e-4 && (was[1] - now[1]).abs() < 1e-4, + "point {point:?} déplacé (zoom {zoom}, focus {focus:?}) : {was:?} → {now:?}" + ); + } + } + } + } + + /// Ce que l'issue demande, et la régression que le testeur a vue : dès qu'on zoome, la + /// boîte doit déborder le rect paddé — y compris (surtout) avec un focus décentré. + #[test] + fn any_zoom_overflows_the_padding() { + for &zoom in &ZOOMS { + for &focus in &FOCUSES { + let (dst, _) = drawn(PADDED, zoom, focus); + let grew = dst[2] / PADDED[2]; + assert!( + (grew - zoom).abs() < 1e-4, + "la boîte n'a pas pris le zoom (zoom {zoom}, focus {focus:?}) : ×{grew}" + ); + if zoom > 1.0 { + // Elle dépasse le rect paddé d'au moins un bord, donc mange du padding. + assert!( + dst[0] < PADDED[0] - 1e-6 || dst[0] + dst[2] > PADDED[0] + PADDED[2] + 1e-6, + "boîte encore dans le padding (zoom {zoom}, focus {focus:?}) : {dst:?}" + ); + } + } + } + // Focus centré : le padding disparaît des QUATRE côtés dès que le zoom suffit à + // couvrir le cadre (ici 1/0.8 = 1.25). + let (dst, _) = drawn(PADDED, 1.25, [0.5, 0.5]); + assert_rect(dst, [0.0, 0.0, 1.0, 1.0]); + // Sans padding il n'y a rien à déborder, mais la boîte porte quand même le zoom. + let (dst, _) = drawn([0.0, 0.0, 1.0, 1.0], 2.0, [0.5, 0.5]); + assert_rect(dst, [-0.5, -0.5, 2.0, 2.0]); + } + + // --- cover_crop_uv : la caméra n'est jamais étirée -------------------- + // Le ratio de la coupe source, ramené en pixels d'image, doit TOUJOURS égaler + // celui de la boîte : c'est la définition de « pas de déformation ». + + /// Ratio largeur/hauteur de la coupe, exprimé en pixels de l'image source. + fn crop_aspect(uv: (f32, f32, f32, f32), tex: [f32; 2]) -> f32 { + ((uv.2 - uv.0) * tex[0]) / ((uv.3 - uv.1) * tex[1]) + } + + /// L'invariant, balayé sur des boîtes très diverses — dont le slot en colonne + /// du preset side-by-side, qui est précisément le cas qui étirait la caméra. + #[test] + fn cover_crop_never_distorts_whatever_the_destination_box() { + let tex = [1024.0, 1024.0]; + for &cam in &[[1280.0, 720.0], [960.0, 720.0], [640.0, 480.0]] { + for &box_ar in &[0.35, 0.5, 0.75, 1.0, 16.0 / 9.0, 2.4] { + let uv = cover_crop_uv(cam, tex, box_ar); + let got = crop_aspect(uv, tex); + assert!( + (got - box_ar).abs() < 1e-3, + "cam {cam:?} boite {box_ar} → coupe de ratio {got}, attendu {box_ar}", + ); + } + } + } + + /// La coupe reste DANS l'image visible et centrée — on ne va jamais chercher + /// le padding décodeur au-delà de `visible`, qui contient des pixels indéfinis. + #[test] + fn cover_crop_stays_inside_the_visible_frame_and_is_centred() { + let (cam, tex) = ([1280.0, 720.0], [2048.0, 1024.0]); + for &box_ar in &[0.35, 1.0, 2.4] { + let (u0, v0, u1, v1) = cover_crop_uv(cam, tex, box_ar); + assert!(u0 >= 0.0 && v0 >= 0.0, "coupe hors image: {u0},{v0}"); + assert!(u1 <= cam[0] / tex[0] + 1e-6, "u1 {u1} deborde la largeur visible"); + assert!(v1 <= cam[1] / tex[1] + 1e-6, "v1 {v1} deborde la hauteur visible"); + let (mx, my) = (u0 + u1, v0 + v1); + assert!((mx - cam[0] / tex[0]).abs() < 1e-6, "pas centre en x"); + assert!((my - cam[1] / tex[1]).abs() < 1e-6, "pas centre en y"); + } + } + + /// L'écran en layout bloc : le cover s'applique au rect DÉJÀ réduit par le crop + /// et le zoom. Quel que soit ce rect de départ, ce qui atterrit dans la boîte a + /// le ratio de la boîte — c'est ce qui empêche l'étirement. + #[test] + fn cover_uv_rect_gives_the_box_aspect_whatever_the_crop_and_zoom_left() { + let tex = [2048.0, 1024.0]; + // rects source plausibles : plein cadre, bande verticale (crop portrait), zoom serré + for &uv in &[ + [0.0, 0.0, 0.9375, 0.7031], + [0.41, 0.04, 0.55, 0.67], + [0.30, 0.20, 0.55, 0.45], + ] { + for &box_ar in &[0.4, 0.75, 1.0, 1.9, 3.2] { + let out = cover_uv_rect(uv, tex, box_ar); + let got = ((out[2] - out[0]) * tex[0]) / ((out[3] - out[1]) * tex[1]); + assert!( + (got - box_ar).abs() / box_ar < 1e-3, + "uv {uv:?} boite {box_ar} -> ratio {got}", + ); + // le cover RÉDUIT : il ne va jamais chercher des pixels hors du rect source + assert!(out[0] >= uv[0] - 1e-6 && out[1] >= uv[1] - 1e-6, "deborde en haut/gauche"); + assert!(out[2] <= uv[2] + 1e-6 && out[3] <= uv[3] + 1e-6, "deborde en bas/droite"); + } + } + } + + /// Propriété de sûreté : quand la boîte a DÉJÀ le ratio de la source (tous les + /// placements qui étaient corrects — PiP par défaut, vertical-stack, et le + /// center-crop carré de square/circle), la coupe est la frame entière. Le + /// correctif ne peut donc pas déplacer un pixel de ces cas-là. + #[test] + fn cover_crop_is_the_whole_frame_when_the_box_already_matches() { + let (cam, tex) = ([1280.0, 720.0], [2048.0, 1024.0]); + let uv = cover_crop_uv(cam, tex, cam[0] / cam[1]); + assert!((uv.0).abs() < 1e-6 && (uv.1).abs() < 1e-6); + assert!((uv.2 - cam[0] / tex[0]).abs() < 1e-6); + assert!((uv.3 - cam[1] / tex[1]).abs() < 1e-6); + // et une boîte carrée sur une source 4:3 redonne bien le center-crop carré + // que l'ancien branchement `is_square_shape` codait à la main. + let (su0, _, su1, _) = cover_crop_uv([960.0, 720.0], tex, 1.0); + assert!((su0 - (960.0 - 720.0) * 0.5 / tex[0]).abs() < 1e-6); + assert!((su1 - (960.0 + 720.0) * 0.5 / tex[0]).abs() < 1e-6); + } +} diff --git a/crates/compositor/src/gif_export.rs b/crates/compositor/src/gif_export.rs index 56bb594313..5f6c878e64 100644 --- a/crates/compositor/src/gif_export.rs +++ b/crates/compositor/src/gif_export.rs @@ -71,7 +71,8 @@ use crate::compositor::Compositor; use crate::config::Cfg; use crate::d3d::Gpu; -use crate::pipeline::{walk_composited_timeline, ClipSource, Decoder}; +use crate::pipeline::{ClipSource, Decoder}; +use crate::timeline_walk::walk_composited_timeline; use anyhow::{anyhow, bail, Context, Result}; use std::collections::HashMap; use std::fs::File; diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs index c1d01bb342..3a7930e05f 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -1,21 +1,105 @@ -//! 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 frame_geometry; pub mod gif_export; -pub mod live; -pub mod pipeline; pub mod regions; pub mod scene; -pub mod text; pub mod text_anim; +pub(crate) mod timeline_walk; + +// 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 0ba8e9e884..850ca6bdb1 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -27,7 +27,6 @@ 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; @@ -126,6 +125,7 @@ impl Player { 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, @@ -214,16 +214,6 @@ impl Player { self.sdec.cur_time_sec() } - /// Durée totale du flux écran (en secondes), telle qu'annoncée par le - /// conteneur. `None` quand le conteneur n'expose ni `duration` ni un - /// `nb_frames` fiable — l'appelant doit alors itérer jusqu'à EOF - /// plutôt que de calculer un `target_frames` à l'avance. Sert - /// principalement à l'export GIF (slice 1) pour estimer le nombre de - /// frames à produire avant d'entrer dans la boucle de rendu. - pub unsafe fn screen_duration_sec(&self) -> Option { - self.sdec.available_duration_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 @@ -510,6 +500,13 @@ struct Shared { /// vide la ferait repartir à 1 — donc rejouer des générations déjà peintes. Monotone, /// jamais remise à zéro. frame_gen: AtomicU64, + /// 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. @@ -556,12 +553,16 @@ impl LiveView { stop: AtomicBool::new(false), latest_frame: Mutex::new(None), frame_gen: AtomicU64::new(0), + 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, &s, &wc, &cj) } { + 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:#}")); + } } }); @@ -581,6 +582,12 @@ impl LiveView { } } + /// 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 @@ -854,6 +861,7 @@ unsafe fn maybe_start_prefetch( 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 || { @@ -1019,7 +1027,8 @@ unsafe fn render_thread( webcam: &str, cursor_json: &str, ) -> Result<()> { - let gpu = Gpu::create(false)?; + // 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. @@ -1421,7 +1430,25 @@ unsafe fn render_thread( } // ---------- 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::*; @@ -1558,6 +1585,20 @@ unsafe fn client_size(hwnd: HWND) -> (u32, u32) { ((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::*; diff --git a/crates/compositor/src/mac_frames.rs b/crates/compositor/src/mac_frames.rs new file mode 100644 index 0000000000..ec2432637f --- /dev/null +++ b/crates/compositor/src/mac_frames.rs @@ -0,0 +1,377 @@ +//! 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` garde la même mécanique pour la symétrie ; (2) robustesse — si +//! VideoToolbox refuse un flux (codec hors spec, profil non supporté), le repli 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. +//! +//! # Pourquoi IOSurface n'est PAS optionnel +//! +//! `CVMetalTextureCacheCreateTextureFromImage` REFUSE un `CVPixelBuffer` qui n'est pas +//! IOSurface-backed : c'est l'IOSurface qui est la mémoire partagée CPU/GPU. Créer le +//! buffer avec `attributes = NULL` (ce que faisait la première version, en le documentant +//! comme un « scaffold » à compléter plus tard) donne une allocation malloc ordinaire, et +//! chaque frame décodée en logiciel échouait donc au moment de devenir une texture. Les +//! attributs ci-dessous — `IOSurfaceProperties` + `MetalCompatibility` — sont ce qui rend +//! ce chemin fonctionnel, pas une optimisation. +//! +//! 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::d3d::Gpu; +use crate::ffi::*; +use anyhow::{bail, Result}; +use core_foundation::base::TCFType; +use core_foundation::boolean::CFBoolean; +use core_foundation::dictionary::CFDictionary; +use core_foundation::string::{CFString, CFStringRef}; +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. `kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange`. +const K_CV_PIXEL_FORMAT_TYPE_420_Y_P_C_B_CR_8_BI_PLANAR_VIDEO_RANGE: u32 = 0x34323076; + +/// 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)] +pub(crate) struct CVPixelBufferRef(ptr::NonNull); + +unsafe impl Send for CVPixelBufferRef {} +unsafe impl Sync for CVPixelBufferRef {} + +impl Clone for CVPixelBufferRef { + /// `Clone` DOIT retenir. Un `#[derive(Clone)]` sur un type dont le `Drop` fait + /// `CVPixelBufferRelease` copie le pointeur sans toucher au compteur : deux `Drop` + /// pour un seul `retain`, donc un double-release et un buffer libéré sous le GPU. + fn clone(&self) -> Self { + unsafe { CVPixelBufferRetain(self.0.as_ptr()) }; + CVPixelBufferRef(self.0) + } +} + +impl CVPixelBufferRef { + pub fn as_ptr(&self) -> *mut std::ffi::c_void { + self.0.as_ptr() + } +} + +impl Drop for CVPixelBufferRef { + fn drop(&mut self) { + unsafe { CVPixelBufferRelease(self.0.as_ptr()) }; + } +} + +#[link(name = "CoreVideo", 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 + 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; + + static kCVPixelBufferIOSurfacePropertiesKey: CFStringRef; + static kCVPixelBufferMetalCompatibilityKey: CFStringRef; +} + +/// Crée un `CVPixelBufferRef` NV12 IOSurface-backed, dimensions paires `(w, h)`. +/// +/// 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 { + // `{ IOSurfaceProperties: {}, MetalCompatibility: true }` — un dictionnaire + // IOSurface vide suffit à demander le backing, `MetalCompatibility` fait valider + // par CoreVideo que le résultat est utilisable depuis Metal. + let io_surface_props: CFDictionary = CFDictionary::from_CFType_pairs(&[]); + let attributes = CFDictionary::from_CFType_pairs(&[ + ( + CFString::wrap_under_get_rule(kCVPixelBufferIOSurfacePropertiesKey).as_CFType(), + io_surface_props.as_CFType(), + ), + ( + CFString::wrap_under_get_rule(kCVPixelBufferMetalCompatibilityKey).as_CFType(), + CFBoolean::true_value().as_CFType(), + ), + ]); + + 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, + attributes.as_concrete_TypeRef() as *const std::ffi::c_void, + &mut pixel_buffer, + ); + if status != 0 { + bail!("CVPixelBufferCreate NV12 {w}x{h} a échoué avec CVReturn={status}"); + } + if pixel_buffer.is_null() { + bail!("CVPixelBufferCreate NV12 {w}x{h} a renvoyé un pointeur nul"); + } + 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, consommé par + /// le `CVMetalTextureCache` du `Compositor` (cf. `compositor_macos`). + 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> { + if src.is_null() { + bail!("mac_frames::present: frame source nulle"); + } + 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(src, 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 la frame source en NV12 système, puis la recopie dans le + /// `CVPixelBufferRef` IOSurface-backed, plan par plan. + unsafe fn upload(&mut self, src: *mut AVFrame, 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"); + + // La SOURCE de swscale est la frame décodée. La première version passait + // `self.nv12` des deux côtés : elle convertissait donc la destination en + // elle-même, et le CVPixelBuffer ne recevait jamais un seul pixel du décodeur. + let converted = sws_scale( + self.sws, + (*src).data.as_ptr() as *const *const u8, + (*src).linesize.as_ptr(), + 0, + h, + (*self.nv12).data.as_ptr() as *const *mut u8, + (*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 : une ligne UV pour deux lignes Y, et deux + // octets (Cb, Cr) par paire de colonnes — donc `tex_w` octets par ligne. + 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 : `compositor_macos::nv12_srvs` sait qu'un + // `AV_PIX_FMT_D3D11` sur macOS veut dire « CVPixelBufferRef dans data[0] ». + // Le buffer reste possédé par `self.pixel_buffer` ; `av_frame_free` ignore + // `data[0]` parce qu'on n'a 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; + // Le compositor lit `best_effort_timestamp`/`pts` sur la frame présentée : les + // reporter depuis la source, sinon toute la timeline se croit à t=0. + (*self.present).pts = (*src).pts; + (*self.present).best_effort_timestamp = (*src).best_effort_timestamp; + 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 + } + + /// Le `CVPixelBufferRef` de la dernière frame présentée, retenu. Le caller doit le + /// dropper (son `Drop` fait le `CVPixelBufferRelease` correspondant). + pub(crate) fn current_pixel_buffer(&self) -> Option { + self.pixel_buffer.clone() + } +} + +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. + } + } +} diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs new file mode 100644 index 0000000000..18ed75667c --- /dev/null +++ b/crates/compositor/src/pipeline_macos.rs @@ -0,0 +1,1104 @@ +//! 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::audio::{ + assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, + stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm, +}; +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) }; + } +} + +/// Au-delà de cette distance vers l'avant, `Decoder::seek_to` repart d'une image clé +/// plutôt que de dérouler. Identique à `pipeline_windows::SEEK_FORWARD_MAX_SEC` — le +/// seuil dépend du GOP des captures, pas du backend de décodage. +const SEEK_FORWARD_MAX_SEC: f64 = 0.5; + +/// 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, + /// PTS de la frame actuellement décodée dans `frame`, ou `None` si l'état du décodeur + /// vient d'être jeté (ouverture, seek). Sert au chemin rapide de `seek_to` — symétrique + /// de `pipeline_windows::Decoder::cur_pts`. + cur_pts: Option, + /// 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(); + // VideoToolbox n'est PAS toujours le chemin rapide, et sur les enregistrements + // d'openscreen il est le LENT. Mesuré sur une capture 1920x1080@60 Constrained + // Baseline, décodage seul : VT 215 fps, libavcodec logiciel 3000 fps — 13x. Bout + // en bout sur l'export (décode + composite + encode), 76 fps contre 182, soit + // 2,4x, alors même que le chemin logiciel paie en plus swscale et un memcpy + // complet vers l'IOSurface. + // + // La raison est structurelle : le décodeur matériel a une latence fixe par frame + // et alloue un CVPixelBuffer/IOSurface à chacune, là où un profil trivial se + // décode en quelques centaines de microsecondes sur des cœurs qui, eux, sont + // multiples. Baseline est précisément ce que produit la capture d'openscreen + // (cf. `crates/fixture/fixture.json`, profile_idc 66) et ce que Chrome émet via + // MediaRecorder — donc le cas courant, pas un cas limite. + // + // Au-delà de Baseline (High, 10 bits, HEVC, 4K) l'arbitrage s'inverse : le + // décodeur logiciel devient le goulot et VT reprend l'avantage. D'où un choix + // sur le profil plutôt qu'un défaut unique. + let profile = (*dctx).profile; + // 66 = baseline, 578 = 66 | 0x200 (le flag « constrained »). Écrits en clair : + // bindgen ne génère pas les `FF_PROFILE_*` (des macros), et leurs valeurs sont + // figées par l'ABI de libavcodec. + const FF_PROFILE_H264_BASELINE: i32 = 66; + const FF_PROFILE_H264_CONSTRAINED_BASELINE: i32 = 578; + let is_baseline = + profile == FF_PROFILE_H264_BASELINE || profile == FF_PROFILE_H264_CONSTRAINED_BASELINE; + let forced = std::env::var("OPENSCREEN_MAC_DECODE").ok(); + let want_hw = match forced.as_deref() { + Some("software") => false, + Some("videotoolbox") => true, + _ => !is_baseline, + }; + let r = if want_hw { + crate::ffi::av_hwdevice_ctx_create( + &mut hwdev, + crate::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX, + ptr::null(), + ptr::null_mut(), + 0, + ) + } else { + -1 // repli logiciel délibéré, pas un échec + }; + 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); + // Pas de repli logiciel sur ce chemin : VideoToolbox rend les frames. + None + }; + + 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, + cur_pts: None, + 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`, chemin + /// rapide compris : mêmes seuils, même critère d'arrêt (`decode_forward_to`), pour + /// que les deux moteurs rendent la même frame au même coût relatif. + pub unsafe fn seek_to(&mut self, seconds: f64) -> Result<*mut crate::ffi::AVFrame> { + let tb_sec = self.tb_sec(); + + if tb_sec > 0.0 { + if let Some(pts) = self.cur_pts { + let cur = pts as f64 * tb_sec; + let frame_dur = 1.0 / self.fps().max(1.0); + // 1) La frame courante EST celle demandée : rien à décoder du tout. + // `cur_frame()`, pas `self.frame` : en backend CPU la frame exploitable + // est la texture NV12 déjà présentée, pas la frame système du décodeur. + if (cur - seconds).abs() < frame_dur * 0.5 { + return Ok(self.cur_frame()); + } + // 2) La cible est DEVANT et à portée : dérouler depuis ici plutôt que de + // repartir d'une image clé (cf. `pipeline_windows::SEEK_FORWARD_MAX_SEC`). + if cur < seconds && seconds - cur <= SEEK_FORWARD_MAX_SEC { + return self.decode_forward_to(seconds, 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); + // L'état vient d'être jeté : plus aucune frame courante exploitable. + self.cur_pts = None; + 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); + } + } + } + + /// Déroule le décodeur en avant jusqu'à la première frame à `seconds` ou après, SANS + /// jeter son état. Symétrique de `pipeline_windows::Decoder::decode_forward_to`. + unsafe fn decode_forward_to(&mut self, seconds: f64, tb_sec: f64) -> Result<*mut crate::ffi::AVFrame> { + loop { + let f = self.next()?; + if f.is_null() { + return Ok(ptr::null_mut()); + } + let pts = (*f).best_effort_timestamp; + if pts == i64::MIN { + 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 { + let pts = (*self.frame).best_effort_timestamp; + self.cur_pts = if pts == i64::MIN { None } else { Some(pts) }; + 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 + } + } +} + +/// Même contrat que `pipeline_windows`: le `Decoder` est déplacé vers le thread de +/// rendu de `live.rs` (et vers le thread de préchargement du clip suivant). Tous ses +/// pointeurs ffmpeg sont possédés exclusivement par lui, et rien n'y accède depuis +/// deux threads à la fois — d'où `Send` mais pas `Sync`. +unsafe impl Send for Decoder {} + +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 source_start_sec: f64, + pub source_end_sec: f64, + pub webcam_offset_sec: f64, + pub has_audio: bool, +} + +/// 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 => &[ + // `h264_videotoolbox` annonce `videotoolbox_vld nv12 yuv420p` : il accepte + // donc des frames LOGICIELLES NV12 et fait l'upload lui-même. C'est + // exactement le format que le compositeur produit, donc pas de + // `hw_frames_ctx` à construire ni de pool à partager entre décodeur et + // encodeur — un étage de complexité que le port avait écrit et qui n'a + // jamais tourné. + // Zero-copy d'abord : la frame composée est rendue DIRECTEMENT dans le + // `CVPixelBuffer` de l'encodeur, elle ne redescend jamais au CPU. Si le + // pool VideoToolbox refuse de s'ouvrir, la marche des candidats retombe + // sur la variante NV12 logicielle juste en dessous — même encodeur, un + // aller-retour CPU en plus. + EncoderCandidate { + name: "h264_videotoolbox", + pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX, + }, + EncoderCandidate { + name: "h264_videotoolbox", + pix_fmt: crate::ffi::AVPixelFormat::AV_PIX_FMT_NV12, + }, + 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_NV12, + }, + 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 unsafe { 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). + + // Pool de frames VideoToolbox pour le chemin zero-copy : c'est lui qui fournit les + // `CVPixelBuffer` dans lesquels le compositeur rend directement. Sans lui, + // `avcodec_open2` réussit quand même et `av_hwframe_get_buffer` déréférence un + // `hw_frames_ctx` nul à la première frame. + if candidate.pix_fmt == crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX { + // `av_hwframe_ctx_alloc(device_ref)` prend UN argument et REND l'AVBufferRef ; + // et il lui faut un device VideoToolbox, qu'il faut donc créer d'abord. + let mut hw_device: *mut crate::ffi::AVBufferRef = ptr::null_mut(); + let r = crate::ffi::av_hwdevice_ctx_create( + &mut hw_device, + crate::ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX, + ptr::null(), + ptr::null_mut(), + 0, + ); + if r < 0 || hw_device.is_null() { + crate::ffi::avcodec_free_context(&mut ctx); + bail!("av_hwdevice_ctx_create (VT, encodeur) : {r}"); + } + let hw_frames = crate::ffi::av_hwframe_ctx_alloc(hw_device); + if hw_frames.is_null() { + crate::ffi::av_buffer_unref(&mut hw_device); + crate::ffi::avcodec_free_context(&mut ctx); + bail!("av_hwframe_ctx_alloc (VT)"); + } + let fc = (*hw_frames).data as *mut crate::ffi::AVHWFramesContext; + (*fc).format = crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX; + (*fc).sw_format = crate::ffi::AVPixelFormat::AV_PIX_FMT_NV12; + (*fc).width = w; + (*fc).height = h; + let mut hw_frames = hw_frames; + if crate::ffi::av_hwframe_ctx_init(hw_frames) < 0 { + crate::ffi::av_buffer_unref(&mut hw_frames); + crate::ffi::av_buffer_unref(&mut hw_device); + crate::ffi::avcodec_free_context(&mut ctx); + bail!("av_hwframe_ctx_init (VT)"); + } + (*ctx).hw_frames_ctx = crate::ffi::av_buffer_ref(hw_frames); + crate::ffi::av_buffer_unref(&mut hw_frames); + crate::ffi::av_buffer_unref(&mut hw_device); + } + + 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 { + if self.sw.is_null() { + // Chemin zero-copy : une frame du pool VideoToolbox, dont `data[3]` porte le + // `CVPixelBuffer` dans lequel le compositeur va rendre directement. + let frame = crate::ffi::av_frame_alloc(); + if frame.is_null() { + bail!("av_frame_alloc (frame VT)"); + } + let mut frame = frame; + if crate::ffi::av_hwframe_get_buffer((*self.ctx).hw_frames_ctx, frame, 0) < 0 { + crate::ffi::av_frame_free(&mut frame); + bail!("av_hwframe_get_buffer (pool VT épuisé)"); + } + let pb = (*frame).data[3] as *mut std::ffi::c_void; + if pb.is_null() { + crate::ffi::av_frame_free(&mut frame); + bail!("frame VT sans CVPixelBuffer dans data[3]"); + } + let rendered = compositor.rgb_to_nv12(pb, 0); + if let Err(e) = rendered { + crate::ffi::av_frame_free(&mut frame); + return Err(e); + } + (*frame).pts = pts; + let sent = crate::ffi::averr( + crate::ffi::avcodec_send_frame(self.ctx, frame), + "send_frame_composited_vt", + ); + crate::ffi::av_frame_free(&mut frame); + return sent; + } + // Rendre le RGBA composé en NV12 côté GPU, PUIS le relire dans les plans de la + // frame. Le port appelait bien `render_nv12()` mais envoyait ensuite une frame + // que rien n'avait remplie, sans pts : l'encodeur recevait du contenu + // indéterminé et des timestamps absents. + compositor.render_nv12()?; + crate::ffi::averr( + crate::ffi::av_frame_make_writable(self.sw), + "make_writable_sw", + )?; + compositor.read_nv12_scaled( + w, + h, + (*self.sw).data[0], + (*self.sw).linesize[0] as usize, + (*self.sw).data[1], + (*self.sw).linesize[1] as usize, + )?; + (*self.sw).pts = pts; + crate::ffi::averr( + crate::ffi::avcodec_send_frame(self.ctx, self.sw), + "send_frame_composited", + ) + } + } +} + +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 mut 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); + 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); + } + // L'encodeur AAC doit exister AVANT l'en-tête : le muxer y écrit la table des flux, et + // un flux ajouté après coup n'y figure pas. Tout ce qu'il consomme (`audio.rs` : + // décodage, WSOLA, mix, plan de concaténation) était déjà portable — c'est le muxing + // qui manquait, pas la machinerie. + let mut audio_encoder = unsafe { AacEncoder::open(octx)? }; + unsafe { + crate::ffi::averr( + crate::ffi::avformat_write_header(octx, ptr::null_mut()), + "write_header", + )?; + } + // Un PCM par clip, assemblé après la marche vidéo : c'est elle qui dit combien de + // frames chaque clip a réellement produit, donc combien d'audio lui revient. + let mut clip_pcm: Vec> = (0..clips.len()).map(|_| None).collect(); + let mut clip_frame_counts: Vec = vec![0; clips.len()]; + + let mut opkt = unsafe { crate::ffi::av_packet_alloc() }; + + // La marche de timeline est PARTAGÉE (`timeline_walk`) : c'est elle qui décide quelle + // frame source appartient à quelle frame de sortie, en tenant compte des régions de + // vitesse, du fenêtrage de scène par clip et du curseur. La version maison qui vivait + // ici décodait 1:1 en avançant `t` de `1/fps`, donc elle ignorait tout cela — et c'est + // exactement le bug de troncature en slow-motion que la doc de `walk_composited_timeline` + // raconte avoir déjà coûté une fois. + let scene = comp.scene_snapshot(); + frames = unsafe { + crate::timeline_walk::walk_composited_timeline( + clips, + gpu, + comp, + cfg, + out_fps, + &scene, + &mut screen_decs, + &mut webcam_decs, + &mut |n| { + enc.send_composited(comp, out_w, out_h, n as i64)?; + drain_encoder(ectx, octx, ostream, opkt)?; + progress(n + 1); + Ok(()) + }, + &mut |clip_index, source_end_sec, frames_in_clip, speed_segments| { + clip_frame_counts[clip_index] = frames_in_clip; + let clip = &clips[clip_index]; + if clip.has_audio && frames_in_clip > 0 { + match decode_clip_audio(&clip.screen, clip.source_start_sec, source_end_sec) { + Ok(Some(pcm)) => { + clip_pcm[clip_index] = Some(stretch_clip_pcm_by_speed( + &pcm, + speed_segments, + out_fps as f64, + )); + } + Ok(None) => eprintln!( + "[pipeline] warning: clip #{clip_index} déclaré audio mais sans flux décodable; silence conservé", + ), + Err(error) => eprintln!( + "[pipeline] warning: décodage audio du clip #{clip_index} échoué ({error:#}); silence conservé", + ), + } + } + Ok(()) + }, + )? + }; + + // 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)?; + + // Le plan part des frames RÉELLEMENT produites par clip, pas des durées demandées : + // un clip raccourci (source plus courte que sa borne) doit voir son audio raccourci + // d'autant, sinon la piste dérive pour tous les suivants. + let declared_audio: Vec = clips.iter().map(|clip| clip.has_audio).collect(); + let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64); + audio_encoder.encode(&assemble_concatenated_pcm(&clip_pcm, &plan), octx)?; + + 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); + } + + 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 85% rename from crates/compositor/src/pipeline.rs rename to crates/compositor/src/pipeline_windows.rs index f2361da4a2..1126d8e48f 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -8,11 +8,16 @@ 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, SpeedSegment}; use crate::scene::Scene; +// `walk_composited_timeline` / `advance_decoder_to` vivaient ici ; ils sont +// portables et servent aussi au pipeline macOS et à `gif_export` — voir +// `timeline_walk.rs` pour le pourquoi du déplacement. +use crate::timeline_walk::walk_composited_timeline; use anyhow::{anyhow, bail, Result}; use std::collections::HashMap; use std::ffi::{c_void, CString}; @@ -21,8 +26,10 @@ use std::time::Instant; use windows::core::Interface; // Macros libav non générées par bindgen (function-like). Valeurs Windows/MSVC. -const AVERROR_EAGAIN: i32 = -11; // -EAGAIN (EAGAIN=11 sur MSVC) -const AVERROR_EOF: i32 = -541478725; // -MKTAG('E','O','F',' ') +// `AVERROR(EAGAIN)` dépend de la plateforme (cf. `crate::ffi`) ; ce fichier est +// Windows-only, mais garder une troisième copie de la valeur est ce qui a laissé +// le port macOS naître avec la mauvaise. +use crate::ffi::{AVERROR_EAGAIN, AVERROR_EOF}; const AVSEEK_FLAG_BACKWARD: i32 = 1; // seek vers la keyframe <= ts (macro non générée) // Accesseurs shim.c (AVFormatContext opaque côté bindgen). @@ -389,6 +396,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 { @@ -404,6 +482,10 @@ pub(crate) struct Decoder { /// impossible de savoir si `frame` contient quoi que ce soit d'exploitable — un /// `AVFrame` fraîchement alloué a un `best_effort_timestamp` indéterminé. cur_pts: Option, + /// 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 @@ -433,15 +515,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 { @@ -453,13 +554,22 @@ impl Decoder { frame: av_frame_alloc(), sent_eof: false, cur_pts: None, + 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 @@ -500,8 +610,10 @@ impl Decoder { let cur = pts as f64 * tb_sec; let frame_dur = 1.0 / self.fps().max(1.0); // 1) La frame courante EST celle demandée : rien à décoder du tout. + // `cur_frame()`, pas `self.frame` : en backend CPU la frame exploitable + // est la texture NV12 déjà présentée, pas la frame système du décodeur. if (cur - seconds).abs() < frame_dur * 0.5 { - return Ok(self.frame); + return Ok(self.cur_frame()); } // 2) La cible est DEVANT et à portée : dérouler depuis ici. Au-delà du seuil, // repartir d'une image clé redevient moins cher — un seek coûte en moyenne @@ -593,7 +705,10 @@ impl Decoder { if r == 0 { let pts = (*self.frame).best_effort_timestamp; self.cur_pts = if pts == i64::MIN { None } else { Some(pts) }; - 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()); @@ -631,27 +746,6 @@ impl Drop for Decoder { } } -/// Avance un décodeur jusqu'au premier pts dans le référentiel écran qui atteint la cible. -/// `timeline_offset_sec` remet les pts webcam dans ce référentiel (`webcam + offset = screen`) : -/// chaque source garde ainsi sa cadence propre au lieu d'être consommée 1:1 avec l'autre. -unsafe fn advance_decoder_to( - decoder: &mut Decoder, - target_source_time: f64, - timeline_offset_sec: f64, -) -> Result { - loop { - if decoder.cur_frame().is_null() { - return Ok(false); - } - if decoder.cur_time_sec() + timeline_offset_sec >= target_source_time { - return Ok(true); - } - if decoder.next()?.is_null() { - return Ok(false); - } - } -} - /// Frames-context de l'encodeur : NV12 sur notre device, bind RENDER_TARGET (§5) pour /// que le compositeur rende directement dans les surfaces de l'encodeur. /// Au-delà de cette distance vers l'avant, `seek_to` repart d'une image clé plutôt que de @@ -924,10 +1018,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) => { @@ -1037,6 +1138,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. @@ -1103,178 +1236,6 @@ impl Default for ExportParams { } -/// The format-agnostic half of a multiclip export: clip iteration, decoder -/// reuse, availability clamping, per-clip scene windowing, keyframe seeks, -/// cursor binding, speed segments, and — the part that matters — advancing the -/// decoders by OUTPUT time rather than by source frames. -/// -/// MP4 and GIF differ only in what they do with a composed frame (hardware NV12 -/// encode vs CPU readback + palette quantize), so that is all they supply here. -/// Sharing this walk is what keeps "which source frame belongs at output frame -/// N" defined exactly once: a GIF driven by its own loop is how the slow-motion -/// truncation bug happened. -/// -/// `on_frame` runs after `compose_frame` with the running output index; -/// `on_clip_end` runs once per clip with its clamped source window, the frames -/// it produced, and the speed segments used (MP4 needs those for audio). -#[allow(clippy::too_many_arguments)] -pub(crate) unsafe fn walk_composited_timeline( - clips: &[ClipSource], - gpu: &Gpu, - comp: &Compositor, - cfg: &Cfg, - out_fps: i32, - scene: &Option, - screen_decs: &mut HashMap, - webcam_decs: &mut HashMap, - on_frame: &mut dyn FnMut(u64) -> Result<()>, - on_clip_end: &mut dyn FnMut(usize, f64, u64, &[SpeedSegment]) -> Result<()>, -) -> Result { - let cursor_enabled = scene.as_ref().map(|s| s.cursor.show).unwrap_or(false); - let cursor_smoothing = scene.as_ref().map(|s| s.cursor.smoothing).unwrap_or(0.0); - let mut cursor_tracks: HashMap = HashMap::new(); - let mut cursor_active_path: Option = None; - let mut frames: u64 = 0; - - for (clip_index, clip) in clips.iter().enumerate() { - 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(); - - let screen_available_duration = sdec.available_duration_sec(); - let webcam_available_duration = wdec.available_duration_sec(); - if screen_available_duration.is_none() || webcam_available_duration.is_none() { - eprintln!( - "[pipeline] warning: clip #{}: durée de flux indéterminée (screen={}, webcam={}); la borne demandée {:.3}s ne peut pas être entièrement validée", - clip_index, - screen_available_duration - .map(|v| format!("{v:.3}s")) - .unwrap_or_else(|| "inconnue".to_string()), - webcam_available_duration - .map(|v| format!("{v:.3}s")) - .unwrap_or_else(|| "inconnue".to_string()), - clip.source_end_sec, - ); - } - // Les bornes de clip sont en temps écran. La disponibilité webcam est donc translatée - // par le même offset que le seek (`webcam_time = screen_time - offset`). - let webcam_available_screen_end = - webcam_available_duration.map(|duration| duration + clip.webcam_offset_sec); - let mut source_end_sec = clip.source_end_sec; - if let Some(duration) = screen_available_duration { - source_end_sec = source_end_sec.min(duration); - } - if let Some(duration) = webcam_available_screen_end { - source_end_sec = source_end_sec.min(duration); - } - if source_end_sec + 1e-6 < clip.source_end_sec { - eprintln!( - "[pipeline] warning: clip #{} raccourci de {:.3}s (fin demandée {:.3}s, fin disponible {:.3}s; screen=\"{}\", webcam=\"{}\")", - clip_index, - clip.source_end_sec - source_end_sec, - clip.source_end_sec, - source_end_sec, - clip.screen, - clip.webcam, - ); - } - if source_end_sec <= clip.source_start_sec { - continue; - } - - let clip_scene = scene.as_ref().map(|base_scene| { - base_scene.for_clip_window(clip_index, clip.source_start_sec, source_end_sec) - }); - let speed_segments = speed_segments_for_window( - clip_scene - .as_ref() - .map(|s| s.speed_regions.as_slice()) - .unwrap_or(&[]), - clip.source_start_sec, - source_end_sec, - out_fps as f64, - ); - if clip_scene.is_some() { - comp.set_scene(clip_scene); - } - - // un seul seek keyframe, puis chaque décodeur avance selon son propre pts jusqu'aux - // temps source demandés par les spans de vitesse. - if sdec.seek_to(clip.source_start_sec)?.is_null() { - continue; // clip vide / au-delà de la source - } - if wdec - .seek_to((clip.source_start_sec - clip.webcam_offset_sec).max(0.0))? - .is_null() - { - continue; - } - - if cursor_enabled { - if !cursor_tracks.contains_key(&clip.screen) { - let path = format!("{}.cursor.json", clip.screen); - if let Ok(raw) = CursorTrack::load(&path, 0.0, 24.0 * 3600.0) { - cursor_tracks.insert(clip.screen.clone(), raw.smoothed(cursor_smoothing)); - } - // absente/illisible → pas d'entrée : ce clip s'exporte sans curseur (visible, - // pas masqué en un curseur fantôme d'un autre clip). - } - if cursor_active_path.as_deref() != Some(clip.screen.as_str()) { - if let Some(track) = cursor_tracks.get(&clip.screen) { - comp.set_cursor(track.clone()); - cursor_active_path = Some(clip.screen.clone()); - } else { - comp.clear_cursor(); - comp.set_cursor_time(None); - cursor_active_path = None; - } - } - } - - let frames_before_clip = frames; - 'clip_frames: for segment in &speed_segments { - for segment_frame in 0..segment.frame_count { - let target_source_time = segment.start_sec - + segment_frame as f64 * segment.speed / out_fps as f64; - if !advance_decoder_to(sdec, target_source_time, 0.0)? { - break 'clip_frames; - } - if !advance_decoder_to( - wdec, - target_source_time, - clip.webcam_offset_sec, - )? { - break 'clip_frames; - } - let sf = sdec.cur_frame(); - let wf = wdec.cur_frame(); - if sf.is_null() || wf.is_null() { - break 'clip_frames; - } - - comp.set_timeline_time(Some(target_source_time as f32)); - if cursor_enabled && cursor_active_path.is_some() { - comp.set_cursor_time(Some(target_source_time as f32)); - } - comp.compose_frame(sf, wf, frames as f32, cfg)?; - - on_frame(frames)?; - frames += 1; - } - } - on_clip_end(clip_index, source_end_sec, frames - frames_before_clip, &speed_segments)?; - } - - comp.set_cursor_time(None); - comp.set_timeline_time(None); - Ok(frames) -} - unsafe fn run_multi_inner( clips: &[ClipSource], out: &str, @@ -1306,7 +1267,16 @@ unsafe fn run_multi_inner( let scene = comp.scene_snapshot(); // ---- 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); @@ -1356,17 +1326,26 @@ unsafe fn run_multi_inner( &mut screen_decs, &mut webcam_decs, &mut |frame_index| { - // Hardware path: the composed texture goes straight into an NV12 - // encoder frame, so nothing ever descends to system memory. - 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 = frame_index as i64; - enc.send(outf)?; - drain_encoder(ectx, octx, ostream, opkt)?; - av_frame_free(&mut (outf as *mut _)); + // Backend CPU (WARP) : la frame composée descend en mémoire système via + // `send_composited` (le compositeur relit son NV12 interne vers un AVFrame + // YUV420P / NV12 et l'encodeur le consomme directement). Pas de hw_frames_ctx, + // pas de rgb_to_nv12 — c'est exactement le repli WARP que PR #162 a câblé. + if software_frames { + enc.send_composited(comp, out_w, out_h, frame_index as i64)?; + drain_encoder(ectx, octx, ostream, opkt)?; + } else { + // Hardware path: the composed texture goes straight into an NV12 + // encoder frame, so nothing ever descends to system memory. + 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 = frame_index as i64; + enc.send(outf)?; + drain_encoder(ectx, octx, ostream, opkt)?; + av_frame_free(&mut (outf as *mut _)); + } progress(frame_index + 1); Ok(()) }, diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal new file mode 100644 index 0000000000..64dd8d4768 --- /dev/null +++ b/crates/compositor/src/shaders.metal @@ -0,0 +1,616 @@ +// 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`) +// +// DIFFÉRENCE STRUCTURELLE, et c'est la seule qui n'est pas cosmétique : HLSL déclare +// `cbuffer`, `Texture2D` et `SamplerState` en portée GLOBALE, MSL ne le permet pas. +// « 'texture' attribute only applies to parameters » et « program scope variable must +// reside in constant address space » : les ressources doivent être des PARAMÈTRES de +// chaque entry point, et les helpers qui les lisent doivent les recevoir en argument. +// Un port ligne-pour-ligne des globales HLSL ne compile donc pas du tout — d'où les +// signatures ci-dessous, qui sont la seule liberté prise avec le fichier d'origine. +// (Les `constexpr sampler` restent légaux en portée globale : ils sont immuables et +// résolus à la compilation.) +// +// IMPORTANT : ce fichier est inclus via `include_str!("shaders.metal")` côté Rust et +// compilé à l'exécution via `MTLDevice.makeLibrary(source:options:)`. Le test +// `compositor_macos::tests::every_shader_entry_point_compiles` le compile sur le device +// système au `cargo test`, pour qu'une faute de syntaxe MSL ne se découvre pas à +// l'ouverture de l'éditeur chez un utilisateur. + +#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é) +}; + +// `layer` est passé en `constant Layer& [[buffer(0)]]` à chaque entry point qui le lit +// (cf. la note « DIFFÉRENCE STRUCTURELLE » en tête de fichier). Côté Rust, il est lié par +// `set_vertex_bytes(0, …)` ET `set_fragment_bytes(0, …)` : `vs_main` le lit autant que +// `ps_main`. + +// ================================================================================= +// 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]], + constant Layer &layer [[buffer(0)]]) +{ + 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. +// ================================================================================= + +// `mip_filter::linear` n'est PAS décoratif : sans lui MSL retombe sur `mip_filter::none`, +// et `sample(..., level(lod))` rend le mip 0 quel que soit `lod`. Le masque « flou » +// d'annotation (mode 10) échantillonne la pyramide de mips de la copie du RT — sans ce +// filtre il ne floute rien, alors que la mosaïque, qui demande explicitement `level(0)`, +// marche par accident. Équivalent de `D3D11_FILTER_MIN_MAG_MIP_LINEAR` côté Windows. +constexpr sampler samp(filter::linear, mip_filter::linear, address::clamp_to_edge); +constexpr sampler sampNV(filter::linear, address::clamp_to_edge); + +// Slots de texture, tenus par les paramètres des entry points : +// ps_main : 0 = texY (Y, R8), 1 = texUV (CbCr, RG8), 2 = texImg (RGBA) +// ps_fs_* : 0 = rgbTex (RGBA) + +// ================================================================================= +// 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); +} + +// `texture2d::sample` rend TOUJOURS un `float4` en MSL, là où le HLSL +// `Texture2D` rend un scalaire : d'où les `.r` / `.rg` que le port d'origine +// n'avait pas (et qui ne compilaient pas). +inline float3 sample_yuv(float2 uv, + texture2d texY, + texture2d texUV) +{ + float y = texY.sample(samp, uv).r; + float2 cbcr = texUV.sample(samp, uv).rg; + 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 * n2.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]], + constant Layer &layer [[buffer(0)]], + texture2d texY [[texture(0)]], + texture2d texUV [[texture(1)]], + texture2d texImg [[texture(2)]]) +{ + // 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 en alpha DÉJÀ prémultiplié (CoreText/Direct2D rendent ainsi) — on + // module juste l'opacité globale. + // + // Le commentaire du port disait « ne PAS re-multiplier » et le code faisait exactement + // ça : `s.rgb * (s.a * color.a)`, soit un alpha appliqué deux fois. Le texte sortait + // trop sombre sur ses bords adoucis et disparaissait sur les fines. + if (layer.mode > 10.5 && layer.mode < 11.5) + { + return texImg.sample(samp, i.uv) * layer.color.a; + } + + // mode 12 : ombre du quad projeté. Pénombre douce autour du quad tilté. + if (layer.mode > 11.5) + { + // Le port lisait `spread` dans `fx.x`, recentrait `i.local` sur `quad_px * 0.5`, et + // remplaçait l'inset de rayon par un simple `+ spread`. Trois écarts : `fx` porte les + // COINS (pas le spread, qui vit dans `mb.y`), `i.local` est déjà dans le repère de la + // bbox, et sans l'inset l'ombre n'a aucun coin arrondi. Signature du dernier : + // `line_cross` était défini et jamais appelé nulle part dans le fichier. + float2 quad[5] = { layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.fx.xy }; + // Coins arrondis du même rayon que le plan. Une ombre à coins vifs derrière un écran + // arrondi dépasse en pointe à chaque coin, d'autant plus que le rayon monte. + float r = max(layer.radius_px, 0.0); + float2 v[4]; + for (int k = 0; k < 4; k++) + { + // TL→TR→BR→BL tourne dans le sens horaire en y-bas, donc (e.y, -e.x) sort du quad. + // Division par la longueur plutôt que `normalize` : une arête dégénérée donnerait + // un NaN qui effacerait l'ombre entière. + float2 ep = quad[k] - quad[(k + 3) & 3]; + float2 ec = quad[k + 1] - quad[k]; + float2 np = float2(ep.y, -ep.x) / max(length(ep), 1e-6); + float2 nc = float2(ec.y, -ec.x) / max(length(ec), 1e-6); + v[k] = line_cross(np, dot(quad[(k + 3) & 3], np) - r, nc, dot(quad[k], nc) - r); + } + float d = sd_convex_quad(i.local, v[0], v[1], v[2], v[3]) - r; + float spread = max(layer.mb.y, 1e-3); + float a = layer.color.a * (1.0 - smoothstep(0.0, spread, d)); + 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) + { + // PAS de test de clip sur `dst_prev` ici — le port en avait copié un depuis le + // mode 13. En mode 8 `dst_prev.xy` porte `plane_px`, la taille du plan en PIXELS + // (~1600), comparée à `i.pout` qui vit dans [0,1] : la condition était vraie pour + // tout pixel et la branche rendait du transparent partout. Le tilt ne dessinait rien. + 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); // hors du quad projeté + } + // La coupe source s'applique ICI : `r` est une position DANS le plan (0..1), pas + // une coordonnée de texture. Le port échantillonnait `r` directement, ignorant le + // crop et le zoom. + float2 uv = float2(mix(layer.src.x, layer.src.z, clamp(r.x, 0.0, 1.0)), + mix(layer.src.y, layer.src.w, clamp(r.y, 0.0, 1.0))); + // Coins arrondis DANS LE REPÈRE DU PLAN : le rayon reste constant le long du bord, + // là où un arrondi calculé dans la bbox s'étirerait avec la perspective. + // Inconditionnel, rayon 0 compris — `sd_round_rect` dégénère en SDF de rectangle et + // le feather de 1,5 px subsiste, ce qui fait lire une arête inclinée COMME une arête + // plutôt que comme une troncature en marches d'escalier. + float2 plane_px = layer.dst_prev.xy; + float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; + float d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); + float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); + // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent `color` + // à zéro, donc le port rendait de toute façon un plan totalement transparent. + return float4(sample_yuv(uv, texY, texUV) * tilt_a, tilt_a); + } + + // mode 7 : sprite curseur thème (PNG alpha droite). Prémultiplie ici, comme partout + // ailleurs. `fx` = rect de clip « Clip to canvas » en espace sortie 0..1 [x,y,w,h] + // (= s_dst quand actif, sinon un rect englobant tout, donc sans effet). + // + // Le port avait omis ce test : `plan_cursor` calcule bien le rect et le draw le passe + // dans `fx`, mais le shader l'ignorait — `cursor.clipToBounds` était inerte sur macOS. + if (layer.mode > 6.5 && layer.mode < 7.5) + { + if (i.pout.x < layer.fx.x || i.pout.x > layer.fx.x + layer.fx.z || + i.pout.y < layer.fx.y || i.pout.y > layer.fx.y + layer.fx.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float4 s = texImg.sample(samp, i.uv); + float a = s.a * layer.color.a; + return float4(s.rgb * a, a); + } + + // mode 6 : wallpaper image RGBA (cover-fit). src = rect uv déjà calculé (crop de + // recouvrement), i.uv l'interpole. OPAQUE — comme le HLSL. + // + // Le port lisait `layer.color.a` ici. `LayerCB::default()` met `color` à zéro, donc + // l'alpha valait 0 et le fond image était rigoureusement invisible : un fond noir, + // qu'on lit comme « le compositeur ne dessine pas le wallpaper » plutôt que comme + // « le wallpaper est dessiné avec alpha 0 ». + if (layer.mode > 5.5 && layer.mode < 6.5) + { + return float4(texImg.sample(samp, i.uv).rgb, 1.0); + } + + // mode 5 : gradient linéaire 2 stops (parité web wallpaper dégradé). color = stop0, + // src.xyz = stop1, fx.xy = direction unitaire (espace sortie, y vers le bas). t est + // normalisé coin-à-coin (dénominateur = |dx|+|dy|) pour couvrir toute la diagonale. + // + // Le port avait remplacé tout ce calcul par une couleur plate : un dégradé s'affichait + // comme son premier stop, uniformément. + if (layer.mode > 4.5 && layer.mode < 5.5) + { + float2 dir = layer.fx.xy; + float denom = max(abs(dir.x) + abs(dir.y), 1e-4); + float t = clamp(0.5 + dot(i.pout - 0.5, dir) / denom, 0.0, 1.0); + float3 g = mix(layer.color.rgb, layer.src.xyz, t); + return float4(g, 1.0); // opaque, prémultiplié (a=1) + } + + // 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 : annotation « figure » — une flèche. Parité EXACTE avec `ArrowSvgs.tsx`, dont + // chaque direction est un tracé de trois segments à bouts ronds : une hampe et deux + // barbes. Trois `sd_segment` et un `min` reproduisent la forme telle quelle. + // fx = hampe, src_prev = barbe 1, dst_prev = barbe 2 ; mb.y = demi-épaisseur px. + // + // Le port avait INVENTÉ une forme : un seul segment dérivé de `quad_px`, avec + // `radius_px` en épaisseur. Ce n'était pas une approximation de la flèche, c'était une + // autre figure — et elle ignorait la géométrie que `regions::arrow_local_geometry` + // calcule et uploade. + if (layer.mode > 8.5 && layer.mode < 9.5) + { + float d = sd_segment(i.local, layer.fx.xy, layer.fx.zw); + d = min(d, sd_segment(i.local, layer.src_prev.xy, layer.src_prev.zw)); + d = min(d, sd_segment(i.local, layer.dst_prev.xy, layer.dst_prev.zw)); + // Couverture sur ~1 px : le trait reste net sans crénelage, et une flèche fine ne + // disparaît pas quand la demi-épaisseur descend sous le pixel. + float a = clamp(layer.mb.y - d + 0.5, 0.0, 1.0) * layer.color.a; + return float4(layer.color.rgb * a, a); + } + + // mode 10 : annotation « flou » — masque la zone en réutilisant l'image DÉJÀ composée, + // qui arrive dans `texImg` (recopie mipmappée du render target : on ne peut pas + // échantillonner la cible sur laquelle on dessine). `i.pout` donne directement l'UV de + // sortie. fx.x = 0 mosaïque / 1 flou ; fx.y = taille de bloc px ou rayon px ; + // fx.z = 0 rectangle / 1 ovale ; fx.w = 1 si le masque doit être teinté. + // + // Le port se contentait de recopier `texImg` : ni forme, ni flou, ni mosaïque, ni teinte. + if (layer.mode > 9.5 && layer.mode < 10.5) + { + float2 n = i.local / max(layer.quad_px, float2(1e-6)); + float cov = 1.0; + if (layer.fx.z > 0.5) + { + // Ovale inscrit : distance au centre en unités de demi-axes, adoucie sur ~1px. + float2 dd = (n - 0.5) * 2.0; + float r = length(dd); + float aa = 2.0 / max(min(layer.quad_px.x, layer.quad_px.y), 1.0); + cov = 1.0 - smoothstep(1.0 - aa, 1.0, r); + } + if (cov <= 0.0) return float4(0.0, 0.0, 0.0, 0.0); + + float3 rgb; + if (layer.fx.x > 0.5) + { + // Flou : un niveau de mip de l'image composée. `log2(rayon)` donne le niveau dont + // un texel couvre à peu près le rayon demandé. Un noyau de quelques taps espacés + // du rayon ne floute PAS, il superpose des copies décalées — du texte fantôme. + float lod = log2(max(layer.fx.y, 1.0)); + rgb = texImg.sample(samp, i.pout, level(lod)).rgb; + } + else + { + // Mosaïque : UV quantifié sur une grille de `fx.y` px, alignée sur le quad pour + // que les blocs ne rampent pas quand l'annotation bouge. + float2 px_uv = layer.dst.zw / max(layer.quad_px, float2(1e-6)); + float2 block = max(layer.fx.y, 1.0) * px_uv; + float2 origin = layer.dst.xy; + float2 q = origin + (floor((i.pout - origin) / block) + 0.5) * block; + // Niveau 0 explicite : l'UV quantifié est une marche d'escalier, ses dérivées + // explosent en bord de bloc et le choix automatique de mip ramollirait justement + // les arêtes qui font la mosaïque. + rgb = texImg.sample(samp, q, level(0.0)).rgb; + } + + if (layer.fx.w > 0.5) + { + rgb = mix(rgb, layer.color.rgb, 0.5); + } + float a = cov * layer.color.a; + return float4(rgb * a, 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 - smoothstep(0.0, spread, d)); + 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, texY, texUV); + } + else + { + float3 acc = float3(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, texY, texUV); + } + 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 - smoothstep(0.0, 1.5, d); + } + 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]], + texture2d rgbTex [[texture(0)]]) +{ + return rgb2y(rgbTex.sample(sampNV, i.uv).rgb); +} + +fragment float2 ps_uv(FSOut i [[stage_in]], + texture2d rgbTex [[texture(0)]]) +{ + 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). +// ================================================================================= + +// Une variable de portée programme doit vivre dans `constant` en MSL. +constant int BLUR_R = 24; + +fragment float4 ps_blur(FSOut i [[stage_in]], + constant Layer &layer [[buffer(0)]], + texture2d rgbTex [[texture(0)]]) +{ + float sigma = max(layer.fx.x, 0.001); + float2 step = layer.fx.y * layer.fx.zw; + float4 acc = float4(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]], + texture2d rgbTex [[texture(0)]]) +{ + return rgbTex.sample(sampNV, i.uv); +} + +// ================================================================================= +// Dual-Kawase (fond flouté rapide). +// ================================================================================= + +fragment float4 ps_kawase_down(FSOut i [[stage_in]], + constant Layer &layer [[buffer(0)]], + texture2d rgbTex [[texture(0)]]) +{ + 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; +} + +// Poids 1,2,1,2,1,2,1,2 — somme 12, d'où le `/ 12.0`. Le port avait doublé les deux taps +// purement verticaux : somme 14 divisée par 12, soit +16,7 % de luminosité PAR PASSE et un +// biais vertical. Trois passes UP → un fond flouté 1,59× trop clair et étiré. +fragment float4 ps_kawase_up(FSOut i [[stage_in]], + constant Layer &layer [[buffer(0)]], + texture2d rgbTex [[texture(0)]]) +{ + 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)); + 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)); + 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..0b136adcd4 --- /dev/null +++ b/crates/compositor/src/text_macos.rs @@ -0,0 +1,540 @@ +//! 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. +//! +//! # Pipeline +//! +//! Tout passe par les API **C** de CoreText/CoreGraphics, pas par `msg_send!` : +//! `CTFont`, `CTFramesetter`, `CTFrame`, `CGColor` et `CGContext` sont des CFTypes, pas +//! des classes Objective-C. (La première version de ce fichier envoyait +//! `deviceRGBColorSpace` à une classe `CGColorSpace` et `stringWithCString:encoding:` à +//! une classe `CFString` ; aucune des deux n'existe dans le runtime ObjC, donc +//! `AnyClass::get` rendait `None` et chaque attribut était silencieusement sauté. Les +//! clés d'attribut étaient elles aussi fabriquées : `Sel::register("NSColor")` produit un +//! sélecteur, là où `CFAttributedString` attend la CFString `kCTForegroundColorAttributeName`.) +//! +//! 1. `CGBitmapContextCreate` sur un buffer CPU, BGRA prémultiplié +//! (`kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little`) — l'ordre d'octets +//! que `MTLPixelFormat::BGRA8Unorm` attend. +//! 2. fond optionnel (`spec.background`, alpha 0 = transparent). +//! 3. `CFAttributedString` avec police (`kCTFontAttributeName`), couleur +//! (`kCTForegroundColorAttributeName`), soulignement (`kCTUnderlineStyleAttributeName`) +//! et alignement (`kCTParagraphStyleAttributeName`). +//! 4. `CTFramesetterCreateFrame` sur un `CGPath` rectangulaire couvrant la boîte, puis +//! `CTFrameDraw`. +//! 5. `MTLTexture` BGRA8Unorm + `replace_region` depuis le buffer CPU. +//! +//! CoreGraphics a son origine en BAS à gauche : le contexte est retourné +//! (`CGContextTranslateCTM` + `CGContextScaleCTM`) pour que la boîte `box_px` se lise +//! comme côté Windows, origine en haut à gauche. +//! +//! `TextSpec::cache_key()` est byte-identique à la version Windows — la policy de cache +//! est partagée. + +use crate::d3d::Gpu; +use anyhow::{anyhow, bail, Result}; +use std::ffi::c_void; + +/// Spécification d'un texte à rastériser. Mêmes champs que `text_windows::TextSpec` +/// — le moteur macOS les consomme via `cache_key` pour déterminer si une re-rastérisation +/// est nécessaire. +#[derive(Clone, PartialEq)] +pub struct TextSpec { + pub content: String, + /// RGBA 0..1 (déjà parsé depuis la chaîne CSS côté appelant). + pub color: [f32; 4], + /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). + pub background: [f32; 4], + pub font_size_px: f32, + pub font_family: String, + pub bold: bool, + pub italic: bool, + pub underline: bool, + /// "left" | "center" | "right". + pub align: String, + /// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne). + pub box_px: [u32; 2], +} + +impl TextSpec { + /// Clé de cache : couvre exactement les champs dont la variation provoque un + /// changement de pixels. Identique côté Windows/macOS (la policy est partagée). + pub fn cache_key(&self) -> u64 { + // FNV-1a sur les mêmes octets, dans le même ordre, que + // `text_windows::TextSpec::cache_key`. La version précédente appelait + // `Hash::hash(&mut h)` avec un `u64` en guise de `Hasher` — ça ne compile pas, + // et même corrigé, `DefaultHasher` ne donne pas la même clé que Windows. + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |bytes: &[u8]| { + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + }; + mix(self.content.as_bytes()); + mix(self.font_family.as_bytes()); + mix(&self.font_size_px.to_bits().to_le_bytes()); + for c in self.color.iter().chain(self.background.iter()) { + mix(&c.to_bits().to_le_bytes()); + } + mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); + mix(self.align.as_bytes()); + mix(&self.box_px[0].to_le_bytes()); + mix(&self.box_px[1].to_le_bytes()); + h + } +} + +// --------------------------------------------------------------------------- +// FFI CoreFoundation / CoreGraphics / CoreText +// --------------------------------------------------------------------------- + +type CFTypeRef = *const c_void; +type CFIndex = isize; +type CGFloat = f64; + +#[repr(C)] +#[derive(Clone, Copy)] +struct CFRange { + location: CFIndex, + length: CFIndex, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct CGPoint { + x: CGFloat, + y: CGFloat, +} +#[repr(C)] +#[derive(Clone, Copy)] +struct CGSize { + width: CGFloat, + height: CGFloat, +} +#[repr(C)] +#[derive(Clone, Copy)] +struct CGRect { + origin: CGPoint, + size: CGSize, +} + +/// `kCGImageAlphaPremultipliedFirst` (=2) | `kCGBitmapByteOrder32Little` (=2 << 12). +/// Ensemble : ARGB prémultiplié en mémoire little-endian, soit l'ordre d'octets B,G,R,A — +/// exactement `MTLPixelFormat::BGRA8Unorm`. +const CG_BITMAP_INFO_BGRA_PREMUL: u32 = 2 | (2 << 12); +/// `kCFStringEncodingUTF8`. +const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; +/// `kCFNumberSInt32Type`. +const K_CF_NUMBER_S_INT32_TYPE: CFIndex = 3; +/// `kCTParagraphStyleSpecifierAlignment`. +const K_CT_PARAGRAPH_STYLE_SPECIFIER_ALIGNMENT: u32 = 0; +/// `CTFontSymbolicTraits` : italique / gras. +const K_CT_FONT_TRAIT_ITALIC: u32 = 1 << 0; +const K_CT_FONT_TRAIT_BOLD: u32 = 1 << 1; + +#[repr(C)] +#[derive(Clone, Copy)] +struct CTParagraphStyleSetting { + spec: u32, + value_size: usize, + value: *const c_void, +} + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFRelease(cf: CFTypeRef); + fn CFStringCreateWithBytes( + alloc: CFTypeRef, + bytes: *const u8, + num_bytes: CFIndex, + encoding: u32, + is_external_representation: u8, + ) -> CFTypeRef; + fn CFNumberCreate(alloc: CFTypeRef, the_type: CFIndex, value_ptr: *const c_void) -> CFTypeRef; + fn CFDictionaryCreate( + alloc: CFTypeRef, + keys: *const CFTypeRef, + values: *const CFTypeRef, + num_values: CFIndex, + key_callbacks: *const c_void, + value_callbacks: *const c_void, + ) -> CFTypeRef; + fn CFAttributedStringCreate( + alloc: CFTypeRef, + str_: CFTypeRef, + attributes: CFTypeRef, + ) -> CFTypeRef; + static kCFTypeDictionaryKeyCallBacks: c_void; + static kCFTypeDictionaryValueCallBacks: c_void; +} + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGColorSpaceCreateDeviceRGB() -> CFTypeRef; + fn CGColorSpaceRelease(space: CFTypeRef); + fn CGColorCreate(space: CFTypeRef, components: *const CGFloat) -> CFTypeRef; + fn CGBitmapContextCreate( + data: *mut c_void, + width: usize, + height: usize, + bits_per_component: usize, + bytes_per_row: usize, + space: CFTypeRef, + bitmap_info: u32, + ) -> CFTypeRef; + fn CGContextRelease(ctx: CFTypeRef); + fn CGContextTranslateCTM(ctx: CFTypeRef, tx: CGFloat, ty: CGFloat); + fn CGContextScaleCTM(ctx: CFTypeRef, sx: CGFloat, sy: CGFloat); + fn CGContextSetRGBFillColor(ctx: CFTypeRef, r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat); + fn CGContextFillRect(ctx: CFTypeRef, rect: CGRect); + fn CGPathCreateWithRect(rect: CGRect, transform: *const c_void) -> CFTypeRef; +} + +#[link(name = "CoreText", kind = "framework")] +extern "C" { + fn CTFontCreateWithName(name: CFTypeRef, size: CGFloat, matrix: *const c_void) -> CFTypeRef; + fn CTFontCreateCopyWithSymbolicTraits( + font: CFTypeRef, + size: CGFloat, + matrix: *const c_void, + sym_trait_value: u32, + sym_trait_mask: u32, + ) -> CFTypeRef; + fn CTParagraphStyleCreate(settings: *const CTParagraphStyleSetting, count: usize) -> CFTypeRef; + fn CTFramesetterCreateWithAttributedString(attr: CFTypeRef) -> CFTypeRef; + fn CTFramesetterCreateFrame( + framesetter: CFTypeRef, + string_range: CFRange, + path: CFTypeRef, + frame_attributes: CFTypeRef, + ) -> CFTypeRef; + fn CTFrameDraw(frame: CFTypeRef, context: CFTypeRef); + + static kCTFontAttributeName: CFTypeRef; + static kCTForegroundColorAttributeName: CFTypeRef; + static kCTUnderlineStyleAttributeName: CFTypeRef; + static kCTParagraphStyleAttributeName: CFTypeRef; +} + +/// Garde RAII sur un CFType : `CFRelease` au Drop. Sans elle, chaque rastérisation fuit +/// une police, une couleur, un framesetter et une frame — et la rastérisation est +/// re-déclenchée à chaque changement du texte. +struct CFOwned(CFTypeRef); + +impl CFOwned { + fn new(r: CFTypeRef) -> Option { + if r.is_null() { + None + } else { + Some(CFOwned(r)) + } + } + fn get(&self) -> CFTypeRef { + self.0 + } +} + +impl Drop for CFOwned { + fn drop(&mut self) { + unsafe { CFRelease(self.0) }; + } +} + +unsafe fn cf_string(s: &str) -> Option { + CFOwned::new(CFStringCreateWithBytes( + std::ptr::null(), + s.as_ptr(), + s.len() as CFIndex, + K_CF_STRING_ENCODING_UTF8, + 0, + )) +} + +/// Rastériseur de texte macOS. Pas d'état persistant : CoreText et CoreGraphics sont +/// prêts dès le link des frameworks (côté Windows, `TextRasterizer::new` alloue les +/// factories DirectWrite/Direct2D — d'où le `Result` conservé pour la symétrie). +pub struct TextRasterizer; + +impl TextRasterizer { + pub fn new() -> Result { + Ok(TextRasterizer) + } + + /// Rastérise `spec` dans une `MTLTexture` BGRA8Unorm neuve (alpha prémultiplié). + /// + /// Rend la texture **possédée** — la version précédente renvoyait `texture.as_ptr()` + /// alors que le `metal::Texture` local était droppé au `return`, soit un + /// `id` déjà relâché. + pub unsafe fn rasterize(&self, gpu: &Gpu, spec: &TextSpec) -> Result { + let (w, h) = (spec.box_px[0].max(1) as usize, spec.box_px[1].max(1) as usize); + if spec.content.is_empty() { + bail!("text_macos::rasterize: texte vide"); + } + + let bytes_per_row = w * 4; + let mut buffer: Vec = vec![0u8; bytes_per_row * h]; + + let space = CGColorSpaceCreateDeviceRGB(); + if space.is_null() { + bail!("CGColorSpaceCreateDeviceRGB a renvoyé NULL"); + } + let ctx = CGBitmapContextCreate( + buffer.as_mut_ptr() as *mut c_void, + w, + h, + 8, + bytes_per_row, + space, + CG_BITMAP_INFO_BGRA_PREMUL, + ); + if ctx.is_null() { + CGColorSpaceRelease(space); + bail!("CGBitmapContextCreate {w}x{h} a renvoyé NULL"); + } + + let box_rect = CGRect { + origin: CGPoint { x: 0.0, y: 0.0 }, + size: CGSize { + width: w as CGFloat, + height: h as CGFloat, + }, + }; + + // Fond avant le flip : un rect plein est invariant par retournement. + if spec.background[3] > 0.0 { + CGContextSetRGBFillColor( + ctx, + spec.background[0] as CGFloat, + spec.background[1] as CGFloat, + spec.background[2] as CGFloat, + spec.background[3] as CGFloat, + ); + CGContextFillRect(ctx, box_rect); + } + + // PAS de flip du CTM ici, et c'est contre-intuitif. `CGBitmapContext` a bien son + // origine en bas à gauche, MAIS il stocke la ligne 0 du buffer EN HAUT de l'image — + // et `CTFrameDraw` remplit son cadre du haut vers le bas. La première ligne de texte + // atterrit donc déjà dans les premières lignes du buffer, c'est-à-dire en haut de la + // `MTLTexture`. Le `ScaleCTM(1, -1)` que ce code faisait retournait une image déjà + // correcte : le texte s'affichait en miroir vertical. + + let drawn = self.draw_text(ctx, space, spec, box_rect); + + CGContextRelease(ctx); + CGColorSpaceRelease(space); + drawn?; + + let desc = metal::TextureDescriptor::new(); + desc.set_texture_type(metal::MTLTextureType::D2); + 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); + + texture.replace_region( + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: w as u64, + height: h as u64, + depth: 1, + }, + }, + 0, + buffer.as_ptr() as *const c_void, + bytes_per_row as u64, + ); + + Ok(texture) + } + + /// Le corps CoreText, isolé pour que `rasterize` puisse relâcher contexte et + /// colorspace sur TOUS les chemins de sortie, y compris les `?`. + unsafe fn draw_text( + &self, + ctx: CFTypeRef, + space: CFTypeRef, + spec: &TextSpec, + box_rect: CGRect, + ) -> Result<()> { + let content = + cf_string(&spec.content).ok_or_else(|| anyhow!("CFStringCreateWithBytes NULL"))?; + + // --- police --- + let family = cf_string(&spec.font_family); + let base_font = CFOwned::new(CTFontCreateWithName( + family.as_ref().map(|f| f.get()).unwrap_or(std::ptr::null()), + spec.font_size_px.max(1.0) as CGFloat, + std::ptr::null(), + )) + .ok_or_else(|| anyhow!("CTFontCreateWithName a renvoyé NULL"))?; + // Gras/italique : une variante symbolique de la même famille. Si la famille n'a + // pas la variante, CoreText renvoie NULL — on garde alors la police de base + // plutôt que d'échouer sur un détail de style. + let mut traits = 0u32; + if spec.bold { + traits |= K_CT_FONT_TRAIT_BOLD; + } + if spec.italic { + traits |= K_CT_FONT_TRAIT_ITALIC; + } + let styled_font = if traits != 0 { + CFOwned::new(CTFontCreateCopyWithSymbolicTraits( + base_font.get(), + 0.0, // 0 = conserver la taille de la police source + std::ptr::null(), + traits, + K_CT_FONT_TRAIT_BOLD | K_CT_FONT_TRAIT_ITALIC, + )) + } else { + None + }; + let font = styled_font.as_ref().unwrap_or(&base_font); + + // --- couleur --- + let components: [CGFloat; 4] = [ + spec.color[0] as CGFloat, + spec.color[1] as CGFloat, + spec.color[2] as CGFloat, + spec.color[3] as CGFloat, + ]; + let color = CFOwned::new(CGColorCreate(space, components.as_ptr())) + .ok_or_else(|| anyhow!("CGColorCreate a renvoyé NULL"))?; + + // --- alignement --- + // `CTTextAlignment` : 0 = left, 1 = right, 2 = center, 3 = justified, 4 = natural. + let alignment: u8 = match spec.align.as_str() { + "left" => 0, + "right" => 1, + _ => 2, + }; + let settings = [CTParagraphStyleSetting { + spec: K_CT_PARAGRAPH_STYLE_SPECIFIER_ALIGNMENT, + value_size: std::mem::size_of::(), + value: &alignment as *const u8 as *const c_void, + }]; + let paragraph = CFOwned::new(CTParagraphStyleCreate(settings.as_ptr(), settings.len())); + + // --- soulignement --- + let underline_value: i32 = 1; + let underline = if spec.underline { + CFOwned::new(CFNumberCreate( + std::ptr::null(), + K_CF_NUMBER_S_INT32_TYPE, + &underline_value as *const i32 as *const c_void, + )) + } else { + None + }; + + // --- dictionnaire d'attributs --- + let mut keys: Vec = vec![kCTFontAttributeName, kCTForegroundColorAttributeName]; + let mut values: Vec = vec![font.get(), color.get()]; + if let Some(p) = paragraph.as_ref() { + keys.push(kCTParagraphStyleAttributeName); + values.push(p.get()); + } + if let Some(u) = underline.as_ref() { + keys.push(kCTUnderlineStyleAttributeName); + values.push(u.get()); + } + let attrs = CFOwned::new(CFDictionaryCreate( + std::ptr::null(), + keys.as_ptr(), + values.as_ptr(), + keys.len() as CFIndex, + &kCFTypeDictionaryKeyCallBacks as *const c_void, + &kCFTypeDictionaryValueCallBacks as *const c_void, + )) + .ok_or_else(|| anyhow!("CFDictionaryCreate (attributs) a renvoyé NULL"))?; + + let attributed = CFOwned::new(CFAttributedStringCreate( + std::ptr::null(), + content.get(), + attrs.get(), + )) + .ok_or_else(|| anyhow!("CFAttributedStringCreate a renvoyé NULL"))?; + + let framesetter = CFOwned::new(CTFramesetterCreateWithAttributedString(attributed.get())) + .ok_or_else(|| anyhow!("CTFramesetterCreateWithAttributedString NULL"))?; + let path = CFOwned::new(CGPathCreateWithRect(box_rect, std::ptr::null())) + .ok_or_else(|| anyhow!("CGPathCreateWithRect NULL"))?; + // `length: 0` = « jusqu'à la fin de la chaîne », la convention CoreText — pas + // besoin de compter les caractères (et surtout pas en `chars()`, qui compte des + // scalaires Unicode là où CFAttributedString compte des unités UTF-16). + let frame = CFOwned::new(CTFramesetterCreateFrame( + framesetter.get(), + CFRange { + location: 0, + length: 0, + }, + path.get(), + std::ptr::null(), + )) + .ok_or_else(|| anyhow!("CTFramesetterCreateFrame NULL"))?; + + CTFrameDraw(frame.get(), ctx); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Une ligne de texte se dessine EN HAUT de sa boîte. Le test regarde où est l'encre + /// plutôt que de faire confiance au sens du CTM : c'est la seule façon de distinguer + /// « bien orienté » de « retourné », et le retournement était précisément le bug. + #[test] + fn text_lands_in_the_upper_half_not_mirrored() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let spec = TextSpec { + content: "Ag".into(), + color: [1.0, 1.0, 1.0, 1.0], + background: [0.0, 0.0, 0.0, 0.0], + font_size_px: 48.0, + font_family: "Helvetica".into(), + bold: false, + italic: false, + underline: false, + align: "left".into(), + box_px: [256, 256], + }; + let raster = TextRasterizer::new().expect("TextRasterizer::new"); + let tex = unsafe { raster.rasterize(&gpu, &spec) }.expect("rasterize"); + + let (w, h) = (256usize, 256usize); + let mut px = vec![0u8; w * h * 4]; + tex.get_bytes( + px.as_mut_ptr() as *mut c_void, + (w * 4) as u64, + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { width: w as u64, height: h as u64, depth: 1 }, + }, + 0, + ); + let ink = |rows: std::ops::Range| -> u64 { + rows.map(|y| { + (0..w).map(|x| px[(y * w + x) * 4 + 3] as u64).sum::() + }) + .sum() + }; + let (top, bottom) = (ink(0..h / 2), ink(h / 2..h)); + assert!(top > 0, "aucune encre : le texte n'a pas été rastérisé du tout"); + assert!( + top > bottom * 4, + "texte retourné : encre haut={top}, bas={bottom} (attendu très majoritairement en haut)" + ); + } +} 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/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs new file mode 100644 index 0000000000..e5146e6cd6 --- /dev/null +++ b/crates/compositor/src/timeline_walk.rs @@ -0,0 +1,219 @@ +//! La marche de timeline partagée par tous les exports composités. +//! +//! Ce module ne contient QUE du code portable : il ne parle qu'au `Decoder` et au +//! `Compositor` ré-exportés par `lib.rs` (`crate::pipeline`, `crate::compositor`), donc +//! D3D11VA sur Windows et VideoToolbox sur macOS sans une seule ligne de `cfg`. +//! +//! Il vivait dans `pipeline_windows.rs`, ce qui n'était pas tenable une fois le port +//! macOS entré : `gif_export.rs` importe `crate::pipeline::walk_composited_timeline`, et +//! `crate::pipeline` pointe sur `pipeline_macos` sur un Mac — l'export GIF ne compilait +//! donc pas du tout côté macOS. Les deux réponses possibles étaient recopier ~170 lignes +//! dans `pipeline_macos.rs`, ou les sortir ici. La duplication est précisément ce que la +//! doc de `walk_composited_timeline` interdit — « a GIF driven by its own loop is how the +//! slow-motion truncation bug happened » — et l'argument vaut autant entre deux +//! plateformes qu'entre deux formats de sortie. + +use crate::compositor::Compositor; +use crate::config::Cfg; +use crate::cursor::CursorTrack; +use crate::d3d::Gpu; +use crate::pipeline::{ClipSource, Decoder}; +use crate::regions::{speed_segments_for_window, SpeedSegment}; +use crate::scene::Scene; +use anyhow::Result; +use std::collections::HashMap; + +/// Avance un décodeur jusqu'au premier pts dans le référentiel écran qui atteint la cible. +/// `timeline_offset_sec` remet les pts webcam dans ce référentiel (`webcam + offset = screen`) : +/// chaque source garde ainsi sa cadence propre au lieu d'être consommée 1:1 avec l'autre. +pub(crate) unsafe fn advance_decoder_to( + decoder: &mut Decoder, + target_source_time: f64, + timeline_offset_sec: f64, +) -> Result { + loop { + if decoder.cur_frame().is_null() { + return Ok(false); + } + if decoder.cur_time_sec() + timeline_offset_sec >= target_source_time { + return Ok(true); + } + if decoder.next()?.is_null() { + return Ok(false); + } + } +} + +/// The format-agnostic half of a multiclip export: clip iteration, decoder +/// reuse, availability clamping, per-clip scene windowing, keyframe seeks, +/// cursor binding, speed segments, and — the part that matters — advancing the +/// decoders by OUTPUT time rather than by source frames. +/// +/// MP4 and GIF differ only in what they do with a composed frame (hardware NV12 +/// encode vs CPU readback + palette quantize), so that is all they supply here. +/// Sharing this walk is what keeps "which source frame belongs at output frame +/// N" defined exactly once: a GIF driven by its own loop is how the slow-motion +/// truncation bug happened. +/// +/// `on_frame` runs after `compose_frame` with the running output index; +/// `on_clip_end` runs once per clip with its clamped source window, the frames +/// it produced, and the speed segments used (MP4 needs those for audio). +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn walk_composited_timeline( + clips: &[ClipSource], + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + out_fps: i32, + scene: &Option, + screen_decs: &mut HashMap, + webcam_decs: &mut HashMap, + on_frame: &mut dyn FnMut(u64) -> Result<()>, + on_clip_end: &mut dyn FnMut(usize, f64, u64, &[SpeedSegment]) -> Result<()>, +) -> Result { + let cursor_enabled = scene.as_ref().map(|s| s.cursor.show).unwrap_or(false); + let cursor_smoothing = scene.as_ref().map(|s| s.cursor.smoothing).unwrap_or(0.0); + let mut cursor_tracks: HashMap = HashMap::new(); + let mut cursor_active_path: Option = None; + + let mut frames: u64 = 0; + + for (clip_index, clip) in clips.iter().enumerate() { + 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(); + + let screen_available_duration = sdec.available_duration_sec(); + let webcam_available_duration = wdec.available_duration_sec(); + if screen_available_duration.is_none() || webcam_available_duration.is_none() { + eprintln!( + "[pipeline] warning: clip #{}: durée de flux indéterminée (screen={}, webcam={}); la borne demandée {:.3}s ne peut pas être entièrement validée", + clip_index, + screen_available_duration + .map(|v| format!("{v:.3}s")) + .unwrap_or_else(|| "inconnue".to_string()), + webcam_available_duration + .map(|v| format!("{v:.3}s")) + .unwrap_or_else(|| "inconnue".to_string()), + clip.source_end_sec, + ); + } + // Les bornes de clip sont en temps écran. La disponibilité webcam est donc translatée + // par le même offset que le seek (`webcam_time = screen_time - offset`). + let webcam_available_screen_end = + webcam_available_duration.map(|duration| duration + clip.webcam_offset_sec); + let mut source_end_sec = clip.source_end_sec; + if let Some(duration) = screen_available_duration { + source_end_sec = source_end_sec.min(duration); + } + if let Some(duration) = webcam_available_screen_end { + source_end_sec = source_end_sec.min(duration); + } + if source_end_sec + 1e-6 < clip.source_end_sec { + eprintln!( + "[pipeline] warning: clip #{} raccourci de {:.3}s (fin demandée {:.3}s, fin disponible {:.3}s; screen=\"{}\", webcam=\"{}\")", + clip_index, + clip.source_end_sec - source_end_sec, + clip.source_end_sec, + source_end_sec, + clip.screen, + clip.webcam, + ); + } + if source_end_sec <= clip.source_start_sec { + continue; + } + + let clip_scene = scene.as_ref().map(|base_scene| { + base_scene.for_clip_window(clip_index, clip.source_start_sec, source_end_sec) + }); + let speed_segments = speed_segments_for_window( + clip_scene + .as_ref() + .map(|s| s.speed_regions.as_slice()) + .unwrap_or(&[]), + clip.source_start_sec, + source_end_sec, + out_fps as f64, + ); + if clip_scene.is_some() { + comp.set_scene(clip_scene); + } + + // un seul seek keyframe, puis chaque décodeur avance selon son propre pts jusqu'aux + // temps source demandés par les spans de vitesse. + if sdec.seek_to(clip.source_start_sec)?.is_null() { + continue; // clip vide / au-delà de la source + } + if wdec + .seek_to((clip.source_start_sec - clip.webcam_offset_sec).max(0.0))? + .is_null() + { + continue; + } + + if cursor_enabled { + if !cursor_tracks.contains_key(&clip.screen) { + let path = format!("{}.cursor.json", clip.screen); + if let Ok(raw) = CursorTrack::load(&path, 0.0, 24.0 * 3600.0) { + cursor_tracks.insert(clip.screen.clone(), raw.smoothed(cursor_smoothing)); + } + // absente/illisible → pas d'entrée : ce clip s'exporte sans curseur (visible, + // pas masqué en un curseur fantôme d'un autre clip). + } + if cursor_active_path.as_deref() != Some(clip.screen.as_str()) { + if let Some(track) = cursor_tracks.get(&clip.screen) { + comp.set_cursor(track.clone()); + cursor_active_path = Some(clip.screen.clone()); + } else { + comp.clear_cursor(); + comp.set_cursor_time(None); + cursor_active_path = None; + } + } + } + + let frames_before_clip = frames; + 'clip_frames: for segment in &speed_segments { + for segment_frame in 0..segment.frame_count { + let target_source_time = + segment.start_sec + segment_frame as f64 * segment.speed / out_fps as f64; + if !advance_decoder_to(sdec, target_source_time, 0.0)? { + break 'clip_frames; + } + if !advance_decoder_to(wdec, target_source_time, clip.webcam_offset_sec)? { + break 'clip_frames; + } + let sf = sdec.cur_frame(); + let wf = wdec.cur_frame(); + if sf.is_null() || wf.is_null() { + break 'clip_frames; + } + + comp.set_timeline_time(Some(target_source_time as f32)); + if cursor_enabled && cursor_active_path.is_some() { + comp.set_cursor_time(Some(target_source_time as f32)); + } + comp.compose_frame(sf, wf, frames as f32, cfg)?; + + on_frame(frames)?; + frames += 1; + } + } + on_clip_end( + clip_index, + source_end_sec, + frames - frames_before_clip, + &speed_segments, + )?; + } + + comp.set_cursor_time(None); + comp.set_timeline_time(None); + Ok(frames) +} 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 c29d5d6c8f..cb45254bd1 100644 --- a/crates/poc-d3d/src/bench.rs +++ b/crates/poc-d3d/src/bench.rs @@ -6,6 +6,7 @@ use anyhow::{Context as _, Result}; use openscreen_compositor::compositor::Compositor; use openscreen_compositor::gif_export::{GifExportParams, GifStats}; +use openscreen_compositor::pipeline::ClipSource; use openscreen_compositor::{config, cursor, d3d, gif_export, live, pipeline, scene}; use std::fmt::Write as _; use std::path::Path; @@ -54,20 +55,13 @@ fn run_bench(args: &[String]) -> Result<()> { let repeat: u32 = get("--repeat", "3").parse().unwrap_or(3); let cfg_arg = get("--cfg", "C0..C8"); - // The GIF bench is a different shape (single clip, no encoder chain, - // reads out to a `.gif` file). Detected by name so a typical - // `--cfg C0..C8,GIF` invocation still works. - if cfg_arg.split(',').any(|n| n.trim().eq_ignore_ascii_case("gif")) { - return run_gif_bench(args, &fixture, &out, repeat); - } - let screen = format!("{fixture}/screen.mp4"); let webcam = format!("{fixture}/webcam.mp4"); std::fs::create_dir_all(&out).ok(); // 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 @@ -76,12 +70,57 @@ 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); + // The GIF bench is a different shape (single clip, no encoder chain, + // reads out to a `.gif` file). Detected by name so a typical + // `--cfg C0..C8,GIF` invocation still works. Routed AFTER device setup + // because `export_gif` now takes the same `(gpu, comp, cfg)` triple as + // `run_composited_multi` (slice-2 alignment, see PR #189's macOS port). + if cfg_arg.split(',').any(|n| n.trim().eq_ignore_ascii_case("gif")) { + let cfg = config::Cfg::by_name("C1").or_else(|| config::all().into_iter().next()).unwrap(); + return run_gif_bench(args, &fixture, &out, repeat, &gpu, &comp, &cfg); + } + // `--scene ` : compose avec une VRAIE scène d'app au lieu du planning fixture. // Sert à deux choses : sortir une preuve visuelle pour ce que seule une scène peut décrire // (les annotations, qu'aucune UI ne crée encore pour certains types), et mesurer un @@ -94,6 +133,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"); @@ -102,14 +166,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); } @@ -164,6 +240,9 @@ fn run_gif_bench( fixture: &str, out: &str, repeat: u32, + gpu: &d3d::Gpu, + comp: &Compositor, + cfg: &config::Cfg, ) -> Result<()> { let get = |k: &str, d: &str| -> String { arg(args, k, d) }; let screen = format!("{fixture}/screen.mp4"); @@ -199,16 +278,27 @@ fn run_gif_bench( let mut wall_runs = Vec::new(); let mut file_bytes: u64 = 0; let mut last_stats: Option = None; + // The GIF bench is a single-clip export today; the slice-2 work expands + // it to a multi-clip timeline the same way `run_composited_multi` does. + let clips = [ClipSource { + screen: screen.clone(), + webcam: webcam.clone(), + source_start_sec: 0.0, + source_end_sec: f64::MAX, + webcam_offset_sec: 0.0, + has_audio: false, + }]; for r in 0..repeat { // Each run writes to the same path — the last frame wins. The // encoder itself is `Drop`-flushed, so re-running is safe and // produces a fresh file (the `gif` crate writes the trailer // on drop, not on each frame). let s = gif_export::export_gif( - &screen, - &webcam, - Some(&cursor), + &clips, &out_path, + &gpu, + &comp, + cfg, ¶ms, &mut |_| {}, )?; @@ -276,6 +366,18 @@ fn run_gif_bench( 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 b411ae2e98..c99126b64e 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -346,6 +346,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/main.ts b/electron/main.ts index a0cb07dcab..1fa013935f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -590,10 +590,21 @@ appReady?.then(async () => { // Request mic permission now. Screen Recording is requested lazily from the // source-picker action so its prompt isn't hidden behind the selector window. + // + // NOT awaited, on purpose. `askForMediaAccess` resolves only once the user + // answers the modal TCC prompt, and `createWindow()` is 70 lines below this in + // the same async block — so on a Mac where the microphone is still + // `not-determined` (every first run, and every fresh dev machine) the app + // showed a permission dialog with NO window behind it and created the HUD only + // after it was dismissed. Nothing between here and `createWindow()` needs the + // answer: the recorder re-checks the status when the user actually arms the mic. if (process.platform === "darwin") { const micStatus = systemPreferences.getMediaAccessStatus("microphone"); if (micStatus !== "granted") { - await systemPreferences.askForMediaAccess("microphone"); + systemPreferences + .askForMediaAccess("microphone") + .then((granted) => console.info(`[permissions] microphone granted=${granted}`)) + .catch((error) => console.warn("[permissions] microphone request failed:", error)); } } diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 297d3f5ef2..0c04e7bc1b 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { CURSOR_THEMES, DEFAULT_CURSOR_SPRITES } from "../../../src/lib/cursor/cursorThemes"; import { + buildCandidatePaths, CompositorViewService, ffmpegSharedBinCandidates, resolveSceneAssetPaths, @@ -100,6 +101,75 @@ describe("ffmpegSharedBinCandidates", () => { }); }); +describe("buildCandidatePaths", () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-addon-path-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + const norm = (p: string) => p.replace(/\\/g, "/"); + + it("finds the addon from dist-electron, which is what app.getAppPath() returns unpackaged", () => { + // The regression this exists for: `electron dist-electron/main.js` — the entry + // `npm run dev` uses — makes app.getAppPath() the dist-electron DIRECTORY, not + // the checkout root. Every candidate was built by joining "electron/native/..." + // onto it, so none of them existed and the editor ran with a no-op compositor + // on a machine that had a perfectly good addon one level up. + fs.mkdirSync(path.join(tmpRoot, "electron", "native", "bin"), { recursive: true }); + const appRoot = path.join(tmpRoot, "dist-electron"); + fs.mkdirSync(appRoot, { recursive: true }); + + const candidates = buildCandidatePaths(appRoot, false, null).map(norm); + + expect( + candidates.some((c) => c.startsWith(norm(tmpRoot)) && !c.includes("dist-electron")), + ).toBe(true); + }); + + it("stops walking up when no ancestor holds electron/native", () => { + const appRoot = path.join(tmpRoot, "nothing", "here"); + fs.mkdirSync(appRoot, { recursive: true }); + + const candidates = buildCandidatePaths(appRoot, false, null).map(norm); + + expect(candidates.every((c) => c.startsWith(norm(appRoot)) || c.includes("resources"))).toBe( + true, + ); + }); + + it("also probes process.resourcesPath, where extraResources actually puts the addon", () => { + // electron-builder ships electron/native/bin//** through extraResources, + // so in a packaged build the addon is at /electron/native/bin/ + // and NEVER inside app.asar — the .asar → .asar.unpacked rewrite cannot reach a + // file that was never in the archive. + const original = process.resourcesPath; + Object.defineProperty(process, "resourcesPath", { + value: "/fake/resources", + configurable: true, + }); + try { + const candidates = buildCandidatePaths("/fake/resources/app.asar", true, null).map(norm); + expect( + candidates.some( + (c) => c.startsWith("/fake/resources/electron/native/bin/") && c.endsWith(".node"), + ), + ).toBe(true); + } finally { + Object.defineProperty(process, "resourcesPath", { value: original, configurable: true }); + } + }); + + it("keeps the env override first", () => { + const candidates = buildCandidatePaths(tmpRoot, false, "/explicit/compositor_view.node"); + expect(norm(candidates[0]!)).toBe("/explicit/compositor_view.node"); + }); +}); + describe("CompositorViewService ffmpeg PATH prepend", () => { let tmpRoot: string; let originalPath: string | undefined; diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 2e835d4b1d..7400b4a6d0 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, @@ -184,28 +185,61 @@ function platformArchTag(): string { return process.arch === "arm64" ? `${platformPrefix}-arm64` : `${platformPrefix}-x64`; } -function buildCandidatePaths( +/** + * Bases that may hold `electron/native/…`, most specific first. + * + * `appRoot` alone is not enough, and was not enough on either side of the packaged + * line: + * + * - **Unpackaged.** `app.getAppPath()` resolves to the directory holding the entry + * script, i.e. `/dist-electron` — the entry every dev run uses, `npm run dev` + * included. Joining `electron/native/...` onto that gives + * `/dist-electron/electron/native/...`, which no build step ever writes, so + * the loader fell through to "addon not present; running as no-op" and the editor + * silently ran without a compositor. Walking up to the first ancestor that actually + * has an `electron/native` finds the checkout root. + * - **Packaged.** `electron/native/bin//**` ships exclusively through + * `extraResources` (see electron-builder.json5's mac/win/linux blocks), so it lands + * at `/electron/native/bin/` and is never inside `app.asar` — the + * `.asar` → `.asar.unpacked` rewrite below cannot reach it either, since the file + * was never in the archive. `process.resourcesPath` is the base that holds it. + * + * Same pair of bases `ffmpegSharedBinCandidates` already walks for the ffmpeg dir; the + * addon needs it for exactly the same reason. + */ +function nativeAssetBaseDirs(appRoot: string): string[] { + const bases = [appRoot]; + // Bounded walk: dist-electron → repo root is one level, but a nested layout could + // be deeper. Stop at the filesystem root rather than looping. + let dir = appRoot; + for (let i = 0; i < 4; i += 1) { + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + if (fs.existsSync(path.join(dir, "electron", "native"))) { + bases.push(dir); + break; + } + } + if (typeof process.resourcesPath === "string" && process.resourcesPath.length > 0) { + bases.push(process.resourcesPath); + } + return bases; +} + +export function buildCandidatePaths( appRoot: string, isPackaged: boolean, envOverride: string | null | undefined, ): string[] { - const builtPath = path.join( - appRoot, - "electron", - "native", - "compositor-view", - "build", - "compositor_view.node", - ); - const archBinPath = path.join( - appRoot, - "electron", - "native", - "bin", - platformArchTag(), - "compositor_view.node", - ); - const ordered = [envOverride, archBinPath, builtPath].filter( + const tag = platformArchTag(); + const perBase = nativeAssetBaseDirs(appRoot).flatMap((base) => [ + path.join(base, "electron", "native", "bin", tag, "compositor_view.node"), + path.join(base, "electron", "native", "compositor-view", "build", "compositor_view.node"), + ]); + const ordered = [envOverride, ...perBase].filter( (value): value is string => typeof value === "string" && value.length > 0, ); if (!isPackaged) { @@ -364,6 +398,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 67e617515f..da0172c314 100644 --- a/electron/native/compositor-view/addon.d.ts +++ b/electron/native/compositor-view/addon.d.ts @@ -89,7 +89,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/electron/stt/gpuDetector.test.ts b/electron/stt/gpuDetector.test.ts index 9c4ac93649..5790032d4c 100644 --- a/electron/stt/gpuDetector.test.ts +++ b/electron/stt/gpuDetector.test.ts @@ -39,7 +39,13 @@ describe("gpuDetector", () => { const paths = candidateBinaryPaths(here); expect(paths.length).toBeGreaterThanOrEqual(2); const resolved = paths.map((p) => p.replace(/\\/g, "/")); - expect(resolved).toContain(`${here}/electron/native/bin/win32-x64/whisper-stt-server.exe`); + // The tag is `${platform}-${arch}`, and only `platform` is stubbed above — + // `arch` stays the HOST's. Hardcoding `win32-x64` therefore asserted that the + // host is x64, so this passed on CI (linux-x64) and on an Intel Mac but failed + // on every Apple Silicon machine, where the tag is `win32-arm64`. + expect(resolved).toContain( + `${here}/electron/native/bin/win32-${process.arch}/whisper-stt-server.exe`, + ); } finally { Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); } diff --git a/package.json b/package.json index 0c8c0194a2..4bb0f3d65f 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,10 @@ "docs:check": "node scripts/check-docs.mjs", "preview": "vite preview", "build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs", - "build:mac": "npm run build:native:mac && tsc && vite build && electron-builder --mac", + "build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac", "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "build:native:compositor": "node scripts/build-windows-compositor-addon.mjs", + "build:native:compositor:mac": "node scripts/build-macos-compositor-addon.mjs", "build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false", "build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false", @@ -53,7 +54,8 @@ "test:e2e": "playwright test", "test:e2e:windows-native-checklist": "playwright test tests/e2e/windows-native-checklist.spec.ts", "prepare": "husky", - "fetch:ffmpeg": "node scripts/fetch-ffmpeg.mjs" + "fetch:ffmpeg": "node scripts/fetch-ffmpeg.mjs", + "fetch:ffmpeg:mac": "node scripts/fetch-ffmpeg-macos.mjs" }, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", diff --git a/scripts/build-macos-compositor-addon.mjs b/scripts/build-macos-compositor-addon.mjs new file mode 100644 index 0000000000..a66bb28032 --- /dev/null +++ b/scripts/build-macos-compositor-addon.mjs @@ -0,0 +1,249 @@ +// Builds the native Metal/VideoToolbox compositor addon (crates/compositor-view-napi) +// and vendors it to electron/native/compositor-view/build/compositor_view.node — the +// path compositorViewService.ts's candidate list resolves at runtime. +// +// The macOS twin of build-windows-compositor-addon.mjs. Where that one has to sweep +// for vcvarsall to put MSVC on PATH, this one only needs the Xcode command-line tools +// (`xcrun` finds the SDK and libclang for bindgen, see crates/compositor/build.rs). +// +// FFMPEG — the one thing that is NOT symmetric. On Windows, scripts/fetch-ffmpeg.mjs +// vendors BtbN's pinned "-lgpl-shared" build into crates/thirdparty/. BtbN publishes +// no macOS target (that script exits 1 on darwin, by design), so there is no +// equivalent download and the tree has to be built locally. `--print-ffmpeg-recipe` +// prints the exact configure line; the LGPL posture is the part that matters and is +// not negotiable: +// +// * NO --enable-gpl and NO --enable-nonfree. ffmpeg is LGPL by default and becomes +// GPL the moment either is passed (x264/x265 come in with --enable-gpl), which +// would relicense this MIT app. Same rule fetch-ffmpeg.mjs enforces on Windows +// with `ffmpeg -L`. +// * --enable-shared: the addon dynamically links libavcodec/libavformat/… . +// +// A Homebrew ffmpeg will NOT do as a drop-in: brew's formula builds with +// --enable-gpl. It is fine to develop against, never fine to ship. + +import { execFileSync, spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const CRATES_DIR = path.join(ROOT, "crates"); +const BUILD_OUT_DIR = path.join(ROOT, "electron", "native", "compositor-view", "build"); + +/** Where crates/compositor/build.rs looks for the macOS ffmpeg tree when MAC_FFMPEG_DIR is unset. */ +const VENDORED_FFMPEG_DIR = path.join( + CRATES_DIR, + "thirdparty", + "ffmpeg-n8.1.2-macos64-lgpl-shared", +); + +const FFMPEG_RECIPE = ` +# ffmpeg 8.1.2, LGPL, shared — the tree crates/compositor/build.rs expects. +# Run from an unpacked https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz: + +./configure \\ + --prefix="${VENDORED_FFMPEG_DIR}" \\ + --enable-shared --disable-static \\ + --disable-doc --disable-debug \\ + --enable-videotoolbox --enable-audiotoolbox \\ + --disable-x86asm --arch=arm64 --enable-neon --cc=clang +make -j"$(sysctl -n hw.ncpu)" && make install + +# No --enable-gpl, no --enable-nonfree: either one relicenses OpenScreen under the GPL. +`.trim(); + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit", cwd: CRATES_DIR, ...options }); + child.on("error", reject); + child.on("close", (code) => + code === 0 + ? resolve() + : reject(new Error(`${command} ${args.join(" ")} exited with ${code}`)), + ); + }); +} + +if (process.argv.includes("--print-ffmpeg-recipe")) { + console.log(FFMPEG_RECIPE); + process.exit(0); +} + +if (process.platform !== "darwin") { + console.log("Skipping native Metal compositor addon build: macOS-only."); + process.exit(0); +} + +// The addon links ffmpeg, so a missing tree is a hard stop with the recipe attached +// rather than a bindgen error 200 lines deep in cargo output. +const macFfmpegDir = process.env.MAC_FFMPEG_DIR ?? VENDORED_FFMPEG_DIR; +if (!fs.existsSync(path.join(macFfmpegDir, "include"))) { + throw new Error( + `No ffmpeg SDK at ${macFfmpegDir} (looked for its include/).\n` + + "Set MAC_FFMPEG_DIR, or build the vendored tree:\n\n" + + `${FFMPEG_RECIPE}\n`, + ); +} + +const cargo = path.join(os.homedir(), ".cargo", "bin", "cargo"); +if (!fs.existsSync(cargo)) { + throw new Error(`cargo not found at ${cargo}. Install Rust (https://rustup.rs) first.`); +} + +// cwd = crates/ so cargo picks up crates/.cargo/config.toml, same as the Windows script. +await run(cargo, ["build", "-p", "compositor-view-napi", "--release"], { + env: { ...process.env, MAC_FFMPEG_DIR: macFfmpegDir }, +}); + +// cdylib on macOS is a .dylib; node's require() wants the .node extension but does not +// care what is inside — it dlopens it and calls the napi entry point either way. +const builtDylib = path.join(CRATES_DIR, "target", "release", "libcompositor_view.dylib"); +if (!fs.existsSync(builtDylib)) { + throw new Error(`Compositor addon build completed but ${builtDylib} was not found.`); +} + +/** + * Install by atomic rename, NEVER by copying over the existing file. + * + * macOS validates code pages lazily against the Mach-O signature. Overwriting a + * loaded `.node` in place leaves the kernel's page cache holding pages from the OLD + * binary on a vnode whose signature is now the NEW one, and the next process to fault + * one of those pages is killed outright: + * + * signal: SIGKILL (Code Signature Invalid) + * termination: { namespace: "CODESIGNING", indicator: "Invalid Page" } + * + * No JS error, no stack — the app just dies at `require()`. It only bites on the + * SECOND build onwards, which is what makes it so confusing: the addon works, you + * change one line of Rust, and now nothing loads. + * + * Writing to a temp name in the same directory and renaming gives the new content a + * fresh inode, so the stale pages belong to a vnode nothing will fault again. + */ +function installAtomically(from, to) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + const tmp = `${to}.${process.pid}.tmp`; + fs.copyFileSync(from, tmp); + fs.renameSync(tmp, to); +} + +/** + * Vendors the ffmpeg dylibs next to the addon and rewrites every install name to + * `@rpath`, so the packaged app loads its own copies instead of a build-machine path. + * + * Straight out of `cargo`, the `.node` references its dependencies by ABSOLUTE path + * (`otool -L` shows `/Users/…/crates/thirdparty/…/libavcodec.62.dylib`). That works on + * the machine that built it and nowhere else — the installed app would fail at + * `require()` with a dyld error naming a directory the user has never had. Windows does + * not hit this because `LoadLibrary` searches `PATH`, which is what + * `ensureFfmpegSharedDllsOnPath()` prepends; macOS has no such search, so the fix has to + * be baked into the binary. + * + * Three edits per artefact: + * - each dylib's own id becomes `@rpath/`; + * - every inter-library reference (libavcodec -> libavutil, and so on) is rewritten; + * - the addon gains `@loader_path` as an rpath, so `@rpath/libavutil.60.dylib` + * resolves next to `compositor_view.node` — which is exactly where electron-builder + * puts them via the mac `extraResources` filter `darwin-*​/*`. + * + * Re-signing is required and not optional: `install_name_tool` invalidates the existing + * ad-hoc signature, and macOS kills a process that faults a page of a binary whose + * signature no longer matches (the same `SIGKILL (Code Signature Invalid)` documented on + * `installAtomically` above). + */ +function vendorFfmpegDylibs(nodePath, ffmpegDir) { + const outDir = path.dirname(nodePath); + const libDir = path.join(ffmpegDir, "lib"); + const linked = execFileSync("otool", ["-L", nodePath], { encoding: "utf8" }) + .split("\n") + .map((line) => line.trim().split(" ")[0]) + .filter((p) => /\/lib(av|sw)\w+\.\d+\.dylib$/.test(p)); + if (linked.length === 0) { + throw new Error(`${nodePath} links no ffmpeg dylib — nothing to vendor, which is wrong.`); + } + + const names = linked.map((p) => path.basename(p)); + for (const name of names) { + const from = path.join(libDir, name); + if (!fs.existsSync(from)) { + throw new Error(`Missing ${from}; the addon links it but the SDK does not ship it.`); + } + const to = path.join(outDir, name); + fs.copyFileSync(from, to); + fs.chmodSync(to, 0o755); + execFileSync("install_name_tool", ["-id", `@rpath/${name}`, to]); + } + // Inter-library references, and the addon's own. + for (const target of [...names.map((n) => path.join(outDir, n)), nodePath]) { + const deps = execFileSync("otool", ["-L", target], { encoding: "utf8" }) + .split("\n") + .map((line) => line.trim().split(" ")[0]) + .filter((p) => p.startsWith("/") && /lib(av|sw)\w+\.\d+\.dylib$/.test(p)); + for (const dep of deps) { + execFileSync("install_name_tool", ["-change", dep, `@rpath/${path.basename(dep)}`, target]); + } + execFileSync("install_name_tool", ["-add_rpath", "@loader_path", target]); + // install_name_tool invalidates the signature; re-sign ad-hoc. + execFileSync("codesign", ["--force", "--sign", "-", target]); + } + + const remaining = execFileSync("otool", ["-L", nodePath], { encoding: "utf8" }) + .split("\n") + .map((l) => l.trim().split(" ")[0]) + .filter((p) => p.startsWith("/") && /lib(av|sw)/.test(p)); + if (remaining.length > 0) { + throw new Error(`Still absolute after rewriting: ${remaining.join(", ")}`); + } + console.log(`Vendored ${names.length} ffmpeg dylibs next to ${path.basename(nodePath)}`); +} + +/** + * Refuses to package a GPL ffmpeg. `--enable-gpl` pulls x264/x265 in and relicenses this + * MIT app; a Homebrew ffmpeg is exactly that and is an easy thing to point MAC_FFMPEG_DIR + * at by accident. Same check `fetch-ffmpeg.mjs` runs on the Windows build. + */ +function assertLgpl(ffmpegDir) { + const bin = path.join(ffmpegDir, "bin", "ffmpeg"); + if (!fs.existsSync(bin)) { + console.warn(`No ffmpeg binary at ${bin}; skipping the licence check.`); + return; + } + const banner = execFileSync(bin, ["-hide_banner", "-L"], { encoding: "utf8" }); + if (/GNU General Public License/i.test(banner) || !/Lesser General Public/i.test(banner)) { + throw new Error( + `${ffmpegDir} is not an LGPL build — its own -L banner says so.\n` + + "Homebrew's ffmpeg is GPL (--enable-gpl). Build the LGPL tree instead:\n\n" + + `${FFMPEG_RECIPE}\n`, + ); + } + console.log("ffmpeg licence: LGPL (checked via `ffmpeg -L`)"); +} + +assertLgpl(macFfmpegDir); + +const dest = path.join(BUILD_OUT_DIR, "compositor_view.node"); +installAtomically(builtDylib, dest); + +// The arch-tagged dir is the first candidate compositorViewService.ts probes, and the +// one electron-builder ships via extraResources. Keeping both in sync means a dev build +// and a packaged build load the same binary. +const archBinDir = path.join(ROOT, "electron", "native", "bin", `darwin-${process.arch}`); +const archDest = path.join(archBinDir, "compositor_view.node"); +installAtomically(builtDylib, archDest); + +// Only the arch-tagged copy ships (mac `extraResources`, filter `darwin-*/*`), so that +// is the one that gets its dylibs and its @rpath. +vendorFfmpegDylibs(archDest, macFfmpegDir); + +console.log(`Built ${builtDylib}`); +console.log(`Copied ${dest}`); +console.log(`Copied ${archDest}`); +console.log( + `\nThe shipped copy (${archDest}) carries its own LGPL ffmpeg dylibs and resolves them\n` + + "through @rpath/@loader_path, so the packaged app does not depend on this machine's\n" + + "paths. The dev copy under electron/native/compositor-view/build/ keeps its absolute\n" + + "links, which is fine — it never leaves this checkout.", +); diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs new file mode 100644 index 0000000000..c3f9468123 --- /dev/null +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -0,0 +1,120 @@ +// Vendors an LGPL, shared ffmpeg for macOS into crates/thirdparty/, the tree +// crates/compositor/build.rs probes. +// +// The macOS counterpart of scripts/fetch-ffmpeg.mjs, and it BUILDS rather than downloads +// — not by preference. BtbN, whose pinned "-lgpl-shared" archives that script fetches for +// Windows, publishes no macOS target. The macOS binaries that do circulate (evermeet, +// osxexperts, Homebrew) are all GPL: they ship x264/x265, which `--enable-gpl` pulls in. +// Linking any of them would relicense this MIT app under the GPL, so none of them is a +// candidate however convenient. +// +// The build is therefore from source, from the pinned release tarball, checksummed, with +// neither --enable-gpl nor --enable-nonfree — and the result is verified by asking the +// binary itself (`ffmpeg -L`) rather than by trusting the configure line. That last step +// is the one that matters: it is the same check fetch-ffmpeg.mjs performs on Windows, and +// it is what catches a tree that was replaced by hand with a GPL one. +// +// Roughly five minutes on an M-series. Idempotent: an existing tree that passes the +// licence check is left alone. + +import { execFileSync, spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const CRATES_DIR = path.join(ROOT, "crates"); + +/** Pinned release. The directory name is what build.rs looks for. */ +const VERSION = "8.1.2"; +const TARBALL_SHA256 = "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c"; +const DEST = path.join(CRATES_DIR, "thirdparty", `ffmpeg-n${VERSION}-macos64-lgpl-shared`); + +function run(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { stdio: "inherit", ...opts }); + if (r.status !== 0) { + throw new Error(`${cmd} ${args.join(" ")} exited with ${r.status}`); + } +} + +/** The binary's own licence banner — the only claim worth trusting. */ +function isLgpl(dir) { + const bin = path.join(dir, "bin", "ffmpeg"); + if (!fs.existsSync(bin)) return false; + const banner = execFileSync(bin, ["-hide_banner", "-L"], { encoding: "utf8" }); + return /Lesser General Public/i.test(banner) && !/GNU General Public License/i.test(banner); +} + +if (process.platform !== "darwin") { + console.log("Skipping macOS ffmpeg vendoring: macOS-only (Windows uses fetch:ffmpeg)."); + process.exit(0); +} + +if (fs.existsSync(path.join(DEST, "include")) && isLgpl(DEST)) { + console.log(`ffmpeg already vendored at ${DEST} and its -L banner says LGPL. Nothing to do.`); + process.exit(0); +} +if (fs.existsSync(path.join(DEST, "include"))) { + throw new Error( + `${DEST} exists but is not an LGPL build (checked with \`ffmpeg -L\`).\n` + + "Refusing to reuse it — linking a GPL ffmpeg would relicense OpenScreen.\n" + + "Delete the directory and re-run to rebuild it from source.", + ); +} + +const work = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-ffmpeg-")); +const tarball = path.join(work, `ffmpeg-${VERSION}.tar.xz`); + +console.log(`Downloading ffmpeg ${VERSION}…`); +run("curl", ["-sSL", "-o", tarball, `https://ffmpeg.org/releases/ffmpeg-${VERSION}.tar.xz`]); + +const actual = crypto.createHash("sha256").update(fs.readFileSync(tarball)).digest("hex"); +if (actual !== TARBALL_SHA256) { + throw new Error( + `Checksum mismatch for the ffmpeg tarball.\n expected ${TARBALL_SHA256}\n got ${actual}`, + ); +} +console.log("Checksum OK."); + +run("tar", ["-xJf", tarball, "-C", work]); +const src = path.join(work, `ffmpeg-${VERSION}`); + +// No --enable-gpl, no --enable-nonfree. ffmpeg is LGPL by default and stops being so the +// moment either appears; everything else here is about size and the macOS hardware paths. +console.log("Configuring (LGPL, shared)…"); +run( + "./configure", + [ + `--prefix=${DEST}`, + "--enable-shared", + "--disable-static", + "--disable-doc", + "--disable-debug", + "--enable-videotoolbox", + "--enable-audiotoolbox", + "--disable-x86asm", + `--arch=${process.arch === "arm64" ? "arm64" : "x86_64"}`, + "--cc=clang", + ], + { cwd: src }, +); + +console.log("Building…"); +const jobs = execFileSync("sysctl", ["-n", "hw.ncpu"], { encoding: "utf8" }).trim(); +run("make", ["-j", jobs], { cwd: src }); +run("make", ["install"], { cwd: src }); + +if (!isLgpl(DEST)) { + fs.rmSync(DEST, { recursive: true, force: true }); + throw new Error( + "The freshly built ffmpeg does not report an LGPL licence. The tree has been removed " + + "rather than left where build.rs would link it.", + ); +} + +fs.rmSync(work, { recursive: true, force: true }); +console.log(`\nVendored LGPL ffmpeg ${VERSION} at ${DEST}`); +console.log("Verified with `ffmpeg -L`. `npm run build:native:compositor:mac` can now link it."); diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index 0d09dca373..363c55d051 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -32,7 +32,7 @@ import { type GifSizePreset, } from "@/lib/exporter"; import { calculateMp4ExportSettings } from "@/lib/exporter/mp4ExportSettings"; -import { exportGifNative, exportMultiNative } from "@/native"; +import { exportGifNative, exportMultiNative, useIsCpuCompositor } from "@/native"; import type { CompositorClipInput } from "@/native/contracts"; import { buildSceneDescription, resolveVisibleClips } from "@/native/sceneDescription"; import { ModalShell } from "./Modals"; @@ -112,6 +112,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); @@ -608,6 +611,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 @@ -218,10 +256,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 bf035d34fa..8a450b47a7 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": "تم إلغاء التصدير", @@ -388,5 +389,9 @@ "cancel": "إلغاء", "save": "حفظ", "saveAndUse": "حفظ واستخدام" + }, + "cpuCompositor": { + "notice": "لا توجد بطاقة رسومات متوافقة — تتم المعالجة على المعالج، لذا يكون التشغيل أبطأ.", + "exportWarning": "لا توجد بطاقة رسومات متوافقة: يتم هذا التصدير على المعالج وسيستغرق وقتًا أطول بكثير من المعتاد." } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 90a5f02f98..34c5e64380 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", @@ -388,5 +389,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 09219b0c60..b0f07e8ea4 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", @@ -388,5 +389,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 8e463b6692..3b03b98718 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é", @@ -388,5 +389,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 0f7fbe7496..1b2bbbf0cc 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", @@ -388,5 +389,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 0b50559052..04970a21fe 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": "エクスポートがキャンセルされました", @@ -388,5 +389,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 4e97f0736c..e7b9860171 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": "내보내기가 취소되었습니다", @@ -388,5 +389,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 63e56581fd..5833af8aa6 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", @@ -388,5 +389,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 6722076484..6ebd184537 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": "Экспорт отменён", @@ -388,5 +389,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 c683d9273a..067f0f0697 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", @@ -388,5 +389,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 34e86cc22f..e5e6494861 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", @@ -388,5 +389,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 8afd7be95d..1500049a15 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": "导出已取消", @@ -388,5 +389,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 7d4db14ffe..b0b36e5a79 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": "匯出已取消", @@ -388,5 +389,9 @@ "cancel": "取消", "save": "儲存", "saveAndUse": "儲存並使用" + }, + "cpuCompositor": { + "notice": "沒有相容的 GPU,正在使用 CPU 算繪,因此播放較慢。", + "exportWarning": "沒有相容的 GPU:這次匯出將在 CPU 上執行,耗時會比平常長很多。" } } diff --git a/src/native/compositorViewClient.ts b/src/native/compositorViewClient.ts index 859dcb19f5..68f062e62a 100644 --- a/src/native/compositorViewClient.ts +++ b/src/native/compositorViewClient.ts @@ -9,6 +9,8 @@ import { requireNativeBridgeData } from "./client"; import type { + CompositorBackend, + CompositorBackendResult, CompositorClipInput, CompositorExportGifParams, CompositorExportGifResult, @@ -20,6 +22,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 e2ba5b18c3..c9b1cf85fc 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 @@ -655,6 +670,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 1b129b069c..b26c9ed410 100644 --- a/src/native/hooks/useNativeCompositorView.ts +++ b/src/native/hooks/useNativeCompositorView.ts @@ -51,6 +51,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 @@ -79,6 +91,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); @@ -98,6 +111,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` @@ -233,9 +249,20 @@ export function useNativeCompositorView( // plutôt que moyennés avec des périodes de repos. noteUiProbePreviewFrame(); }) - .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)); }); }; @@ -311,5 +338,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..3cdfe4bd76 100644 --- a/technical-documentation/architecture/native-compositor.md +++ b/technical-documentation/architecture/native-compositor.md @@ -11,7 +11,7 @@ zero CPU readback between stages — and is shared, with the same scene contract numbers and the rejected alternatives that drove the design live in [engineering/rendering-performance.md](../engineering/rendering-performance.md). -A single `ID3D11Device` ([`crates/compositor/src/d3d.rs`](../../crates/compositor/src/d3d.rs)) +A single `ID3D11Device` ([`crates/compositor/src/d3d.rs`](../../crates/compositor/src/d3d_windows.rs)) is shared by every consumer on the path: the ffmpeg `D3D11VA` decoder, the HLSL compositor, the `h264_amf` encoder, and the live swapchain. It is created with feature level **11_1**, the flags `VIDEO_SUPPORT | BGRA_SUPPORT` (`VIDEO_SUPPORT` @@ -25,20 +25,36 @@ and without that flag the runtime corruption is silent. | source | responsibility | |---|---| | [`crates/compositor/src/lib.rs`](../../crates/compositor/src/lib.rs) | crate root: declares every module below. Nothing else — the CLI/bench/GUI dispatcher it used to hold lives in the POC crate now | -| [`crates/compositor/src/d3d.rs`](../../crates/compositor/src/d3d.rs) | the single `ID3D11Device` (feature level 11_1, `VIDEO_SUPPORT`, multithread-protected) | +| [`crates/compositor/src/d3d.rs`](../../crates/compositor/src/d3d_windows.rs) | the single `ID3D11Device` (feature level 11_1, `VIDEO_SUPPORT`, multithread-protected) | | [`crates/compositor/src/ffi.rs`](../../crates/compositor/src/ffi.rs) | bindgen-generated `libav*` bindings (ffmpeg 8.x headers) | -| [`crates/compositor/src/compositor.rs`](../../crates/compositor/src/compositor.rs) | HLSL compositor — every per-frame pass (`compose_frame`, background, screen, webcam, cursor, annotations, shadows, blur) | +| [`crates/compositor/src/compositor.rs`](../../crates/compositor/src/compositor_windows.rs) | HLSL compositor — every per-frame pass (`compose_frame`, background, screen, webcam, cursor, annotations, shadows, blur) | | [`crates/compositor/src/scene.rs`](../../crates/compositor/src/scene.rs) | the `Scene` struct parsed from the app's `SceneDescription` JSON | | [`crates/compositor/src/regions.rs`](../../crates/compositor/src/regions.rs) | zoom / speed / Full Camera regions — envelope shapes and per-frame state sampling | -| [`crates/compositor/src/pipeline.rs`](../../crates/compositor/src/pipeline.rs) | demux + `D3D11VA` decode + composite + AMF encode + mux (`run_c0` for decode/encode only, `run_composited` for the full path) | +| [`crates/compositor/src/pipeline.rs`](../../crates/compositor/src/pipeline_windows.rs) | demux + `D3D11VA` decode + composite + AMF encode + mux (`run_c0` for decode/encode only, `run_composited` for the full path) | | [`crates/compositor/src/audio.rs`](../../crates/compositor/src/audio.rs) | per-clip audio decode, swresample → f32 planar 48 kHz stereo, WSOLA speed stretch, multi-track mix, AAC encoder | | [`crates/compositor/src/cursor.rs`](../../crates/compositor/src/cursor.rs) | `.cursor.json` parser + interpolated cursor track (position, click bounces, adaptive follow samples) | -| [`crates/compositor/src/text.rs`](../../crates/compositor/src/text.rs) | DirectWrite + Direct2D text rasterisation for annotation labels, cached per (content, style, box) | +| [`crates/compositor/src/text.rs`](../../crates/compositor/src/text_windows.rs) | DirectWrite + Direct2D text rasterisation for annotation labels, cached per (content, style, box) | | [`crates/compositor/src/text_anim.rs`](../../crates/compositor/src/text_anim.rs) | text-annotation appearance animations (port of the TS animation curves, in fractions of the output short side) | | [`crates/compositor/src/config.rs`](../../crates/compositor/src/config.rs) | cumulative bench configs C0..C8 (each adds one layer: composite, rounded corners, shadow, background blur, zoom, layout animation, cursor, motion blur) | | [`crates/compositor/src/live.rs`](../../crates/compositor/src/live.rs) | off-screen view: `Player` + `Compositor::compose_frame` → RGBA8 staging texture, pulled by the napi addon | | [`crates/compositor/src/shaders.hlsl`](../../crates/compositor/src/shaders.hlsl) | all GPU effects (modes 0/1/5/8/9/10/12 — see pipeline below), compiled at runtime via Fxc | +Six of those modules exist twice, once per platform, and `lib.rs` cfg-re-exports the pair +under one name — so `crate::d3d`, `crate::compositor`, `crate::pipeline` and `crate::text` +mean the Windows file above on Windows and the macOS file below on a Mac, and every +call-site stays portable. The rows above name the `_windows` half because that is what +their prose describes (HLSL, D3D11VA, AMF, DirectWrite); the macOS half is: + +| source | responsibility | +|---|---| +| [`crates/compositor/src/d3d_macos.rs`](../../crates/compositor/src/d3d_macos.rs) | the `MTLDevice` + `MTLCommandQueue`. Metal has no software rasteriser, so `Backend::Cpu` exists only for API symmetry and is never produced | +| [`crates/compositor/src/compositor_macos.rs`](../../crates/compositor/src/compositor_macos.rs) | Metal compositor. `CVPixelBuffer` → `MTLTexture` via `CVMetalTextureCache` (zero-copy over IOSurface), then the MSL passes. First-pass engine: full-canvas only, the layered passes are not driven yet | +| [`crates/compositor/src/pipeline_macos.rs`](../../crates/compositor/src/pipeline_macos.rs) | demux + VideoToolbox decode (+ software fallback) + composite + `h264_videotoolbox` encode + mux | +| [`crates/compositor/src/mac_frames.rs`](../../crates/compositor/src/mac_frames.rs) | the software-decode axis: libavcodec → swscale → NV12 → IOSurface-backed `CVPixelBuffer`, presented under the same 4-field AVFrame contract as the hardware path | +| [`crates/compositor/src/text_macos.rs`](../../crates/compositor/src/text_macos.rs) | CoreText + CoreGraphics text rasterisation, same `TextSpec::cache_key` as the Windows side | +| [`crates/compositor/src/shaders.metal`](../../crates/compositor/src/shaders.metal) | the MSL port of `shaders.hlsl`, compiled at runtime via `newLibraryWithSource`. Same nine entry points; resources are entry-point parameters rather than globals, which is the one thing MSL does not let you transcribe from HLSL | +| [`crates/compositor/src/timeline_walk.rs`](../../crates/compositor/src/timeline_walk.rs) | portable, not per-platform: the clip walk both exports (MP4, GIF) and both backends share, so "which source frame belongs at output frame N" is defined exactly once | + Two more modules sit in the POC crate ([`crates/poc-d3d/`](../../crates/poc-d3d)) rather than the library. Nothing packages them — they drive the library for measurement and eyeballing: @@ -53,32 +69,32 @@ library. Nothing packages them — they drive the library for measurement and ey Every visible frame is rasterised into one RGBA render target (`OUT_W × OUT_H = 1920×1080`, then optionally bilinearly resized to the real output aspect). `Compositor::compose_frame` -([`crates/compositor/src/compositor.rs:1421`](../../crates/compositor/src/compositor.rs)) +([`crates/compositor/src/compositor.rs:1421`](../../crates/compositor/src/compositor_windows.rs)) runs one fixed draw order — derived from the per-frame math it performs, not from any list the caller can reorder — and `shaders.hlsl` keys each effect off the `mode` field of the same `LayerCB` struct, so the modes below are exactly what `shaders.hlsl` actually implements: 1. **Background** (the wallpaper). `mode = 1` for a solid colour - ([`compositor.rs:1761`](../../crates/compositor/src/compositor.rs)), + ([`compositor.rs:1761`](../../crates/compositor/src/compositor_windows.rs)), `mode = 5` for a CSS gradient - ([`compositor.rs:1765`](../../crates/compositor/src/compositor.rs)), or a cover-fitted + ([`compositor.rs:1765`](../../crates/compositor/src/compositor_windows.rs)), or a cover-fitted image loaded through `draw_image_bg` - ([`compositor.rs:1156`](../../crates/compositor/src/compositor.rs)). When the + ([`compositor.rs:1156`](../../crates/compositor/src/compositor_windows.rs)). When the scene's `effects.blur` is on, `blur_bg` (dual-Kawase, ~18 px) blurs whatever was just drawn — that is what "Blur BG" does, mirroring the web `frameRenderer.blurredBackgroundLayer` - ([`compositor.rs:1807`](../../crates/compositor/src/compositor.rs)). + ([`compositor.rs:1807`](../../crates/compositor/src/compositor_windows.rs)). 2. **Screen shadow.** Drop-shadow SDF; opacity scales with the `shadow` slider. The shadow tracks the rendered silhouette — a tilted plane gets a tilted shadow, not a rect shadow - ([`compositor.rs:1885`](../../crates/compositor/src/compositor.rs) / - [`compositor.rs:1882`](../../crates/compositor/src/compositor.rs), `mode = 12`). + ([`compositor.rs:1885`](../../crates/compositor/src/compositor_windows.rs) / + [`compositor.rs:1882`](../../crates/compositor/src/compositor_windows.rs), `mode = 12`). 3. **Screen video.** Cropped from the active clip's source, with the zoom region applied to its UVs and rounded corners via SDF. `mode = 0` for the standard - quad ([`compositor.rs:1898`](../../crates/compositor/src/compositor.rs)); + quad ([`compositor.rs:1898`](../../crates/compositor/src/compositor_windows.rs)); `mode = 8` for the 3D tilt path (zoom `rotation: iso | left | right`, - [`compositor.rs:1950`](../../crates/compositor/src/compositor.rs)). Motion blur + [`compositor.rs:1950`](../../crates/compositor/src/compositor_windows.rs)). Motion blur uses the previous frame's UV delta as a per-pixel velocity vector and samples along it (the "blur by velocity" optimisation — early-outs on still frames). 4. **Cursor.** A sprite picked from `cursor.cursorSprites` by the OS cursor state @@ -94,26 +110,26 @@ what `shaders.hlsl` actually implements: Motion blur is its own accumulation buffer (additive blend into an isolated RT, then "over"-composited onto the scene), independent of the scene's `effects.motionBlur` - ([`compositor.rs:2038`](../../crates/compositor/src/compositor.rs) / - [`compositor.rs:2060`](../../crates/compositor/src/compositor.rs)). Click + ([`compositor.rs:2038`](../../crates/compositor/src/compositor_windows.rs) / + [`compositor.rs:2060`](../../crates/compositor/src/compositor_windows.rs)). Click bounce amplitude comes from the `.cursor.json` track ([`crates/compositor/src/cursor.rs`](../../crates/compositor/src/cursor.rs)). 5. **Webcam shadow.** Drawn only when `cfg.shadow` is on AND the layout is PiP (not the block layouts, which weld the camera flush to the screen — no floating bubble, no shadow). The strength fades to zero as the camera enters Full Camera mode - ([`compositor.rs:2118`](../../crates/compositor/src/compositor.rs), `mode = 12`). + ([`compositor.rs:2118`](../../crates/compositor/src/compositor_windows.rs), `mode = 12`). 6. **Webcam video.** UV rectangle derived from the destination aspect, with mirror, mask shape (rectangle / circle / square / rounded), and reactive scale applied - ([`compositor.rs:2127`](../../crates/compositor/src/compositor.rs), `mode = 0`). + ([`compositor.rs:2127`](../../crates/compositor/src/compositor_windows.rs), `mode = 0`). Full Camera lerps the destination to `[0, 0, 1, 1]` and dissolves the mask shape — same rule as `computeCameraFullscreenRect` on the TS side. 7. **Annotations.** Highest layer. One full-frame `CopySubresourceRegion` of the composed scene is taken at the top of `draw_annotations` so that multiple blur annotations on the same frame read from a consistent snapshot of the underlying pixels - ([`compositor.rs:2162`](../../crates/compositor/src/compositor.rs)). + ([`compositor.rs:2162`](../../crates/compositor/src/compositor_windows.rs)). Per-annotation: figure (arrow, `mode = 9`), blur/mosaic (`mode = 10`), text (DirectWrite → D3D11 SRV, then `mode = 0`), image (cached per annotation id). @@ -149,13 +165,13 @@ contain-fitted frame and the export at full output size: - `layout.screenRadiusFrac`, `layout.webcamRadiusFrac` — of their own box's short side (the only way two halves of a block layout can agree on a radius; see `compositor.rs` §1's comment on - [`compositor.rs:1702`](../../crates/compositor/src/compositor.rs)). + [`compositor.rs:1702`](../../crates/compositor/src/compositor_windows.rs)). - `annotation.x|y|w|h` and `annotation.text.fontSizeRel` — of the screen rect (annotations anchor to the screen box, not the output frame, and intentionally bypass the zoom transform). The consumer is `Compositor::compose_frame` -([`compositor.rs:1421`](../../crates/compositor/src/compositor.rs)). It reads +([`compositor.rs:1421`](../../crates/compositor/src/compositor_windows.rs)). It reads the scene per frame, derives per-clip and per-frame values through `Scene::for_clip_window` ([`scene.rs:436`](../../crates/compositor/src/scene.rs)) — which retains only the @@ -206,7 +222,7 @@ on-screen view, so editing playback is silent against the exported file. The crate links `libav*` (ffmpeg) via bindgen ([`build.rs`](../../crates/compositor/build.rs) / -[`wrapper.h`](../../crates/compositor/wrapper.h)). ffmpeg is not vendored — the +[`wrapper.h`](../../crates/compositor/wrapper_windows.h)). ffmpeg is not vendored — the path is pinned in `crates/.cargo/config.toml`: ``` @@ -250,16 +266,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/architecture/preview.md b/technical-documentation/architecture/preview.md index 9b25d80dd9..666207c520 100644 --- a/technical-documentation/architecture/preview.md +++ b/technical-documentation/architecture/preview.md @@ -119,7 +119,7 @@ current editor settings to a `SceneDescription` JSON string. It resolves: The descriptor mirrors the Rust struct in [`crates/compositor/src/scene.rs`](../../crates/compositor/src/scene.rs); field rename is `camelCase` on both sides. The Rust consumer (`compositor.rs::compose_frame`, -[`crates/compositor/src/compositor.rs:1421`](../../crates/compositor/src/compositor.rs:1421)) reads +[`crates/compositor/src/compositor.rs:1421`](../../crates/compositor/src/compositor_windows.rs:1421)) reads the JSON per frame, derives the per-clip and per-frame values it needs (zoom state from `regions.rs::zoom_state_at`, camera-fullscreen progress from `regions.rs::camera_fullscreen_progress_at`, screen crop from diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index 37d4443258..d3d442912f 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_windows.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.