diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..16c0d4afe8 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "vite-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5199", "--strictPort"], + "port": 5199, + "env": { "NO_ELECTRON": "1" } + }, + { + "name": "desktop-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5201", "--strictPort"], + "port": 5201, + "env": { "NO_ELECTRON": "1" } + }, + { + "name": "v4preview", + "runtimeExecutable": "node", + "runtimeArgs": ["node_modules/vite/bin/vite.js", "--config", "vite.v4preview.config.ts"], + "port": 5207 + }, + { + "name": "docs", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "website", "run", "serve", "--", "--port", "3111"], + "port": 3111 + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..154f7c37f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# The compositor is developed across Windows, macOS and Linux. Without this, a +# checkout on Windows can reintroduce CRLF and the shader sources drift from the +# `end_of_line = lf` / `charset = utf-8` that .editorconfig already mandates — +# which is how blur.wgsl, layer.wgsl and linux_decode.rs ended up with CRLF and +# double-encoded French comments in the first place. +*.wgsl text eol=lf +*.rs text eol=lf +*.ts text eol=lf +*.tsx text eol=lf diff --git a/.github/workflows/build-whisper-stt.yml b/.github/workflows/build-whisper-stt.yml new file mode 100644 index 0000000000..0d50a40067 --- /dev/null +++ b/.github/workflows/build-whisper-stt.yml @@ -0,0 +1,220 @@ +name: Build whisper-stt binaries + +# Builds the whisper.cpp-based `whisper-stt-server` helper for each desktop +# platform and uploads the binary + ggml backend sidecars as GitHub artifacts +# so `build.yml` can bundle them into installers. +# +# Triggered: +# * manually via workflow_dispatch (release-blocking binary refresh) +# * automatically when the helper, build script, or this workflow changes +# +# ponytail: one binary per platform is enough because whisper.cpp selects the +# right backend at runtime (Metal on Apple Silicon, Vulkan on Windows/Linux, +# CPU fallback when no GPU/driver is available). A CUDA variant is supported +# by the build script but is not built by default; Vulkan already accelerates +# NVIDIA cards. + +on: + workflow_dispatch: + inputs: + enable_cuda: + description: "Also build a CUDA variant when the host has an nvcc toolchain" + required: false + default: "false" + type: choice + options: + - "true" + - "false" + push: + paths: + - "scripts/build-whisper-stt.sh" + - "electron/native/whisper-stt/**" + - ".github/workflows/build-whisper-stt.yml" + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.label }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + arch: arm64 + # Matches `os_arch_tag()` in scripts/build-whisper-stt.sh — this is + # the directory name the build script actually stages into + # (electron/native/bin//), NOT `${{ matrix.os }}-${{ matrix.arch }}`. + tag: darwin-arm64 + label: macOS arm64 (Metal) + vulkan: false + - os: macos-15-intel + arch: x64 + tag: darwin-x64 + label: macOS x64 (CPU) + vulkan: false + - os: ubuntu-latest + arch: x64 + tag: linux-x64 + label: Linux x64 (Vulkan + CPU fallback) + vulkan: true + - os: windows-latest + arch: x64 + tag: win32-x64 + label: Windows x64 (Vulkan + CPU fallback) + vulkan: true + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Install Ninja (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build build-essential + + - name: Install Ninja (macOS) + if: startsWith(matrix.os, 'macos') + run: brew install ninja + + - name: Setup MSVC (Windows) + if: matrix.os == 'windows-latest' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Install Vulkan SDK + if: matrix.vulkan + # ponytail: humbletim/setup-vulkan-sdk builds Glslang from source via + # CMake ExternalProject, which fails ("ENABLE_OPT set but SPIR-V + # tools not found") because Glslang's build needs a sibling + # SPIRV-Tools checkout it doesn't fetch on its own — reproduced on + # both the Linux and Windows legs. jakoch/install-vulkan-sdk-action + # downloads LunarG's official prebuilt SDK (glslc included, no + # compilation), which is both more reliable and much faster. + uses: jakoch/install-vulkan-sdk-action@v1 + with: + vulkan_version: 1.4.304.1 + install_runtime: false + cache: true + + - name: Put glslc on PATH + if: matrix.vulkan + shell: bash + # Belt-and-braces: the action exports VULKAN_SDK but its own PATH + # handling isn't documented in enough detail to rely on for a tool + # (glslc) the ggml Vulkan backend's CMake configure step needs to find. + run: | + set -euo pipefail + for cand in "${VULKAN_SDK}/Bin" "${VULKAN_SDK}/bin"; do + if [[ -d "${cand}" ]]; then + echo "${cand}" >> "$GITHUB_PATH" + fi + done + + - name: Install SPIRV-Headers (Windows only) + if: matrix.os == 'windows-latest' + shell: bash + # ponytail: ggml-vulkan's CMakeLists does + # `find_package(SPIRV-Headers CONFIG REQUIRED)`. The Linux LunarG SDK + # tarball bundles that CMake config alongside the SDK, so the Linux + # leg resolves it for free; the Windows installer .exe (as fetched by + # jakoch/install-vulkan-sdk-action) does not ship it at all, so + # find_package fails with "Could not find a package configuration + # file". vcpkg (preinstalled on GitHub's windows-latest image) ships + # a spirv-headers port that provides the missing config; point + # CMAKE_PREFIX_PATH at its install dir so ggml's own + # `if (DEFINED ENV{VULKAN_SDK}) list(APPEND CMAKE_PREFIX_PATH ...)` + # logic has a second, working prefix to fall back to. + run: | + set -euo pipefail + VCPKG_ROOT_DIR="$(dirname "$(command -v vcpkg)")" + "${VCPKG_ROOT_DIR}/vcpkg" install spirv-headers:x64-windows + echo "CMAKE_PREFIX_PATH=${VCPKG_ROOT_DIR}/installed/x64-windows" >> "$GITHUB_ENV" + + - name: Cache whisper.cpp build tree + uses: actions/cache@v4 + with: + # Matches scripts/build-whisper-stt.sh's own BUILD_ROOT default for + # each OS (short `C:/wstbuild` on Windows to dodge the vulkan-shaders-gen + # MAX_PATH issue; `.cache/whisper-stt-build` elsewhere) — cache the + # FetchContent checkout + object files directly, no env override needed. + path: ${{ runner.os == 'Windows' && 'C:/wstbuild' || '.cache/whisper-stt-build' }} + # Keyed on the pinned WHISPER_REF/backend flags in CMakeLists.txt so a + # bump there invalidates the cache instead of silently reusing a stale + # FetchContent checkout; falls back to the newest cache for the same + # platform + runner image on a miss so incremental compilation still + # helps. The runner image version ($ImageOS/$ImageVersion) is part of + # the key AND the restore-keys prefix because CMake bakes absolute + # toolchain paths (e.g. the Xcode SDK's libz.tbd) into the cached build + # tree — when GitHub rolls the image's Xcode/SDK, those paths vanish and + # a restored tree fails with "No rule to make target …libz.tbd". Scoping + # the cache to the image version auto-busts it on every toolchain roll. + key: whisper-stt-build-${{ matrix.tag }}-${{ env.ImageOS }}-${{ env.ImageVersion }}-${{ hashFiles('electron/native/whisper-stt/CMakeLists.txt') }} + restore-keys: | + whisper-stt-build-${{ matrix.tag }}-${{ env.ImageOS }}-${{ env.ImageVersion }}- + + - name: Run whisper-stt build script + env: + ENABLE_CUDA: ${{ github.event.inputs.enable_cuda || 'false' }} + run: bash scripts/build-whisper-stt.sh + + - name: Stage binaries for upload + shell: bash + run: | + set -euo pipefail + BAG="whisper-stt-${{ matrix.tag }}" + mkdir -p "$BAG" + # Copy the whole per-platform directory: the helper executable plus + # every ggml backend sidecar/library it needs at runtime. + # + # -a, because build-whisper-stt.sh stages a symlink farm on Linux and + # macOS (libggml-vulkan.so -> .so.0 -> .so.0.15.1) and plain cp follows + # every link named on the command line, expanding each into a full copy + # of the payload. libggml-vulkan.so.0.15.1 alone is 60 MB, so the bag + # carried it three times: measured 188 MB instead of 63 MB, and the + # uploaded tarball 59 MB instead of 20 MB (gzip's 32 KiB window cannot + # dedupe copies that far apart). `dir/.` rather than `dir/*` so the copy + # does not depend on the shell's glob skipping dotfiles. + # + # tar preserves the links from here on (no -h below), so this shrinks + # the artifact and the CI download. It does NOT change the installers: + # scripts/stage-whisper-stt.sh:65 is a second plain `cp` that re-expands + # the farm before electron-builder ever sees it. Making that one -a too + # would cut ~131 MB from every package (electron-builder recreates + # symlinks, and squashfs/AppImage stores them natively — both verified), + # but deb/pacman go through fpm, whose behaviour here is unverified, so + # that step needs a real package inspection rather than a guess. + cp -av "electron/native/bin/${{ matrix.tag }}/." "$BAG/" + tar -czf "${BAG}.tar.gz" "$BAG" + echo "Staged ${BAG}.tar.gz" + + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: whisper-stt-${{ matrix.tag }} + path: whisper-stt-${{ matrix.tag }}.tar.gz + if-no-files-found: error + # build.yml now stages these into the installers + # (scripts/stage-whisper-stt.sh), so an expired artifact fails a + # release build. 90 days instead of 30 to make that rarer. + retention-days: 90 + + - name: Workflow summary + if: always() + # Explicit shell: the bash `{ ... } >> file` grouping syntax below is + # not valid PowerShell, which is the default `run:` shell on Windows + # runners — this step silently failed on Windows without this. + shell: bash + run: | + { + echo "## whisper-stt build" + echo "" + echo "- Matrix: \`${{ matrix.label }}\`" + echo "- Result: ${{ job.status }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffd88d8c33..923a5c435b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,11 +38,16 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + # STT is the bundled whisper-stt-server (whisper.cpp with native DTW token + # timestamps); no VAD model is fetched here. The binary is built by + # build-whisper-stt.yml and staged below — without that step the installer + # ships without speech-to-text. See + # technical-documentation/architecture/transcription-and-captions.md. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows app run: npm run build:win -- --publish never @@ -65,11 +70,11 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows Store package run: npm run build:win:store -- --publish never @@ -84,6 +89,9 @@ jobs: build-macos: name: macOS ${{ matrix.arch }} DMG + # RELEASE-BRANCH-ONLY: 1.8.0 ships Windows-only. Do NOT let this `if: false` + # reach main when promoting, or every later release becomes Windows-only too. + if: false runs-on: macos-latest strategy: fail-fast: false @@ -106,11 +114,11 @@ jobs: env: npm_config_build_from_source: "false" - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh darwin-${{ matrix.arch }} - name: Resolve macOS signing id: signing @@ -161,6 +169,40 @@ jobs: env: OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }} + # The two steps below are what `npm run build:mac` does and this job did not. + # Windows gets them for free because its job just runs `npm run build:win`, + # which chains fetch:ffmpeg + build:native:compositor; macOS spells its steps + # out (it needs `--dir` plus a hand-rolled DMG and signing) and drifted. The + # result was a .app with no compositor addon — preview and export dead in the + # installed app, silently. `scripts/before-pack.cjs` now refuses to package + # that, so this is also what keeps the job from failing at the pack step. + - name: Cache LGPL ffmpeg tree + uses: actions/cache@v4 + with: + # fetch-ffmpeg-macos.mjs BUILDS ffmpeg from source (~5 min): BtbN ships no + # macOS target and every circulating macOS build is GPL, which would + # relicense this MIT app. The script pins the release and checksums it, so + # keying on the script itself busts the cache when the pin moves. + path: crates/thirdparty + key: ffmpeg-macos-${{ matrix.arch }}-${{ hashFiles('scripts/fetch-ffmpeg-macos.mjs') }} + + - name: Vendor LGPL ffmpeg + run: npm run fetch:ffmpeg:mac + + - name: Cache cargo + compositor build tree + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + crates/target + key: cargo-macos-${{ matrix.arch }}-${{ hashFiles('crates/Cargo.lock') }} + restore-keys: | + cargo-macos-${{ matrix.arch }}- + + - name: Build Metal compositor addon + run: npm run build:native:compositor:mac + - name: Package .app bundle run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never env: @@ -258,6 +300,8 @@ jobs: build-linux: name: Linux packages + # RELEASE-BRANCH-ONLY: see the note on build-macos above. + if: false runs-on: ubuntu-latest steps: - name: Checkout code @@ -269,11 +313,11 @@ jobs: - name: Install pacman build dependencies run: sudo apt-get update && sudo apt-get install -y libarchive-tools - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh linux-x64 - name: Build Linux app run: npm run build:linux -- --publish never @@ -293,10 +337,11 @@ jobs: publish-release: name: Publish GitHub release runs-on: ubuntu-latest + # RELEASE-BRANCH-ONLY: build-macos / build-linux are disabled for this + # Windows-only release. A skipped job in `needs` skips this one too, so they + # must come out of the list, not just be gated. Restore all three on main. needs: - build-windows - - build-macos - - build-linux if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }} steps: - name: Checkout code @@ -378,6 +423,9 @@ jobs: path: artifacts/mac-x64 - name: Download Linux packages + # RELEASE-BRANCH-ONLY: tolerate the missing artifact from the disabled + # Linux job (the macOS downloads below already do). Drop with the rest. + continue-on-error: true uses: actions/download-artifact@v4 with: name: openscreen-linux diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c65e41b6b..d228826d90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,65 +1,214 @@ -name: CI - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run lint - - typecheck: - name: Type Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx tsc --noEmit - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run test - - run: npm run test:browser:install - - run: npm run test:browser - - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx vite build - - semantic-pr: - name: Validate PR title (semantic) - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - chore - refactor - perf - docs - test - build - ci - style - revert - requireScope: false +name: CI + +# feat/ai-edition is a long-lived integration branch that PRs land on for +# months at a time. Without it listed here, every one of those PRs merged with +# no lint, no typecheck, no tests and no PR-title check. +# +# release/** is here for the same reason and it cost more: PRs #167, #168 and +# #169 — 30k+ lines of deletion and refactor — merged into release/1.8.0 with +# every one of those jobs skipped, because a release branch matched neither +# pattern. A release branch is the LAST place to run a build unguarded. +on: + pull_request: + branches: [main, feat/ai-edition, "release/**"] + push: + branches: [main, feat/ai-edition, "release/**"] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx tsc --noEmit + + typecheck-tests: + name: Typecheck (tests) + runs-on: ubuntu-latest + # A GATE now, and the baseline logic is gone — as its own comment instructed + # ("delete this job's baseline logic at zero"). The history: tsconfig.json + # includes only src + electron and excludes **/*.test.ts, and vitest + # transpiles without checking, so no test file had ever been typechecked and + # fixture types had drifted from the schemas they claim to build for years. + # Failing on that whole backlog at once would have put a red X on every PR + # that nobody could fix, so the job shipped as a ratchet (fail only if the + # count GROWS), walked down 80 -> 74 -> 71 -> 66. The backlog is now cleared: + # every fixture carries the fields its schema actually requires, which is + # the point — a fixture missing `cameraTrack` or `reason` was not testing + # the shape it claimed to. Keep it at zero. If a genuine error is not worth + # fixing on the spot, fix the fixture anyway: re-introducing a baseline + # re-opens the drift this closed. + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - name: Typecheck tests + run: npx tsc -p tsconfig.test.json --noEmit + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # No ./.github/actions/setup: check-docs.mjs imports only node builtins, + # so npm ci would be a minute of install for nothing. Node 22 is here for + # import.meta.dirname (needs >= 20.11). + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm run docs:check + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run test + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - 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 + if: github.event_name == 'pull_request' + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + chore + refactor + perf + docs + test + build + ci + style + revert + requireScope: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..9164f164d0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,65 @@ +name: Docs + +on: + pull_request: + branches: [main] + paths: + - "website/**" + - ".github/workflows/docs.yml" + push: + branches: [main] + paths: + - "website/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +# Cancel in-flight runs on the same ref so fast follow-up pushes +# don't queue stale builds. +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json + - name: Install dependencies + working-directory: website + run: npm ci + - name: Type-check + working-directory: website + run: npm run typecheck + - name: Build + working-directory: website + run: npm run build + - name: Upload artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: website/build + + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 33cf7a9d2f..a900aef86b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,10 +16,13 @@ dist-ssr # Native helper build outputs /electron/native/wgc-capture/build/ +/electron/native/compositor-view/build/ /electron/native/screencapturekit/build/ /electron/native/screencapturekit/.build/ /electron/native/screencapturekit/.swiftpm/ /electron/native/bin/ +/electron/native/pipewire-capture/build/ +/electron/native/pipewire-capture/target/ # Native macOS generated files DerivedData/ @@ -40,6 +43,7 @@ xcuserdata/ release/** *.kiro/ .claude/ +.worktrees/ # npx electron-builder --mac --win # Playwright @@ -67,3 +71,50 @@ result-* # Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs /caption-assets/ + +# Native STT artifacts — whisper-stt-server binaries + ggml backend sidecars. +/electron/native/bin/ +/electron/native/models/ + +# Build cache for the native STT server (FetchContent clones + CMake build). +/.cache/ + +# Vite's dependency cache, moved out of node_modules so concurrent dev servers +# (main checkout + worktrees, which share node_modules via a junction) stop +# re-optimising into each other's cache. See `cacheDir` in vite.config.ts. +/.vite-cache/ + +# whisper.cpp DTW POC (tools/stt-eval/whispercpp-dtw-poc) — vendored engine +# clone, downloaded GGML models, and generated build/results artifacts. +# See technical-documentation/architecture/transcription-and-captions.md. +/tools/stt-eval/whispercpp-dtw-poc/whisper.cpp/ +/tools/stt-eval/whispercpp-dtw-poc/build-cpu/ +/tools/stt-eval/whispercpp-dtw-poc/build-vulkan/ +/tools/stt-eval/whispercpp-dtw-poc/build-cuda/ +/tools/stt-eval/whispercpp-dtw-poc/build-harness/ +/tools/stt-eval/whispercpp-dtw-poc/fixtures/*.wav +/tools/stt-eval/whispercpp-dtw-poc/results/ + +opencode.json +opencode.json + +# Frames dumped by `npm run bench:export -- --dumpFrame=N`, for eyes only. +bench-frame-*.png + +# POC inputs (real recordings) and outputs — big, and not source. +poc/media/ +poc/out/ + +# cc-delegate worker worktrees/branches + scratch test artifacts. +.cc-delegate/ + +# Workbench LLM — secrets locaux, jamais versionnés +.env.workbench + +# Workbench — sorties de run et bundle CLI, jamais versionnés +workbench/reports/ +workbench/.build/ +workbench/runs/ + +# Fixture d'évaluation — enregistrement réel de l'utilisateur, jamais versionné +workbench/fixtures/ diff --git a/.harness/docs/architecture-overview.md b/.harness/docs/architecture-overview.md index 373f9618e6..dbc2e4df54 100644 --- a/.harness/docs/architecture-overview.md +++ b/.harness/docs/architecture-overview.md @@ -1,6 +1,6 @@ # OpenScreen Architecture Notes -Quick map of how the app fits together, for the Mavis reins. For deeper details, see `../docs/architecture/native-bridge.md` and `../docs/engineering/`. +Quick map of how the app fits together, for the Mavis reins. For deeper details, see `../technical-documentation/architecture/native-bridge.md` and `../technical-documentation/engineering/`. ## Process layout diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index cd0895fdc3..68f2486452 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -41,7 +41,7 @@ All five must be green before merge. Native helper code is NOT covered by CI — ## Release flow -Two `workflow_dispatch` workflows cut a release. Trunk-based on `main`, but **release branches freeze the RC codebase between cut and promote** (see § Release branches below). Both require the `OPENSCREEN_RELEASE_TOKEN` secret — see `docs/secrets.md`. +Two `workflow_dispatch` workflows cut a release. Trunk-based on `main`, but **release branches freeze the RC codebase between cut and promote** (see § Release branches below). Both require the `OPENSCREEN_RELEASE_TOKEN` secret — see `technical-documentation/engineering/release-and-secrets.md`. ### Step 1: cut a release candidate diff --git a/.harness/reins/openscreen-dev/agent.md b/.harness/reins/openscreen-dev/agent.md index 33c4a28a18..4bd9ce150e 100644 --- a/.harness/reins/openscreen-dev/agent.md +++ b/.harness/reins/openscreen-dev/agent.md @@ -15,7 +15,7 @@ You are the generalist implementer for the OpenScreen project — a free, open-s ## How you work - Read `AGENTS.md` at the repo root before touching anything — it has the canonical commands, layout, and conventions. -- When the change touches recording, IPC, or the native bridge, read `.harness/docs/architecture-overview.md` (start here), `docs/architecture/native-bridge.md` (deeper dive), and `docs/engineering/` (native helper roadmaps). +- When the change touches recording, IPC, or the native bridge, read `.harness/docs/architecture-overview.md` (start here), `technical-documentation/architecture/native-bridge.md` (deeper dive), and `technical-documentation/engineering/` (native helper roadmaps). - TypeScript strict mode, Biome format (tabs, double quotes, 100-col). Run `npm run lint:fix` before committing. - For renderer-only iteration use `npm run build-vite`. For full packaging use `npm run build` (electron-builder, slow). - Native helpers require a real platform to test — don't claim "done" on macOS/Windows native code without a manual smoke test. diff --git a/.harness/reins/openscreen-tester/agent.md b/.harness/reins/openscreen-tester/agent.md index cbf3afdc6c..bd4658ce4a 100644 --- a/.harness/reins/openscreen-tester/agent.md +++ b/.harness/reins/openscreen-tester/agent.md @@ -15,7 +15,7 @@ You are the test specialist for the OpenScreen project — a free, open-source s ## How you work - Read `AGENTS.md` at the repo root for commands and conventions. -- Read `docs/tests/writing-tests.md` for the project's test style guide. +- Read `technical-documentation/testing/writing-tests.md` for the project's test style guide. - Match the style of neighboring `*.test.` files in the same package — don't invent new patterns. - Unit tests: `npm run test` (Vitest, jsdom). Browser tests: `npm run test:browser` (needs `npm run test:browser:install` once). E2E: `npm run test:e2e` (Playwright). - E2E specs in `tests/e2e/windows-native-checklist.spec.ts` are Windows-only — gate with `test.skip` for other platforms rather than deleting. diff --git a/.opencode/prompts/playwright-test-generator.md b/.opencode/prompts/playwright-test-generator.md new file mode 100644 index 0000000000..08f19919dd --- /dev/null +++ b/.opencode/prompts/playwright-test-generator.md @@ -0,0 +1,53 @@ +You are a Playwright Test Generator, an expert in browser automation and end-to-end testing. +Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate +application behavior. + +# For each test you generate +- Obtain the test plan with all the steps and verification specification +- Run the `generator_setup_page` tool to set up page for the scenario +- For each step and verification in the scenario, do the following: + - Use Playwright tool to manually execute it in real-time. + - Use the step description as the intent for each Playwright tool call. +- Retrieve generator log via `generator_read_log` +- Immediately after reading the test log, invoke `generator_write_test` with the generated source code + - File should contain single test + - File name must be fs-friendly scenario name + - Test must be placed in a describe matching the top-level test plan item + - Test title must match the scenario name + - Includes a comment with the step text before each step execution. Do not duplicate comments if step requires + multiple actions. + - Always use best practices from the log when generating tests. + + + For following plan: + + ```markdown file=specs/plan.md + ### 1. Adding New Todos + **Seed:** `tests/seed.spec.ts` + + #### 1.1 Add Valid Todo + **Steps:** + 1. Click in the "What needs to be done?" input field + + #### 1.2 Add Multiple Todos + ... + ``` + + Following file is generated: + + ```ts file=add-valid-todo.spec.ts + // spec: specs/plan.md + // seed: tests/seed.spec.ts + + test.describe('Adding New Todos', () => { + test('Add Valid Todo', async { page } => { + // 1. Click in the "What needs to be done?" input field + await page.click(...); + + ... + }); + }); + ``` + + +Context: User wants to generate a test for the test plan item. \ No newline at end of file diff --git a/.opencode/prompts/playwright-test-healer.md b/.opencode/prompts/playwright-test-healer.md new file mode 100644 index 0000000000..70173c4d3f --- /dev/null +++ b/.opencode/prompts/playwright-test-healer.md @@ -0,0 +1,37 @@ +You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and +resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix +broken Playwright tests using a methodical approach. + +Your workflow: +1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests +2. **Debug failed tests**: For each failing test run `test_debug`. +3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to: + - Examine the error details + - Capture page snapshot to understand the context + - Analyze selectors, timing issues, or assertion failures +4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining: + - Element selectors that may have changed + - Timing and synchronization issues + - Data dependencies or test environment problems + - Application changes that broke test assumptions +5. **Code Remediation**: Edit the test code to address identified issues, focusing on: + - Updating selectors to match current application state + - Fixing assertions and expected values + - Improving test reliability and maintainability + - For inherently dynamic data, utilize regular expressions to produce resilient locators +6. **Verification**: Restart the test after each fix to validate the changes +7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly + +Key principles: +- Be systematic and thorough in your debugging approach +- Document your findings and reasoning for each fix +- Prefer robust, maintainable solutions over quick hacks +- Use Playwright best practices for reliable test automation +- If multiple errors exist, fix them one at a time and retest +- Provide clear explanations of what was broken and how you fixed it +- You will continue this process until the test runs successfully without any failures or errors. +- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme() + so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead + of the expected behavior. +- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test. +- Never wait for networkidle or use other discouraged or deprecated apis diff --git a/.opencode/prompts/playwright-test-planner.md b/.opencode/prompts/playwright-test-planner.md new file mode 100644 index 0000000000..59c50f1710 --- /dev/null +++ b/.opencode/prompts/playwright-test-planner.md @@ -0,0 +1,44 @@ +You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test +scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage +planning. + +You will: + +1. **Navigate and Explore** + - Invoke the `planner_setup_page` tool once to set up page before using any other tools + - Explore the browser snapshot + - Do not take screenshots unless absolutely necessary + - Use `browser_*` tools to navigate and discover interface + - Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality + +2. **Analyze User Flows** + - Map out the primary user journeys and identify critical paths through the application + - Consider different user types and their typical behaviors + +3. **Design Comprehensive Scenarios** + + Create detailed test scenarios that cover: + - Happy path scenarios (normal user behavior) + - Edge cases and boundary conditions + - Error handling and validation + +4. **Structure Test Plans** + + Each scenario must include: + - Clear, descriptive title + - Detailed step-by-step instructions + - Expected outcomes where appropriate + - Assumptions about starting state (always assume blank/fresh state) + - Success criteria and failure conditions + +5. **Create Documentation** + + Submit your test plan using `planner_save_plan` tool. + +**Quality Standards**: +- Write steps that are specific enough for any tester to follow +- Include negative testing scenarios +- Ensure scenarios are independent and can be run in any order + +**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and +professional formatting suitable for sharing with development and QA teams. diff --git a/.worktrees/wt-9ce78f24/HANDOFF.md b/.worktrees/wt-9ce78f24/HANDOFF.md deleted file mode 100644 index 9ab72d3187..0000000000 --- a/.worktrees/wt-9ce78f24/HANDOFF.md +++ /dev/null @@ -1,299 +0,0 @@ -# AI-Edition Implementation Handoff - -**Branch**: `docs/ai-edition-plan` (commit `cf25858`, pushed to `origin`) -**Worktree**: `G:\repos\openscreen\.worktrees\wt-9ce78f24` -**Dev server**: `http://localhost:5173/?windowType=editor` (browser mode with shim) - ---- - -## 1. Context - -The user (Etienne Lescot, repo owner) was working through the implementation of the **OpenScreen x Axcut AI-edition merge**. The original PR #35 (commit `1e9db17` on the same branch) introduced the planning docs only: - -- `docs/architecture/ai-edition-merge-plan.md` — the 10-phase merge plan -- `docs/architecture/axcut-inventory.md` — catalog of the axcut codebase -- `docs/architecture/openscreen-inventory.md` — catalog of the OpenScreen codebase -- `docs/architecture/ai-edition-collision-analysis.md` — collision analysis - -This implementation PR (`cf25858`) delivers the **code** for that plan — all phases 0, 1, 3, 4, 6-8, and partial 9, plus a developer-convenience browser shim and spec updates that changed the framing. - -The plan was re-framed mid-implementation. The user clarified: -- **New editing model** (multi-asset, clips, skips, transcript, virtual-time preview) = **default for all users**, not opt-in -- **AI features** (LLM provider config, chat) = **opt-in** behind `AI_FEATURES_ENABLED` -- **Local Whisper** = **privacy-safe, not gated** (runs in-browser, never calls out) - -This is the spec's `§0 Framing` section. See `docs/architecture/ai-edition-merge-plan.md` lines ~13-65. - ---- - -## 2. What was built (file by file) - -### 2.1 Schema & migration (`src/lib/ai-edition/`) - -| File | Purpose | -|------|---------| -| `schema/index.ts` | Vendored axcut v2 schema + v3 additions (`annotations[]`, `zoomRanges[]`, `legacyEditor` envelope, `transcripts[]`). `axcutSchemaVersion = 3`. `clip.sourceEndSec` made optional (duration unknown at migration time). | -| `schema/index.test.ts` | 15 schema tests (version enforcement, optional clip duration, envelope passthrough, etc.) | -| `document/timeline.ts` | Pure interval math: `normalizeIntervals`, `subtractInterval`, `invertIntervals`, `buildTimelineFromIntervals`, `replaceTimeline`, `restoreFullTimeline`. Ported from axcut `apps/server/src/lib/timeline.ts` (no event bridge, no agent — just the math). | -| `document/timeline.test.ts` | 14 tests covering all the above. | -| `document/migrate.ts` | Bidirectional `EditorProjectData` (v2) ↔ `AxcutDocument` (v3). Notes: `zoomRanges`/`annotations` use **ms** units to mirror the legacy types; timeline ops use **sec** units. The migration is lossless in both directions thanks to the `legacyEditor` passthrough. | -| `document/migrate.test.ts` | 14 tests including round-trip, v1 legacy, focus clamping. | -| `document/transcribe.ts` | `transcribeAsset(document, assetId)` wraps the existing `extractMono16kFromVideoUrl` + `transcribeMono16kToSegments` (from `src/lib/captioning/`). Returns an `AxcutTranscript`. `withTranscript` writes it back to the document. | -| `document/ids.ts` | `createId(prefix)` using `uuid.v4()`. | -| `timeline/virtual-preview.ts` | Pure time-mapping: `totalVirtualDuration`, `clampVirtualTime`, `locateVirtualPosition`, `locateSourcePosition`, `keptWordIdSet`, `formatSeconds`. | -| `timeline/virtual-preview.test.ts` | 8 tests. | -| `store/projectStore.ts` | Zustand store: `projectId`, `document`, `revision`, `status`, `error`, `sourceDurationSec`, `currentTimeSec`. Actions: `loadProject`, `createProject`, `addAsset`, `removeAsset`, `replaceTimeline`, `restoreFullTimeline`, `setTranscript`, `setSourceDuration`, `setCurrentTime`, `saveDocument`, `setDocument`, `clear`. | -| `store/projectStore.test.ts` | 5 tests with `nativeBridgeClient.aiEdition` mocked. | -| `exporter/documentExporter.ts` | Adapter: maps `AxcutDocument` → `VideoExporterConfig` / `GifExporterConfig`. Clips → `trimRegions` (inverse). Reads `legacyEditor` for wallpaper, cursor, webcam, etc. `sourceWidth`/`sourceHeight` come from caller. | - -### 2.2 Main-process services (`electron/ai-edition/`) - -| File | Purpose | -|------|---------| -| `document-service.ts` | `DocumentService(projectsRoot)`: `listProjects`, `getProject(projectId)`, `createProject(title)`, `saveProject(doc)`, `deleteProject(projectId)`, `addAsset(projectId, {path, label?})`, `removeAsset(projectId, assetId)`. One `.axcut` JSON file per project under `app.getPath('userData')/projects/`. Validates paths against an allowlist of video extensions. Cascades clips + skipRanges on asset removal. | -| `document-service.test.ts` | 16 tests (CRUD, path traversal, cascade, primary-asset reassignment). | -| `provider-registry.ts` | 8 provider definitions (anthropic, openai, google, mistral, openrouter, openai-compatible, openai-oauth, copilot-proxy) with `authKind`, `supportsReasoningEffort`, `envKeys`, `baseUrl`. Ported from axcut `provider-registry.ts`. | -| `llm-config-store.ts` | `LlmConfigStore(userDataPath)`: config in `llm-config.json` plain JSON, **credentials in `safeStorage`-encrypted bytes** at `llm-credentials.enc`. Env vars override stored keys (same precedence as axcut). | -| `chat-service.ts` | `runChat(projectId, message, llmConfig)`: validates config + API key, stores messages in a `Map`, **returns a stub assistant message** (LLM call needs `@langchain/*` deps). `getChatHistory(projectId)` returns the in-memory list. | -| `native-bridge/services/aiEditionService.ts` | Adapter to the existing `native-bridge` envelope: wraps `DocumentService`, `LlmConfigStore`, and the chat stubs into the `domain: "aiEdition"` IPC contract. | - -### 2.3 IPC bridge extensions - -- `electron/ipc/nativeBridge.ts` — added the `aiEdition` domain case. Each action calls into `AiEditionService` (`document.listProjects`, `document.get`, `document.create`, `document.save`, `document.delete`, `document.addAsset`, `document.removeAsset`, `llm.getSnapshot`, `llm.setConfig`, `llm.setApiKey`, `llm.removeApiKey`, `chat.run`, `chat.history`). -- `electron/ipc/handlers.ts` — wires `DocumentService` + `LlmConfigStore` + chat functions into the `NativeBridgeContext`. -- `src/native/contracts.ts` — adds `AiEditionLlmConfig`, `AiEditionLlmSnapshot`, `AiEditionChatMessage`, `AiEditionChatResult` types and the new `aiEdition` action cases to the `NativeBridgeRequest` union. -- `src/native/client.ts` — adds the `nativeBridgeClient.aiEdition` namespace with `listProjects`, `get`, `create`, `save`, `delete`, `addAsset`, `removeAsset`, `llmGetSnapshot`, `llmSetConfig`, `llmSetApiKey`, `llmRemoveApiKey`, `chatRun`, `chatHistory`. -- `src/native/browserShim.ts` — **new**. Browser-mode shim that: - - Stubs `window.electronAPI` (no-op `openVideoFilePicker`, `pickExportSavePath`, etc.) - - Overrides `nativeBridgeClient` methods to return mock data - - Persists projects/docs in `localStorage` (`browser-shim-projects`, `browser-shim-document`) - - Auto-installs when running in a plain browser at `http://localhost:5173/?windowType=editor` - - Detected via absence of `window.electronAPI` - -### 2.4 Renderer UI (`src/components/ai-edition/`) - -| File | Purpose | -|------|---------| -| `IconRail.tsx` | Vertical 36-44px icon rail with collapse/expand chevron. Used for both left and right rails. Tooltip on hover. | -| `NewEditorShell.tsx` | **The default editor** for all users (replaces legacy `VideoEditor`). Layout: top header (project title + 3 toggle buttons) + body with left rail | left content (Project/Chat) | center (video + timeline) | right content (Transcript/Background/Video effects/Camera/Cursor/Crop/Export) | right rail. Recording → asset on editor open (auto-creates project + adds asset). Legacy `.openscreen` loading via the "Open" header button (migrates v2 → v3). | -| `AiEditionShell.tsx` | Re-exports `AiEditionOrLegacy` which delegates to `NewEditorShell` (legacy VideoEditor is now unused but kept for rollback). | -| `ProjectPanel.tsx` | Left content: project list + create input + assets list. Uses raw Tailwind matching OpenScreen's dark surface. | -| `TimelinePane.tsx` + `.module.css` | Ported from axcut `apps/web/src/components/TimelinePane.tsx` (~837 lines). Ruler, kept/cut segments, playhead, zoom (Ctrl+wheel), pan (Alt+drag), add cut, delete cut, resize cut handles, fit button, navigator overview. | -| `VirtualPreview.tsx` + `.module.css` | Ported from axcut `apps/web/src/components/VirtualPreview.tsx`. Single-video element with virtual-time seeking; seeks across clip boundaries; reports metadata via `onLoadedMetadata`; exposes video element via `onVideoElement` callback. | -| `TranscriptEditor.tsx` + `.module.css` | Click word / shift-click word → range → "Cut" button → `dropWordRange` op. Kept words = default, skipped = red strikethrough. | -| `ChatPanel.tsx` | Right content when `leftTab === "chat"`. Messages list + input + send. In-memory history. | -| `EditorSettings.tsx` | Bridge that wraps the **original `SettingsPanel`** (from `src/components/video-editor/SettingsPanel.tsx`, unchanged). Reads from `AxcutDocument.legacyEditor` (wallpaper, cursor, webcam, shadow, etc.), `document.zoomRanges`, `document.annotations`. Writes back through `setDocument` / `saveDocument`. Maps `activeTab` to `SettingsPanelMode` (background/effects/layout/cursor/timeline/export). Calls `SettingsPanel` with `hideInternalRail` so the right rail is the only navigation. | - -### 2.5 App-level wiring - -- `src/App.tsx` — imports and calls `installBrowserShims()` before render. The `editor` windowType still lazy-loads the `AiEditionShell` (which now renders `NewEditorShell`). -- `src/components/video-editor/featureFlags.ts` — renamed `AI_EDITION_ENABLED` → `AI_FEATURES_ENABLED`, default `false`. The flag now **only** gates the LLM/agent UI (chat panel). The new editor is the default for everyone. -- `package.json` — added `zod: ^3.23.8` and `zustand: ^5.0.8`. - -### 2.6 Documentation - -- `docs/architecture/ai-edition-merge-plan.md` — **major rewrite**: - - **New §0 Framing** — two layers (new editor = default, AI features = opt-in) - - **§5.8 locked decision** updated: flag now gates only LLM/agent UI - - **§10 cut-over** — no editor cut-over (new editor is default); only AI features opt-in - - Locked decisions list re-ordered: framing change recorded - ---- - -## 3. What was tested - -- **`npx tsc --noEmit`**: clean (no errors) -- **`npm run lint`**: clean (1 warning, not error — `useExhaustiveDependencies` in TimelinePane, pre-existing pattern) -- **`npm run test`**: **313 / 313 tests pass** across 39 test files - - 16 `document-service.test.ts` - - 15 `schema/index.test.ts` - - 14 `timeline.test.ts` - - 14 `migrate.test.ts` - - 8 `virtual-preview.test.ts` - - 5 `projectStore.test.ts` - - + 239 pre-existing tests (all still passing) -- **Browser smoke test**: `http://localhost:5173/?windowType=editor` renders the editor with shim data, project create/select works, asset add works (mocked), transcript/chat panels render, settings panel shows correct view per right-rail tab. - ---- - -## 4. Key decisions and rationale - -### 4.1 The framing change (user-driven) - -The original plan treated "AI-edition" as a single opt-in feature. Mid-implementation the user said: *multi-asset/clips/etc. is valid outside of user opt-in. It is valid outside of user opt-in. The opt-in should be limited to llm/conversation.* This led to: -- `AI_EDITION_ENABLED` → `AI_FEATURES_ENABLED` (the rename makes the semantic explicit) -- New editor ships to all users by default (kill-switch removed) -- The right rail's chat / LLM config is the only gated surface -- Local Whisper stays ungated (privacy-safe by construction) - -### 4.2 Why the new editor ships as the default despite incomplete feature parity - -The spec calls for full feature parity (annotations, zoom, cursor, webcam, blur, crop, export, legacy `.openscreen` loading). The implementation delivers the **architecture** and the **export, legacy loading, transcript, transcription, settings panel** integrations, but the new editor's UI is intentionally simpler than the legacy `VideoEditor` for some affordances (no annotations/zoom UI for adding new ones, just editing existing ones from the `SettingsPanel`). This is acceptable for a first cut because: -- The legacy `VideoEditor` is still on disk and reachable via git (rollback path) -- Adding the remaining UI affordances is incremental (no new architecture needed) -- The `SettingsPanel` integration already lets users edit every field that exists in their v3 document - -### 4.3 Why the AI runtime is stubbed - -Phases 6-8 require `@langchain/openai`, `@langchain/anthropic`, `deepagents`, `better-sqlite3`. These are heavy (multi-MB native modules, OAuth flows, langgraph runtime). The implementation: -- Ships the IPC contracts, provider registry, LLM config store (with `safeStorage`), chat history -- Stubs the actual LLM call (returns a fixed message reminding the user to install deps) -- The 8 providers, OAuth flow, reasoning effort mapping, and the chat-service scaffolding are all in place — adding the real `@langchain/*` calls is a focused follow-up - -### 4.4 Why ms for `annotations[]` / `zoomRanges[]` but sec for timeline - -`AxcutDocument.annotations` and `AxcutDocument.zoomRanges` mirror the legacy `ProjectEditorState.annotationRegions` / `.zoomRegions` which use **ms**. The timeline ops (`skipRanges`, `clips.sourceStartSec`, etc.) follow axcut's convention of **sec** because axcut's `clips` are authored from the agent/runtime where the second-based model is canonical. This dual-unit is contained to the document schema and handled by the `document/timeline.ts` math + `migrate.ts` conversion. The renderer reads `document.zoomRanges` directly as ms. - -### 4.5 Why `safeStorage` for credentials (not plain JSON) - -Per locked decision 4 in the spec: LLM credentials are stored in `safeStorage`-encrypted bytes (OS keychain on macOS, libsecret on Linux, DPAPI on Windows). Config (provider, model, baseUrl, reasoningEffort) is plain JSON. This matches axcut's security improvement over their original plain-JSON approach. - ---- - -## 5. What's NOT in this PR (deferred work) - -These are deliberate deferrals, not oversights: - -1. **Full feature parity UI** — adding new annotations/zoom regions from the new shell (the SettingsPanel can only edit existing ones). Follow-up: port the legacy `VideoEditor`'s annotation/zoom add flows to `NewEditorShell`. -2. **Real LLM calls** — `@langchain/*` deps not installed. Follow-up: `npm i @langchain/openai @langchain/anthropic deepagents` and replace the stub in `chat-service.ts:runChat`. -3. **SQLite for sessions/checkpoints** — `better-sqlite3` not installed. Follow-up: port axcut's `DatabaseService` and `PersistentFileCheckpointSaver`. -4. **Webcam real-time preview** in `VirtualPreview` — current is a single-video component; axcut has a two-layer crossfade. Follow-up for a richer preview experience. -5. **13-locale i18n** — the new components use hardcoded English strings ("Transcribe", "Remove cuts", "Export", etc.). Follow-up: add to `src/i18n/locales//*.json`. -6. **Settings sync to `userPreferences.ts`** — `AI_FEATURES_ENABLED` toggle is a constant, not user-toggleable. Follow-up: wire to the existing settings sync. -7. **Export dialog integration** — the new editor's "Export" button shows a toast. Follow-up: wire the `ExportDialog` component with the full options. -8. **The legacy `VideoEditor.tsx`** (2961 lines) is unchanged on disk. It can be deleted in a follow-up once confidence is high. - ---- - -## 6. How to continue - -### 6.1 Resume this branch - -```bash -cd G:\repos\openscreen\.worktrees\wt-9ce78f24 -git status # should be clean -git log --oneline -3 -npm run dev # already running, port 5173 -# Open http://localhost:5173/?windowType=editor -``` - -### 6.2 Add a real LLM provider - -1. `npm i @langchain/openai @langchain/anthropic deepagents better-sqlite3` -2. In `electron/ai-edition/chat-service.ts:runChat`, replace the stub with a real call: - ```ts - import { ChatOpenAI } from "@langchain/openai"; - const model = new ChatOpenAI({ model: config.model, apiKey }); - const result = await model.invoke(message); - ``` -3. Add the corresponding provider in `provider-registry.ts` if it's not already there. - -### 6.3 Add full feature parity (annotations/zoom creation UI) - -1. Port the legacy `VideoEditor`'s annotation-add flow (around line 2500+) to a new component. -2. Mount it in `NewEditorShell` alongside `SettingsPanel`. -3. Wire it to `documentStore.setDocument` (already wired through `EditorSettings`). - -### 6.4 Open a PR - -```bash -git push origin docs/ai-edition-plan # already pushed -gh pr create \ - --base main \ - --head docs/ai-edition-plan \ - --title "feat(ai-edition): implement v3 editor model + AI features scaffold" \ - --body-file PR_BODY.md -``` - -### 6.5 Delete the legacy `VideoEditor` when ready - -The file `src/components/video-editor/VideoEditor.tsx` (2961 lines) is now unused in the default flow. `grep -r "from.*VideoEditor" src/` to confirm. Then delete and remove from `App.tsx` lazy import. - ---- - -## 7. File map (where to look) - -``` -G:\repos\openscreen\.worktrees\wt-9ce78f24\ -├── docs/architecture/ -│ └── ai-edition-merge-plan.md # updated §0, §5.9, §10 -├── electron/ -│ ├── ai-edition/ -│ │ ├── document-service.ts # CRUD on .axcut files -│ │ ├── document-service.test.ts -│ │ ├── llm-config-store.ts # safeStorage credentials -│ │ ├── provider-registry.ts # 8 providers, static -│ │ └── chat-service.ts # in-memory, LLM stub -│ ├── ipc/ -│ │ ├── handlers.ts # wires services to bridge -│ │ └── nativeBridge.ts # adds aiEdition domain -│ └── native-bridge/services/ -│ └── aiEditionService.ts # bridge adapter -├── src/ -│ ├── App.tsx # installs browser shim -│ ├── native/ -│ │ ├── browserShim.ts # NEW - browser-mode stubs -│ │ ├── client.ts # adds aiEdition namespace -│ │ └── contracts.ts # adds aiEdition types -│ ├── components/ -│ │ ├── video-editor/ -│ │ │ ├── SettingsPanel.tsx # + hideInternalRail prop -│ │ │ └── featureFlags.ts # AI_EDITION_ENABLED → AI_FEATURES_ENABLED -│ │ └── ai-edition/ # NEW directory -│ │ ├── AiEditionShell.tsx # kill-switch removed -│ │ ├── NewEditorShell.tsx # main shell, the default -│ │ ├── IconRail.tsx -│ │ ├── ProjectPanel.tsx -│ │ ├── TimelinePane.tsx + .module.css -│ │ ├── VirtualPreview.tsx + .module.css -│ │ ├── TranscriptEditor.tsx + .module.css -│ │ ├── ChatPanel.tsx -│ │ └── EditorSettings.tsx # bridge → SettingsPanel -│ └── lib/ai-edition/ # NEW directory -│ ├── schema/index.ts + .test.ts -│ ├── document/ -│ │ ├── timeline.ts + .test.ts -│ │ ├── migrate.ts + .test.ts -│ │ ├── transcribe.ts -│ │ └── ids.ts -│ ├── timeline/ -│ │ └── virtual-preview.ts + .test.ts -│ ├── store/ -│ │ └── projectStore.ts + .test.ts -│ └── exporter/ -│ └── documentExporter.ts -└── package.json # +zod, +zustand -``` - ---- - -## 8. The conversation arc (for context) - -1. User asked to check PR #35 and start implementation per its plan. -2. Implemented Phase 0 (schema, migration, feature flag) — 29 tests pass, human-testable via dev server. -3. Implemented PR 1.1 (project panel, document service, IPC bridge) — human-testable. -4. User asked for total spec completion. Implemented PR 1.2 + 1.3 (timeline port, preview port, new editor shell with kill-switch). -5. User said "implement 1, 2, and 3" with the axcut `\\wsl.localhost\Ubuntu\home\etienne\repos\axcut\` path. Implemented: - - Export (Phase 3) — adapter to existing VideoExporter - - Legacy `.openscreen` loading — migrate v2 → v3 - - Settings panel (annotations, zoom, cursor, webcam, wallpaper) — bridge to `SettingsPanel` -6. User said "go on → full implementation". Implemented Phases 6-8 scaffolding (provider registry, LLM config store with safeStorage, chat service stub, IPC contracts) and Phase 9 partial (settings toggle, i18n deferred). -7. User asked to relaunch in browser. Added `browserShim.ts` for `http://localhost:5173/?windowType=editor`. -8. User asked for UI redesign to match original OpenScreen + axcut layout. Implemented: - - Left icon rail (Project / Chat) - - Right icon rail (Transcript / Background / Video effects / Camera / Cursor / Crop / Export) - - Top header (project title + PanelLeft / PanelRight / Download) - - NewEditorShell with full-height columns - - Removed the chevron collapse buttons (user requested) - - Used original OpenScreen SettingsPanel icons - - Added `hideInternalRail` prop so the right rail is the only navigation -9. User asked about worktree, branch, commit, push. Confirmed branch (`docs/ai-edition-plan`), committed and pushed. -10. User asked for a handoff summary in English for a coding agent. - ---- - -**The next coding agent should**: -- Open `http://localhost:5173/?windowType=editor` to see the current state -- Read `docs/architecture/ai-edition-merge-plan.md` for the plan -- Pick up from §5 (deferred work) — most impactful next steps are real LLM calls (#2) and feature parity UI (#1) -- All architecture is in place; the remaining work is wiring and UI polish, not new design diff --git a/AGENTS.md b/AGENTS.md index 566132003b..1f1a63d40e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R - `src/` — React app: UI, editor components, timeline, i18n, captioning/cursor/exporter libs - `electron/` — main process, IPC, recording orchestration - `electron/native/` — **native** capture helpers: `screencapturekit/` (Swift, macOS) and `wgc-capture/` (C++/Win32, Windows). These are built and shipped with the app, not loaded from npm -- `docs/` — architecture, engineering roadmaps, testing guides +- `technical-documentation/` — architecture, engineering and testing reference (start at its README) - `tests/` — Playwright e2e specs + fixtures - `scripts/` — native build scripts, diagnostic tools - `nix/`, `flake.nix` — Linux packaging @@ -59,6 +59,9 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi **The HUD widget** (recording controller) +- **It is invisible in screenshots by default.** The HUD (and the Notes window) call `setContentProtection(true)` so the recording controls never end up baked into a recording — the same `SetWindowDisplayAffinity` that WGC honours also hides them from *your* screenshots. The window is there, and clicks land, but you are aiming blind at a rectangle you cannot see. Set **`OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`** in the app's environment to turn it off for a session; every skipped window logs a warning. Unset it before recording anything real, or the HUD ends up in the video. +- **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron. +- The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). @@ -75,6 +78,11 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi 4. Exercise the feature in the editor (e.g. Full Camera: press **C** to add a segment on the timeline, scrub to see the webcam grow to fullscreen and ease back; **Ctrl+Z** / **Ctrl+Shift+Z** undo/redo). 5. Capture a screenshot as proof. Clean up: stop `npm run dev`, remove temporary worktree junctions/lock. +**Judging the rendered picture** + +- A preview screenshot is a **downscaled** view of the compositor's output (a 1920-wide render shown in a ~600px pane, then downscaled again by the screenshot). Fine detail — a corner radius, a 1° edge slope, a soft shadow — does not survive that, and squinting at it produces confident wrong conclusions. To decide anything about pixels, **export and measure**: `Export → MP4 1080p`, then `ffmpeg -ss -i out.mp4 -frames:v 1 -c:v ppm frame.ppm` and walk the raw bytes (a P6 PPM is a 15-line parser) for the exact edges. That is what settled a "the tilt is truncated" report: measured right edge 1539 px against a computed corner at 1540 — no clipping at all, the real defect was elsewhere. +- ffmpeg lives at `crates/thirdparty/ffmpeg-*/bin/ffmpeg.exe` (also needed on `PATH` for the compositor addon to load). + ## PR & commit conventions - Branch from `main`; never push to it directly. @@ -91,7 +99,7 @@ Two `workflow_dispatch` workflows cut a release with a pre-release candidate (RC - **Promote RC**: Actions → "Promote RC to stable release" → Run workflow. Input: `rc_tag` (e.g. `v1.5.0-rc.2`), optional `release_notes_extra`. Closes the `vX.Y.Z` milestone, strips `-rc.N` from `package.json`, pushes `vX.Y.Z` tag, which triggers `build.yml` to publish a stable release (full notarization, Tier 3 homebrew/winget/nix/aur fires). Notifies `#announcements` on Discord. - **Manual fallback**: `git tag vX.Y.Z-rc.N && git push origin vX.Y.Z-rc.N` does the same as Cut RC (minus the milestone migration and Discord announce) — useful for emergency cuts. -Both workflows require the `OPENSCREEN_RELEASE_TOKEN` secret (a fine-grained PAT with `contents: write` + `issues: write`). This is the standard fix for `release: published` not triggering downstream workflows when the release is created by `GITHUB_TOKEN`. See `docs/secrets.md`. +Both workflows require the `OPENSCREEN_RELEASE_TOKEN` secret (a fine-grained PAT with `contents: write` + `issues: write`). This is the standard fix for `release: published` not triggering downstream workflows when the release is created by `GITHUB_TOKEN`. See `technical-documentation/engineering/release-and-secrets.md`. **Release branches freeze the build between cut and promote.** Every RC cut creates `release/vX.Y.Z-rc.N`. The branch is *not* merged into `main` until the stable tag is published; only cherry-picks of bugfixes land on the release branch during the RC window. The stable tag points at the branch tip (RC + cherry-picks), then `promote.yml` opens a `release/vX.Y.Z-sync → main` PR to bring main into line. This contract exists because of the v1.6.0 incident (2026-07-05) where the original promote workflow tagged `main` instead of the RC snapshot, causing 23 unreleased commits to ship in `v1.6.0`. Full rules in `.harness/docs/git-workflow.md` § Release branches. diff --git a/README.md b/README.md index 6d098d97c2..6ce5cc87c5 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ The goal of this continuation is to keep OpenScreen alive as a fully open-source > Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.

- - + +

## Core Features @@ -47,14 +47,15 @@ The goal of this continuation is to keep OpenScreen alive as a fully open-source - Webcam overlay with picture-in-picture, drag-to-position, mirroring, and shape options. - Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position; auto-zoom follows your cursor as you work. - Custom cursor size, smoothing, and click effects, with cursor themes and post-recording path smoothing. -- Automatic captions for voiceovers, generated on-device with no upload (works offline). +- Automatic captions for voiceovers, transcribed on-device with no upload (works offline), with an editable transcript you can cut from and optional subtitle translation. +- AI editing assistant: describe the edit you want in chat and it applies to the timeline — cuts, zooms, speed ramps, annotations, camera framing. Bring your own key (Claude, OpenAI, Gemini, Mistral, OpenRouter, MiniMax, or any OpenAI-compatible endpoint); nothing is enabled by default. - Wallpapers, solid colors, gradients, or your own background image. - Motion blur. - Crop, trim, and per-segment speed control on the timeline. - Text, arrow, and image annotations, with text animation presets. - Timeline snapping guides and an audio waveform to make trimming easier. - Customizable keyboard shortcuts. -- Export to MP4 or GIF in multiple aspect ratios and resolutions. +- Export to MP4 or GIF in multiple aspect ratios and resolutions, rendered and encoded on the GPU (Metal on macOS, D3D11 on Windows, Vulkan on Linux) with an automatic CPU fallback. - Languages supported: Arabic, English, Spanish, French, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese. @@ -140,11 +141,11 @@ You may need to grant screen recording permissions depending on your desktop env ### Platform differences -Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, projects, export, and all languages. The differences are in **capture**, where macOS and Windows use a native pipeline that Linux doesn't have: +Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be: -- **Native recording**: macOS (ScreenCaptureKit) and Windows (Windows Graphics Capture) record through a native pipeline for higher quality and clean window-level capture. Linux records through the browser pipeline instead. -- **Custom cursors**: on macOS and Windows the real cursor is captured (shape, type, and clicks), which powers the cursor themes, click effects, and editable cursor overlay. On Linux only the cursor position is captured (used for auto-zoom), so those cursor options aren't available. -- **Webcam**: captured natively on macOS and Windows; on Linux it's recorded through the browser, but still works as a picture-in-picture overlay. +- **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. +- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so cursor themes and the editable cursor overlay work there too — but the portal reports no mouse button events, so **click effects remain macOS and Windows only**. +- **Webcam**: Windows muxes the webcam natively into the recording; macOS and Linux record it alongside as a separate file. It works as a picture-in-picture overlay on all three. - **System audio** support varies by OS: - **macOS**: requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below can't capture system audio (mic still works). - **Windows**: works out of the box. diff --git a/ROADMAP.md b/ROADMAP.md index 85b827ed53..27fee379f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ This roadmap is the source of truth for what we're shipping next in OpenScreen. OpenScreen is, first and foremost, a polished screen recorder. Record, trim on the timeline, export. Most users will keep using exactly this workflow. -We're also exploring an optional AI editing layer — for users who want to edit by talking or by editing a transcript. It's opt-in, off by default, and never required. If you don't enable it, the AI layer doesn't exist for your install: nothing downloads, nothing leaves your machine, no LLM is contacted. +There is also an optional AI editing layer — for users who want to edit by talking or by editing a transcript. It's opt-in, off by default, and never required. If you don't enable it, the AI layer doesn't exist for your install: nothing downloads, nothing leaves your machine, no LLM is contacted. Three axes guide every decision on this roadmap: @@ -16,19 +16,30 @@ Three axes guide every decision on this roadmap: - **Sleek UX stays** — every AI feature must keep the OpenScreen feel: minimal clicks, instant feedback, no clutter. - **100% free, forever** — no paywalls, no premium tier, no usage caps. Every feature on this page ships under MIT. -## 🤖 Direction — the optional AI Edition -A Screen Studio + Descript clone, open-source and free forever. The recorder-first UX stays intact, and the AI layer sits beside it, off by default. +## 🤖 The optional AI Edition — shipped, off by default +A Screen Studio + Descript alternative, open-source and free forever. The recorder-first UX stays intact, and the AI layer sits beside it, off by default. -Capabilities we're exploring (each one opt-in, each one toggleable independently): +What ships today (each one opt-in, each one toggleable independently): -- **Local Whisper transcription (opt-in, on-device)** — OpenScreen already ships on-device Whisper transcription for automatic captions. This extends that foundation: the same local transcript powers the editing features below, with no upload, no cloud, no extra setup required. -- **Transcript-driven editing (opt-in, local)** — edit video like a doc (Descript-style: delete a word, cut the span). Works with the local transcript; no cloud needed. -- **One-click cleanup (opt-in, local)** — filler-word removal, silence trimming, Studio Sound voice enhancement. All on-device. -- **Edit by chat (opt-in, requires BYO LLM key)** — say "cut the part where I repeat myself between 0:42 and 1:10" and the agent applies a structured timeline operation. Off until you connect a provider. -- **Non-destructive project document (always on)** — every edit, AI or manual, is undoable; the timeline is always recoverable. -- **Bring-your-own LLM (opt-in)** — OpenAI, Anthropic, Google, Mistral, OpenRouter, GitHub Copilot, OpenAI-compatible endpoints, ChatGPT account auth. You choose; we never see your keys or your data. +- [x] **Local Whisper transcription (on-device)** — whisper.cpp with the compute backend picked at runtime: Metal on Apple Silicon, Vulkan on Windows and Linux, CPU everywhere else. No upload, no cloud, works offline. It's the foundation every feature below stands on. +- [x] **Transcript-driven editing (local)** — edit video like a doc: select words, press `Delete`, the span is cut from playback and export. Silences are marked inline and trimmable the same way. Word boundaries are re-anchored on the audio so a cut lands where the word actually starts. +- [x] **Captions as a derived layer (local)** — cues are a live view of the transcript, not generated text you then maintain; restyle or regroup them with no regeneration step. Optional translation into 15 languages, stored beside the transcript and never in it. +- [x] **Edit by chat (requires BYO LLM key)** — describe an edit in plain language; the agent applies real, undoable timeline operations (trims, zooms, speed, annotations, clip ranges, reordering). Off until you connect a provider. +- [x] **Non-destructive project document (always on)** — `.openscreen` projects keep every edit re-editable, and `Ctrl/Cmd + Z` covers agent edits exactly like manual ones. +- [x] **Bring-your-own LLM (opt-in)** — Anthropic, OpenAI, Google, Mistral, OpenRouter, MiniMax, and any OpenAI-compatible endpoint. Keys live in your OS credential store via Electron `safeStorage`; requests go straight from your machine to the provider. We never see them, because there is no server to see them with. -This section is a direction, not a sprint plan. Concrete items land here as RFCs once the recorder is stable enough to build on top of. +Still open on this axis: + +- [ ] **One-click cleanup** — silence trimming ships in the transcript pane, but there's no dedicated filler-word pass (only the agent names a word a filler today), and voice enhancement ("Studio Sound") isn't started. +- [ ] **Sanctioned ChatGPT / GitHub Copilot sign-in** — both were removed in 1.8.0: reaching a user's subscription meant shipping GitHub's and OpenAI's own client IDs and an editor `User-Agent` against endpoints reserved for first-party clients, from inside a signed installer. They come back on the vendors' sanctioned surfaces — GitHub's Copilot SDK (we register our own OAuth App) and `codex app-server` (drives the user's own `codex login`, no client ID shipped at all). Separate integrations, not a header swap. + +## 🖥️ Rendering & platform parity +The live preview and MP4 export run on one native Rust + Direct3D 11 compositor: demux → decode → composite → hardware encode → mux, GPU-resident, no CPU readback between stages. Both consume the same scene description, so the frame you see in the editor is the frame the export writes — there is no second renderer that can drift. + +That engine is **Windows-only today**, which makes this the largest gap on the roadmap: + +- [ ] **MP4 export on macOS and Linux** — needs a Metal and a Vulkan backend behind the same scene contract. Recording, editing, transcription and GIF export already work on all three platforms; MP4 export does not. +- [ ] **Feature:** software H.264 fallback when no GPU encoder is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments. ## 🛠️ Stability & quality (what we're actually shipping) Pulled from real user bug reports on getopenscreen/openscreen. This is the queue for the next release window. @@ -37,17 +48,15 @@ Pulled from real user bug reports on getopenscreen/openscreen. This is the queue - [ ] **Fix:** crash after stopping macOS recording — [#21](../../issues/21) (macOS 26.4.1, Apple Silicon). Crash is in the Electron / Node async fs shutdown path; recording artifacts are written correctly. - [ ] **Fix:** macOS cursor offset in single-window capture — [#22](../../issues/22). - [ ] **Fix:** recover preview from WebGL context loss on Linux / Wayland — [#19](../../issues/19). -- [ ] **Feature:** software H.264 fallback when no GPU encoder MFT is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments. -- [ ] **Feature:** copy / paste attributes & effects in the timeline — [#24](../../issues/24). Right-click menu + standard Ctrl/Cmd+C / Ctrl/Cmd+V shortcuts. -- [ ] **Feature:** restore blur regions (rectangle / oval / freehand, mosaic + CSS) — [#76](../../issues/76). Upstream v1.5.0 dropped the feature in the final release before archiving. The renderer pipeline (`BlurSettingsPanel`, `blurEffects`, `annotationRenderer`) is already present in this fork; the export guard in `src/lib/exporter/videoExporter.ts:151-152` (refuses export while `showBlur` or `motionBlurAmount` is set) is what needs to be unblocked, plus a regression test in the timeline. +- [x] **Feature:** copy / paste attributes in the timeline — [#24](../../issues/24). `Ctrl/Cmd + C` / `Ctrl/Cmd + V` copy a selected region's attributes onto another region of the same kind. +- [ ] **Feature:** right-click context menu for the copy / paste above — the other half of [#24](../../issues/24), still open. +- [x] **Feature:** restore blur regions — [#76](../../issues/76). Shipped as an annotation **type** rather than its own region kind: Gaussian or mosaic, rectangle or oval, composited natively in both preview and export. Freehand is deliberately not offered when creating one — its input was broken and the renderer only ever masked the bounding box, and a half-reliable privacy tool is worse than no tool, because people trust it. Existing freehand shapes still render as their bounding box, with the inspector saying so. ## 📚 Site & documentation -- [ ] **Feature:** Docusaurus site — landing + docs, deployed to GitHub Pages via CI. - - Monorepo at `website/`. Versioning off until v2. - - Landing (pitch, demo, quick start, downloads) + migrate `docs/` → `website/docs/`. - - Bespoke theme (TBD). - - CI: build on PR (artifact preview), deploy to Pages on `main`. Custom domain as follow-up. - - MIT, no tracking, no paywall — same posture as the app. +- [x] **Feature:** Docusaurus site — landing + docs, live at [getopenscreen.github.io/openscreen](https://getopenscreen.github.io/openscreen/), built from `website/` and deployed to GitHub Pages by `.github/workflows/docs.yml` on every push to `main`. Landing page plus a Features section covering recording, the media library, the timeline, captions, AI editing and export. MIT, no tracking, no paywall — same posture as the app. + - [ ] Custom domain. + - [ ] Versioning — still off until v2. + - Engineering docs stay in `technical-documentation/` on purpose: they track the code rather than the product, are link-checked by `npm run docs:check`, and aren't user-facing. ## 📬 How to influence this roadmap - **Discord** — join the OpenScreen Discord and post in [#🗺️・roadmap](https://discord.com/channels/1489517664467681310/1493586210675884265). The fastest way to get a thumbs-up or thumbs-down on a feature. @@ -61,4 +70,5 @@ Anything not on this list yet? Open an issue and tag it `roadmap` — we'll tria ## Changelog - **2026-06-24** — initial draft. Stability items pulled from open issues / PRs on getopenscreen/openscreen. AI section presented as opt-in / off by default. Whisper entry updated to reflect existing caption feature. - **2026-06-25** — added "Site & documentation" tier: Docusaurus + GitHub Pages. Cleaned smoke-test noise from the changelog (internal CI sync validation, not user-facing). -- **2026-07-06** — added blur regions to the stability & quality tier. Confirmed upstream deprecated the feature in v1.5.0 without an explicit reason; the renderer code carried over to the fork, so the work is unblocking the export guard + adding coverage. Tracked via #76. \ No newline at end of file +- **2026-07-06** — added blur regions to the stability & quality tier. Confirmed upstream deprecated the feature in v1.5.0 without an explicit reason; the renderer code carried over to the fork, so the work is unblocking the export guard + adding coverage. Tracked via #76. +- **2026-07-27** — reconciled the roadmap with the code. The AI Edition tier moved from "a direction, not a sprint plan" to shipped: on-device transcription, transcript-driven editing, captions as a derived layer with translation, the chat agent, and `.openscreen` projects are all in. Provider list corrected — ChatGPT and GitHub Copilot were removed in 1.8.0 and are now blocked on the vendors' sanctioned surfaces, and MiniMax was missing. New "Rendering & platform parity" tier: preview and MP4 export share one native D3D11 compositor, and porting it off Windows is now the biggest open item; #18 moved there since it's an encoder concern. Blur (#76) marked shipped — as an annotation type, not a region kind, so the old note pointing at `src/lib/exporter/videoExporter.ts` was doubly stale (that file was deleted with the web export pipeline). Copy/paste (#24) split: the shortcuts shipped, the right-click menu didn't. Docusaurus site marked shipped. \ No newline at end of file diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000000..38ce24d92d --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,78 @@ +# Third-party notices + +OpenScreen is MIT licensed (see [LICENSE](LICENSE)). The installers additionally +bundle the pre-built native components below. This file ships inside the +application resources and satisfies the attribution and source-offer obligations +that come with them. + +npm dependencies are not listed here: they are resolved from `package.json` and +distributed by their own registries, not redistributed inside our binaries. + +--- + +## FFmpeg — shared libraries (Windows only) + +- **Components**: `avcodec-*.dll`, `avformat-*.dll`, `avutil-*.dll`, + `swresample-*.dll`, `swscale-*.dll` and their siblings, under + `resources/electron/native/bin/win32-*/`. +- **Used by**: the native D3D11 compositor addon, which links against them at + load time. +- **License**: **GNU Lesser General Public License v2.1 or later** + (). FFmpeg's own + licensing page: . +- **This is an LGPL build, not a GPL one.** It is configured without + `--enable-gpl` and without `--enable-nonfree`, and links no GPL-only library + (x264, x265, xvid, vidstab, rubberband, frei0r, …). `scripts/fetch-ffmpeg.mjs` + verifies this before vendoring — it reads `ffmpeg -L`, `-buildconf` and + `-encoders` and refuses any binary that reports otherwise. +- **Upstream binaries**: BtbN/FFmpeg-Builds, release + `autobuild-2026-07-30-13-32`, the `*-lgpl-shared-8.1` assets. Pinned by + SHA-256 in `scripts/fetch-ffmpeg.mjs`; the digests there identify the exact + artifacts we ship. + +- **Corresponding source**: FFmpeg n8.1.2, commit `cfa62de001`, from + . The build configuration and scripts that + produced these exact binaries are published at + . +- **Relinking**: as required by the LGPL, these are dynamic libraries. You may + replace them with your own build of the same FFmpeg version by overwriting the + DLLs in `resources/electron/native/bin/win32-*/`. + +## whisper.cpp and ggml + +- **Components**: `whisper-stt-server` and its ggml backend sidecars, under + `resources/electron/native/bin/-/`. +- **License**: MIT — and + . +- Built from source by `scripts/build-whisper-stt.sh`; the pinned upstream + revision is in `electron/native/whisper-stt/CMakeLists.txt`. +- The speech model (`ggml-*.bin`) is **not** bundled — it is downloaded into the + user's data directory on first use by `electron/stt/modelManager.ts`. + +## PipeWire — headers (Linux only) + +- **Components**: header sources under + `electron/native/pipewire-capture/vendor/pipewire-1.0.5/include/`, compiled + into `openscreen-pipewire-helper` (the Linux cursor/capture helper) under + `resources/electron/native/bin/linux-*/`. +- **License**: **MIT** — . + Every vendored file keeps its upstream `SPDX-License-Identifier: MIT` header, + and the project's licence text is copied alongside them as `COPYING`. +- **Upstream**: PipeWire release 1.0.5. Only the header subset the helper + includes was vendored; `vendor/README.md` records exactly what was copied and + how to reproduce the selection. +- **No PipeWire binary is redistributed.** The helper resolves + `libpipewire-0.3.so.0` with `dlopen` at runtime, from the user's own system, + so nothing of PipeWire's ships inside our installers beyond the compiled + result of its headers (inline functions and struct layouts). + +## OpenScreen native helpers + +`wgc-capture` (Windows Graphics Capture), the ScreenCaptureKit helper (macOS), +the PipeWire helper (Linux) and the compositor addon are part of this repository +and are covered by [LICENSE](LICENSE). + +--- + +To report an omission or request source for anything bundled here, open an issue +at . diff --git a/biome.json b/biome.json index 517be7287f..b7c87298dd 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,10 @@ { "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!**/*.css", "!**/design/**", "!**/.worktrees/**"] + }, "formatter": { "enabled": true, "indentStyle": "tab", @@ -92,7 +95,14 @@ "useGetterReturn": "error" } }, - "includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"] + "includes": [ + "**", + "**/dist", + "**/.eslintrc.cjs", + "!**/*.css", + "!**/design/**", + "!**/.worktrees/**" + ] }, "javascript": { "formatter": { "quoteStyle": "double" } }, "overrides": [ diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml new file mode 100644 index 0000000000..caae3cfdb5 --- /dev/null +++ b/crates/.cargo/config.toml @@ -0,0 +1,20 @@ +# FFMPEG_DIR relatif au dossier crates/ (portable dans le repo). Y déposer le build +# ffmpeg LGPL-shared (voir README). LIBCLANG_PATH = install LLVM locale (bindgen). +# Ces valeurs cèdent à une vraie variable d'environnement (force=false par défaut). +# +# Pinné sur le MÊME build release-branch (n8.1.2-32-gcfa62de001, tag BtbN +# autobuild-2026-07-30-13-32) que scripts/fetch-ffmpeg.mjs vendorise dans +# electron/native/bin// pour le packaging — pas un snapshot "master-latest" +# flottant. L'addon (compositor-view-napi) doit être lié à la même version de +# ffmpeg que celle shippée, sinon les DLLs vendorisées ne matchent pas les +# noms de DLL importés (avcodec-NN.dll etc.) et le require() échoue au runtime. +[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 new file mode 100644 index 0000000000..dcc6a0f143 --- /dev/null +++ b/crates/.gitignore @@ -0,0 +1,24 @@ +# build +/target +# `cargo test` lancé DEPUIS crates/compositor/ y crée son propre target/, et les tests +# compose_linux y écrivent leurs PPM de preuve. Même nature que /target ci-dessus. +/compositor/target + +# 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, 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 + +# médias de fixture (provenance dans fixture/fixture.json, qui est suivi) +fixture/*.mp4 +fixture/*.cursor.json + +# artefacts des spikes S0 +/spikes/*.exe +/spikes/*.obj +/spikes/*.log diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 0000000000..dbe2f253a7 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,1857 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[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" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "compositor-view-napi" +version = "0.0.0" +dependencies = [ + "anyhow", + "napi", + "napi-build", + "napi-derive", + "openscreen-compositor", + "windows", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +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 = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.3", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[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 = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +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 = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[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 = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg_aliases", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "strum", + "termcolor", + "thiserror 2.0.19", + "unicode-xid", +] + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags 2.13.1", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn 2.0.119", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openscreen-compositor" +version = "0.0.0" +dependencies = [ + "anyhow", + "bindgen", + "block", + "cc", + "core-foundation", + "cosmic-text", + "image", + "metal 0.29.0", + "objc", + "pollster", + "serde", + "serde_json", + "wgpu", + "windows", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poc-d3d" +version = "0.0.0" +dependencies = [ + "anyhow", + "openscreen-compositor", + "windows", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "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 = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.19", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal 0.31.0", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.19", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.13.1", + "js-sys", + "log", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/Cargo.toml b/crates/Cargo.toml new file mode 100644 index 0000000000..208f376cc5 --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,64 @@ +# Workspace Rust d'OpenScreen. +# +# compositor/ la bibliothèque : D3D11, pipeline, effets HLSL, scène, curseur, audio, +# vue live. C'est du code de PRODUCTION — l'addon Electron s'y lie. +# compositor-view-napi/ l'addon napi-rs empaqueté dans l'app (compositor_view.node). +# poc-d3d/ le POC d'origine (GUI Win32 de preview/export + bench fps). Rien ici +# n'est packagé ; il est conservé comme banc de mesure et pièce d'époque. +# +# Le tout vivait sous `poc-d3d/`, ce qui laissait croire que la bibliothèque était jetable alors +# que l'app en dépend : un `.node` périmé s'est ainsi retrouvé packagé sans que personne tique. +# Les dépendances communes sont déclarées ici une seule fois plutôt que recopiées par crate. + +[workspace] +resolver = "2" +members = ["compositor", "compositor-view-napi", "poc-d3d"] +# `cargo build` (x.bat) ne construit que le POC ; l'addon se build explicitement +# (`cargo build -p compositor-view-napi`), comme avant le découpage. +default-members = ["poc-d3d"] + +[workspace.package] +version = "0.0.0" +edition = "2021" + +[workspace.dependencies] +openscreen-compositor = { path = "compositor" } +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Décodage des wallpapers image (jpg/png) pour le fond natif. default-features off → +# on ne tire que les décodeurs nécessaires (build plus léger). +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +# Port Linux du compositor (PR #183) : wgpu (Vulkan) + pollster (block_on) + +# cosmic-text (rastérisation texte, remplace DirectWrite/CoreText). +wgpu = { version = "24", features = ["wgsl"] } +pollster = "0.4" +cosmic-text = "0.19" + +[workspace.dependencies.windows] +version = "0.58" +features = [ + "Win32_Foundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D_Fxc", # D3DCompile (HLSL runtime) + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Foundation_Numerics", # requis par les méthodes D2D à brosse (CreateSolidColorBrush) + "Win32_Graphics_Direct2D", # cible de rendu D2D sur surface DXGI (texte, text.rs) + "Win32_Graphics_Direct2D_Common", # D2D1_COLOR_F / PIXEL_FORMAT + "Win32_Graphics_DirectWrite", # mise en page + rastérisation du texte + "Win32_Graphics_Gdi", # brosses/police GUI de la preview (poc-d3d/src/app.rs) + "Win32_System_Performance", # QueryPerformanceCounter / Frequency (§10) + "Win32_System_LibraryLoader", # GetModuleHandleW (hInstance) + "Win32_UI_WindowsAndMessaging", # fenêtres (harnais live + GUI du POC) + "Win32_UI_Controls", # barre de progression (poc-d3d/src/app.rs) + "Win32_UI_Input_KeyboardAndMouse", # EnableWindow (poc-d3d/src/app.rs) +] + +[profile.release] +opt-level = 3 +lto = true +debug = false +# §10 : debug_assertions OFF dans tout run mesuré +overflow-checks = false diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000000..605c5a3663 --- /dev/null +++ b/crates/README.md @@ -0,0 +1,101 @@ +# crates/ — le compositeur natif D3D11 (le fast-path natif retenu) + +| Crate | Rôle | +|---|---| +| [`compositor/`](compositor) | La bibliothèque : D3D11, décodage, pipeline, effets HLSL, scène, curseur, audio, vue live. **Code de production** — la preview et l'export de l'app passent par là. | +| [`compositor-view-napi/`](compositor-view-napi) | L'addon napi-rs. C'est lui qui produit `compositor_view.node`, **le binaire packagé dans l'app**. | +| [`poc-d3d/`](poc-d3d) | Le POC d'origine : GUI Win32 de preview/export + harnais de bench fps. **Rien ici n'est packagé** ; conservé comme banc de mesure et pièce d'époque. | + +Tout vivait auparavant sous `poc-d3d/`, ce qui laissait croire que la bibliothèque était +jetable alors que l'app en dépend — un `.node` périmé s'est ainsi retrouvé packagé sans que +personne tique. Le garde-fou est décrit dans +[`build-and-packaging.md`](../technical-documentation/engineering/build-and-packaging.md#stale-native-artifacts) ; +en un mot : sur Windows, packager avec `npm run build:win`, jamais `npm run build`. + +Le reste de ce document est l'historique du POC qui a produit ce compositeur. + +Troisième POC de rendu d'OpenScreen, à côté de [`poc/`](../poc) (web, WebCodecs + WebGPU) +et [`poc-native/`](../poc-native) (Rust + wgpu/Vulkan). Voir la spec : +[`technical-documentation/engineering/rendering-performance.md`](../technical-documentation/engineering/rendering-performance.md), +annexe D (D.6 en particulier). + +**Ce qu'il prouve.** Le chemin natif GPU-résident — celui que `poc-native` a montré bloqué +sur le driver AMD (Vulkan Video absent, cf. D.5) — est en réalité **débloqué via D3D11**, +sur le driver actuel, sans mise à jour. Application Windows native écrite de zéro (pas un +fork) : **un seul `ID3D11Device`, zéro readback CPU entre les étapes** — + +``` +décode D3D11VA (×2 sources, NV12 GPU) → compositeur HLSL → RGB→NV12 (2 passes RTV) + → encode h264_amf (GPU→GPU) → mux MP4 +``` + +Effets, écrits depuis les maths (mêmes que le compositeur WGSL) : layout animé, zooms, +NV12→RGB BT.709, coins arrondis + masques (SDF), ombres portées (pénombre SDF), fond flouté +(dual-Kawase), flou de mouvement par vélocité, curseur custom + click bounce. + +**Résultat mesuré** (protocole §C.2 du doc : régime soutenu, tour de chauffe jeté, +spread < 15 %) : config tous-effets **~126 fps** en 1080p60, au-dessus du web (79) et de +wgpu (48–68). Le fps enveloppe demux → décode → composite → encode → mux (§10 : une lecture +d'horloge avant/après tout le run). Détail des couches C0→C8 : [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md). + +C'est le **fast-path natif retenu** pour Windows (cf. le marqueur de décision dans l'annexe D). +`poc/` reste l'hôte de lancement portable ; `poc-native` reste la preuve de portabilité du +compositeur (WGSL natif à l'identique) + la carte des coûts. + +## Stack + +- **Rust + windows-rs**, D3D11 nu (feature level 11_1, `VIDEO_SUPPORT`, multithread-protected). + Décision framework en [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md) (Vulkan/Direct2D/GStreamer/wgpu instruits, écartés). +- **ffmpeg (libav\*)** LGPL pour demux / décode D3D11VA / encode `h264_amf` / mux. Bindings + générés par `bindgen` (choix vs `ffmpeg-next` : suit ffmpeg 8.x — voir [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md)), + shim C (`shim.c`) pour les structs opaques. +- Compositeur HLSL (`src/shaders.hlsl`) compilé au runtime. + +## Prérequis + +- Rust (toolchain msvc), Visual Studio (MSVC + Windows SDK), LLVM (libclang, pour bindgen). +- **Build ffmpeg LGPL-shared** dans `thirdparty/` (non versionné) : récupérer + `ffmpeg-n8.1.2-win64-lgpl-shared` (le build pinné dans `.cargo/config.toml` — le + MÊME que `scripts/fetch-ffmpeg.mjs` vendorise, sinon le require() de l'addon échoue), + dézipper dans + `thirdparty/`. Le chemin est relatif au dossier, fixé dans `.cargo/config.toml` (`FFMPEG_DIR`). + `LIBCLANG_PATH` y pointe l'install LLVM. Ajuste le chemin vcvars dans `x.bat` si besoin. + +## Build & run + +`x.bat` encapsule vcvars + ffmpeg/bin sur le PATH runtime. Deux modes : + +**GUI (défaut)** — preview/playback interactive + export. Rapproche le POC d'une intégration +app : le même compositeur/pipeline mesuré alimente une vraie boucle de rendu. + +``` +x.bat run --release [-- --fixture fixture --out out] +``` + +Fenêtre native : preview du compositing en lecture bouclée (swapchain DXGI flip, blit +zéro-copie du RT), sélecteur de preset C0→C8, Play/Pause, **Export** (barre de progression + +bilan _temps + fps_). Écrit `out/export.mp4`. Détail : [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md). + +**Bench (§9/§10)** — la mesure fps headless, inchangée : + +``` +x.bat run --release -- --cfg C0..C8 --fixture fixture --repeat 3 --out out/ +``` + +Produit `out/C{0..8}.mp4` (1080p60, 360 frames), `out/C{n}_f{60,180,300}.png`, +`out/report.json` + table markdown sur stdout. + +## Fixture + +Deux flux screen (le 2ᵉ simule une webcam HQ), 1080p60 CBP, 360 frames, coupés en `-c copy`. +Médias non versionnés (convention des autres POC) — provenance et commandes de régénération +dans `fixture/fixture.json`, qui est suivi. + +## Docs + +L'architecture du compositeur est documentée dans +[`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md), +et les mesures qui l'ont choisie dans +[`technical-documentation/engineering/rendering-performance.md`](../technical-documentation/engineering/rendering-performance.md). +Ce README ne couvre que le build et le run de la crate. `spikes/` = les probes jetables +(Direct2D, NV12 render-target) conservés comme preuve. diff --git a/crates/compositor-view-napi/Cargo.toml b/crates/compositor-view-napi/Cargo.toml new file mode 100644 index 0000000000..cf4b6e15a3 --- /dev/null +++ b/crates/compositor-view-napi/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "compositor-view-napi" +version.workspace = true +edition.workspace = true +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"] +name = "compositor_view" + +[dependencies] +napi = { version = "2", default-features = false, features = ["napi6"] } +napi-derive = "2" +openscreen-compositor.workspace = true +anyhow.workspace = true + +# 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" \ No newline at end of file diff --git a/crates/compositor-view-napi/build.rs b/crates/compositor-view-napi/build.rs new file mode 100644 index 0000000000..f609ed3c1b --- /dev/null +++ b/crates/compositor-view-napi/build.rs @@ -0,0 +1,5 @@ +fn main() { + // Configure le linker Windows pour un module chargeable par Node (symboles napi_* + // résolus au chargement par le process Node/Electron). + napi_build::setup(); +} diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs new file mode 100644 index 0000000000..a68b0fe7aa --- /dev/null +++ b/crates/compositor-view-napi/src/lib.rs @@ -0,0 +1,711 @@ +//! Addon napi-rs : pont Electron ↔ `openscreen_compositor::live::LiveView`. Expose la vue +//! offscreen (Option B, post-readback `Vec` RGBA8 → `` HTML) à la +//! glue TS (native-bridge domaine "compositor"). Les `#[napi]` sont appelés +//! depuis le thread principal Node (là où vit la `BrowserWindow`) ; le rendu et +//! la publication de la dernière frame vivent sur le thread dédié de `LiveView` +//! et sont récupérés via `read_frame`. + +use napi::bindgen_prelude::*; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use napi::{Env, JsFunction, Task}; +use napi_derive::napi; +use openscreen_compositor::compositor::{live_params_from_scene, Compositor}; +use openscreen_compositor::d3d::{Backend, Gpu}; +use openscreen_compositor::gif_export::{GifExportParams, GifStats}; +use openscreen_compositor::live::{LiveView, PausedPreviews}; +use openscreen_compositor::scene::Scene; +use openscreen_compositor::{config, pipeline}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; + +/// Résolution cible du preview en pixels device (largeur/hauteur du `` +/// Electron affichant la preview). `x`/`y` ne sont plus utilisés (Option B : +/// la position est gérée par CSS côté web) — conservés dans l'objet pour +/// compatibilité structurelle avec l'ancien code de la glue TS, simplement +/// ignorés côté Rust. +#[napi(object)] +pub struct CompositorViewRect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +static REGISTRY: OnceLock>> = OnceLock::new(); +static NEXT_ID: Mutex = Mutex::new(1); + +fn registry() -> &'static Mutex> { + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Crée une vue **offscreen** (pas de HWND, pas de fenêtre native). Démarre juste +/// un thread de rendu qui compose chaque frame, blit-resize vers `rect.width`× +/// `rect.height` (réutilise le même `ensure_resize_target`/`blit_resized` que +/// l'export), lit le résultat vers CPU via staging `D3D11_USAGE_STAGING` + +/// `Map`/`Unmap` et stocke un `Vec` RGBA8 tightly-packed dans la vue pour +/// que `read_frame` le retourne à la glue TS. +/// +/// `screen_path` est requis (F3 : le vrai enregistrement de l'app — deux +/// fichiers H264 séparés). `webcam_path`/`cursor_path` sont optionnels +/// (absents → pas de caméra / pas de curseur). +/// +/// `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, + screen_path: Option, + webcam_path: Option, + cursor_path: Option, +) -> Result { + let screen = screen_path + .ok_or_else(|| Error::from_reason("create_view: screen_path is required"))?; + let webcam = webcam_path.unwrap_or_default(); + let cursor = cursor_path.unwrap_or_default(); + let view = LiveView::create( + rect.width.max(1) as u32, + rect.height.max(1) as u32, + &screen, + &webcam, + &cursor, + ) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + let id = { + let mut n = NEXT_ID.lock().unwrap(); + let id = *n; + *n += 1; + id + }; + registry().lock().unwrap().insert(id, view); + Ok(id) +} + +/// Met à jour la résolution cible du preview. L'ancienne sémantique « position +/// + taille de la fenêtre overlay » (`x, y, w, h`) n'a plus lieu d'être (la +/// preview est un bitmap posé sur un `` Electron, positionné en CSS) : +/// on garde la même forme d'objet `CompositorViewRect` côté TS pour ne pas +/// casser l'ABI, mais `x`/`y` sont silencieusement ignorés et seules +/// `width`/`height` sont propagées au thread de rendu. La résolution prend +/// effet au prochain tour (`compositor::readback_resized` reconstruit la +/// staging si `width`/`height` ont changé). +#[napi] +pub fn set_rect(id: i32, rect: CompositorViewRect) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_rect(rect.width.max(1) as u32, rect.height.max(1) as u32); + } +} + +/// Une frame de preview auto-descriptive : ses pixels PLUS tout ce qu'il faut pour +/// les interpréter (dimensions) et pour décider s'il faut les repeindre (génération). +/// Retournée par `read_frame`. Le consommateur JS n'a plus à deviner la taille depuis +/// `canvas.width` (ancien couplage implicite fragile) : elle voyage avec les octets. +#[napi(object)] +pub struct FramePacket { + /// Génération monotone de CETTE frame (≥ 1). Le consommateur la retient et la + /// repasse en `since_gen` au prochain appel ; tant qu'elle ne change pas, il n'y + /// a rien de neuf à peindre. `f64` car napi n'expose pas `u64` — sans risque : la + /// génération n'atteindra jamais 2^53 (ce serait des milliards d'années de rendu). + pub gen: f64, + pub width: u32, + pub height: u32, + /// R,G,B,A tightly-packed, `width * height * 4` octets — ce que `putImageData` / + /// `ImageData` attendent côté JS (canvas 2D, format natif RGBA8). + pub data: Buffer, +} + +/// Renvoie la dernière frame readback du thread de rendu SI elle est plus récente que +/// `since_gen`, sous forme de {@link FramePacket} (génération + dimensions + pixels). +/// +/// `Ok(None)` — le consommateur n'a rien à peindre — si : +/// - la vue `id` n'existe pas dans le registre (jamais créée ou déjà détruite), +/// - aucune frame n'a encore été composée (1er appel avant que le thread de rendu +/// n'ait publié quoi que ce soit), OU +/// - le consommateur possède déjà la génération courante (`gen <= since_gen`). C'est +/// le cas dominant en édition (preview figée en pause) : on renvoie `None` SANS +/// cloner le buffer ni traverser l'IPC. Tout le coût `O(w·h)` par frame — clone +/// Rust + structured-clone IPC + copies canvas — disparaît tant que rien ne bouge. +/// Passer `since_gen = 0` force la livraison de la frame courante (1re lecture). +/// +/// Quand une frame EST retournée, son `data` est détaché du `Vec` interne +/// (l'ownership passe au JS GC) ; le thread de rendu continue à composer sans bloquer +/// le thread Node. +#[napi] +pub fn read_frame(id: i32, since_gen: f64) -> Result> { + // Snapshot le pixel buffer HORS du lock du registre : on en a besoin vivant + // (r#[napi] retourne un Buffer qui consomme l'ownership du Vec). Sinon le + // MutexGuard serait tenu pendant que la frame est consommée par JS, ce qui + // bloquerait tout autre appel napi (`set_rect`, `destroy_view`, ...). + let slot = match registry().lock().unwrap().get(&id) { + None => return Ok(None), + 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); + FramePacket { + gen: gen as f64, + width: w, + height: h, + data: Buffer::from(pixels), + } + })) +} + +/// Param live (inspector). Le type de valeur route vers le bon setter : +/// bool = switch (backgroundBlur…), number = slider (shadow/roundness/motionBlur), +/// string = sélection (backgroundColor "#rrggbb"). +#[napi] +pub fn set_param(id: i32, key: String, value: Either3) { + if let Some(v) = registry().lock().unwrap().get(&id) { + match value { + Either3::A(b) => v.set_param_bool(&key, b), + Either3::B(n) => v.set_param_num(&key, n), + Either3::C(s) => v.set_param_str(&key, &s), + } + } +} + +#[napi] +pub fn set_playing(id: i32, playing: bool) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_playing(playing); + } +} + +/// Positionne la vue au temps SOURCE du clip actif (conversion timeline faite côté renderer). +#[napi] +pub fn present_time(id: i32, seconds: f64) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_time(seconds); + } +} + +/// Remplace les sources du clip actif sans recréer la vue ni son thread de rendu. L'identité +/// timeline et le playhead source sont atomiques avec le switch : deux clips partageant les +/// mêmes fichiers restent distincts, et les deux décodeurs ouvrent directement la bonne frame. +#[napi] +pub fn set_active_clip( + id: i32, + screen_path: String, + webcam_path: String, + webcam_offset_sec: f64, + clip_index: u32, + source_time_sec: f64, +) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_active_clip( + &screen_path, + &webcam_path, + webcam_offset_sec, + clip_index as usize, + source_time_sec, + ); + } +} + +/// Installe la scène de l'app (JSON `SceneDescription`) sur la vue : layout preset piloté par +/// l'app au lieu de la fixture. JSON invalide → ignoré côté natif. +#[napi] +pub fn set_scene(id: i32, scene_json: String) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_scene(&scene_json); + } +} + +#[napi] +pub fn destroy_view(id: i32) { + // remove hors du lock : le Drop (join du thread de rendu) ne le tient pas. + let removed = registry().lock().unwrap().remove(&id); + drop(removed); +} + +/// Bilan d'un export natif (mesure §10 : une lecture d'horloge avant-après tout le run). +#[napi(object)] +pub struct ExportStats { + pub frames: u32, + pub wall_s: f64, + pub fps: f64, + /// Durée de la vidéo exportée (secondes) — distincte de `wall_s` (temps de rendu réel). + pub video_duration_s: f64, +} + +/// Bilan d'un export GIF natif. Mêmes champs que `ExportStats` (frames / +/// wall / fps / durée) plus la taille du fichier sur disque — le format +/// est petit (256-color indexed + LZW) et la taille est une mesure +/// d'utilité, pas un détail technique. Sert à la fois au bench et à +/// l'UI d'export, seul chemin GIF de l'app. +#[napi(object)] +pub struct GifExportStats { + pub frames: u32, + pub wall_s: f64, + pub fps: f64, + /// Durée du GIF exporté (s) — distincte de `wall_s` (temps de rendu). + pub video_duration_s: f64, + /// Taille du fichier `.gif` final sur disque (octets), mesurée après + /// le drop de l'encodeur (donc après le flush du trailer GIF89a). + pub file_bytes: f64, +} + +/// Builds a `progress: &mut dyn FnMut(u64)` closure (the shape both `run_composited` and +/// `run_composited_multi` already call once per encoded frame, for free — measured to not +/// affect the C8 benchmark's fps) that forwards to `tsfn`, throttled to ~10/s. Encoding at +/// typical export rates would otherwise cross the JS thread boundary dozens of times a +/// second for no UI benefit; the throttle keeps that cost negligible regardless of encode +/// speed. Always reports the very first tick (frame <= 1) so a fast/short export still +/// shows at least one progress update instead of jumping straight to the final Promise +/// resolution. +fn throttled_progress( + tsfn: Option>, +) -> impl FnMut(u64) { + let mut last_sent = std::time::Instant::now() - std::time::Duration::from_secs(1); + move |frames: u64| { + let Some(tsfn) = &tsfn else { return }; + let now = std::time::Instant::now(); + if frames <= 1 || now.duration_since(last_sent).as_millis() >= 100 { + last_sent = now; + tsfn.call(frames as u32, ThreadsafeFunctionCallMode::NonBlocking); + } + } +} + +/// Pauses every live preview of this process for the duration of an export — their render +/// threads stop composing/presenting, which frees the GPU's 3D engine (measured: preview on +/// ~72 fps → preview off ~125 fps) — and gives each one back the transport it was found with +/// when dropped, including on an early `return Err` or a panic. +/// +/// Restoring the saved state instead of resuming everything is the whole point; see +/// `PausedPreviews` for the bug the blanket resume caused (a preview left free-running behind +/// a paused editor, ending up on another clip's scene with the zoom regions filtered out). +struct PreviewPause(PausedPreviews); + +impl PreviewPause { + fn begin() -> Self { + // A poisoned registry means some other napi call panicked mid-mutation; the export + // itself is still worth running, we just have no previews we can speak for. + Self(match registry().lock() { + Ok(reg) => PausedPreviews::pause(reg.iter().map(|(id, view)| (*id, view))), + Err(_) => PausedPreviews::default(), + }) + } +} + +impl Drop for PreviewPause { + fn drop(&mut self) { + if let Ok(reg) = registry().lock() { + self.0.restore(reg.iter().map(|(id, view)| (*id, view))); + } + } +} + +/// Convertit une fonction JS optionnelle en `ThreadsafeFunction` appelable depuis le thread +/// libuv qui exécute `Task::compute` — c'est la seule façon de rappeler JS depuis là. Chaque +/// appel transporte juste le nombre de frames encodées (`u32`) ; le JS connaît déjà le total +/// attendu (durée × fps des clips) et calcule le pourcentage lui-même. +fn make_progress_tsfn( + f: Option, +) -> Result>> { + f.map(|f| f.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))) + .transpose() +} + +/// Un clip de la timeline pour l'export multiclip (JS : camelCase). +#[napi(object)] +pub struct ClipInput { + pub screen_path: String, + pub webcam_path: String, + pub source_start_sec: f64, + pub source_end_sec: f64, + /// Décalage caméra (s) : temps source webcam = temps source screen - offset. + pub webcam_offset_sec: f64, + /// `false` évite une ouverture ffmpeg vouée à échouer et réserve du silence à ce clip. + pub has_audio: bool, +} + +/// Taille/cadence/codec de sortie voulus par l'app (modale d'export). Tous optionnels : +/// absent → comportement historique (1920x1080, fps du 1er clip, h264). `width`/`height` +/// sont arrondis au pair le plus proche (exigence NV12 4:2:0) côté `export_multi`. +#[napi(object)] +pub struct ExportParamsInput { + pub width: Option, + pub height: Option, + pub fps: Option, + /// "h264" | "h265". Toute autre valeur (ex. "vp9", pas d'équivalent matériel AMF) fait + /// échouer l'export avec un message clair plutôt que de silencieusement retomber sur h264. + pub codec: Option, +} + +/// Export multiclip mesuré (worker libuv). Rend la vraie timeline (clips + trims) en un MP4. +/// `scene_json` (optionnel) = la même scène que la preview live : fond/layout/webcam/curseur — +/// sans elle on ne retomberait QUE sur le layout fixture A↔B, plus du tout ce que l'utilisateur +/// a configuré (le bug corrigé ici). Layout/zoom restent statiques (pas encore de zoom regions +/// ni de camera-fullscreen animés côté export). Rend aux previews le transport qu'elles avaient +/// (même en erreur) — voir `PreviewPause`. +pub struct ExportMultiTask { + out_path: String, + clips: Vec, + scene_json: Option, + params: Option, + on_progress: Option>, +} + +impl Task for ExportMultiTask { + type Output = (u32, f64, f64, f64); + type JsValue = ExportStats; + + fn compute(&mut self) -> Result { + // 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(); + // 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; + cfg.mblur_n = 1; // layout statique → pas de motion blur de layout (pas de surcoût) + + // scène de l'app = même chemin que la preview live : fond, layout, webcam, curseur. + // JSON absent/invalide → pas de scène (fixture), pareil que si la preview n'en avait + // jamais reçu — jamais un fallback masquant, juste rien de configuré. + let scene = self.scene_json.as_deref().and_then(|j| Scene::from_json(j).ok()); + if let Some(scene) = &scene { + cfg.bg_blur = scene.effects.blur; + cfg.cursor = scene.cursor.show; + } else { + cfg.cursor = false; + } + + let mut export_params = pipeline::ExportParams::default(); + if let Some(p) = &self.params { + if let Some(w) = p.width { + export_params.width = w.max(2) & !1; // pair le plus proche (>=2, NV12) + } + if let Some(h) = p.height { + export_params.height = h.max(2) & !1; + } + export_params.fps = p.fps; + if let Some(codec) = &p.codec { + export_params.codec = match codec.as_str() { + "h264" => pipeline::ExportCodec::H264, + "h265" => pipeline::ExportCodec::H265, + other => { + return Err(Error::from_reason(format!( + "codec d'export \"{other}\" non supporté par le pipeline natif (h264/h265 seulement — pas d'équivalent matériel AMF pour VP9, et le chemin logiciel testé était trop lent pour être utile)" + ))); + } + }; + } + } + + // Le compositeur rastérise à la taille RÉELLEMENT encodée — d'où sa + // construction ici, une fois `export_params` résolu. + // + // Avant : il composait toujours en 1920×1080 puis `blit_resized` étirait + // vers la taille d'export. Deux défauts, une seule cause — tout export + // dépassant 1080p sur un axe était un AGRANDISSEMENT (un 4K portait le + // quart de l'information), et tout ratio ≠ 16:9 devait passer par une + // compensation géométrique. En construisant le compositeur à la + // géométrie de sortie, `blit_resized` devient une identité et les pixels + // sont rastérisés exactement là où ils seront encodés. + let comp = Compositor::new_sized(&gpu, export_params.width, export_params.height) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + if let Some(scene) = &scene { + comp.set_live_params(live_params_from_scene(scene)); + } + comp.set_scene(scene); + + let mut progress = throttled_progress(self.on_progress.take()); + let s = pipeline::run_composited_multi( + &self.clips, + &self.out_path, + &gpu, + &comp, + &cfg, + &export_params, + &mut progress, + ) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + Ok((s.frames as u32, s.wall_s, s.fps, s.video_duration_s)) + } + + fn resolve(&mut self, _env: Env, out: Self::Output) -> Result { + Ok(ExportStats { frames: out.0, wall_s: out.1, fps: out.2, video_duration_s: out.3 }) + } +} + +/// Lance un export multiclip natif (vraie timeline → MP4) et résout `Promise`. +/// `scene_json` : même `SceneDescription` que la preview (fond/layout/webcam/effets/curseur). +/// `params` : taille/cadence/codec de sortie voulus (absent → 1920x1080/fps du 1er clip/h264). +/// `on_progress(framesEncodées)` optionnel — rappelé côté JS à ~10 Hz max pendant le rendu ; +/// le JS calcule lui-même le pourcentage (il connaît déjà le total attendu, durée×fps des clips). +#[napi] +pub fn export_multi( + clips: Vec, + out_path: String, + scene_json: Option, + params: Option, + on_progress: Option, +) -> Result> { + let clips = clips + .into_iter() + .map(|c| pipeline::ClipSource { + screen: c.screen_path, + webcam: c.webcam_path, + source_start_sec: c.source_start_sec, + source_end_sec: c.source_end_sec, + webcam_offset_sec: c.webcam_offset_sec, + has_audio: c.has_audio, + }) + .collect(); + Ok(AsyncTask::new(ExportMultiTask { + out_path, + clips, + scene_json, + params, + on_progress: make_progress_tsfn(on_progress)?, + })) +} + +/// Sortie GIF native (slice 1) — taille, cadence, compteur de loop, dithering. +/// Tout optionnel : absent → 854×480, 12 fps, boucle infinie, pas de +/// dithering. Les défauts sont choisis pour un export « petit / net » : +/// GIF est un format 256-couleurs, 12 fps est la cadence historique de +/// `gif.js` côté renderer, et 854×480 tient confortablement dans la +/// palette 8 bits sans banding visible sur du contenu de présentation. +#[napi(object)] +pub struct GifParamsInput { + pub width: Option, + pub height: Option, + pub fps: Option, + /// Compteur de loop GIF : `None` ou `0` = infini, sinon `n` boucles. + pub loop_count: Option, + /// Floyd-Steinberg error diffusion avant quantification. Off par + /// défaut (qualité acceptable sans, et ça double تقريبًا le coût + /// CPU du quantize par frame). + pub dither: Option, +} + +/// Tâche d'export GIF (worker libuv, comme `ExportMultiTask`). Le +/// pipeline natif vit dans `openscreen_compositor::gif_export` ; ce +/// binding n'est qu'un adaptateur qui : +/// 1. résout la `screen.cursor.json` sidecar selon la convention +/// `ExportDialog` (même chemin que `run_composited_multi` côté +/// MP4 — voir `pipeline.rs:1199`), +/// 2. construit un `GifExportParams` à partir du `GifParamsInput`, +/// 3. appelle `gif_export::export_gif` et reporte le `GifStats` au JS. +/// +/// `cursor_path` est optionnel : un export sans curseur (utile pour +/// tester la pipeline) est légitime. La fonction côté Rust prend +/// `Option<&str>`, et `None` désactive le rendu du curseur côté +/// `Compositor` (équivalent de `cfg.cursor = false` dans +/// `run_composited_multi`). +pub struct ExportGifTask { + /// Same clip list the MP4 export takes — GIF is now a multiclip export + /// driven by the same walk, not a single-file special case. + clips: Vec, + /// Scene JSON from the app, exactly as `exportMulti` receives it: it + /// carries background, layout, webcam and cursor. Absent/invalid → no + /// scene, same as a preview that was never configured. + scene_json: Option, + out_path: PathBuf, + params: GifExportParams, + on_progress: Option>, +} + +impl Task for ExportGifTask { + type Output = GifStats; + type JsValue = GifExportStats; + + fn compute(&mut self) -> Result { + // Mêmes garanties que `ExportMultiTask` : previews paused for the + // whole render et restored exactement comme trouvées, y compris + // sur les chemins d'erreur. L'export GPU+CPU ne partage pas le + // RT avec la preview (sa propre `Compositor::new_sized`) mais le + // 3D engine de la preview pollue quand même, d'où la pause. + let _previews = PreviewPause::begin(); + + // Same construction as ExportMultiTask — GIF and MP4 differ only in the + // encoder, so everything up to it is built identically. + let gpu = Gpu::create(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; + cfg.mblur_n = 1; + + let scene = self.scene_json.as_deref().and_then(|j| Scene::from_json(j).ok()); + if let Some(scene) = &scene { + cfg.bg_blur = scene.effects.blur; + cfg.cursor = scene.cursor.show; + } else { + cfg.cursor = false; + } + + let width = self + .params + .width + .unwrap_or(openscreen_compositor::gif_export::DEFAULT_GIF_WIDTH); + let height = self + .params + .height + .unwrap_or(openscreen_compositor::gif_export::DEFAULT_GIF_HEIGHT); + let comp = Compositor::new_sized(&gpu, width, height) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + if let Some(scene) = &scene { + comp.set_live_params(live_params_from_scene(scene)); + } + comp.set_scene(scene); + + let mut progress = throttled_progress(self.on_progress.take()); + openscreen_compositor::gif_export::export_gif( + &self.clips, + &self.out_path, + &gpu, + &comp, + &cfg, + &self.params, + &mut progress, + ) + .map_err(|e| Error::from_reason(format!("{e:#}"))) + } + + fn resolve(&mut self, _env: Env, out: Self::Output) -> Result { + Ok(GifExportStats { + frames: out.frames as u32, + wall_s: out.wall_s, + fps: out.fps, + video_duration_s: out.video_duration_s, + file_bytes: out.file_bytes as f64, + }) + } +} + +/// Lance un export GIF natif — le seul chemin GIF depuis la suppression de +/// l'exporteur `gif.js` — et résout `Promise`. `screen_path` et `webcam_path` sont +/// requis (la convention de l'app : deux fichiers H264 séparés, voir +/// `ClipInput` côté MP4). `cursor_path` est optionnel — s'il est `null` ou +/// pointe vers un fichier absent, l'export rend sans curseur (le `Player` +/// compose quand même les frames, la scène du curseur est juste vide). +/// `params` : taille, cadence, loop, dithering — tous optionnels, défauts +/// dans `GifExportParams::default`. `on_progress(framesProduced)` optionnel, +/// throttled à ~10/s comme l'export MP4 (voir `throttled_progress`). +#[napi] +pub fn export_gif( + clips: Vec, + out_path: String, + scene_json: Option, + params: Option, + on_progress: Option, +) -> Result> { + // Deliberately the same argument shape as `export_multi`: the caller builds + // one clip list and one scene, and picks the container. Cursor comes from + // the scene like every other effect — there is no GIF-specific input left. + let clips = clips + .into_iter() + .map(|c| pipeline::ClipSource { + screen: c.screen_path, + webcam: c.webcam_path, + source_start_sec: c.source_start_sec, + source_end_sec: c.source_end_sec, + webcam_offset_sec: c.webcam_offset_sec, + has_audio: c.has_audio, + }) + .collect(); + let gif_params = params + .map(|p| GifExportParams { + width: p.width, + height: p.height, + fps: p.fps, + loop_count: p.loop_count, + dither: p.dither.unwrap_or(false), + }) + .unwrap_or_default(); + Ok(AsyncTask::new(ExportGifTask { + clips, + scene_json, + out_path: PathBuf::from(out_path), + params: gif_params, + on_progress: make_progress_tsfn(on_progress)?, + })) +} + +/// Bilan d'un remux, tel que le voit la glue TS. +#[napi(object)] +pub struct RemuxStats { + /// Paquets recopiés, toutes pistes confondues. `f64` car napi n'expose pas + /// `u64` — sans risque, un enregistrement plausible reste très loin de 2^53. + pub packets: f64, + pub streams: u32, + pub wall_s: f64, +} + +pub struct RemuxTask { + input_path: String, + output_path: String, +} + +impl Task for RemuxTask { + type Output = openscreen_compositor::remux::RemuxStats; + type JsValue = RemuxStats; + + fn compute(&mut self) -> Result { + openscreen_compositor::remux::remux_to_seekable_matroska(&self.input_path, &self.output_path) + .map_err(|e| Error::from_reason(format!("{e:#}"))) + } + + fn resolve(&mut self, _env: Env, out: Self::Output) -> Result { + Ok(RemuxStats { + packets: out.packets as f64, + streams: out.streams, + wall_s: out.wall_s, + }) + } +} + +/// Recopie `input_path` vers `output_path` par le muxer matroska (aucun +/// ré-encodage) pour doter le fichier des `Cues`/`SeekHead` que `MediaRecorder` +/// n'écrit pas. Voir `openscreen_compositor::remux` pour le détail. +/// +/// `AsyncTask` et pas une fonction synchrone : le remux lit et réécrit tout le +/// fichier, ce qui se compte en secondes sur un long enregistrement. Le faire +/// sur le thread principal de Node gèlerait la fenêtre pendant la sauvegarde, +/// exactement au moment où l'UI affiche « enregistrement en cours ». +/// +/// `output_path` doit être un chemin TEMPORAIRE : c'est au caller TS de +/// renommer par-dessus l'original une fois la promesse résolue, pour qu'un échec +/// laisse l'enregistrement intact. +#[napi] +pub fn remux_seekable(input_path: String, output_path: String) -> AsyncTask { + AsyncTask::new(RemuxTask { + input_path, + output_path, + }) +} diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml new file mode 100644 index 0000000000..f968b54970 --- /dev/null +++ b/crates/compositor/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "openscreen-compositor" +version.workspace = true +edition.workspace = true +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" +path = "src/lib.rs" + +[build-dependencies] +bindgen = "0.70" +cc = "1" + +[dependencies] +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" + +# Linux : wgpu (Vulkan) pour le rendu, pollster pour block_on les ops wgpu, +# cosmic-text pour la rastérisation du texte (remplace DirectWrite/CoreText). +[target.'cfg(target_os = "linux")'.dependencies] +wgpu.workspace = true +pollster.workspace = true +cosmic-text.workspace = true diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs new file mode 100644 index 0000000000..33db6e6c78 --- /dev/null +++ b/crates/compositor/build.rs @@ -0,0 +1,254 @@ +use std::env; +use std::path::{Path, PathBuf}; + +fn main() { + // 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"; + + if target_is_macos { + point_libclang_at_the_xcode_toolchain(); + } + + // 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), + // - Linux : software/VAAPI, aucun hwcontext propriétaire (le d3d11.h / + // les headers VT n'existent pas → wrapper dédié). + let wrapper = if target_is_macos { + "wrapper_macos.h" + } else if target_os == "linux" { + "wrapper_linux.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(&include_dir) + .compile("sn_shim"); + + // --- bindings générés sur les VRAIS headers 8.x (immunisé contre la version) --- + // 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_.*") + .allowlist_var("AVIO_.*") + // enums en constantes simples : plus simple à manipuler en FFI brut + .default_enum_style(bindgen::EnumVariation::ModuleConsts) + .derive_default(true) + .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"); + + let out = PathBuf::from(env::var("OUT_DIR").unwrap()); + let ffi_path = out.join("ffi.rs"); + bindings.write_to_file(&ffi_path).expect("écriture ffi.rs"); + + // Sur Linux, les symboles ffmpeg des .so vendorisés sont renommés avec un préfixe + // avant l'édition de liens (cf. scripts/build-linux-compositor-addon.mjs). Il faut + // donc que CES déclarations importent les noms préfixés — sinon l'éditeur de liens + // ne trouve rien, ou pire, l'addon se rattache au ffmpeg de Chromium au runtime. + if let Ok(prefix) = env::var("OPENSCREEN_FFMPEG_SYMBOL_PREFIX") { + prefix_ffmpeg_symbols(&ffi_path, &prefix); + } +} + +/// Ajoute `#[link_name = ""]` devant chaque `pub fn` ffmpeg de `ffi.rs`. +/// +/// Seul le SYMBOLE importé change ; l'identifiant Rust reste `avformat_open_input`, donc +/// aucun site d'appel du crate ne bouge. bindgen ne génère ici que des fonctions (aucun +/// `pub static`), ce qui rend la réécriture sûre et purement textuelle. +/// +/// Pourquoi ce détour plutôt qu'un `#[link(name = ...)]` : le problème n'est pas de +/// désigner une bibliothèque mais d'éviter une COLLISION DE NOMS. Electron charge +/// `libffmpeg.so` (le ffmpeg de Chromium) comme dépendance directe, donc ses symboles +/// occupent la portée globale avant tout addon ; sous ELF, nos imports s'y rattachent +/// quoi que dise notre RUNPATH. Renommer supprime la collision à la racine, là où jouer +/// sur la portée de résolution (RTLD_DEEPBIND) casse l'interposition de l'allocateur de +/// Chromium et fait planter le process. +fn prefix_ffmpeg_symbols(ffi_path: &Path, prefix: &str) { + let source = std::fs::read_to_string(ffi_path).expect("relecture ffi.rs"); + let mut out = String::with_capacity(source.len() + 64 * 1024); + let mut renamed = 0usize; + + for line in source.lines() { + if let Some(name) = line + .strip_prefix(" pub fn ") + .and_then(|rest| rest.split('(').next()) + .filter(|n| is_ffmpeg_symbol(n)) + { + out.push_str(&format!(" #[link_name = \"{prefix}{name}\"]\n")); + renamed += 1; + } + out.push_str(line); + out.push('\n'); + } + + assert!( + renamed > 0, + "OPENSCREEN_FFMPEG_SYMBOL_PREFIX est posé mais aucune fonction ffmpeg n'a été \ + trouvée dans ffi.rs — le format de sortie de bindgen a changé, et l'addon se \ + lierait silencieusement au ffmpeg de Chromium." + ); + std::fs::write(ffi_path, out).expect("réécriture ffi.rs"); + println!("cargo:warning=ffmpeg: {renamed} symboles préfixés en `{prefix}`"); +} + +/// Les préfixes que ffmpeg expose : `av*` (avutil/avcodec/avformat + `avpriv_`), +/// `sws_*` (swscale) et `swr_*` (swresample). Aligné sur le filtre du script de build, +/// qui dérive la table de renommage des mêmes bibliothèques. +fn is_ffmpeg_symbol(name: &str) -> bool { + name.starts_with("av") || name.starts_with("sws_") || name.starts_with("swr_") +} + +/// 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"), + } +} diff --git a/crates/compositor/shim.c b/crates/compositor/shim.c new file mode 100644 index 0000000000..5f0741ed32 --- /dev/null +++ b/crates/compositor/shim.c @@ -0,0 +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 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 new file mode 100644 index 0000000000..3c37326f8e --- /dev/null +++ b/crates/compositor/src/audio.rs @@ -0,0 +1,1063 @@ +//! Piste audio native de l'export multiclip : ffmpeg décode les sources écran, swresample +//! normalise tout en f32 planaire 48 kHz stéréo, WSOLA applique les speed regions, puis un +//! unique encodeur AAC alimente le même muxer que la vidéo. + +use crate::ffi::*; +use crate::regions::SpeedSegment; +use anyhow::{bail, Result}; +use std::f32::consts::PI; +use std::ffi::CString; +use std::ptr; + +pub const AUDIO_OUTPUT_SAMPLE_RATE: i32 = 48_000; +pub const AUDIO_OUTPUT_CHANNELS: usize = 2; +pub const AUDIO_BITRATE: i64 = 128_000; +pub const AUDIO_BOUNDARY_FADE_SAMPLES: usize = 240; + +// 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; +const DEFAULT_SEARCH_SEC: f64 = 0.01; +const TARGET_GRAINS: usize = 8; +const PASSTHROUGH_EPSILON: f64 = 1e-3; + +pub type PlanarPcm = Vec>; + +extern "C" { + fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; + // bindgen rend `AVFormatContext` opaque (atteinte seulement par pointeur), d'où l'accesseur + // compilé contre les vrais headers — voir shim.c. + fn sn_fmt_nb_streams(s: *mut AVFormatContext) -> u32; +} + +fn averr(ret: i32, ctx: &str) -> Result<()> { + if ret < 0 { + let mut buf = [0i8; 256]; + unsafe { av_strerror(ret, buf.as_mut_ptr(), buf.len()) }; + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy(); + bail!("{ctx}: {ret} ({msg})"); + } + Ok(()) +} + +struct AudioResampler { + ctx: *mut SwrContext, + input_rate: i32, +} + +impl AudioResampler { + unsafe fn from_frame(frame: *mut AVFrame, dctx: *mut AVCodecContext) -> Result { + let mut output_layout = AVChannelLayout::default(); + av_channel_layout_default(&mut output_layout, AUDIO_OUTPUT_CHANNELS as i32); + + let mut fallback_layout = AVChannelLayout::default(); + let input_layout = if (*frame).ch_layout.nb_channels > 0 { + &(*frame).ch_layout as *const AVChannelLayout + } else if (*dctx).ch_layout.nb_channels > 0 { + &(*dctx).ch_layout as *const AVChannelLayout + } else { + av_channel_layout_default(&mut fallback_layout, 1); + &fallback_layout as *const AVChannelLayout + }; + let input_rate = if (*frame).sample_rate > 0 { + (*frame).sample_rate + } else { + (*dctx).sample_rate + }; + if input_rate <= 0 { + av_channel_layout_uninit(&mut output_layout); + av_channel_layout_uninit(&mut fallback_layout); + bail!("fréquence audio source invalide"); + } + + let mut ctx: *mut SwrContext = ptr::null_mut(); + let ret = swr_alloc_set_opts2( + &mut ctx, + &output_layout, + AVSampleFormat::AV_SAMPLE_FMT_FLTP, + AUDIO_OUTPUT_SAMPLE_RATE, + input_layout, + (*frame).format as AVSampleFormat::Type, + input_rate, + 0, + ptr::null_mut(), + ); + av_channel_layout_uninit(&mut output_layout); + av_channel_layout_uninit(&mut fallback_layout); + averr(ret, "swr_alloc_set_opts2")?; + if ctx.is_null() { + bail!("swr_alloc_set_opts2: contexte nul"); + } + averr(swr_init(ctx), "swr_init")?; + Ok(Self { ctx, input_rate }) + } + + unsafe fn push(&mut self, frame: *mut AVFrame, output: &mut PlanarPcm) -> Result<()> { + let out_capacity = swr_get_out_samples(self.ctx, (*frame).nb_samples).max(1) as usize; + let mut planes = vec![vec![0.0f32; out_capacity]; AUDIO_OUTPUT_CHANNELS]; + let mut output_ptrs: Vec<*mut u8> = planes + .iter_mut() + .map(|plane| plane.as_mut_ptr() as *mut u8) + .collect(); + let input_ptrs = (*frame).extended_data as *const *const u8; + let converted = swr_convert( + self.ctx, + output_ptrs.as_mut_ptr(), + out_capacity as i32, + input_ptrs, + (*frame).nb_samples, + ); + averr(converted, "swr_convert")?; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + planes[channel].truncate(converted as usize); + output[channel].extend_from_slice(&planes[channel]); + } + Ok(()) + } + + unsafe fn flush(&mut self, output: &mut PlanarPcm) -> Result<()> { + loop { + let delay = swr_get_delay(self.ctx, self.input_rate as i64); + if delay <= 0 { + break; + } + let out_capacity = (((delay * AUDIO_OUTPUT_SAMPLE_RATE as i64) + + self.input_rate as i64 - 1) + / self.input_rate as i64 + + 32) as usize; + let mut planes = vec![vec![0.0f32; out_capacity]; AUDIO_OUTPUT_CHANNELS]; + let mut output_ptrs: Vec<*mut u8> = planes + .iter_mut() + .map(|plane| plane.as_mut_ptr() as *mut u8) + .collect(); + let converted = swr_convert( + self.ctx, + output_ptrs.as_mut_ptr(), + out_capacity as i32, + ptr::null(), + 0, + ); + averr(converted, "swr_convert(flush)")?; + if converted == 0 { + break; + } + for channel in 0..AUDIO_OUTPUT_CHANNELS { + planes[channel].truncate(converted as usize); + output[channel].extend_from_slice(&planes[channel]); + } + } + Ok(()) + } +} + +impl Drop for AudioResampler { + fn drop(&mut self) { + unsafe { swr_free(&mut self.ctx) }; + } +} + +/// Un décodeur + resampler par piste audio du conteneur, tous alimentés par la même passe de +/// démux. Chaque piste produit du f32 planaire 48 kHz stéréo recadré sur la même fenêtre +/// source, si bien que le mixage final est une simple somme échantillon par échantillon. +struct AudioTrackDecoder { + stream_index: i32, + dctx: *mut AVCodecContext, + tb_sec: f64, + resampler: Option, + decoded: PlanarPcm, + origin_sec: Option, + reached_end: bool, + decoder_eof: bool, +} + +impl Drop for AudioTrackDecoder { + fn drop(&mut self) { + unsafe { avcodec_free_context(&mut self.dctx) }; + } +} + +/// Décode uniquement la fenêtre source conservée. Le seek audio retombe sur une trame +/// antérieure ; l'origine pts du premier bloc resamplé permet ensuite de couper précisément la +/// prélecture sans supposer que la piste commence à t=0. +/// +/// TOUTES les pistes audio sont décodées puis mixées. L'enregistreur natif macOS écrit l'audio +/// système et le micro en deux pistes AAC distinctes, toutes deux marquées `default` : +/// `av_find_best_stream` n'en rendait qu'une — la première, silencieuse dès que rien ne joue sur +/// le système — et le micro disparaissait de l'export (issue #108). La PR #109 avait corrigé ce +/// défaut dans l'exporteur navigateur ; l'app n'emprunte plus ce chemin, d'où la même correction +/// ici, dans le chemin natif. +pub fn decode_clip_audio(path: &str, source_start_sec: f64, source_end_sec: f64) -> Result> { + unsafe { decode_clip_audio_inner(path, source_start_sec, source_end_sec) } +} + +unsafe fn decode_clip_audio_inner( + path: &str, + source_start_sec: f64, + source_end_sec: f64, +) -> Result> { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "audio open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "audio find_stream_info")?; + // Énumération de toutes les pistes audio (voir le commentaire de `decode_clip_audio`). + let mut audio_stream_count = 0usize; + let mut tracks: Vec = Vec::new(); + for index in 0..sn_fmt_nb_streams(fmt) as i32 { + let stream = sn_fmt_stream(fmt, index); + let codecpar = (*stream).codecpar; + if (*codecpar).codec_type != AVMediaType::AVMEDIA_TYPE_AUDIO { + continue; + } + audio_stream_count += 1; + // Une piste qu'on ne sait pas ouvrir est ignorée, pas fatale : avant ce mixage une + // seule piste était ouverte, donc une piste annexe exotique ne pouvait pas casser un + // export. Le `bail!` plus bas garantit qu'on ne perd pas l'audio en silence pour + // autant — c'est précisément le défaut qu'on corrige ici. + let decoder = avcodec_find_decoder((*codecpar).codec_id); + if decoder.is_null() { + continue; + } + let mut dctx = avcodec_alloc_context3(decoder); + if dctx.is_null() { + avformat_close_input(&mut fmt); + bail!("audio avcodec_alloc_context3"); + } + if avcodec_parameters_to_context(dctx, codecpar) < 0 + || avcodec_open2(dctx, decoder, ptr::null_mut()) < 0 + { + avcodec_free_context(&mut dctx); + continue; + } + let time_base = (*stream).time_base; + tracks.push(AudioTrackDecoder { + stream_index: index, + dctx, + tb_sec: if time_base.den != 0 { + time_base.num as f64 / time_base.den as f64 + } else { + 0.0 + }, + resampler: None, + decoded: vec![Vec::::new(); AUDIO_OUTPUT_CHANNELS], + origin_sec: None, + reached_end: false, + decoder_eof: false, + }); + } + if tracks.is_empty() { + avformat_close_input(&mut fmt); + if audio_stream_count > 0 { + bail!("audio : {audio_stream_count} piste(s) présentes, aucune décodable"); + } + return Ok(None); + } + + // Un seul seek : il repositionne le conteneur entier. On le cale sur la première piste + // audio puis on vide tous les décodeurs. Si une autre piste reprend légèrement après le + // début de la fenêtre demandée, son recadrage (plus bas) la zéro-padde devant — le + // décalage inter-pistes est absorbé là, pas ici. + let seek_tb_sec = tracks[0].tb_sec; + let seek_stream_index = tracks[0].stream_index; + if seek_tb_sec > 0.0 { + let target = (source_start_sec / seek_tb_sec).floor() as i64; + if av_seek_frame(fmt, seek_stream_index, target, AVSEEK_FLAG_BACKWARD) >= 0 { + for track in tracks.iter_mut() { + avcodec_flush_buffers(track.dctx); + } + } + } + + let mut packet = av_packet_alloc(); + let mut frame = av_frame_alloc(); + let mut input_eof = false; + + // Une seule passe de démux alimente tous les décodeurs : chaque paquet est routé vers la + // piste dont il porte l'index. On continue tant qu'AU MOINS une piste a encore quelque + // chose à produire. + while tracks.iter().any(|t| !t.reached_end && !t.decoder_eof) { + if !input_eof { + let read = av_read_frame(fmt, packet); + if read == AVERROR_EOF { + for track in tracks.iter_mut() { + avcodec_send_packet(track.dctx, ptr::null()); + } + input_eof = true; + } else { + averr(read, "audio av_read_frame")?; + let packet_stream = (*packet).stream_index; + if let Some(track) = tracks + .iter_mut() + .find(|t| t.stream_index == packet_stream && !t.reached_end) + { + averr(avcodec_send_packet(track.dctx, packet), "audio send_packet")?; + } + av_packet_unref(packet); + } + } + + for track in tracks.iter_mut() { + if track.reached_end || track.decoder_eof { + continue; + } + loop { + let ret = avcodec_receive_frame(track.dctx, frame); + if ret == AVERROR_EOF { + track.decoder_eof = true; + break; + } + if ret == AVERROR_EAGAIN { + if input_eof { + track.decoder_eof = true; + } + break; + } + averr(ret, "audio receive_frame")?; + + let pts = (*frame).best_effort_timestamp; + let frame_sec = if pts != i64::MIN && track.tb_sec > 0.0 { + pts as f64 * track.tb_sec + } else { + track.origin_sec.unwrap_or(source_start_sec) + }; + if frame_sec >= source_end_sec { + track.reached_end = true; + av_frame_unref(frame); + break; + } + if track.origin_sec.is_none() { + track.origin_sec = Some(frame_sec); + } + if track.resampler.is_none() { + track.resampler = Some(AudioResampler::from_frame(frame, track.dctx)?); + } + track + .resampler + .as_mut() + .unwrap() + .push(frame, &mut track.decoded)?; + av_frame_unref(frame); + } + } + } + + for track in tracks.iter_mut() { + if let Some(r) = track.resampler.as_mut() { + r.flush(&mut track.decoded)?; + } + } + + av_frame_free(&mut frame); + av_packet_free(&mut packet); + avformat_close_input(&mut fmt); + + let target_samples = (((source_end_sec - source_start_sec).max(0.0) + * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round()) as usize; + let aligned: Vec<(f64, &PlanarPcm)> = tracks + .iter() + .map(|track| (track.origin_sec.unwrap_or(source_start_sec), &track.decoded)) + .collect(); + Ok(Some(mix_aligned_tracks( + &aligned, + source_start_sec, + target_samples, + ))) +} + +/// Recadre chaque piste sur la MÊME fenêtre (`target_samples` échantillons à partir de +/// `source_start_sec`) puis les somme. Une piste dont le premier paquet décodé arrive après le +/// début de la fenêtre est zéro-paddée devant ; la prélecture d'une piste décodée trop tôt (le +/// seek retombe sur une trame antérieure) est coupée. C'est ce recadrage qui absorbe les +/// décalages de départ entre pistes, si bien que le mixage lui-même n'est qu'une somme. +/// +/// Séparé du décodage pour être testable sans ffmpeg. +fn mix_aligned_tracks( + tracks: &[(f64, &PlanarPcm)], + source_start_sec: f64, + target_samples: usize, +) -> PlanarPcm { + let mut mixed = vec![vec![0.0f32; target_samples]; AUDIO_OUTPUT_CHANNELS]; + for &(origin_sec, decoded) in tracks { + let relative_start = + ((source_start_sec - origin_sec) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as i64; + let (src_start, dst_start) = if relative_start >= 0 { + (relative_start as usize, 0usize) + } else { + (0usize, (-relative_start) as usize) + }; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + if src_start >= decoded[channel].len() || dst_start >= target_samples { + continue; + } + let count = (decoded[channel].len() - src_start).min(target_samples - dst_start); + for offset in 0..count { + mixed[channel][dst_start + offset] += decoded[channel][src_start + offset]; + } + } + } + // Sommer plusieurs pistes peut dépasser la pleine échelle : on écrête. Sur une source + // mono-piste, sommer dans un buffer nul est l'identité et on n'écrête pas — le comportement + // d'avant ce mixage est conservé tel quel. + if tracks.len() > 1 { + for plane in mixed.iter_mut() { + for sample in plane.iter_mut() { + *sample = sample.clamp(-1.0, 1.0); + } + } + } + mixed +} + +fn hann(length: usize) -> Vec { + let mut window = vec![0.0; length]; + for (i, value) in window.iter_mut().enumerate() { + *value = 0.5 - 0.5 * ((2.0 * PI * i as f32) / (length - 1) as f32).cos(); + } + window +} + +/// Port direct du WSOLA web. Tous les canaux partagent les positions choisies sur un downmix +/// mono, sinon deux recherches indépendantes déplaceraient l'image stéréo. +pub struct WsolaTimeStretcher { + channels: usize, + passthrough: bool, + n: usize, + hs: usize, + ha: f64, + search_radius: i64, + window: Vec, + buf: PlanarPcm, + mono: Vec, + buf_start: i64, + out: PlanarPcm, + win_sum: Vec, + out_start: usize, + ideal_pos: f64, + grain_pos: i64, + frame: usize, + placed_any: bool, +} + +impl WsolaTimeStretcher { + pub fn new( + sample_rate: i32, + channels: usize, + speed: f64, + expected_output_samples: usize, + ) -> Self { + let channels = channels.max(1); + let passthrough = (speed - 1.0).abs() < PASSTHROUGH_EPSILON; + let mut n = (sample_rate as f64 * DEFAULT_FRAME_SEC).round() as usize; + n = n.max(4); + if n % 2 != 0 { + n += 1; + } + let mut hs = n / 2; + if expected_output_samples > 0 { + let min_hs = 2usize.max( + ((sample_rate as f64 * MIN_FRAME_SEC) / 2.0).round() as usize, + ); + let target_hs = expected_output_samples / TARGET_GRAINS; + hs = hs.min(min_hs.max(target_hs)); + } + let n = hs * 2; + let search_radius = ((sample_rate as f64 * DEFAULT_SEARCH_SEC).round() as usize) + .min(hs) as i64; + Self { + channels, + passthrough, + n, + hs, + ha: hs as f64 * speed, + search_radius, + window: hann(n), + buf: vec![Vec::new(); channels], + mono: Vec::new(), + buf_start: 0, + out: vec![Vec::new(); channels], + win_sum: Vec::new(), + out_start: 0, + ideal_pos: 0.0, + grain_pos: 0, + frame: 0, + placed_any: false, + } + } + + pub fn push(&mut self, planar: &[Vec]) -> PlanarPcm { + if self.passthrough { + return (0..self.channels) + .map(|channel| { + planar + .get(channel) + .or_else(|| planar.first()) + .cloned() + .unwrap_or_default() + }) + .collect(); + } + self.append(planar); + self.process(false) + } + + pub fn flush(&mut self) -> PlanarPcm { + if self.passthrough { + return self.empty_chunk(); + } + self.process(true) + } + + fn empty_chunk(&self) -> PlanarPcm { + vec![Vec::new(); self.channels] + } + + fn append(&mut self, planar: &[Vec]) { + let add_len = planar.first().map(|p| p.len()).unwrap_or(0); + if add_len == 0 { + return; + } + for channel in 0..self.channels { + if let Some(source) = planar.get(channel).or_else(|| planar.first()) { + self.buf[channel].extend_from_slice(&source[..add_len.min(source.len())]); + if source.len() < add_len { + let target_len = self.buf[channel].len() + add_len - source.len(); + self.buf[channel].resize(target_len, 0.0); + } + } + } + for i in 0..add_len { + let mut sum = 0.0f32; + for channel in 0..self.channels { + sum += planar + .get(channel) + .and_then(|p| p.get(i)) + .or_else(|| planar.first().and_then(|p| p.get(i))) + .copied() + .unwrap_or(0.0); + } + self.mono.push(sum / self.channels as f32); + } + } + + fn buf_end(&self) -> i64 { + self.buf_start + self.buf[0].len() as i64 + } + + fn sample_at(&self, channel: usize, absolute_index: i64) -> f32 { + let index = absolute_index - self.buf_start; + if index < 0 { + 0.0 + } else { + self.buf[channel].get(index as usize).copied().unwrap_or(0.0) + } + } + + fn mono_at(&self, absolute_index: i64) -> f32 { + let index = absolute_index - self.buf_start; + if index < 0 { + 0.0 + } else { + self.mono.get(index as usize).copied().unwrap_or(0.0) + } + } + + fn process(&mut self, final_chunk: bool) -> PlanarPcm { + let mut emitted = self.empty_chunk(); + loop { + let search_target = (self.ideal_pos + self.ha).round() as i64; + let required_end = (self.grain_pos + self.n as i64) + .max(self.grain_pos + self.hs as i64 + self.n as i64) + .max(search_target + self.search_radius + self.n as i64); + if !final_chunk && self.buf_end() < required_end { + break; + } + if self.grain_pos + self.n as i64 > self.buf_end() { + break; + } + + self.place_grain(self.grain_pos); + let placed_frame = self.frame; + let reference_start = self.grain_pos + self.hs as i64; + let best_delta = self.find_best_delta(reference_start, search_target); + self.grain_pos = search_target + best_delta; + self.ideal_pos += self.ha; + self.frame += 1; + + self.collect(placed_frame * self.hs, &mut emitted); + self.discard_below(self.grain_pos); + } + if final_chunk { + self.collect_all(&mut emitted); + } + emitted + } + + fn place_grain(&mut self, position: i64) { + let output_absolute = self.frame * self.hs; + self.ensure_out(output_absolute + self.n); + let base = output_absolute - self.out_start; + for channel in 0..self.channels { + for k in 0..self.n { + let sample = self.sample_at(channel, position + k as i64); + self.out[channel][base + k] += sample * self.window[k]; + } + } + for k in 0..self.n { + self.win_sum[base + k] += self.window[k]; + } + self.placed_any = true; + } + + fn find_best_delta(&self, reference_start: i64, target: i64) -> i64 { + if reference_start + self.n as i64 > self.buf_end() { + return 0; + } + let mut reference_energy = 0.0f32; + for k in 0..self.n { + let sample = self.mono_at(reference_start + k as i64); + reference_energy += sample * sample; + } + if reference_energy == 0.0 { + return 0; + } + + let mut best_delta = 0; + let mut best_score = f32::NEG_INFINITY; + let low = (-self.search_radius).max(self.buf_start - target); + let high = self + .search_radius + .min(self.buf_end() - self.n as i64 - target); + for delta in low..=high { + let candidate_start = target + delta; + let mut dot = 0.0f32; + let mut energy = 0.0f32; + for k in 0..self.n { + let candidate = self.mono_at(candidate_start + k as i64); + dot += candidate * self.mono_at(reference_start + k as i64); + energy += candidate * candidate; + } + let score = if energy > 0.0 { dot / energy.sqrt() } else { 0.0 }; + if score > best_score { + best_score = score; + best_delta = delta; + } + } + best_delta + } + + fn ensure_out(&mut self, absolute_end: usize) { + let needed = absolute_end - self.out_start; + if needed <= self.out[0].len() { + return; + } + let next_len = needed.max(self.out[0].len() * 2).max(self.n * 4); + for channel in 0..self.channels { + self.out[channel].resize(next_len, 0.0); + } + self.win_sum.resize(next_len, 0.0); + } + + fn collect(&mut self, absolute_end: usize, emitted: &mut PlanarPcm) { + let count = absolute_end.saturating_sub(self.out_start); + if count == 0 { + return; + } + for channel in 0..self.channels { + for i in 0..count { + let weight = self.win_sum[i]; + let mut sample = self.out[channel][i]; + if weight > 1e-6 { + sample /= weight; + } + emitted[channel].push(sample); + } + self.out[channel] = self.out[channel][count..].to_vec(); + } + self.win_sum = self.win_sum[count..].to_vec(); + self.out_start = absolute_end; + } + + fn collect_all(&mut self, emitted: &mut PlanarPcm) { + if !self.placed_any { + return; + } + let end = (self.frame - 1) * self.hs + self.n; + self.collect(end, emitted); + } + + fn discard_below(&mut self, absolute_index: i64) { + let drop_count = absolute_index - self.buf_start; + if drop_count <= 0 { + return; + } + let drop_count = drop_count as usize; + for channel in 0..self.channels { + self.buf[channel] = self.buf[channel][drop_count.min(self.buf[channel].len())..].to_vec(); + } + self.mono = self.mono[drop_count.min(self.mono.len())..].to_vec(); + self.buf_start = absolute_index; + } +} + +fn stretch_pcm_to_length(pcm: &[Vec], target_samples: usize) -> PlanarPcm { + if target_samples == 0 { + return vec![Vec::new(); AUDIO_OUTPUT_CHANNELS]; + } + let source_samples = pcm.first().map(|channel| channel.len()).unwrap_or(0); + if source_samples == 0 { + return vec![vec![0.0; target_samples]; AUDIO_OUTPUT_CHANNELS]; + } + if source_samples.abs_diff(target_samples) <= 1 { + let mut exact = vec![vec![0.0; target_samples]; AUDIO_OUTPUT_CHANNELS]; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + if let Some(source) = pcm.get(channel) { + let count = source.len().min(target_samples); + exact[channel][..count].copy_from_slice(&source[..count]); + } + } + return exact; + } + + let speed = source_samples as f64 / target_samples as f64; + let mut stretcher = WsolaTimeStretcher::new( + AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_OUTPUT_CHANNELS, + speed, + target_samples, + ); + let chunks = [stretcher.push(pcm), stretcher.flush()]; + let mut exact = vec![vec![0.0; target_samples]; AUDIO_OUTPUT_CHANNELS]; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + let mut written = 0usize; + for chunk in &chunks { + let source = &chunk[channel]; + let count = source.len().min(target_samples - written); + if count > 0 { + exact[channel][written..written + count].copy_from_slice(&source[..count]); + written += count; + } + if written == target_samples { + break; + } + } + } + exact +} + +/// Découpe le PCM gardé avec les mêmes spans et la même quantification frame que la vidéo. +pub fn stretch_clip_pcm_by_speed( + pcm: &[Vec], + speed_segments: &[SpeedSegment], + output_fps: f64, +) -> PlanarPcm { + let total_source_samples = pcm.first().map(|channel| channel.len()).unwrap_or(0); + let mut source_cursor = 0usize; + let mut chunks: Vec = Vec::with_capacity(speed_segments.len()); + for segment in speed_segments { + let input_samples = ((segment.end_sec - segment.start_sec) + * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round() + .max(0.0) as usize; + let input_start = source_cursor; + let input_end = (input_start + input_samples).min(total_source_samples); + source_cursor = input_start + input_samples; + let output_samples = ((segment.frame_count as f64 / output_fps) + * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round() + .max(0.0) as usize; + if input_end <= input_start { + chunks.push(vec![vec![0.0; output_samples]; AUDIO_OUTPUT_CHANNELS]); + continue; + } + let slice: PlanarPcm = (0..AUDIO_OUTPUT_CHANNELS) + .map(|channel| pcm[channel][input_start..input_end].to_vec()) + .collect(); + chunks.push(stretch_pcm_to_length(&slice, output_samples)); + } + + let mut output = vec![Vec::new(); AUDIO_OUTPUT_CHANNELS]; + for chunk in chunks { + for channel in 0..AUDIO_OUTPUT_CHANNELS { + output[channel].extend_from_slice(&chunk[channel]); + } + } + output +} + +#[derive(Clone, Copy)] +pub struct AudioConcatSegmentPlan { + pub start_sample: usize, + pub sample_count: usize, + pub silence: bool, +} + +pub struct AudioConcatPlan { + pub total_samples: usize, + pub segments: Vec, +} + +/// Les offsets sont la somme ENTIÈRE des longueurs arrondies clip par clip ; recalculer depuis +/// une durée cumulée ferait dériver les jonctions sur une longue timeline. +pub fn build_audio_concat_plan( + output_frame_counts: &[u64], + has_audio: &[bool], + output_fps: f64, +) -> AudioConcatPlan { + let mut cursor = 0usize; + let mut segments = Vec::with_capacity(output_frame_counts.len()); + for (index, &frame_count) in output_frame_counts.iter().enumerate() { + let sample_count = if output_fps > 0.0 { + ((frame_count as f64 / output_fps) * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round() + .max(0.0) as usize + } else { + 0 + }; + segments.push(AudioConcatSegmentPlan { + start_sample: cursor, + sample_count, + silence: !has_audio.get(index).copied().unwrap_or(false), + }); + cursor += sample_count; + } + AudioConcatPlan { total_samples: cursor, segments } +} + +pub fn assemble_concatenated_pcm( + clip_pcm: &[Option], + plan: &AudioConcatPlan, +) -> PlanarPcm { + let mut output = vec![vec![0.0f32; plan.total_samples]; AUDIO_OUTPUT_CHANNELS]; + for (index, segment) in plan.segments.iter().enumerate() { + if segment.sample_count == 0 || segment.silence { + continue; + } + let Some(Some(pcm)) = clip_pcm.get(index) else { + continue; + }; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + let Some(source) = pcm.get(channel) else { + continue; + }; + let count = segment.sample_count.min(source.len()); + output[channel][segment.start_sample..segment.start_sample + count] + .copy_from_slice(&source[..count]); + } + } + + for boundary in plan.segments.windows(2) { + let current = boundary[0]; + let next = boundary[1]; + let fade = AUDIO_BOUNDARY_FADE_SAMPLES + .min(current.sample_count / 2) + .min(next.sample_count / 2); + if fade == 0 { + continue; + } + let tail_start = current.start_sample + current.sample_count - fade; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + for k in 0..fade { + let phase = (k as f32 / fade as f32) * PI * 0.5; + output[channel][tail_start + k] *= phase.cos(); + output[channel][next.start_sample + k] *= phase.sin(); + } + } + } + output +} + +/// Encodeur AAC attaché au muxer avant son header. Les paquets utilisent le même interleaver +/// que la vidéo ; les pts restent en unités échantillon jusqu'au rescale vers l'AVStream. +pub(crate) struct AacEncoder { + context: *mut AVCodecContext, + stream: *mut AVStream, + packet: *mut AVPacket, +} + +impl AacEncoder { + pub(crate) unsafe fn open(output: *mut AVFormatContext) -> Result { + let name = CString::new("aac")?; + let codec = avcodec_find_encoder_by_name(name.as_ptr()); + if codec.is_null() { + bail!("encodeur aac introuvable"); + } + let context = avcodec_alloc_context3(codec); + if context.is_null() { + bail!("aac avcodec_alloc_context3"); + } + (*context).sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_FLTP; + (*context).sample_rate = AUDIO_OUTPUT_SAMPLE_RATE; + (*context).bit_rate = AUDIO_BITRATE; + (*context).time_base = AVRational { num: 1, den: AUDIO_OUTPUT_SAMPLE_RATE }; + av_channel_layout_default(&mut (*context).ch_layout, AUDIO_OUTPUT_CHANNELS as i32); + averr(avcodec_open2(context, codec, ptr::null_mut()), "aac avcodec_open2")?; + + let stream = avformat_new_stream(output, ptr::null()); + if stream.is_null() { + bail!("aac avformat_new_stream"); + } + averr( + avcodec_parameters_from_context((*stream).codecpar, context), + "aac parameters_from_context", + )?; + (*stream).time_base = (*context).time_base; + let packet = av_packet_alloc(); + if packet.is_null() { + bail!("aac av_packet_alloc"); + } + Ok(Self { context, stream, packet }) + } + + pub(crate) unsafe fn encode(&mut self, pcm: &[Vec], output: *mut AVFormatContext) -> Result<()> { + let total_samples = pcm.first().map(|channel| channel.len()).unwrap_or(0); + let frame_size = if (*self.context).frame_size > 0 { + (*self.context).frame_size as usize + } else { + 1024 + }; + let mut offset = 0usize; + while offset < total_samples { + let sample_count = frame_size.min(total_samples - offset); + let mut frame = av_frame_alloc(); + if frame.is_null() { + bail!("aac av_frame_alloc"); + } + (*frame).format = (*self.context).sample_fmt as i32; + (*frame).sample_rate = AUDIO_OUTPUT_SAMPLE_RATE; + (*frame).nb_samples = sample_count as i32; + averr( + av_channel_layout_copy(&mut (*frame).ch_layout, &(*self.context).ch_layout), + "aac channel_layout_copy", + )?; + averr(av_frame_get_buffer(frame, 0), "aac frame_get_buffer")?; + averr(av_frame_make_writable(frame), "aac frame_make_writable")?; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + let destination = *(*frame).extended_data.add(channel) as *mut f32; + ptr::write_bytes(destination, 0, sample_count); + if let Some(source) = pcm.get(channel) { + let available = source.len().saturating_sub(offset).min(sample_count); + if available > 0 { + ptr::copy_nonoverlapping(source.as_ptr().add(offset), destination, available); + } + } + } + (*frame).pts = offset as i64; + averr(avcodec_send_frame(self.context, frame), "aac send_frame")?; + self.drain(output)?; + av_frame_free(&mut frame); + offset += sample_count; + } + averr(avcodec_send_frame(self.context, ptr::null()), "aac flush")?; + self.drain(output) + } + + unsafe fn drain(&mut self, output: *mut AVFormatContext) -> Result<()> { + loop { + let ret = avcodec_receive_packet(self.context, self.packet); + if ret == AVERROR_EAGAIN || ret == AVERROR_EOF { + return Ok(()); + } + averr(ret, "aac receive_packet")?; + (*self.packet).stream_index = (*self.stream).index; + av_packet_rescale_ts(self.packet, (*self.context).time_base, (*self.stream).time_base); + averr( + av_interleaved_write_frame(output, self.packet), + "aac interleaved_write_frame", + )?; + av_packet_unref(self.packet); + } + } +} + +impl Drop for AacEncoder { + fn drop(&mut self) { + unsafe { + av_packet_free(&mut self.packet); + avcodec_free_context(&mut self.context); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Même contenu sur les deux canaux : le mixage travaille canal par canal, donc asserter sur + /// un seul suffit, mais les deux plans doivent exister (le format de sortie est stéréo). + fn planar(samples: &[f32]) -> PlanarPcm { + vec![samples.to_vec(), samples.to_vec()] + } + + #[test] + fn single_track_passes_through_unchanged() { + let track = planar(&[0.25, -0.5, 0.75]); + let mixed = mix_aligned_tracks(&[(0.0, &track)], 0.0, 3); + assert_eq!(mixed[0], vec![0.25, -0.5, 0.75]); + assert_eq!(mixed[1], vec![0.25, -0.5, 0.75]); + } + + #[test] + fn single_track_is_not_clamped() { + // Promesse de non-régression : une source mono-piste ressort telle quelle, y compris + // hors pleine échelle. Seul le mixage multipiste écrête. + let track = planar(&[1.5, -1.5]); + let mixed = mix_aligned_tracks(&[(0.0, &track)], 0.0, 2); + assert_eq!(mixed[0], vec![1.5, -1.5]); + } + + #[test] + fn silent_first_track_does_not_swallow_the_microphone() { + // Le cas de l'issue #108 : l'enregistreur natif macOS écrit l'audio système en première + // piste (silencieuse si rien ne joue) et le micro en seconde. `av_find_best_stream` ne + // rendait que la première, et l'export sortait muet. + let system_audio = planar(&[0.0, 0.0, 0.0]); + let microphone = planar(&[0.3, -0.4, 0.5]); + let mixed = mix_aligned_tracks(&[(0.0, &system_audio), (0.0, µphone)], 0.0, 3); + assert_eq!(mixed[0], vec![0.3, -0.4, 0.5]); + } + + #[test] + fn tracks_are_summed() { + let a = planar(&[0.1, 0.2]); + let b = planar(&[0.2, 0.3]); + let mixed = mix_aligned_tracks(&[(0.0, &a), (0.0, &b)], 0.0, 2); + assert!((mixed[0][0] - 0.3).abs() < 1e-6); + assert!((mixed[0][1] - 0.5).abs() < 1e-6); + } + + #[test] + fn multi_track_sum_is_clamped_to_full_scale() { + let a = planar(&[0.8, -0.8]); + let b = planar(&[0.8, -0.8]); + let mixed = mix_aligned_tracks(&[(0.0, &a), (0.0, &b)], 0.0, 2); + assert_eq!(mixed[0], vec![1.0, -1.0]); + } + + #[test] + fn a_track_starting_late_is_zero_padded_at_the_front() { + // La piste commence 1 ms après le début de la fenêtre demandée : 48 échantillons de + // silence devant, son contenu ensuite. Sans ce recadrage, sommer désalignerait les pistes. + let late = planar(&[0.5; 8]); + let mixed = mix_aligned_tracks(&[(0.001, &late)], 0.0, 64); + assert_eq!(&mixed[0][..48], &[0.0f32; 48][..]); + assert_eq!(&mixed[0][48..56], &[0.5f32; 8][..]); + assert_eq!(&mixed[0][56..], &[0.0f32; 8][..]); + } + + #[test] + fn a_track_decoded_early_has_its_prefetch_trimmed() { + // Le seek audio retombe sur une trame antérieure à la fenêtre : cette prélecture est + // coupée, pas mixée. + let mut samples = vec![0.0f32; 48]; + samples.extend_from_slice(&[0.5; 8]); + let early = planar(&samples); + let mixed = mix_aligned_tracks(&[(-0.001, &early)], 0.0, 8); + assert_eq!(mixed[0], vec![0.5; 8]); + } +} diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs new file mode 100644 index 0000000000..4fa4dd4756 --- /dev/null +++ b/crates/compositor/src/compositor_linux.rs @@ -0,0 +1,2154 @@ +//! Moteur de composition Linux -- wgpu / WGSL. +//! +//! Equivalent Linux de `compositor_windows.rs` / `compositor_macos.rs` : meme +//! surface publique (`Compositor::{new, new_sized, normalize_render_size, +//! render_size, set_scene, set_live_params, set_cursor, set_cursor_time, +//! set_timeline_time, clear_cursor, scene_snapshot, clear_srv_cache, +//! compose_frame, readback_direct}`) pour que `live.rs` et `compositor-view-napi` +//! (cfg-re-exportes via `crate::compositor`) l'utilisent sans connaitre la +//! plateforme. S'y ajoutent, specifiques a ce backend, les trois entrees de la +//! ring de staging (`set_readback_depth`, `readback_submit`, `readback_take`) : +//! seul l'export Linux les utilise, cf. `ReadbackRing`. +//! +//! **Iso-render.** La GEOMETRIE (placement de chaque calque) vient de +//! `frame_geometry::plan_frame` -- la MEME fonction que Windows/macOS, au pixel +//! pres. Ce module ne fait que RENDRE le `FrameGeometry` via wgpu/WGSL +//! (`vk_shaders/layer.wgsl`), la ou macOS le rend via Metal/MSL. +//! +//! **Portee actuelle.** `compose_frame` rend le coeur : fond uni + calque ecran +//! cover-fit (coins arrondis). Les calques riches (webcam PiP, curseur, +//! annotations texte mode 11, blur de fond, motion blur) sont dessines +//! par les memes primitives (`draw_layer`) et arrivent par iterations, comme le +//! port Metal les a ajoutes -- chacun reutilise `layer.wgsl` (modes deja portes) +//! ou une passe dediee (`blur.wgsl`). + +use std::cell::RefCell; + +use anyhow::Result; +use wgpu::util::DeviceExt; + +use crate::config::Cfg; +use crate::d3d::Gpu; +use crate::ffi::AVFrame; +// Re-exports que le code partage (live.rs, compositor-view-napi) consomme via +// `crate::compositor::…`, a l'identique de `compositor_macos`. +pub use crate::frame_geometry::{ + live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, +}; +use crate::frame_geometry::{ + cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, + FrameGeometryInput, +}; +use crate::scene::{Scene, SceneBackground}; + +const LAYER_WGSL: &str = include_str!("vk_shaders/layer.wgsl"); +const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl"); + +/// `&LayerCB` -> `&[u8; 128]`. `LayerCB` est `#[repr(C, align(16))]`, son layout +/// EST le buffer uniforme WGSL (16 vec4 + 1 vec2 + 2 f32 = 128 octets). +fn layer_bytes(cb: &LayerCB) -> &[u8] { + unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, 128) } +} + +/// Une copie RT -> staging DEJA SOUMISE, dont le mapping est arme mais pas +/// encore recolte. On garde `idx` (l'index de soumission rendu par +/// `Queue::submit`) pour n'attendre QUE cette soumission-la, et les dimensions +/// telles qu'elles etaient au moment de la copie -- ce sont elles qui decrivent +/// le contenu du buffer, pas celles du compositeur au moment de la recolte. +struct PendingCopy { + buf: wgpu::Buffer, + idx: wgpu::SubmissionIndex, + rx: std::sync::mpsc::Receiver>, + w: u32, + h: u32, + bpr: u32, +} + +/// Ring de staging de la relecture. +/// +/// AVANT : `readback_direct` enregistrait la copie, la soumettait, puis bloquait +/// dans `device.poll(Maintain::Wait)`. Cette attente n'absorbait pas la copie +/// (8,3 Mo = ~0,33 ms de DMA) mais TOUTE la file GPU en cours -- la chaine +/// Kawase et chaque draw de calque, que `compose_frame` avait soumis sans +/// attendre juste avant. Mesure 1080p : 3,8 ms (scene simple) a 6,2 ms (scene +/// chargee) par frame, pendant que `sws_scale` + `avcodec_send_frame` (12,6 ms +/// de CPU pur) attendaient leur tour. Le GPU et le CPU ne se recouvraient +/// jamais. +/// +/// MAINTENANT : `readback_submit` soumet la copie de la frame N vers un buffer +/// libre, arme son `map_async` et rend la main ; il ne recolte que la frame la +/// plus ANCIENNE encore en vol. Avec `depth = 2`, c'est la frame N-1, dont la +/// copie a ete soumise avant l'encodage de N-1 et le decodage/composition de N : +/// le GPU a eu ~19 ms de CPU pour finir 6 ms de travail, l'attente tombe a zero. +/// +/// PROFONDEUR. 2 est le minimum utile et suffit ici : le seul travail a +/// recouvrir est ce que le CPU fait entre deux relectures (sws + encode, +/// 12,6 ms mesures) et il depasse deja largement la chaine GPU (3,8 a 6,2 ms). +/// Une 3e frame n'ajouterait que 8 Mo de memoire mappable et une frame de +/// latence de plus. La profondeur reste parametrable parce que la POLITIQUE +/// differe par chemin (cf. `set_readback_depth`), pas pour empiler les buffers. +/// +/// UN SEUL RT. Le RT n'est pas double-bufferise : la copie de la frame N est +/// soumise AVANT les commandes de composition de la frame N+1, sur la meme +/// queue, et wgpu insere la barriere qui va bien. Le GPU lit donc le RT avant +/// de le reecrire, sans que le CPU ait a l'attendre. +struct ReadbackRing { + depth: usize, + /// Buffers disponibles (aucune copie en vol, aucun mapping arme). + free: Vec, + /// Copies soumises, dans l'ordre de soumission (FIFO strict : les frames + /// sortent dans l'ordre ou elles ont ete composees). + pending: std::collections::VecDeque, +} + +pub struct Compositor { + gpu: Gpu, + render_w: u32, + render_h: u32, + + // Pipeline de calque (VS + FS `layer.wgsl`), sampler lineaire, bind group + // layout (uniform + 2 textures + sampler). Immuables apres `new_sized`. + pipeline: wgpu::RenderPipeline, + /// Meme shader et meme layout que `pipeline`, blend ADDITIF pondere par la + /// constante de blend. Sert a sommer les copies de la trainee du curseur + /// dans `accum` ; cf. `blend_add` cote Windows. + pipeline_add: wgpu::RenderPipeline, + /// Copie plein ecran d'`accum` vers le RT en « over » premultiplie + /// (`blur.wgsl` : `vs_fullscreen` + `fs_copy`). Utilise le layout du blur. + pipeline_copy: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + + // Chaine de blur Kawase du fond (`blur.wgsl`) : layout dedie (uniform + 1 + // tex + sampler), 2 pipelines (down/up), 3 textures de pyramide (1/2, 1/4, + // 1/8 de la sortie). Les `TextureView` gardent leurs textures en vie. + blur_bgl: wgpu::BindGroupLayout, + blur_down: wgpu::RenderPipeline, + blur_up: wgpu::RenderPipeline, + blur_half: wgpu::TextureView, + blur_qtr: wgpu::TextureView, + blur_oct: wgpu::TextureView, + + // Render target offscreen + ring de staging de la relecture (recrees au resize). + rt: wgpu::Texture, + rt_view: wgpu::TextureView, + /// Cible ISOLEE d'accumulation, meme taille et meme format que le RT. + /// `_accum` garde la texture en vie ; seule la vue est utilisee. + _accum: wgpu::Texture, + accum_view: wgpu::TextureView, + /// `bytes_per_row` padde a 256 (contrainte wgpu de copy_texture_to_buffer). + readback_bpr: u32, + /// Ring de buffers de staging (cf. `ReadbackRing`). `RefCell` : les methodes + /// publiques du compositeur sont `&self`, comme tout le reste de l'etat. + readback: RefCell, + + // Etat pilote par live.rs (interior mutability : les methodes sont `&self`). + live_params: RefCell, + scene: RefCell>, + cursor: RefCell>, + cursor_time: RefCell>, + timeline_time: RefCell>, + + /// Rasterizer de texte (annotations mode 11). `None` si l'init cosmic-text + /// echoue -- le rendu continue sans texte plutot que de tout casser. + #[allow(dead_code)] + text_raster: Option, + + /// Cache des sprites curseur (PNG RGBA -> texture wgpu), par chemin. Meme + /// role que `img_cache` cote macOS : un sprite chargé une fois par session. + img_cache: RefCell>, + + /// Copie mipmappee de la frame composee, lue par les annotations « flou » + /// (mode 10). `ann_copy` garde la texture en vie, `ann_copy_view` porte tous + /// les niveaux (echantillonnage), `ann_copy_mips` un niveau chacune (cibles + /// de la generation). Cf. `make_ann_copy`. + ann_copy: wgpu::Texture, + ann_copy_view: wgpu::TextureView, + ann_copy_mips: Vec, + + /// Images d'annotation, indexees par ID d'annotation -- PAS par chemin comme + /// `img_cache`. Une annotation image porte souvent une data-URI de plusieurs + /// mega-octets ; s'en servir comme cle de HashMap la ferait hacher a chaque + /// frame. La longueur de la source sert de temoin de changement, comme cote + /// macOS. + ann_img_cache: RefCell>, +} + +impl Compositor { + pub fn new(gpu: &Gpu) -> Result { + Self::new_sized(gpu, OUT_W, OUT_H) + } + + pub fn new_sized(gpu: &Gpu, w: u32, h: u32) -> Result { + let (w, h) = Self::normalize_render_size(w, h); + let gpu = Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + backend: gpu.backend, + feature_level: gpu.feature_level, + }; + + let module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("layer.wgsl"), + source: wgpu::ShaderSource::Wgsl(LAYER_WGSL.into()), + }); + let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("layer"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + // Trilineaire pour le LOD fractionnaire du mode 10 : `log2(rayon)` + // tombe entre deux niveaux, et en `Nearest` le flou avancerait par + // paliers visibles quand le rayon varie. Sans effet sur tout le + // reste -- aucune autre texture liee ici n'a plus d'un niveau. + mipmap_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let tex_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let bind_group_layout = + gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("layer"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(128), + }, + count: None, + }, + tex_entry(1), + tex_entry(2), + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("layer"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + // Deux pipelines pour le MEME shader de calque : seul le blend change. + let mk_layer = |label: &str, blend: wgpu::BlendState| { + gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }) + }; + let pipeline = mk_layer("layer", wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); + // SOMME pondere : `src * constante + dst`. La constante (posee par pass + // via `set_blend_constant`) vaut 1/taps, donc N copies d'un curseur + // parfaitement immobile redonnent exactement ce curseur. Transcription + // du `blend_add` D3D11 (BLEND_FACTOR / ONE / OP_ADD sur couleur ET + // alpha) ; l'alpha doit suivre la couleur, sinon la somme n'est plus + // premultipliee et la composition finale delave la trainee. + let add = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Constant, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }; + let pipeline_add = mk_layer( + "layer-add", + wgpu::BlendState { color: add, alpha: add }, + ); + + // --- Chaine de blur Kawase du fond (`blur.wgsl`) --- + let blur_module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("blur.wgsl"), + source: wgpu::ShaderSource::Wgsl(BLUR_WGSL.into()), + }); + // Layout blur : 0 = uniform, 1 = texture, 2 = sampler (blur.wgsl). + let blur_bgl = gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("blur"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(128), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let blur_pl = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("blur"), + bind_group_layouts: &[&blur_bgl], + push_constant_ranges: &[], + }); + let mk_blur = |entry: &str| { + gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(entry), + layout: Some(&blur_pl), + vertex: wgpu::VertexState { + module: &blur_module, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &blur_module, + entry_point: Some(entry), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }) + }; + let blur_down = mk_blur("fs_kawase_down"); + let blur_up = mk_blur("fs_kawase_up"); + // Composition d'`accum` sur le RT : meme layout que le blur (uniform + + // 1 texture + sampler) et blend « over » premultiplie. Son VS est + // `vs_fullscreen` et non le `vs_main` du Kawase -- une passe UNIQUE ne + // pardonne pas une erreur d'orientation, cf. le commentaire la-bas. + let pipeline_copy = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("accum-copy"), + layout: Some(&blur_pl), + vertex: wgpu::VertexState { + module: &blur_module, + entry_point: Some("vs_fullscreen"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &blur_module, + entry_point: Some("fs_copy"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + let mk_pyr = |dw: u32, dh: u32, label: &str| { + gpu.device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: dw.max(1), + height: dh.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) + }; + let blur_half = mk_pyr(w / 2, h / 2, "blur-half"); + let blur_qtr = mk_pyr(w / 4, h / 4, "blur-qtr"); + let blur_oct = mk_pyr(w / 8, h / 8, "blur-oct"); + + let (rt, rt_view, accum, accum_view, readback_bpr) = Self::make_targets(&gpu, w, h); + let (ann_copy, ann_copy_view, ann_copy_mips) = Self::make_ann_copy(&gpu, w, h); + // Profondeur 1 par defaut = chemin synchrone historique, a l'octet et a + // la latence pres. C'est l'export qui demande explicitement 2 (cf. + // `set_readback_depth`) ; tout autre appelant garde l'ancien contrat. + let readback = RefCell::new(ReadbackRing { + depth: 1, + free: vec![Self::make_staging(&gpu, readback_bpr, h)], + pending: std::collections::VecDeque::new(), + }); + + Ok(Compositor { + gpu, + render_w: w, + render_h: h, + pipeline, + pipeline_add, + pipeline_copy, + bind_group_layout, + sampler, + blur_bgl, + blur_down, + blur_up, + blur_half, + blur_qtr, + blur_oct, + rt, + rt_view, + _accum: accum, + accum_view, + readback_bpr, + readback, + live_params: RefCell::new(LiveParams::default()), + scene: RefCell::new(None), + cursor: RefCell::new(None), + cursor_time: RefCell::new(None), + timeline_time: RefCell::new(None), + text_raster: crate::text::TextRasterizer::new().ok(), + img_cache: RefCell::new(std::collections::HashMap::new()), + ann_copy, + ann_copy_view, + ann_copy_mips, + ann_img_cache: RefCell::new(std::collections::HashMap::new()), + }) + } + + /// RT RGBA8, cible d'accumulation de meme geometrie, et `bytes_per_row` de + /// la relecture (padde a 256). + /// + /// `accum` est alloue ICI et pas ailleurs pour qu'il soit impossible de le + /// laisser a l'ancienne taille apres un changement de resolution : c'est le + /// meme appel qui produit les deux, et un accum plus petit que le RT ferait + /// une passe de composition tronquee. + fn make_targets( + gpu: &Gpu, + w: u32, + h: u32, + ) -> (wgpu::Texture, wgpu::TextureView, wgpu::Texture, wgpu::TextureView, u32) { + let mk = |label: &str, extra: wgpu::TextureUsages| { + gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | extra, + view_formats: &[], + }) + }; + let rt = mk("rt", wgpu::TextureUsages::COPY_SRC); + let accum = mk("accum", wgpu::TextureUsages::empty()); + let rt_view = rt.create_view(&wgpu::TextureViewDescriptor::default()); + let accum_view = accum.create_view(&wgpu::TextureViewDescriptor::default()); + let bpr = (w * 4).div_ceil(256) * 256; + (rt, rt_view, accum, accum_view, bpr) + } + + /// Copie du RT avec chaine de mips COMPLETE, source des annotations « flou ». + /// + /// Le mode 10 lit un niveau de mip choisi par `log2(rayon)` : c'est la + /// pyramide qui FAIT le flou, pas un noyau de taps (cf. le commentaire du + /// shader). Il lui faut donc tous les niveaux jusqu'a 1x1, sinon un grand + /// rayon demande un LOD qui n'existe pas et le sampler retombe sur le dernier + /// disponible -- le flou plafonne en silence. + /// + /// Retourne aussi une vue PAR NIVEAU : `generate_ann_mips` rend le niveau i + /// depuis le niveau i-1, et une vue de render target ne peut porter qu'un + /// seul niveau. + fn make_ann_copy( + gpu: &Gpu, + w: u32, + h: u32, + ) -> (wgpu::Texture, wgpu::TextureView, Vec) { + // floor(log2(max)) + 1 : le dernier niveau mesure 1x1. + let levels = 32 - w.max(h).max(1).leading_zeros(); + let tex = gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("ann-copy"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: levels, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let mips = (0..levels) + .map(|level| { + tex.create_view(&wgpu::TextureViewDescriptor { + label: Some("ann-copy-mip"), + base_mip_level: level, + mip_level_count: Some(1), + ..Default::default() + }) + }) + .collect(); + (tex, view, mips) + } + + /// Fige la frame composee dans `ann_copy` et remplit sa pyramide. + /// + /// UNE seule fois par frame, AVANT toute annotation : les flous doivent voir + /// l'image composee SANS les flous eux-memes, sinon deux zones qui se + /// recouvrent s'echantillonnent l'une l'autre selon l'ordre de dessin. Meme + /// contrat que le `blit` + `generate_mipmaps` de `compositor_macos`. + /// + /// wgpu n'a pas de `generate_mipmaps` : chaque niveau est une passe de rendu + /// plein ecran qui echantillonne le precedent. Le filtre lineaire sur une + /// source exactement deux fois plus grande EST la moyenne 2x2, donc cette + /// boucle produit la meme pyramide que le blit Metal. + fn generate_ann_mips(&self, encoder: &mut wgpu::CommandEncoder) { + encoder.copy_texture_to_texture( + self.rt.as_image_copy(), + self.ann_copy.as_image_copy(), + wgpu::Extent3d { + width: self.render_w, + height: self.render_h, + depth_or_array_layers: 1, + }, + ); + // Les bind groups doivent survivre a leur passe : on les garde tous ici. + let mut keep: Vec<(wgpu::Buffer, wgpu::BindGroup)> = Vec::new(); + for level in 1..self.ann_copy_mips.len() { + let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("ann-mip-uniform"), + // `fs_copy` ne lit pas l'uniforme, mais le layout du blur l'exige. + contents: layer_bytes(&LayerCB::default()), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ann-mip"), + layout: &self.blur_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &self.ann_copy_mips[level - 1], + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + keep.push((uniform, bind)); + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ann-mip-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.ann_copy_mips[level], + resolve_target: None, + ops: wgpu::Operations { + // Clear plutot que Load : le niveau n'a jamais ete ecrit, + // et `pipeline_copy` blende « over ». Sur une cible vidée + // le « over » rend la source telle quelle -- l'ecrasement + // qu'on veut ici. + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline_copy); + rpass.set_bind_group(0, &keep[keep.len() - 1].1, &[]); + rpass.draw(0..3, 0..1); + } + } + + /// Un buffer de staging de la ring. La taille depend de `bpr` (donc de la + /// largeur de rendu) et de la hauteur : changer la geometrie de rendu impose + /// de les reallouer -- ce que fait `new_sized`, puisque la preview + /// RECONSTRUIT le compositeur au resize (`live.rs`) au lieu de le + /// redimensionner a chaud. Aucune copie ne peut donc etre en vol au moment + /// ou la taille change : l'ancien compositeur (et sa ring) est detruit + /// entier, wgpu gardant ses buffers vivants jusqu'a la fin des soumissions + /// qui les referencent. + fn make_staging(gpu: &Gpu, bpr: u32, h: u32) -> wgpu::Buffer { + gpu.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: u64::from(bpr) * u64::from(h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }) + } + + /// Une passe Kawase : lit `src`, ecrit `dst` (fullscreen triangle, 3 + /// vertices). `src_px` = dimensions de la source (pour le pas de texel). + fn blur_pass( + &self, + encoder: &mut wgpu::CommandEncoder, + pipeline: &wgpu::RenderPipeline, + src: &wgpu::TextureView, + dst: &wgpu::TextureView, + src_px: [f32; 2], + ) { + let cb = LayerCB { + quad_px: src_px, + mode: -1.0, + color: [1.0, 1.0, 1.0, 1.0], + fx: [2.0, 0.0, 0.0, 0.0], // texel offset Kawase + ..Default::default() + }; + let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("blur-uniform"), + contents: layer_bytes(&cb), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("blur"), + layout: &self.blur_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(src), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("blur-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: dst, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(pipeline); + rpass.set_bind_group(0, &bind, &[]); + rpass.draw(0..3, 0..1); + } + + /// Floute le RT (le fond deja dessine) : dual-Kawase 3 down (RT -> 1/2 -> + /// 1/4 -> 1/8) + 3 up (1/8 -> 1/4 -> 1/2 -> RT). ~gaussien a cout constant. + fn blur_bg(&self, encoder: &mut wgpu::CommandEncoder) { + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let (hw, hh) = (rw * 0.5, rh * 0.5); + let (qw, qh) = (rw * 0.25, rh * 0.25); + let (ow, oh) = (rw * 0.125, rh * 0.125); + self.blur_pass(encoder, &self.blur_down, &self.rt_view, &self.blur_half, [rw, rh]); + self.blur_pass(encoder, &self.blur_down, &self.blur_half, &self.blur_qtr, [hw, hh]); + self.blur_pass(encoder, &self.blur_down, &self.blur_qtr, &self.blur_oct, [qw, qh]); + self.blur_pass(encoder, &self.blur_up, &self.blur_oct, &self.blur_qtr, [ow, oh]); + self.blur_pass(encoder, &self.blur_up, &self.blur_qtr, &self.blur_half, [qw, qh]); + self.blur_pass(encoder, &self.blur_up, &self.blur_half, &self.rt_view, [hw, hh]); + } + + /// Dimensions paires (NV12 4:2:0), min 2x2. Symetrie avec les autres backends. + pub fn normalize_render_size(w: u32, h: u32) -> (u32, u32) { + ((w.max(2) + 1) & !1, (h.max(2) + 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() + } + + /// Pas de cache de SRV cote wgpu (les `TextureView`s sont recreees a chaque + /// draw depuis le carrier) -- no-op conserve pour la symetrie d'API. + pub fn clear_srv_cache(&self) {} + + // -- seam frame (lit le carrier `data[0]`) -- + + fn pixel_buffer_of(frame: *const AVFrame) -> Option<()> { + if frame.is_null() || unsafe { (*frame).data[0] }.is_null() { + None + } else { + Some(()) + } + } + + unsafe fn nv12_srvs( + &self, + frame: *const AVFrame, + ) -> Result<(wgpu::TextureView, wgpu::TextureView)> { + crate::linux_frames::nv12_planes(frame) + } + + unsafe fn tex_dims(&self, frame: *const AVFrame) -> (u32, u32) { + if frame.is_null() || (*frame).data[0].is_null() { + return (1, 1); + } + crate::linux_frames::carrier_dims(frame) + } + + // -- rendu -- + + /// Prepare un draw de calque : buffer uniforme init a `cb` + bind group + /// (uniform + deux textures + sampler). Cree AVANT la render pass pour que + /// les ressources vivent pendant tout le pass. Un buffer PAR draw : + /// `write_buffer` entre draws d'une meme pass ne s'entrelace pas. + /// `LayerCB` d'une ombre portee (mode 2), identique a `draw_shadow` cote + /// macOS et au bloc equivalent cote Windows. + /// + /// Le quad est ELARGI de `spread` de chaque cote et decale de `offset_px` ; + /// le shader y trace un SDF de rect arrondi dont l'alpha decroit sur la + /// largeur du spread. C'est pour ca que `fx.x` porte le spread : le + /// fragment en a besoin pour normaliser sa penombre. + fn shadow_cb( + &self, + dst: [f32; 4], + size_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: f32, + ) -> LayerCB { + 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); + 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() + } + } + + /// `LayerCB` de l'ombre d'un ecran INCLINE (mode 12) : la penombre suit le + /// quadrilatere projete, pas son rect englobant. Port de + /// `compositor_macos::draw_quad_shadow`. + fn quad_shadow_cb( + &self, + corners: &[(f32, f32); 4], + center_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: f32, + ) -> LayerCB { + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + // La boite doit contenir la penombre entiere, sinon elle se coupe net. + let box_w = (max_x - min_x) + 2.0 * spread; + let box_h = (max_y - min_y) + 2.0 * spread; + 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]); + 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], + // Le spread vit ici et NON dans `fx.x` : `fx` porte deja les coins. + mb: [0.0, spread, 1.0, 0.0], + ..Default::default() + } + } + + /// `LayerCB` de l'ecran incline (mode 8) : le quad projete est dessine dans sa + /// BBOX et le fragment remonte au (s,t) du plan par warp bilineaire inverse. + /// Port de `compositor_macos::draw_tilted_screen`. Pas de motion blur sur ce + /// chemin -- le tilt est bref, la simplification ne se voit pas. + fn tilted_screen_cb( + &self, + quad: &crate::regions::TiltedQuad, + s_px: [f32; 2], + center_px: [f32; 2], + cut: [f32; 4], + radius: f32, + ) -> LayerCB { + let (rw, rh) = (self.render_w as f32, self.render_h as f32); + let corners = quad.corners; + // Taille du plan dans son propre repere, AVANT projection : c'est la que vit + // le rayon, pour qu'il reste constant le long du bord au lieu de s'etirer + // avec la perspective. + let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let bbox_w = (max_x - min_x).max(1.0); + let bbox_h = (max_y - min_y).max(1.0); + // Coins en px LOCAUX a la bbox, pour matcher `i.local` du shader. + 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]); + 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() + } + } + + fn make_bind( + &self, + cb: &LayerCB, + planes: Option<(&wgpu::TextureView, &wgpu::TextureView)>, + dummy: &wgpu::TextureView, + ) -> (wgpu::Buffer, wgpu::BindGroup) { + let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("layer-uniform"), + contents: layer_bytes(cb), + usage: wgpu::BufferUsages::UNIFORM, + }); + let (y, uv) = planes.unwrap_or((dummy, dummy)); + let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("layer"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(y), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(uv), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + (uniform, bind) + } + + /// Charge un PNG/JPEG (chemin fichier ou data URI) en texture RGBA8. Port + /// wgpu du `load_image_texture` macOS, memes chemins (`decode_data_uri` + /// partage, crate `image`). Sert aux sprites de curseur (mode 7). + fn load_image_texture(&self, path: &str) -> Result<(wgpu::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::anyhow!("data URI image ({} octets) : {e}", bytes.len()))? + .to_rgba8() + } else { + image::open(path) + .map_err(|e| anyhow::anyhow!("sprite {path} : {e}"))? + .to_rgba8() + }; + let (w, h) = (img.width(), img.height()); + let pixels = img.into_raw(); + let tex = self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("sprite"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.gpu.context.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w * 4), + rows_per_image: Some(h), + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + Ok((tex, w, h)) + } + + /// Rend une frame dans le RT interne. Le screen `screen`/`webcam` sont des + /// carriers `linux_frames` ; la geometrie vient de `plan_frame`. Coeur : + /// fond uni + ecran cover-fit. `readback_direct` lit ensuite le RT. + 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)?; + 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 = 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(), + }); + // (`wtw`/`wth` sont les dims de la TEXTURE webcam, consommees par le + // cover-crop du calque PiP plus bas.) + + // Fond : Color -> clear a la couleur ; Gradient -> mode 5 ; Image -> + // mode 6 wallpaper cover-fit (via load_image_texture). Le blur (si + // cfg.bg_blur) floute ensuite ce fond, avant l'ecran. + enum BgLayer { + Gradient(LayerCB), + Image(String), + } + let (bg_clear, bg_layer) = match scene_ref.as_ref().map(|s| s.background.clone()) { + Some(SceneBackground::Color { color }) => { + (parse_hex(&color).unwrap_or(lp.bg_color), None) + } + Some(SceneBackground::Gradient { angle_deg, stops }) => { + let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); + let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + let a = angle_deg.to_radians(); + let cb = LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [c1[0], c1[1], c1[2], c1[3]], + quad_px: [rw, rh], + mode: 5.0, + color: c0, + fx: [a.sin(), -a.cos(), 0.0, 0.0], + ..Default::default() + }; + ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Gradient(cb))) + } + Some(SceneBackground::Image { path }) => { + ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Image(path))) + } + None => (lp.bg_color, None), + }; + + // ROTATION 3D (presets iso/left/right d'une zoom region). La geometrie du + // tilt est calculee UNE fois : l'ombre et l'ecran doivent porter exactement + // le meme quadrilatere, sinon l'ombre se decolle des que l'un des deux + // change. `regions` fait toute la trigo (partagee avec macOS/Windows) ; ici + // on ne fait que l'empaqueter. + 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, + ]; + + // Calque ecran : mode 0 (rect droit, NV12 -> RGB) quand la rotation est + // neutre, mode 8 (warp bilineaire inverse dans la bbox du quad projete) + // sinon. Place par plan_frame (cover-fit + coins arrondis) ; + // `src = g.cut` (crop utilisateur + zoom en UV texture) dans les deux cas. + // + // FLOU DE VELOCITE, ET POURQUOI SEULEMENT SUR LE MODE 0. `src_prev`/ + // `dst_prev` decrivent le MEME calque a la frame precedente ; le shader + // remappe chaque pixel de sortie par ce couple pour retrouver l'UV qu'il + // occupait alors, et floute le long du segment. `src_prev = g.cut` et non + // un `cut` d'avant : la coupe est identique aux deux frames (`plan_frame` + // ne fait varier que le rect de DESTINATION entre `s_dst` et + // `s_dst_prev`), ce que Windows documente aussi. Le mouvement vient donc + // entierement de `dst_prev`. + // + // Le mode 8 n'en recoit PAS, et ce n'est pas un oubli : ces deux champs y + // portent deja les coins projetes du quad (BR/BL dans `src_prev`, + // `plane_px` dans `dst_prev`). Les deux sens ne peuvent pas cohabiter dans + // un meme draw. macOS et Windows sautent egalement le flou sur le chemin + // incline, pour la meme raison. + let screen_layer = match tilt.as_ref() { + None => LayerCB { + dst: g.s_dst, + src: g.cut, + quad_px: s_px, + radius_px: g.s_radius, + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: g.cut, + dst_prev: g.s_dst_prev, + mb: [g.mb_taps, 1.0, 1.0, 0.0], + ..Default::default() + }, + Some(quad) => self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius), + }; + // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; + // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind). + let dummy = self.dummy_view(); + let (_screen_uniform, screen_bind) = + self.make_bind(&screen_layer, Some((&sy, &suv)), &dummy); + + // OMBRE PORTEE de l'ecran, dessinee JUSTE AVANT le calque ecran. Le shader + // la connait depuis le debut ; ce qui manquait etait uniquement le draw + // cote Rust, si bien que le curseur « Ombre » de l'UI ne faisait rien sur + // Linux. + // + // Les fractions de reglage viennent de `frame_geometry`, partagees avec + // macOS/Windows : l'ombre a la meme taille relative sur les trois + // plateformes quelle que soit la resolution de sortie. + // + // L'ombre suit la silhouette REELLEMENT affichee : rect arrondi (mode 2) + // quand l'ecran est droit, quadrilatere projete (mode 12) quand il penche. + let screen_shadow = cfg.shadow.then(|| { + let spread = crate::frame_geometry::SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; + let offset = [0.0, crate::frame_geometry::SCREEN_SHADOW_OFFSET_FRAC * g.frame_min_px]; + let opacity = 0.45 * lp.shadow_scale; + let cb = match tilt.as_ref() { + None => self.shadow_cb(g.s_dst, s_px, g.s_radius, spread, offset, opacity), + Some(quad) => self.quad_shadow_cb( + &quad.corners, + quad_center_px, + g.s_radius * quad.scale, + spread, + offset, + opacity, + ), + }; + self.make_bind(&cb, None, &dummy) + }); + + // Fond (gradient mode 5 OU image mode 6), dessine dans la passe de fond. + // `_tex`/`_view` gardent l'image en vie pendant le pass. + struct BgDraw { + _buf: wgpu::Buffer, + _tex: Option, + _view: Option, + bind: wgpu::BindGroup, + } + let bg_draw = bg_layer.and_then(|bl| match bl { + BgLayer::Gradient(cb) => { + let (buf, bind) = self.make_bind(&cb, None, &dummy); + Some(BgDraw { _buf: buf, _tex: None, _view: None, bind }) + } + BgLayer::Image(path) => { + // Charge (ou recupere du cache) le wallpaper. Emprunt isole AVANT + // le borrow_mut (piege du double emprunt 1re frame, cf. macOS). + let cached = self.img_cache.borrow().get(path.as_str()).cloned(); + let (tex, iw, ih) = match cached { + Some(v) => v, + None => match self.load_image_texture(&path) { + Ok(v) => { + self.img_cache.borrow_mut().insert(path.clone(), v.clone()); + v + } + Err(e) => { + eprintln!("[fond image] \"{path}\" : {e:#}"); + return None; + } + }, + }; + // Cover-fit : l'image remplit tout le cadre, on rogne l'axe long. + let ai = iw as f32 / ih.max(1) as f32; + let ao = rw / rh; + let src = if ai > ao { + let vis = ao / ai; + [(1.0 - vis) * 0.5, 0.0, 1.0 - (1.0 - vis) * 0.5, 1.0] + } else { + let vis = ai / ao; + [0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5] + }; + let cb = LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src, + mode: 6.0, + ..Default::default() + }; + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy); + Some(BgDraw { _buf: buf, _tex: Some(tex), _view: Some(view), bind }) + } + }); + + // Webcam PiP (mode 0) -- placee par plan_frame (`g.w_dst`, coins + // `g.w_radius`), gardee par `g.shape_fade > 0` (webcam visible). + // `webcam_planes` garde les vues en vie pendant le pass. + // `lp.has_webcam` is the gate Windows (`compositor_windows.rs`) and macOS + // (`compositor_macos.rs`) both apply and this backend did not. It is false + // when the clip has no camera — and in that case the "webcam" decoder holds + // the SCREEN video, because `open_and_seek_clip` falls back to it rather + // than leave the pair half-open. Without this check a recording with no + // camera drew its own screen picture inside the PiP box. + let webcam_planes = if lp.has_webcam && g.shape_fade > 0.0 && !webcam.is_null() { + self.nv12_srvs(webcam).ok() + } else { + None + }; + let webcam_draw = webcam_planes.as_ref().map(|(wy, wuv)| { + // COVER-CROP. `src` etait cable a [0,0,1,1], donc la texture entiere + // etait etiree sur la boite quelle que soit sa forme : le facteur de + // deformation valait exactement `box_ar / cam_ar`. Invisible en PiP + // rectangulaire (`compositeLayout.ts` y preserve deja le ratio), + // spectaculaire des que le masque est un cercle ou un carre, ou la + // boite est forcee carree et une camera 16:9 s'ecrase de 1,78x. + // + // `cover_crop_uv` est la primitive partagee que macOS et Windows + // utilisent ; elle rend le rect inchange quand il a deja le bon + // ratio, donc aucun placement correct ne bouge. + 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), + ); + // MIROIR : on inverse l'intervalle u. Le VS interpole `src` + // lineairement et `fs_main` ne re-clampe pas `i.uv`, donc un + // intervalle a l'envers suffit -- aucune retouche du WGSL. Apres le + // cover-crop les deux bornes sont strictement a l'interieur de la + // texture, donc le sampler ClampToEdge ne bave pas sur les bords. + let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) }; + // `src_prev` doit valoir EXACTEMENT le `src` de ce draw, miroir + // compris : le shader s'en sert pour reconstruire l'UV de la frame + // precedente, et un rect source qui ne correspond pas au calque + // dessine ferait diverger la trainee vers une zone de la texture qui + // n'a jamais ete affichee. Seul `dst_prev` porte le mouvement. + let cb = 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() + }; + self.make_bind(&cb, Some((wy, wuv)), &dummy) + }); + + // OMBRE de la camera. Pas dans les presets « bloc » (dual-frame, + // vertical-stack) : la camera y est collee a l'ecran comme une tuile, + // et une ombre entre les deux dessinerait une couture. Meme condition + // que macOS. + let webcam_shadow = (cfg.shadow + && g.shape_fade > 0.0 + && webcam_draw.is_some() + && !matches!( + g.scene_preset.as_deref(), + Some("dual-frame") | Some("vertical-stack") + )) + .then(|| { + let cb = self.shadow_cb( + g.w_dst, + g.w_px, + g.w_radius, + crate::frame_geometry::WEBCAM_SHADOW_SPREAD_FRAC * g.frame_min_px, + [0.0, crate::frame_geometry::WEBCAM_SHADOW_OFFSET_FRAC * g.frame_min_px], + crate::frame_geometry::WEBCAM_SHADOW_OPACITY * g.shape_fade, + ); + self.make_bind(&cb, None, &dummy) + }); + + // ANNOTATIONS -- calque le plus haut, place relativement au rect ecran + // `g.s_dst` (les coords x/y/w/h de l'annotation sont des fractions de ce + // rect, cf. `scene.rs`). Port de `compositor_macos::draw_annotations` : + // memes modes, memes replis, meme ordre. Seul le texte diverge, tinte + // cote shader (atlas R8) au lieu d'une couleur bakee dans la texture. + struct AnnDraw { + _buf: wgpu::Buffer, + /// Gardent l'atlas / la texture image en vie jusqu'au submit. `None` + /// pour les quads qui n'echantillonnent rien (plaque de fond, fleche). + _glyphs: Option, + _tex: Option, + bind: wgpu::BindGroup, + } + impl AnnDraw { + fn plain(buf: wgpu::Buffer, bind: wgpu::BindGroup) -> AnnDraw { + AnnDraw { _buf: buf, _glyphs: None, _tex: None, bind } + } + } + // FENETRE TEMPORELLE. Sans ce test, TOUTES les annotations du projet sont + // peintes sur TOUTES les frames : cinq sous-titres s'empilent les uns sur + // les autres du debut a la fin de l'export. C'est le defaut qui se lit + // comme « le texte s'affiche bizarrement » avant meme de regarder les + // glyphes. Mirroir de `visible()` dans compositor_macos.rs. + let visible = |a: &crate::scene::SceneAnnotation| { + g.source_t >= a.start_sec as f32 && g.source_t < a.end_sec as f32 + }; + // Un flou lit la frame composee ; il faut donc la figer AVANT de dessiner + // la moindre annotation. On ne le fait que si un flou est reellement + // visible : la pyramide coute une passe par niveau. + let needs_ann_copy = scene_ref + .as_ref() + .is_some_and(|s| s.annotations.iter().any(|a| a.kind == "blur" && visible(a))); + let mut ann_draws: Vec = Vec::new(); + if let Some(scene) = scene_ref.as_ref() { + // La liste arrive deja triee par zIndex cote app : l'ordre d'iteration + // EST l'ordre de peinture. + for a in &scene.annotations { + if !visible(a) { + continue; + } + let dst = [ + g.s_dst[0] + a.x * g.s_dst[2], + g.s_dst[1] + a.y * g.s_dst[3], + a.w * g.s_dst[2], + a.h * g.s_dst[3], + ]; + let quad_px = [dst[2] * rw, dst[3] * rh]; + // Une boite degeneree ferait un atlas 0x0 et un draw invisible ; + // macOS l'ecarte de la meme facon. + 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, + ); + let cb = 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() + }; + let (buf, bind) = self.make_bind(&cb, None, &dummy); + ann_draws.push(AnnDraw::plain(buf, bind)); + } + "blur" => { + let Some(blur) = a.blur.as_ref() else { continue }; + // Le masque en trace libre demanderait une liste de points + // cote GPU : on masque la BOITE ENGLOBANTE. Choix + // deliberement asymetrique -- ne rien dessiner laisserait + // passer en clair ce que l'utilisateur a designe comme a + // cacher, et un masque qui ne masque pas donne confiance a + // 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 mosaique : un flou teinte ne + // ressemble plus a 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] + }; + let cb = LayerCB { + dst, + quad_px, + mode: 10.0, + color: tint, + fx: [is_blur, amount.max(1.0), is_oval, tinted], + ..Default::default() + }; + // La copie mipmappee au binding 1 (texY), la ou le mode 10 + // la lit. + let (buf, bind) = self.make_bind( + &cb, + Some((&self.ann_copy_view, &self.ann_copy_view)), + &dummy, + ); + ann_draws.push(AnnDraw::plain(buf, bind)); + } + "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; + } + // CONTAIN, pas cover : l'image tient entiere dans la boite + // et se centre. Etirer au rect deformerait une capture ou + // un logo, ce que le rendu web ne fait pas non plus. + 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]) + }; + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let cb = 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], + // Mode 7 clippe sur `fx` : un rect qui couvre tout le + // cadre = pas de clip. + fx: [0.0, 0.0, 1.0, 1.0], + ..Default::default() + }; + let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy); + ann_draws.push(AnnDraw { + _buf: buf, + _glyphs: None, + _tex: Some(tex), + bind, + }); + } + "text" => { + let Some(raster) = self.text_raster.as_ref() else { continue }; + let Some(text) = a.text.as_ref() else { continue }; + if text.content.trim().is_empty() { + continue; + } + let color = parse_hex(&text.color).unwrap_or([1.0, 1.0, 1.0, 1.0]); + let background = + parse_hex(&text.background_color).unwrap_or([0.0, 0.0, 0.0, 0.0]); + let spec = crate::text::TextSpec { + content: text.content.clone(), + color, + background, + font_size_px: text.font_size_rel * (g.s_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().max(1.0) as u32, + quad_px[1].round().max(1.0) as u32, + ], + }; + let glyphs = match raster.rasterize(&self.gpu, &spec) { + Ok(gl) => gl, + Err(e) => { + eprintln!("[annotation texte] {}: {e:#}", a.id); + continue; + } + }; + + // ANIMATION D'APPARITION (`text_anim`, partage avec macOS + // et Windows). Les decalages sont exprimes en px A 1080p + // et remis a l'echelle de la sortie, comme la taille de + // police : en px absolus la meme animation sauterait deux + // fois plus haut dans un rendu 4K que dans l'apercu. + let anim = crate::text_anim::text_animation_state( + text.animation.as_deref(), + (g.source_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; + } + // Machine a ecrire : le quad ET son UV sont coupes a la + // meme fraction, donc la texture n'est pas etiree -- elle + // est revelee. + let reveal = anim.reveal.clamp(0.0, 1.0); + if reveal <= 0.0 { + continue; + } + let anim_dst = [ax, ay, aw * reveal, ah]; + + // PLAQUE DE FOND, dessinee AVANT les glyphes. + // + // macOS et Windows la peignent dans la texture de texte + // elle-meme ; ici c'est impossible : l'atlas est en R8, il + // ne porte qu'une couverture alpha et aucune couleur. + // Plutot que de convertir tout l'atlas en RGBA pour un + // aplat, on emet un quad mode 1 (couleur pleine + SDF de + // rect arrondi, cf. layer.wgsl) sous le quad de texte. + // Meme rect, meme rayon que le rendu web + // (`annotationRenderer.ts`). + // + // Sans ca le fond n'existait tout simplement pas : + // `spec.background` arrivait jusqu'au rasteriseur et + // mourait dans `cache_key()`. + if background[3] > 0.0 { + let plate = LayerCB { + dst: anim_dst, + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [anim_dst[2] * rw, anim_dst[3] * rh], + mode: 1.0, + // La plaque suit l'opacite du texte : sinon un + // fondu ferait apparaitre un aplat plein d'un coup + // puis le texte dessus. + color: [ + background[0], + background[1], + background[2], + background[3] * anim.opacity, + ], + radius_px: 4.0 * (rh / 1080.0).max(0.5), + ..Default::default() + }; + let (pbuf, pbind) = self.make_bind(&plate, None, &dummy); + ann_draws.push(AnnDraw::plain(pbuf, pbind)); + } + + let cb = LayerCB { + dst: anim_dst, + src: [0.0, 0.0, reveal, 1.0], + quad_px: [anim_dst[2] * rw, anim_dst[3] * rh], + mode: 11.0, + color: [color[0], color[1], color[2], color[3] * anim.opacity], + ..Default::default() + }; + // Atlas R8 au binding 1 (texY) que le mode 11 echantillonne. + let (buf, bind) = + self.make_bind(&cb, Some((&glyphs.view, &glyphs.view)), &dummy); + ann_draws.push(AnnDraw { + _buf: buf, + _glyphs: Some(glyphs), + _tex: None, + bind, + }); + } + _ => {} + } + } + } + + // Curseur thematise : sprite RGBA droit (mode 7) ou pose sur le plan + // incline (mode 13) selon ce que `plan_cursor` a resolu. + // `_tex`/`_view`/`_bufs` gardent le sprite et les uniformes en vie + // pendant le pass. Miroir de la branche curseur de `compositor_macos`. + // + // TRAINEE (`plan.taps > 1`) : `binds` porte une copie par echantillon, + // interpolee entre `prev_placement` et le placement courant. Elles ne + // sont PAS dessinees sur le RT mais dans `accum`, puis compositees en une + // fois -- cf. le commentaire au point de dessin. + struct CursorDraw { + _bufs: Vec, + _tex: wgpu::Texture, + _view: wgpu::TextureView, + binds: Vec, + } + let cursor_draw: Option = (|| { + let track = cursor_ref.as_ref()?; + let plan = plan_cursor( + &g, + &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), + }, + )?; + + let sprites = scene_ref + .as_ref() + .map(|s| s.cursor.cursor_sprites.clone()) + .unwrap_or_default(); + let sprite = plan + .cursor_type + .as_deref() + .and_then(|t| sprites.get(t)) + .or_else(|| sprites.get("arrow"))?; + // Charge (ou recupere du cache) le sprite. Emprunt isole AVANT le + // borrow_mut, comme cote macOS (piege du double emprunt 1re frame). + let cached = self.img_cache.borrow().get(sprite.path.as_str()).cloned(); + let (tex, iw, ih) = match cached { + Some(v) => v, + None => match self.load_image_texture(&sprite.path) { + Ok(v) => { + self.img_cache.borrow_mut().insert(sprite.path.clone(), v.clone()); + v + } + Err(e) => { + eprintln!("[curseur] sprite \"{}\" : {e:#}", sprite.path); + return None; + } + }, + }; + // Ratio preserve : le sprite tient dans un carre de `size_px` de cote. + let ar = iw as f32 / ih.max(1) as f32; + let (pw, ph) = if ar >= 1.0 { + (plan.size_px, plan.size_px / ar) + } else { + (plan.size_px * ar, plan.size_px) + }; + let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; + // `taps == 1` : un seul placement, celui de l'instant rendu -- le + // chemin net d'avant, inchange. Au-dela, on echelonne les copies + // regulierement de `prev_placement` (inclus) au placement courant + // (inclus) : c'est ce que font les deux autres backends, et inclure + // les deux bornes est ce qui fait que la trainee touche a la fois + // l'endroit d'ou le curseur vient et celui ou il est. + // + // On interpole des PLACEMENTS et non des centres : `lerp` sait + // traiter le cas incline, si bien qu'une trainee sous zoom incline + // reste dans le plan au lieu de repasser par un centre 2D qui + // l'aplatirait. + let placements: Vec = if plan.taps <= 1 { + vec![plan.placement] + } else { + (0..plan.taps) + .map(|k| { + let f = k as f32 / (plan.taps - 1) as f32; + plan.prev_placement.lerp(plan.placement, f) + }) + .collect() + }; + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let (mut bufs, mut binds) = (Vec::new(), Vec::new()); + for placement in placements { + let cb = match placement { + CursorPlacement::Upright { center } => LayerCB { + dst: 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, 1.0], + fx: plan.clip, + ..Default::default() + }, + CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, .. } => { + // Le sprite est pose DANS le plan : sa taille devient une fraction + // du plan et ses quatre coins traversent la meme projection que la + // video. La reduction due au tilt vient donc de la projection -- + // rien a multiplier a 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 projete d'un sprite peut etre tres fin de biais : une bbox + // d'un pixel de large ferait diverger le warp inverse, d'ou le + // plancher a 1 px. + 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, 1.0], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + // Le clip vit ici et NON dans `fx` (mode 7) : `fx` porte les coins. + dst_prev: plan.clip, + ..Default::default() + } + } + }; + // Sprite RGBA au binding 1 (texY) que le mode 7 echantillonne. + let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy); + bufs.push(buf); + binds.push(bind); + } + Some(CursorDraw { _bufs: bufs, _tex: tex, _view: view, binds }) + })(); + // Bind group de la passe de composition d'`accum` (layout du blur : + // uniform + texture + sampler). Construit hors de la pass, comme les + // autres. L'uniforme n'est pas lu par `fs_copy` mais le layout l'exige. + let accum_bind = cursor_draw + .as_ref() + .filter(|c| c.binds.len() > 1) + .map(|_| { + let cb = LayerCB::default(); + let uniform = + self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("accum-copy-uniform"), + contents: layer_bytes(&cb), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("accum-copy"), + layout: &self.blur_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.accum_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + (uniform, bind) + }); + + let mut encoder = self.gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("compose"), + }); + // Passe 1 : fond (clear a `bg_clear` + gradient mode 5 eventuel). + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bg-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg_clear[0] as f64, + g: bg_clear[1] as f64, + b: bg_clear[2] as f64, + a: bg_clear[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + if let Some(bg) = &bg_draw { + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &bg.bind, &[]); + rpass.draw(0..4, 0..1); + } + } + // Blur du fond (avant l'ecran), si active par la scene/l'inspector. + if cfg.bg_blur { + self.blur_bg(&mut encoder); + } + // Passe 2 : avant-plan (ecran + webcam), compose par-dessus le fond + // (eventuellement floute) avec `LoadOp::Load`. Les annotations sont dans + // une passe a part, cf. plus bas. + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("fg-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + // Chaque ombre est dessinee JUSTE AVANT le calque qu'elle porte : + // elle doit passer sous lui mais au-dessus du fond (et, pour la + // camera, au-dessus de l'ecran). + if let Some((_buf, bind)) = &screen_shadow { + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } + rpass.set_bind_group(0, &screen_bind, &[]); + rpass.draw(0..4, 0..1); + if let Some((_buf, bind)) = &webcam_shadow { + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } + if let Some((_buf, bind)) = &webcam_draw { + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } + } + // Fige la frame composee pour les annotations « flou ». ICI et nulle part + // ailleurs : apres l'ecran et la camera (sinon un flou masquerait du vide) + // et avant la premiere annotation (sinon deux flous qui se recouvrent + // s'echantillonnent l'un l'autre). Une passe de rendu ne peut pas lire sa + // propre cible, d'ou la copie -- et d'ou le fait que les annotations + // doivent avoir leur propre passe. + if needs_ann_copy { + self.generate_ann_mips(&mut encoder); + } + // Passe 3 : annotations puis curseur net, par-dessus tout le reste. Elle + // existe meme sans flou : deux passes consecutives sur la MEME cible avec + // `LoadOp::Load` ne coutent rien de plus qu'une seule sur un GPU + // desktop, et un seul chemin de code vaut mieux qu'un branchement qui ne + // serait exerce que dans un projet sur dix. + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ann-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + for a in &ann_draws { + rpass.set_bind_group(0, &a.bind, &[]); + rpass.draw(0..4, 0..1); + } + // Curseur en dernier : au-dessus de l'ecran et des annotations. + // Une seule copie = curseur net, il tient dans cette pass. La + // trainee, elle, a besoin de sa propre cible (voir plus bas). + if let Some(c) = cursor_draw.as_ref().filter(|c| c.binds.len() == 1) { + rpass.set_bind_group(0, &c.binds[0], &[]); + rpass.draw(0..4, 0..1); + } + } + // TRAINEE DU CURSEUR : flou REEL, pas des copies discretes. + // + // Les N echantillons s'accumulent dans une cible ISOLEE partie de zero, + // puis sont compositees « over » sur la scene. Les additionner + // directement sur le RT reviendrait a AJOUTER la couleur du curseur + // (souvent du blanc) a ce qui est deja dessous : sur un fond clair, deja + // proche du blanc, ajouter du blanc*(1/taps) ne change presque rien -- + // curseur quasi invisible. Dans une cible a part la somme reste + // correctement normalisee (alpha ~1 la ou les copies se recouvrent), et + // la composition finale est un « over » ordinaire, correct quel que soit + // le fond. Meme raisonnement, mot pour mot, cote macOS et Windows. + if let (Some(c), Some((_ubuf, abind))) = ( + cursor_draw.as_ref().filter(|c| c.binds.len() > 1), + accum_bind.as_ref(), + ) { + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("cursor-accum-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.accum_view, + resolve_target: None, + ops: wgpu::Operations { + // Le clear EST la raison d'etre de cette cible : elle + // doit partir vide a chaque frame, pas cumuler. + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline_add); + let w = 1.0 / c.binds.len() as f64; + rpass.set_blend_constant(wgpu::Color { r: w, g: w, b: w, a: w }); + for bind in &c.binds { + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } + } + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("cursor-accum-composite"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline_copy); + rpass.set_bind_group(0, abind, &[]); + rpass.draw(0..3, 0..1); + } + self.gpu.context.submit(std::iter::once(encoder.finish())); + Ok(()) + } + + /// Clear le RT a la couleur de fond (ecran absent). + fn clear_rt(&self) -> Result<()> { + let bg = self.live_params.borrow().bg_color; + let mut encoder = self.gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("clear"), + }); + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("clear-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg[0] as f64, + g: bg[1] as f64, + b: bg[2] as f64, + a: bg[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + self.gpu.context.submit(std::iter::once(encoder.finish())); + Ok(()) + } + + fn dummy_view(&self) -> wgpu::TextureView { + let t = self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("dummy"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + t.create_view(&wgpu::TextureViewDescriptor::default()) + } + + /// Regle la profondeur de la ring de staging. A appeler AVANT la premiere + /// relecture (elle vide la ring, donc toute frame encore en vol serait + /// perdue -- d'ou le drain explicite plutot qu'un silence). + /// + /// POLITIQUE PAR CHEMIN, et c'est volontaire : + /// + /// - **Export** (`pipeline_linux::run_composited_multi`) : profondeur 2. Il + /// ne veut que du DEBIT, la latence d'une frame ne se voit nulle part + /// puisque la sortie est un fichier. Il draine la ring a la fin, donc + /// aucune frame ne manque au montage. + /// - **Preview live** (`live.rs`) : profondeur 1, inchangee. Une frame de + /// retard y est perceptible -- le canvas afficherait l'avant-derniere + /// frame composee, et surtout la boucle ne relit QUE quand elle a avance + /// (`stepped`) : au repos (fin d'un scrub, pause) la derniere frame + /// resterait coincee dans la ring et le canvas figerait sur la + /// precedente jusqu'au prochain evenement. Le pipeline demanderait donc + /// un drain sur inactivite pour n'etre que neutre visuellement, pour un + /// gain qui n'est pas le goulot mesure ici. On ne l'impose pas. + /// + /// A profondeur 1 le chemin est exactement l'ancien : soumettre, attendre, + /// mapper, depadder. + pub fn set_readback_depth(&self, depth: usize) -> Result<()> { + let depth = depth.max(1); + // Draine d'abord : les frames en vol appartiennent a l'appelant + // precedent, les jeter en silence serait une perte de donnees muette. + while unsafe { self.readback_take()? }.is_some() {} + let mut ring = self.readback.borrow_mut(); + ring.depth = depth; + while ring.free.len() > depth { + ring.free.pop(); + } + while ring.free.len() < depth { + let buf = Self::make_staging(&self.gpu, self.readback_bpr, self.render_h); + ring.free.push(buf); + } + Ok(()) + } + + /// Soumet la copie RT -> staging de la frame COURANTE sans l'attendre, puis + /// rend la frame la plus ancienne encore en vol des que la ring est pleine. + /// + /// PREMIERES FRAMES. Tant que moins de `depth` copies sont en vol, il n'y a + /// rien a rendre et la reponse est `Ok(None)` : c'est l'amorcage du + /// pipeline, et il coute exactement `depth - 1` frames de decalage (0 a + /// profondeur 1). L'appelant ne doit donc PAS supposer une frame par appel, + /// mais drainer a la fin (`readback_take`) -- sinon les `depth - 1` + /// dernieres frames composees ne sortiraient jamais. + pub unsafe fn readback_submit(&self) -> Result)>> { + let (w, h) = (self.render_w, self.render_h); + let bpr = self.readback_bpr; + // Invariant : cette fonction recolte toujours des que `pending` atteint + // `depth`, donc un buffer est libre a chaque entree. Un echec ici + // signalerait une ring desynchronisee -- on le dit plutot que d'allouer + // 8 Mo de plus en silence a chaque frame. + let buf = self + .readback + .borrow_mut() + .free + .pop() + .ok_or_else(|| anyhow::anyhow!("staging ring saturee (aucun buffer libre)"))?; + + let mut encoder = self.gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("readback"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.rt, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bpr), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + // `submit` rend l'index de soumission : c'est LUI qui permet plus tard + // de n'attendre que cette copie-ci, au lieu de `Maintain::Wait` qui + // draine toute la file (donc la composition qui suit). + let idx = self.gpu.context.submit(std::iter::once(encoder.finish())); + // `map_async` juste apres la soumission : wgpu differe le mapping + // jusqu'a la fin de la soumission qui ecrit le buffer, le callback + // n'est tire que par un `poll`. + let (tx, rx) = std::sync::mpsc::channel(); + buf.slice(..).map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + { + let mut ring = self.readback.borrow_mut(); + ring.pending.push_back(PendingCopy { buf, idx, rx, w, h, bpr }); + if ring.pending.len() < ring.depth { + return Ok(None); // amorcage + } + } + self.readback_take() + } + + /// Recolte la frame la plus ancienne en vol (`None` si la ring est vide). + /// C'est le drain de fin de session : l'appeler en boucle apres la derniere + /// `readback_submit` rend les `depth - 1` frames encore en vol. + pub unsafe fn readback_take(&self) -> Result)>> { + let Some(p) = self.readback.borrow_mut().pending.pop_front() else { + return Ok(None); + }; + // N'attend QUE la soumission de cette copie. A profondeur >= 2 elle est + // terminee depuis longtemps (l'encodage de la frame precedente lui a + // laisse ~19 ms de CPU) et l'appel rend la main immediatement. + self.gpu.device.poll(wgpu::Maintain::WaitForSubmissionIndex(p.idx)); + p.rx + .recv() + .map_err(|_| anyhow::anyhow!("map_async channel"))? + .map_err(|e| anyhow::anyhow!("map_async: {e:?}"))?; + let slice = p.buf.slice(..); + let mapped = slice.get_mapped_range(); + + let (w, h) = (p.w, p.h); + let row = (w * 4) as usize; + let bpr = p.bpr as usize; + let total = row * h as usize; + + // `Vec::with_capacity` + `extend_from_slice`, PAS `vec![0u8; total]` : ce dernier + // memset 8 Mo (en 1080p) qu'on écrase intégralement ligne suivante. Mesuré : la + // relecture pèse 82 % de la frame de preview, et ce zero-fill en est une part + // gratuite à rendre. + let mut out = Vec::with_capacity(total); + if bpr == row { + // Cas courant, et il n'a rien d'exotique : wgpu aligne `bytes_per_row` sur 256 + // et une largeur RGBA multiple de 64 px l'est déjà (1280 et 1920 le sont). + // Il n'y a alors AUCUN padding à retirer, et la boucle ligne à ligne recopiait + // un tampon identique à l'octet près en `h` memcpy au lieu d'un seul. + out.extend_from_slice(&mapped[..total]); + } else { + for y in 0..h as usize { + out.extend_from_slice(&mapped[y * bpr..y * bpr + row]); + } + } + drop(mapped); + p.buf.unmap(); + // Buffer demappe -> reutilisable au prochain `readback_submit`. + self.readback.borrow_mut().free.push(p.buf); + Ok(Some((w, h, out))) + } + + /// Lit le RT en RGBA8 tightly-packed `(render_w * render_h * 4)`. Depadde le + /// `bytes_per_row` aligne a 256 exige par wgpu. + /// + /// Contrat SYNCHRONE : rend la frame que le RT contient MAINTENANT. A la + /// profondeur par defaut (1) c'est litteralement soumettre-attendre-mapper, + /// donc le chemin d'avant la ring. A profondeur > 1 elle vide le pipeline + /// pour honorer ce contrat -- a n'utiliser que la ou la frame courante est + /// exigee (preview, GIF, tests), pas dans une boucle d'export. + pub unsafe fn readback_direct(&self) -> Result<(u32, u32, Vec)> { + let mut last = self.readback_submit()?; + while let Some(next) = self.readback_take()? { + last = Some(next); + } + last.ok_or_else(|| anyhow::anyhow!("readback_direct: aucune frame recoltee")) + } +} diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs new file mode 100644 index 0000000000..cd553fb94d --- /dev/null +++ b/crates/compositor/src/compositor_macos.rs @@ -0,0 +1,1975 @@ +//! 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 SANS ZOOM --- + // `s_ann`, pas `s_dst` : le zoom vit dans la boîte depuis l'issue #179, donc `s_dst` + // grandit avec lui et emmenait annotations et sous-titres dans le mouvement. + self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann)?; + + // 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_windows.rs b/crates/compositor/src/compositor_windows.rs new file mode 100644 index 0000000000..09381e4543 --- /dev/null +++ b/crates/compositor/src/compositor_windows.rs @@ -0,0 +1,2401 @@ +//! Compositeur D3D11 : rend les calques dans un render target RGBA8, un draw par quad. +//! 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; +use crate::ffi::AVFrame; +use anyhow::{bail, Result}; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::ffi::c_void; +use windows::core::{Interface, PCSTR}; +use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3}; +use windows::Win32::Graphics::Direct3D::{ + ID3DBlob, D3D11_SRV_DIMENSION_TEXTURE2DARRAY, D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, +}; +use windows::Win32::Graphics::Direct3D11::*; +use windows::Win32::Graphics::Dxgi::Common::*; + + + + + + + + + + + + + + + + +pub struct Compositor { + dev: ID3D11Device, + ctx: ID3D11DeviceContext, + rt: ID3D11Texture2D, + rtv: ID3D11RenderTargetView, + rt_srv: ID3D11ShaderResourceView, + staging: ID3D11Texture2D, + vs: ID3D11VertexShader, + ps: ID3D11PixelShader, + vs_fs: ID3D11VertexShader, + ps_y: ID3D11PixelShader, + ps_uv: ID3D11PixelShader, + sampler: ID3D11SamplerState, + cbuf: ID3D11Buffer, + blend: ID3D11BlendState, + blend_none: ID3D11BlendState, + nv12: ID3D11Texture2D, // notre NV12 simple (RT), source de la copie vers le pool encodeur + rtv_y: ID3D11RenderTargetView, + rtv_uv: ID3D11RenderTargetView, + // ping-pong demi-résolution pour le flou séparable (§7 E3) + ps_blur: ID3D11PixelShader, + ps_tex: ID3D11PixelShader, + half_a_rtv: ID3D11RenderTargetView, + half_a_srv: ID3D11ShaderResourceView, + half_b_rtv: ID3D11RenderTargetView, + half_b_srv: ID3D11ShaderResourceView, + // dual-Kawase : chaîne quart (480x270) + huitième (240x135) + ps_kdown: ID3D11PixelShader, + ps_kup: ID3D11PixelShader, + q_rtv: ID3D11RenderTargetView, + q_srv: ID3D11ShaderResourceView, + e_rtv: ID3D11RenderTargetView, + e_srv: ID3D11ShaderResourceView, + /// Copie pleine réso de l'image composée, pour les annotations de type flou : on ne peut pas + /// échantillonner le render target sur lequel on dessine, donc on le recopie ici d'abord. + /// Distincte de `accum` à dessein — `accum` sert au motion blur, qui appelle `compose_frame` + /// plusieurs fois par frame et dont l'accumulation serait écrasée. + ann_copy: ID3D11Texture2D, + ann_copy_srv: ID3D11ShaderResourceView, + // accumulateur pour le flou de mouvement (supersampling temporel) + accum: ID3D11Texture2D, + accum_rtv: ID3D11RenderTargetView, + accum_srv: ID3D11ShaderResourceView, + blend_add: ID3D11BlendState, + /// RefCell (pas un simple champ) pour que `set_cursor` reste `&self`, comme `set_scene` / + /// `set_live_params` — nécessaire pour le rebrancher par clip dans l'export multiclip, qui + /// n'a qu'une référence partagée au `Compositor`. + cursor: RefCell>, + /// Override du temps d'échantillonnage curseur (secondes) — `None` = comportement fixture + /// (`frame / FPS`). L'export multiclip et le live le positionnent au PTS écran courant, + /// c'est-à-dire au temps source absolu du clip actif. + cursor_t_override: RefCell>, + /// Override du temps des zoom/full-camera regions (secondes source du clip actif). Le nom + /// `timeline_t_override` est conservé pour l'API existante, mais ce temps n'est plus cumulé + /// entre clips : les régions projetées par l'app portent elles aussi des temps source. + /// Séparé de l'override curseur pour préserver les chemins fixture sans télémétrie. + timeline_t_override: RefCell>, + // cache des SRV décodeur par (texture array, slice) : le pool réutilise ~32 textures, + // donc après warmup plus aucune création de SRV par frame (overhead CPU supprimé). + srv_cache: RefCell>, + live_params: RefCell, + /// Scène pilotée par l'app (contrat) : quand présente, remplace le layout fixture de + /// `timeline()`. Voir `scene.rs` / `SceneDescription` (TS). + scene: RefCell>, + /// Rastériseur de texte (Direct2D/DirectWrite). `Option` parce qu'un échec d'init des + /// fabriques ne doit pas empêcher tout le compositeur de tourner : sans lui, les annotations + /// texte sont simplement absentes, comme avant. + text_raster: Option, + /// Cache des textures de texte, indexé sur l'ID d'annotation. La `u64` est la clé de contenu + /// (`TextSpec::cache_key`) : on ne re-rastérise que si elle change, donc jamais pour un + /// déplacement ou une animation. + text_cache: RefCell>, + /// Cache des textures d'annotation image, indexé sur l'ID d'annotation (SRV, w, h, longueur + /// de la source). Séparé de `img_cache` : les wallpapers sont des chemins disque, ces images + /// des data URL de plusieurs Mo qu'on ne veut pas utiliser comme clés de hachage. + ann_img_cache: RefCell>, + /// Cache des textures wallpaper image (clé = chemin absolu) : décodage/upload une seule + /// fois, puis réutilisées par frame. (SRV, largeur, hauteur). + img_cache: RefCell>, + /// Dimensions du RENDER TARGET en pixels — la taille à laquelle `compose_frame` + /// rastérise réellement, et donc le dénominateur de TOUTE conversion + /// normalisé↔px de ce fichier. + /// + /// Historiquement c'était la constante `OUT_W`×`OUT_H` : un canvas 16:9 figé, + /// étiré en fin de pipeline vers la vraie sortie. Cette constante produisait + /// deux défauts distincts, tous deux issus d'elle seule : + /// - une **forme** fausse dès que la sortie n'est pas 16:9 → rattrapée en + /// aval par `apply_undistort` (9 correctifs successifs sur l'écran, la + /// webcam, le curseur, les ombres, les coins, le crop, le fond) ; + /// - une **résolution** plafonnée → jamais rattrapée, parce qu'aucun + /// correctif au niveau du calque ne peut recréer des pixels qui n'ont pas + /// été rastérisés (un export 4K était du 1080p agrandi). + /// + /// Rendre cette taille variable retire la cause commune. `OUT_W`/`OUT_H` ne + /// sont plus qu'une valeur par défaut, jamais une référence géométrique. + render_size: Cell<(u32, u32)>, + /// Ressources de resize export (allouées paresseusement à la 1re taille de sortie ≠ + /// OUT_W×OUT_H — le live et les exports "Source"/1080p restent sur `rgb_to_nv12` inchangé, + /// zéro coût). Voir `rgb_to_nv12_scaled`. + resize_target: RefCell>, + /// Cache de la staging texture de readback live, dimensionnée à la dernière taille + /// 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 +/// redimensionnement bilinéaire du RT composé, toujours rendu en interne à OUT_W×OUT_H) + +/// sa propre texture NV12 à cette même taille cible (le NV12 principal du `Compositor` reste +/// fixé à OUT_W×OUT_H, partagé par le live). +struct ResizeTarget { + w: u32, + h: u32, + rgba_rtv: ID3D11RenderTargetView, + rgba_srv: ID3D11ShaderResourceView, + nv12: ID3D11Texture2D, + nv12_rtv_y: ID3D11RenderTargetView, + nv12_rtv_uv: ID3D11RenderTargetView, +} + + + + + + + + + + + + +unsafe fn compile(src: &[u8], entry: &[u8], target: &[u8]) -> Result { + let mut code: Option = None; + let mut err: Option = None; + let r = D3DCompile( + src.as_ptr() as *const c_void, + src.len(), + PCSTR::null(), + None, + None, + PCSTR(entry.as_ptr()), + PCSTR(target.as_ptr()), + D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, + &mut code, + Some(&mut err), + ); + if r.is_err() { + if let Some(e) = err { + let msg = std::slice::from_raw_parts( + e.GetBufferPointer() as *const u8, + e.GetBufferSize(), + ); + bail!("D3DCompile {}: {}", String::from_utf8_lossy(entry), String::from_utf8_lossy(msg)); + } + bail!("D3DCompile a échoué"); + } + Ok(code.unwrap()) +} + +impl Compositor { + /// Compositeur à la taille de rendu par défaut (`OUT_W`×`OUT_H`). + /// Préférer `new_sized` dès qu'on connaît la géométrie de sortie réelle. + pub fn new(gpu: &Gpu) -> Result { + Self::new_sized(gpu, OUT_W, OUT_H) + } + + /// Compositeur rastérisant à `w`×`h`. + /// + /// La taille de rendu est fixée à la construction plutôt que mutable à chaud : + /// la rendre variable imposerait de passer le RT, la NV12, la staging et toute + /// la pyramide de flou en `RefCell`, donc d'ajouter de la mutabilité intérieure + /// sur le chemin GPU chaud — pour un événement qui n'arrive quasiment jamais + /// (l'utilisateur change de ratio, ou on bascule preview↔export). L'appelant + /// reconstruit le compositeur quand la sortie change ; c'est quelques dizaines + /// de ms, sur un changement rare. + /// + /// Les dimensions passées sont arrondies via `normalize_render_size` (pair, + /// ≥2 — contrainte NV12). L'appelant qui décide de reconstruire DOIT comparer + /// sa taille voulue à `normalize_render_size(...)` et non à la valeur brute : + /// sinon une cible impaire ne serait jamais atteinte par `render_size()` (qui + /// renvoie la valeur arrondie), et le compositeur se reconstruirait à chaque + /// frame. C'est justement pour rendre cette règle partageable qu'elle est une + /// fonction publique et non un calcul enfoui ici. + pub fn new_sized(gpu: &Gpu, w: u32, h: u32) -> Result { + let (w, h) = Self::normalize_render_size(w, h); + unsafe { Self::new_inner(gpu, w, h) } + } + + /// Arrondit une taille de rendu voulue à ce qu'un render target peut réellement + /// être : au pair supérieur (la texture NV12 est en 4:2:0, chroma + /// sous-échantillonnée 2×2, et `CreateTexture2D` refuse une dimension impaire), + /// jamais sous 2. UNE seule définition de la règle, appelée par `new_sized` + /// (côté production de la taille) et par la boucle de preview (côté décision de + /// reconstruire) — les deux ne peuvent donc pas diverger. + pub fn normalize_render_size(w: u32, h: u32) -> (u32, u32) { + (((w.max(2) + 1) & !1), ((h.max(2) + 1) & !1)) + } + + unsafe fn new_inner(gpu: &Gpu, out_w: u32, out_h: u32) -> Result { + let dev = gpu.device.clone(); + let ctx = gpu.context.clone(); + + // --- render target RGBA8 (gamma natif de la vidéo ; voir note couleur docs) --- + let mut td = D3D11_TEXTURE2D_DESC { + Width: out_w, + Height: out_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut rt: Option = None; + dev.CreateTexture2D(&td, None, Some(&mut rt))?; + let rt = rt.unwrap(); + let mut rtv: Option = None; + dev.CreateRenderTargetView(&rt, None, Some(&mut rtv))?; + let mut rt_srv: Option = None; + dev.CreateShaderResourceView(&rt, None, Some(&mut rt_srv))?; + + // staging pour readback PNG + td.Usage = D3D11_USAGE_STAGING; + td.BindFlags = 0; + td.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32; + let mut staging: Option = None; + dev.CreateTexture2D(&td, None, Some(&mut staging))?; + + // --- shaders --- + let hlsl = include_bytes!("shaders.hlsl"); + let vsb = compile(hlsl, b"vs_main\0", b"vs_5_0\0")?; + let psb = compile(hlsl, b"ps_main\0", b"ps_5_0\0")?; + let vs_bytes = + std::slice::from_raw_parts(vsb.GetBufferPointer() as *const u8, vsb.GetBufferSize()); + let ps_bytes = + std::slice::from_raw_parts(psb.GetBufferPointer() as *const u8, psb.GetBufferSize()); + let mut vs: Option = None; + dev.CreateVertexShader(vs_bytes, None, Some(&mut vs))?; + let mut ps: Option = None; + dev.CreatePixelShader(ps_bytes, None, Some(&mut ps))?; + + // shaders RGB->NV12 + let fsb = compile(hlsl, b"vs_fs\0", b"vs_5_0\0")?; + let yb = compile(hlsl, b"ps_y\0", b"ps_5_0\0")?; + let uvb = compile(hlsl, b"ps_uv\0", b"ps_5_0\0")?; + let fs_bytes = + std::slice::from_raw_parts(fsb.GetBufferPointer() as *const u8, fsb.GetBufferSize()); + let y_bytes = + std::slice::from_raw_parts(yb.GetBufferPointer() as *const u8, yb.GetBufferSize()); + let uv_bytes = + std::slice::from_raw_parts(uvb.GetBufferPointer() as *const u8, uvb.GetBufferSize()); + let mut vs_fs: Option = None; + dev.CreateVertexShader(fs_bytes, None, Some(&mut vs_fs))?; + let mut ps_y: Option = None; + dev.CreatePixelShader(y_bytes, None, Some(&mut ps_y))?; + let mut ps_uv: Option = None; + dev.CreatePixelShader(uv_bytes, None, Some(&mut ps_uv))?; + + // --- sampler bilinéaire clamp --- + let sd = D3D11_SAMPLER_DESC { + Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR, + AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, + AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, + AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, + ComparisonFunc: D3D11_COMPARISON_NEVER, + MaxLOD: f32::MAX, + ..Default::default() + }; + let mut sampler: Option = None; + dev.CreateSamplerState(&sd, Some(&mut sampler))?; + + // --- constant buffer dynamique --- + let bd = D3D11_BUFFER_DESC { + ByteWidth: std::mem::size_of::() as u32, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + ..Default::default() + }; + let mut cbuf: Option = None; + dev.CreateBuffer(&bd, None, Some(&mut cbuf))?; + + // --- blend alpha prémultiplié --- + let mut bl = D3D11_BLEND_DESC::default(); + bl.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { + BlendEnable: true.into(), + SrcBlend: D3D11_BLEND_ONE, + DestBlend: D3D11_BLEND_INV_SRC_ALPHA, + BlendOp: D3D11_BLEND_OP_ADD, + SrcBlendAlpha: D3D11_BLEND_ONE, + DestBlendAlpha: D3D11_BLEND_INV_SRC_ALPHA, + BlendOpAlpha: D3D11_BLEND_OP_ADD, + RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8, + }; + let mut blend: Option = None; + dev.CreateBlendState(&bl, Some(&mut blend))?; + + // blend désactivé mais écriture ACTIVE (le défaut a WriteMask=0 -> rien n'est écrit) + let mut bl_none = D3D11_BLEND_DESC::default(); + bl_none.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8; + let mut blend_none: Option = None; + dev.CreateBlendState(&bl_none, Some(&mut blend_none))?; + + // notre texture NV12 simple (ArraySize=1) : NV12+RT n'est autorisé qu'en non-array + // sur cet iGPU. On y rend la conversion, puis copie GPU->GPU vers le pool encodeur. + let nvd = D3D11_TEXTURE2D_DESC { + Width: out_w, + Height: out_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut nv12: Option = None; + dev.CreateTexture2D(&nvd, None, Some(&mut nv12))?; + let nv12 = nv12.unwrap(); + let mk_rtv = |fmt: DXGI_FORMAT| -> Result { + let d = D3D11_RENDER_TARGET_VIEW_DESC { + Format: fmt, + ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 { + Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 }, + }, + }; + let mut rtv: Option = None; + dev.CreateRenderTargetView(&nv12, Some(&d), Some(&mut rtv))?; + Ok(rtv.unwrap()) + }; + let rtv_y = mk_rtv(DXGI_FORMAT_R8_UNORM)?; + let rtv_uv = mk_rtv(DXGI_FORMAT_R8G8_UNORM)?; + + // shaders de flou + copie + let blurb = compile(hlsl, b"ps_blur\0", b"ps_5_0\0")?; + let texb = compile(hlsl, b"ps_tex\0", b"ps_5_0\0")?; + let mut ps_blur: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(blurb.GetBufferPointer() as *const u8, blurb.GetBufferSize()), + None, + Some(&mut ps_blur), + )?; + let mut ps_tex: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(texb.GetBufferPointer() as *const u8, texb.GetBufferSize()), + None, + Some(&mut ps_tex), + )?; + + // shaders dual-Kawase + let kdb = compile(hlsl, b"ps_kawase_down\0", b"ps_5_0\0")?; + let kub = compile(hlsl, b"ps_kawase_up\0", b"ps_5_0\0")?; + let mut ps_kdown: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(kdb.GetBufferPointer() as *const u8, kdb.GetBufferSize()), + None, + Some(&mut ps_kdown), + )?; + let mut ps_kup: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(kub.GetBufferPointer() as *const u8, kub.GetBufferSize()), + None, + Some(&mut ps_kup), + )?; + + // textures RGBA RT+SRV à une taille donnée (chaîne de flou) + let mk_rgba = |w: u32, h: u32| -> Result<(ID3D11RenderTargetView, ID3D11ShaderResourceView)> { + let hd = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut t: Option = None; + dev.CreateTexture2D(&hd, None, Some(&mut t))?; + let t = t.unwrap(); + let mut rtv: Option = None; + dev.CreateRenderTargetView(&t, None, Some(&mut rtv))?; + let mut srv: Option = None; + dev.CreateShaderResourceView(&t, None, Some(&mut srv))?; + Ok((rtv.unwrap(), srv.unwrap())) + }; + // Pyramide dual-Kawase derivee de la taille de rendu (et non d'un demi de + // 1080 fige) : sinon le rayon effectif du flou de fond changerait d'un format + // a l'autre. `.max(1)` protege les tres petites tailles de preview. + let (half_w, half_h) = ((out_w / 2).max(1), (out_h / 2).max(1)); + let (half_a_rtv, half_a_srv) = mk_rgba(half_w, half_h)?; + let (half_b_rtv, half_b_srv) = mk_rgba(half_w, half_h)?; + let (q_rtv, q_srv) = mk_rgba((half_w / 2).max(1), (half_h / 2).max(1))?; + let (e_rtv, e_srv) = mk_rgba((half_w / 4).max(1), (half_h / 4).max(1))?; + + // accumulateur pleine réso (RGBA) + blend additif pondéré (facteur = 1/N) + let ad = D3D11_TEXTURE2D_DESC { + Width: out_w, + Height: out_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut accum: Option = None; + dev.CreateTexture2D(&ad, None, Some(&mut accum))?; + let accum = accum.unwrap(); + let mut accum_rtv: Option = None; + dev.CreateRenderTargetView(&accum, None, Some(&mut accum_rtv))?; + let mut accum_srv: Option = None; + dev.CreateShaderResourceView(&accum, None, Some(&mut accum_srv))?; + + // Copie de travail des annotations flou. Chaîne de mips COMPLÈTE (`MipLevels: 0`) : c'est + // elle qui fournit le flou. Échantillonner un niveau plus bas donne un vrai lissage pour + // n'importe quel rayon à coût constant, là où un noyau de quelques taps espacés produit + // des copies fantômes au lieu d'un flou. + let ann_desc = D3D11_TEXTURE2D_DESC { + MipLevels: 0, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + MiscFlags: D3D11_RESOURCE_MISC_GENERATE_MIPS.0 as u32, + ..ad + }; + let mut ann_copy: Option = None; + dev.CreateTexture2D(&ann_desc, None, Some(&mut ann_copy))?; + let ann_copy = ann_copy.unwrap(); + let mut ann_copy_srv: Option = None; + dev.CreateShaderResourceView(&ann_copy, None, Some(&mut ann_copy_srv))?; + + let mut bla = D3D11_BLEND_DESC::default(); + bla.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { + BlendEnable: true.into(), + SrcBlend: D3D11_BLEND_BLEND_FACTOR, + DestBlend: D3D11_BLEND_ONE, + BlendOp: D3D11_BLEND_OP_ADD, + SrcBlendAlpha: D3D11_BLEND_BLEND_FACTOR, + DestBlendAlpha: D3D11_BLEND_ONE, + BlendOpAlpha: D3D11_BLEND_OP_ADD, + RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8, + }; + let mut blend_add: Option = None; + dev.CreateBlendState(&bla, Some(&mut blend_add))?; + + Ok(Compositor { + dev, + ctx, + rt, + rtv: rtv.unwrap(), + rt_srv: rt_srv.unwrap(), + staging: staging.unwrap(), + vs: vs.unwrap(), + ps: ps.unwrap(), + vs_fs: vs_fs.unwrap(), + ps_y: ps_y.unwrap(), + ps_uv: ps_uv.unwrap(), + sampler: sampler.unwrap(), + cbuf: cbuf.unwrap(), + blend: blend.unwrap(), + blend_none: blend_none.unwrap(), + nv12, + rtv_y, + rtv_uv, + ps_blur: ps_blur.unwrap(), + ps_tex: ps_tex.unwrap(), + half_a_rtv, + half_a_srv, + half_b_rtv, + half_b_srv, + ps_kdown: ps_kdown.unwrap(), + ps_kup: ps_kup.unwrap(), + q_rtv, + q_srv, + e_rtv, + e_srv, + ann_copy, + ann_copy_srv: ann_copy_srv.unwrap(), + accum, + accum_rtv: accum_rtv.unwrap(), + accum_srv: accum_srv.unwrap(), + blend_add: blend_add.unwrap(), + cursor: RefCell::new(None), + cursor_t_override: RefCell::new(None), + timeline_t_override: RefCell::new(None), + srv_cache: RefCell::new(HashMap::new()), + live_params: RefCell::new(LiveParams::default()), + scene: RefCell::new(None), + text_raster: match crate::text::TextRasterizer::new() { + Ok(r) => Some(r), + Err(e) => { + eprintln!("[texte] init Direct2D/DirectWrite impossible, annotations texte désactivées: {e}"); + None + } + }, + text_cache: RefCell::new(HashMap::new()), + ann_img_cache: RefCell::new(HashMap::new()), + img_cache: RefCell::new(HashMap::new()), + 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), + }) + } + + /// Largeur du render target en px. **Le** dénominateur de toute conversion + /// px→normalisé de ce fichier : à utiliser partout où `OUT_W` servait de + /// référence géométrique. Cf. `Compositor::render_size`. + #[inline] + fn rw(&self) -> f32 { + self.render_size.get().0 as f32 + } + + /// Hauteur du render target en px. Cf. `Compositor::rw`. + #[inline] + fn rh(&self) -> f32 { + self.render_size.get().1 as f32 + } + + /// Dimensions entières du render target — pour les viewports et les boucles + /// de readback, qui veulent des `u32` et non des `f32`. + #[inline] + fn render_dims(&self) -> (u32, u32) { + self.render_size.get() + } + + /// Taille à laquelle ce compositeur rastérise, après l'arrondi au pair de + /// `new_sized`. L'appelant la compare à la géométrie qu'il veut produire pour + /// savoir s'il doit reconstruire le compositeur (cf. `new_sized`). + pub fn render_size(&self) -> (u32, u32) { + self.render_size.get() + } + + /// Met à jour les paramètres continus pilotés par l'inspector (thread live uniquement). + pub fn set_live_params(&self, p: LiveParams) { + *self.live_params.borrow_mut() = p; + } + + /// Installe (ou retire) la scène de l'app. Présente → `compose_frame` prend ses placements + /// depuis le layout preset au lieu du planning fixture. + pub fn set_scene(&self, s: Option) { + *self.scene.borrow_mut() = s; + } + + /// Crée les SRV Y (R8) et UV (R8G8) sur la tranche d'array de la frame décodeur. + pub unsafe fn nv12_srvs( + &self, + frame: *const AVFrame, + ) -> Result<(ID3D11ShaderResourceView, ID3D11ShaderResourceView)> { + let tex_ptr = (*frame).data[0] as *mut c_void; + let slice = (*frame).data[1] as u32; + // cache hit : le pool réutilise les mêmes textures -> zéro création après warmup + let key = (tex_ptr as usize, slice); + if let Some((y, uv)) = self.srv_cache.borrow().get(&key) { + return Ok((y.clone(), uv.clone())); + } + let tex = ID3D11Texture2D::from_raw_borrowed(&tex_ptr) + .ok_or_else(|| anyhow::anyhow!("frame sans texture D3D11"))? + .clone(); + + let mk = |fmt: DXGI_FORMAT| -> Result { + let mut d = D3D11_SHADER_RESOURCE_VIEW_DESC { + Format: fmt, + ViewDimension: D3D11_SRV_DIMENSION_TEXTURE2DARRAY, + ..Default::default() + }; + d.Anonymous.Texture2DArray = D3D11_TEX2D_ARRAY_SRV { + MostDetailedMip: 0, + MipLevels: 1, + FirstArraySlice: slice, + ArraySize: 1, + }; + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&tex, Some(&d), Some(&mut srv))?; + Ok(srv.unwrap()) + }; + let y = mk(DXGI_FORMAT_R8_UNORM)?; + let uv = mk(DXGI_FORMAT_R8G8_UNORM)?; + self.srv_cache.borrow_mut().insert(key, (y.clone(), uv.clone())); + Ok((y, uv)) + } + + /// Dimensions réelles de la texture décodeur (alignée macrobloc : 1080->1088, etc.). + pub unsafe fn tex_dims(&self, frame: *const AVFrame) -> (u32, u32) { + let tex_ptr = (*frame).data[0] as *mut c_void; + let tex = ID3D11Texture2D::from_raw_borrowed(&tex_ptr).unwrap(); + let mut d = D3D11_TEXTURE2D_DESC::default(); + tex.GetDesc(&mut d); + (d.Width, d.Height) + } + + /// État de composition : RT principal, viewport plein, shaders de calque, blend prémultiplié. + /// (Sans clear — sert à reprendre après les passes de flou.) + pub unsafe fn bind_compose_state(&self) { + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.VSSetShader(&self.vs, None); + self.ctx.PSSetShader(&self.ps, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + self.ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); + self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); + } + + /// Prépare la passe : état de composition + clear. + pub unsafe fn begin(&self, clear: [f32; 4]) { + self.bind_compose_state(); + self.ctx.ClearRenderTargetView(&self.rtv, &clear); + } + + /// Passe plein écran générique (triangle unique) : `srv` -> `rtv` via `ps`, avec `fx`. + unsafe fn fs_pass( + &self, + rtv: &ID3D11RenderTargetView, + srv: &ID3D11ShaderResourceView, + ps: &ID3D11PixelShader, + w: u32, + h: u32, + fx: [f32; 4], + ) { + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(ps, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: w as f32, Height: h as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.upload_cb(&LayerCB { fx, ..Default::default() }); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + + /// Fond flouté (§7), dual-Kawase : suppose le screen déjà dessiné plein écran dans le RT. + /// Chaîne down (RT→960→480→240) puis up (240→480→960→RT). ~6 passes de 5-8 taps + /// à résolution décroissante, vs 2 passes gaussiennes 49-tap. Le RT devient le fond. + pub unsafe fn blur_bg(&self, _sigma: f32) { + let off = 2.2; // spread par passe + // La pyramide se dérive de la taille de rendu, pas d'une constante : sinon + // le rayon effectif du flou changerait avec la résolution de sortie (un + // demi de 1080 n'est pas un demi de 2160), et le fond flouté ne serait plus + // le même effet d'un format à l'autre. + let (rw_i, rh_i) = self.render_dims(); + let (half_w, half_h) = (rw_i / 2, rh_i / 2); + let hw = half_w as f32; + let hh = half_h as f32; + // DOWN : texel = 1/(dims de la SOURCE échantillonnée) + self.fs_pass(&self.half_a_rtv, &self.rt_srv, &self.ps_kdown, half_w, half_h, + [1.0 / self.rw(), 1.0 / self.rh(), off, 0.0]); + self.fs_pass(&self.q_rtv, &self.half_a_srv, &self.ps_kdown, half_w / 2, half_h / 2, + [1.0 / hw, 1.0 / hh, off, 0.0]); + self.fs_pass(&self.e_rtv, &self.q_srv, &self.ps_kdown, half_w / 4, half_h / 4, + [2.0 / hw, 2.0 / hh, off, 0.0]); + // UP + self.fs_pass(&self.q_rtv, &self.e_srv, &self.ps_kup, half_w / 2, half_h / 2, + [4.0 / hw, 4.0 / hh, off, 0.0]); + self.fs_pass(&self.half_a_rtv, &self.q_srv, &self.ps_kup, half_w, half_h, + [2.0 / hw, 2.0 / hh, off, 0.0]); + self.fs_pass(&self.rtv, &self.half_a_srv, &self.ps_kup, rw_i, rh_i, + [1.0 / hw, 1.0 / hh, off, 0.0]); + } + + unsafe fn upload_cb(&self, cb: &LayerCB) { + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx + .Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut m)) + .unwrap(); + std::ptr::copy_nonoverlapping(cb as *const LayerCB as *const u8, m.pData as *mut u8, std::mem::size_of::()); + self.ctx.Unmap(&self.cbuf, 0); + self.ctx.VSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())])); + self.ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())])); + } + + /// Calque vidéo NV12. + pub unsafe fn draw_video( + &self, + cb: &LayerCB, + srv_y: &ID3D11ShaderResourceView, + srv_uv: &ID3D11ShaderResourceView, + ) { + self.upload_cb(cb); + self.ctx + .PSSetShaderResources(0, Some(&[Some(srv_y.clone()), Some(srv_uv.clone())])); + self.ctx.Draw(4, 0); + } + + /// Calque couleur pleine (fond). + pub unsafe fn draw_solid(&self, cb: &LayerCB) { + self.upload_cb(cb); + self.ctx.Draw(4, 0); + } + + /// Fond wallpaper image (cover-fit). `path` = chemin absolu (résolu côté app). Décodé et + /// uploadé une fois (cache), puis échantillonné en mode 6. Err → l'appelant retombe sur une + /// couleur plate. Le rect uv `src` recouvre toute la sortie en rognant le débordement. + unsafe fn draw_image_bg(&self, path: &str, output_aspect: f32) -> Result<()> { + // NB : la recherche est isolée dans un `let` pour que l'emprunt immuable soit relâché + // AVANT le `borrow_mut()` (sinon double-emprunt RefCell → panic sur la 1re frame image). + let cached = self.img_cache.borrow().get(path).cloned(); + let (srv, iw, ih) = match cached { + Some(v) => v, + None => { + let loaded = self.load_image_srv(path)?; + self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); + loaded + } + }; + let ai = iw as f32 / ih as f32; + // Le fond remplit TOUJOURS le cadre (dst=[0,0,1,1], jamais rétréci par `undistort`), + // mais le canvas interne est un 16:9 fixe étiré ensuite vers le VRAI ratio de sortie + // (`blit_resized`, non uniforme) : le crop "cover" doit donc être calculé contre ce vrai + // ratio de sortie (`output_aspect`, = final_out_w/final_out_h), pas contre le ratio fixe + // du canvas — sinon l'image, déjà cover-fittée pour du 16:9, se retrouve re-déformée par + // l'étirement final vers un ratio différent (ex. 9:16, cf. rapport utilisateur). + 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) + }; + self.upload_cb(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [u0, v0, u1, v1], + mode: 6.0, + ..Default::default() + }); + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.Draw(4, 0); + Ok(()) + } + + /// Décode un fichier image (jpg/png) → texture RGBA immuable + SRV. + unsafe fn load_image_srv(&self, path: &str) -> Result<(ID3D11ShaderResourceView, u32, u32)> { + // Les annotations image stockent une data URL (cf. `types.ts` : « Separate storage for + // image data URL »), pas un chemin : on décode alors depuis la mémoire. Les wallpapers + // continuent de passer par le disque. + let img = if let Some(bytes) = decode_data_uri(path) { + image::load_from_memory(&bytes) + .map_err(|e| anyhow::anyhow!("data URI image ({} octets): {}", bytes.len(), e))? + .to_rgba8() + } else { + image::open(path) + .map_err(|e| anyhow::anyhow!("wallpaper {}: {}", path, e))? + .to_rgba8() + }; + let (w, h) = (img.width(), img.height()); + let pixels = img.into_raw(); + let td = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_IMMUTABLE, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let init = D3D11_SUBRESOURCE_DATA { + pSysMem: pixels.as_ptr() as *const c_void, + SysMemPitch: w * 4, + SysMemSlicePitch: 0, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&td, Some(&init), Some(&mut tex))?; + let tex = tex.unwrap(); + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&tex, None, Some(&mut srv))?; + Ok((srv.unwrap(), w, h)) + } + + pub fn set_cursor(&self, track: CursorTrack) { + *self.cursor.borrow_mut() = Some(track); + } + + pub fn clear_cursor(&self) { + *self.cursor.borrow_mut() = None; + } + + /// Voir `cursor_t_override`. `None` restaure le comportement fixture (`frame / FPS`). + pub fn set_cursor_time(&self, t: Option) { + *self.cursor_t_override.borrow_mut() = t; + } + + /// Voir `timeline_t_override`. `None` restaure le comportement fixture (`frame / FPS`). + pub fn set_timeline_time(&self, t: Option) { + *self.timeline_t_override.borrow_mut() = t; + } + + /// Copie de la scène courante (si présente) — utilisé par l'export multiclip pour lire les + /// réglages curseur (thème/lissage/show) sans dupliquer le contrat de scène côté pipeline. + pub fn scene_snapshot(&self) -> Option { + self.scene.borrow().clone() + } + + /// Curseur custom (dot+ring) centré en `center` (0..1 sortie), taille `size_px`, opacité `a`. + /// `clip` = rect "Clip to canvas" en espace sortie [x,y,w,h] ; passer un rect englobant tout + /// (ex. [-1,-1,3,3]) pour désactiver l'effet. + unsafe fn draw_cursor(&self, center: [f32; 2], size_px: f32, a: f32, clip: [f32; 4]) { + let w = size_px / self.rw(); + let h = size_px / self.rh(); + let dst = [center[0] - w * 0.5, center[1] - h * 0.5, w, h]; + self.draw_solid(&LayerCB { + dst, + quad_px: [size_px, size_px], + mode: 4.0, + color: [1.0, 1.0, 1.0, a], + fx: clip, + ..Default::default() + }); + } + + /// Curseur thème (sprite PNG, ex. arrow.png) dont le PIVOT `hotspot` (fraction 0..1 de + /// l'image) tombe sur `center`, à la taille de référence `size_px`. `Err` → l'appelant + /// retombe sur `draw_cursor` (math dot+ring). + unsafe fn draw_cursor_sprite( + &self, + placement: CursorPlacement, + size_px: f32, + a: f32, + sprite: &SceneCursorSprite, + clip: [f32; 4], + ) -> Result<()> { + let path = sprite.path.as_str(); + let cached = self.img_cache.borrow().get(path).cloned(); + let (srv, iw, ih) = match cached { + Some(v) => v, + None => { + let loaded = self.load_image_srv(path)?; + self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); + loaded + } + }; + let ar = iw as f32 / ih 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 { + CursorPlacement::Upright { center } => LayerCB { + dst: cursor_sprite_dst(center, pw / self.rw(), ph / self.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() + }, + CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, .. } => { + // Le sprite est posé DANS le plan : sa taille devient une fraction du plan + // (l'unité de `size_px` est le rect d'écran non incliné), et ses 4 coins + // traversent la même projection que la vidéo. La réduction due au tilt vient + // donc de la projection elle-même — 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 / self.rw(), min_y / self.rh(), bw / self.rw(), bh / self.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() + } + } + }; + + self.upload_cb(&cb); + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.Draw(4, 0); + Ok(()) + } + + /// Sprite de l'état courant (`cursor_type`, ex. `"text"`), à défaut celui de la flèche, + /// à défaut le curseur math (dot+ring). + /// + /// Le repli sur la flèche compte : un thème n'apporte que sa flèche et son pointeur, les + /// autres états venant de l'art intégrée — mais si un état inconnu apparaît, mieux vaut + /// une flèche qu'un point dans un cercle. + unsafe fn draw_cur_themed( + &self, + sprites: &HashMap, + cursor_type: Option<&str>, + placement: 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 self.draw_cursor_sprite(placement, size_px, a, sprite, clip).is_ok() { + return; + } + } + // Le repli math reste droit même sur un plan incliné : il ne devrait plus apparaître + // maintenant que l'art par défaut existe, et lui donner sa propre passe de warp pour + // un cas de secours ne se justifie pas. + self.draw_cursor(placement.upright_center(), size_px, a, clip); + } + + /// Ombre portée (§7 E4) sous un quad `dst` (normalisé) de taille `size_px`. + /// Le quad d'ombre est élargi de `spread` px et décalé de `offset_px`. + /// `spread`/`offset_px` sont des px RÉELS de la sortie finale (même convention que + /// `radius_px` pour l'arrondi normal, cf. `compose_frame`) — PAS des px du canvas fixe + /// 16:9. Convertis ici en marge/décalage CANVAS (avant l'étirement final anisotrope de + /// `blit_resized`), par axe (`/stretch_x`, `/stretch_y`), pour que ce halo redevienne un + /// vrai halo isotrope une fois cet étirement appliqué — sans ça (ancien calcul : marge + /// identique en fraction canvas quel que soit l'axe) l'ombre ressort visiblement elliptique + /// dès que la sortie n'est pas 16:9 (rapport utilisateur, ex. export vertical 9:16). + /// `stretch_x`/`stretch_y` sont aussi transmis au shader (`mb.yz`) pour pré-déformer la SDF + /// elle-même — même technique que l'arrondi normal (mode 0) — sinon la COURBURE des coins + /// de l'ombre reste elliptique même une fois sa taille globale corrigée. + pub unsafe fn draw_shadow( + &self, + dst: [f32; 4], + size_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: f32, + ) { + let sx = spread / self.rw(); + let sy = spread / self.rh(); + let ox = offset_px[0] / self.rw(); + let oy = offset_px[1] / self.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(&cb); + } + + /// Ombre d'un écran incliné en 3D : la pénombre suit le QUADRILATÈRE projeté (mode 12), pas + /// son rect englobant. `corners` sont les 4 coins (TL, TR, BR, BL) en px relatifs au centre, + /// tels que `rotated_quad_corners_px` les rend ; `center_px` est ce centre à l'écran. + /// + /// `radius` est le rayon des coins du PLAN, réutilisé tel quel : la projection l'étire de + /// ±10 % selon l'endroit du bord, écart invisible sur une ombre floue, alors qu'une ombre à + /// coins vifs derrière un écran arrondi dépasse en pointe et se voit tout de suite. + pub unsafe fn draw_quad_shadow( + &self, + corners: &[(f32, f32); 4], + center_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: 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 de rendu doit contenir la pénombre entière, sinon elle se coupe net — le + // quadrilatère seul ne suffit pas. + let box_w = (max_x - min_x) + 2.0 * spread; + let box_h = (max_y - min_y) + 2.0 * spread; + let origin_x = center_px[0] + min_x - spread + offset_px[0]; + let origin_y = center_px[1] + min_y - spread + offset_px[1]; + // Coins en px locaux à cette boîte, même convention que le mode 8. + 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(&LayerCB { + dst: [ + origin_x / self.rw(), + origin_y / self.rh(), + box_w / self.rw(), + box_h / self.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() + }); + } + + /// Compose une frame animée (§6/§8) : fond flouté + screen zoomé (padding, coins, ombre) + /// + webcam crop carré (coins, ombre), placements interpolés A↔B par la timeline. + pub unsafe fn compose_frame( + &self, + screen: *const AVFrame, + webcam: *const AVFrame, + frame: f32, + cfg: &Cfg, + ) -> Result<()> { + let (sy, suv) = self.nv12_srvs(screen)?; + let (wy, wuv) = self.nv12_srvs(webcam)?; + 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) = ((*webcam).width as f32, (*webcam).height as f32); + let u_max = scw / stw as f32; + let v_max = sch / sth as f32; + + let scene_ref = self.scene.borrow(); + let cursor_ref = self.cursor.borrow(); + 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_ann, 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(), + }); + + + self.begin([0.0, 0.0, 0.0, 1.0]); + + // --- fond --- + // Parité web (frameRenderer.blurredBackgroundLayer) : le fond est le WALLPAPER sélectionné + // (image/couleur/gradient) et « Blur BG » floute CE wallpaper, PAS la vidéo. Le natif + // dupliquait la vidéo floutée → le « vieux flou ». Côté APP (scène présente) on dessine + // donc le wallpaper (couleur pour l'instant ; gradient/image rendus depuis la scène + // ensuite ; pour une couleur plate le flou est un no-op visuel). Côté fixture/bench + // (pas de scène) on garde le fond screen-flouté, dont le coût est mesuré (C4). + let scene_bg = self.scene.borrow().as_ref().map(|s| (s.background.clone(), s.effects.blur)); + if let Some((bg, blur_wallpaper)) = scene_bg { + match bg { + SceneBackground::Color { color } => { + let c = parse_hex(&color).unwrap_or(lp.bg_color); + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: c, + ..Default::default() + }); + } + 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); + // angle CSS → direction unitaire (espace sortie, y vers le bas) : + // 0° = vers le haut, 90° = vers la droite. + let a = angle_deg.to_radians(); + let dir = [a.sin(), -a.cos()]; + self.draw_solid(&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: [dir[0], dir[1], 0.0, 0.0], + ..Default::default() + }); + } + SceneBackground::Image { path } => { + // image bg (cover-fit, mise en cache) ; fallback couleur si chargement échoue + // (loggé — un fallback silencieux masquerait un chemin cassé, cf. le panic + // borrow qu'on a déjà eu : toute panne doit être visible/traçable). + if let Err(e) = self.draw_image_bg(&path, self.rw() / self.rh()) { + eprintln!("[compositor] wallpaper image \"{}\" : {:#}", path, e); + self.draw_solid(&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 (dual-Kawase, déjà utilisé pour le fond fixture ci-dessous). No-op + // visuel sur une couleur plate, effet réel sur gradient/image. + if blur_wallpaper { + self.blur_bg(18.0); + self.bind_compose_state(); + } + } else if cfg.bg_blur { + let over = 0.06; + self.draw_video( + &LayerCB { + dst: [-over, -over, 1.0 + 2.0 * over, 1.0 + 2.0 * over], + src: [0.0, 0.0, u_max, v_max], + quad_px: [self.rw(), self.rh()], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + ..Default::default() + }, + &sy, + &suv, + ); + self.blur_bg(18.0); + self.bind_compose_state(); + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: [0.0, 0.0, 0.0, 0.35], + ..Default::default() + }); + } else { + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }); + } + + // --- screen : crop du clip actif, puis zoom appliqué dans ce rect source (§8) --- + // `for_clip_window` conserve l'index pour distinguer plusieurs clips du même asset. + // `active_crop` déjà résolu plus haut (utilisé pour dimensionner `s_dst`) — une seule + // source de vérité pour ce lookup. + let s_px = [s_dst[2] * self.rw(), s_dst[3] * self.rh()]; + // Coupes calculées plus haut (elles dimensionnent `s_dst`) : le zoom vit désormais + // dans la boîte, la coupe ne porte que le crop. `dst_prev` porte la vélocité du + // motion blur — la coupe, elle, est la même aux deux frames. + let [su0, sv0, su1, sv1] = cut; + let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); + let [su0_p, sv0_p, su1_p, sv1_p] = cut; + let (hu_p, hv_p) = ((su1_p - su0_p) * 0.5, (sv1_p - sv0_p) * 0.5); + // Géométrie du tilt, calculée UNE fois : l'ombre et l'écran doivent porter exactement le + // même quadrilatère. Deux calculs séparés, c'est une ombre qui se décolle dès qu'un des + // deux change. + let tilt = (!crate::regions::is_identity_rotation(zoom_rotation)) + .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], zoom_rotation)); + let quad_center_px = + [(s_dst[0] + s_dst[2] * 0.5) * self.rw(), (s_dst[1] + s_dst[3] * 0.5) * self.rh()]; + // L'ombre suit la silhouette réellement affichée : le rect arrondi quand l'écran est + // droit, le quadrilatère projeté quand il est incliné. Un rect droit derrière un écran + // penché ne se lisait pas comme son ombre mais comme une seconde surface. Elle suit + // aussi la croissance de la boîte pendant un zoom (issue #179) : quand la boîte sort + // du cadre, l'ombre en sort avec elle, sans jamais se lire comme une bande noire. + if cfg.shadow { + let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; + let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; + let opacity = 0.45 * lp.shadow_scale; + match tilt.as_ref() { + None => self.draw_shadow(s_dst, s_px, s_radius, spread, offset, opacity), + Some(quad) => self.draw_quad_shadow( + &quad.corners, + quad_center_px, + // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). + s_radius * quad.scale, + spread, + offset, + opacity, + ), + } + } + if crate::regions::is_identity_rotation(zoom_rotation) { + self.draw_video( + &LayerCB { + dst: s_dst, + src: [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], + quad_px: s_px, + radius_px: s_radius, + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + src_prev: [su0_p, sv0_p, su0_p + 2.0 * hu_p, sv0_p + 2.0 * hv_p], + dst_prev: s_dst_prev, + mb: [mb_taps, 1.0, 1.0, 0.0], + ..Default::default() + }, + &sy, + &suv, + ); + } else { + // Tilt 3D (zoom "rotation" iso/left/right) : warp bilinéaire inverse (mode 8, voir + // shaders.hlsl). Pas de motion blur dans ce chemin — le tilt est un effet bref, la + // simplification ne se voit pas. Les coins arrondis, eux, se voyaient : sans eux le + // plan a des arêtes de couteau qui tranchent le contenu en pleine phrase, et l'œil lit + // une découpe (« un overflow hidden qui tronque l'enregistrement ») là où il devrait + // lire une inclinaison. Ils sont donc rendus, dans le repère DU PLAN. + let quad = tilt.unwrap_or_else(|| { + crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], zoom_rotation) + }); + 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 un rayon constant le long du bord et non un arrondi qui s'étire avec + // la perspective. + let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; + let (cx_px, cy_px) = (quad_center_px[0], quad_center_px[1]); + 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); + let bbox_dst = [ + (cx_px + min_x) / self.rw(), + (cy_px + min_y) / self.rh(), + bbox_w / self.rw(), + bbox_h / self.rh(), + ]; + // coins en px LOCAUX à la bbox (0..bbox_w/h), 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( + &LayerCB { + dst: bbox_dst, + src: [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], + quad_px: [bbox_w, bbox_h], + // Le rayon suit la réduction du plan : l'écran incliné est plus petit, ses + // coins le sont d'autant, exactement comme s'il s'éloignait. + radius_px: s_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() + }, + &sy, + &suv, + ); + } + + // --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce, + // et flou de mouvement par fantômes le long de sa vélocité (frame-1 -> frame) --- + // Jeu de sprites résolu par l'app (art du thème + art intégrée pour les états qu'il ne + // fournit pas), sinon math dot+ring — fixture/bench sans scène uniquement. + let cursor_sprites: HashMap = self + .scene + .borrow() + .as_ref() + .map(|s| s.cursor.cursor_sprites.clone()) + .unwrap_or_default(); + // « Clip to canvas » : tronque le curseur aux bords de l'écran (utile quand le padding + // crée une marge et que la pointe, près du bord de la vidéo, dépasserait dedans). + // Rect englobant tout par défaut = pas d'effet (le mode 4/7 du shader clippe sur `fx`). + // Écran incliné : le rect droit d'origine rognerait le curseur sur les parties du plan + // qui débordent au-dessus/en dessous, donc on clippe sur la bbox du quad projeté. Un + // rect reste une approximation du quadrilatère — `fx` ne sait pas exprimer autre chose — + // mais qui ne coupe plus rien de ce qui est réellement affiché. + let cursor_bounds: [f32; 4] = match tilt.as_ref() { + None => s_dst, + Some(quad) => { + let (hx, hy) = quad.half_extents_px(); + [ + (quad_center_px[0] - hx) / self.rw(), + (quad_center_px[1] - hy) / self.rh(), + 2.0 * hx / self.rw(), + 2.0 * hy / self.rh(), + ] + } + }; + let cursor_clip_rect: [f32; 4] = match self.scene.borrow().as_ref() { + Some(s) if s.cursor.clip_to_bounds => cursor_bounds, + _ => [-1.0, -1.0, 3.0, 3.0], + }; + // « Show cursor » : piloté par la scène (contrat de l'app) quand elle est posée ; sinon + // par `cfg.cursor` (inspector / bench fixture). + let cursor_show = scene_ref + .as_ref() + .map(|s| s.cursor.show) + .unwrap_or(cfg.cursor); + if cursor_show { + let cursor_ref = self.cursor.borrow(); + if let Some(track) = cursor_ref.as_ref() { + let t = self.cursor_t_override.borrow().unwrap_or(frame / FPS); + // position sortie à un temps donné via un mapping screen (src rect + dst) + let map = |cxy: Option<(f32, f32)>, s0: [f32; 2], h: [f32; 2], dst: [f32; 4]| { + cxy.and_then(|(cx2, cy2)| { + let fx = (cx2 * u_max - s0[0]) / (2.0 * h[0]); + let fy = (cy2 * v_max - s0[1]) / (2.0 * h[1]); + if !(0.0..=1.0).contains(&fx) || !(0.0..=1.0).contains(&fy) { + return None; + } + // Écran incliné : le curseur vit SUR le plan, pas dans un calque + // au-dessus. On garde donc sa position dans le repère DU PLAN et c'est + // le dessin qui projette — position ET sprite. Le poser sur `dst`, le + // rect droit d'origine, le laissait flotter à côté de l'image, l'écart + // se comptant en dizaines de pixels là où le plan s'éloigne le plus. + 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: [self.rw(), self.rh()], + }, + None => CursorPlacement::Upright { + center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]], + }, + }) + }) + }; + let raw_xy = track.at(t); + // Hors de [0,1] = pointeur hors du rect source actuel (zoom serré / hors écran) — + // état normal en cours de lecture, pas une erreur : rien à dessiner cette frame. + let mapped = map(raw_xy, [su0, sv0], [hu, hv], s_dst); + if let Some(cur) = mapped { + // taille + amplitude du bounce pilotées par l'inspector (défauts = fixture). + // `padding_scale` : le curseur est un recouvrement synthétique, pas cuit dans + // la vidéo — quand le padding rétrécit l'écran, le curseur doit rétrécir + // pareil pour rester à l'échelle du contenu (sinon sa pointe semble se + // décaler/dériver à mesure que le padding grandit). + let bounce = 1.0 + (track.bounce(t) - 1.0) * lp.cursor_bounce_scale; + // Pas de facteur de tilt ici : sur un plan incliné la taille est convertie + // en fraction du plan puis projetée avec lui (voir `draw_cursor_sprite`), + // donc la réduction vient de la projection. L'ajouter en plus rétrécirait + // le curseur deux fois. + let sz = CURSOR_BASE_SIZE_FRAC + * frame_min_px + * lp.cursor_size_scale + * bounce + * padding_scale; + // flou de mouvement DU CURSEUR, indépendant de cfg.mblur_n (écran/vidéo). + // BUG corrigé : augmenter l'intensité ne faisait auparavant que sur-échantillonner + // (plus de taps) un écart figé d'1 frame (1/60s) -> la traînée ne s'allongeait + // JAMAIS, donc restait quasi invisible quel que soit le réglage. L'intensité doit + // étirer la FENÊTRE temporelle de la traînée, pas seulement sa densité d'échantillons. + // 0 -> 1 frame en arrière (net) ; 1 -> ~8 frames (~130 ms à 60fps, traînée nette). + let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0); + let has_scene = self.scene.borrow().is_some(); + let trail_frames = if has_scene { 1.0 + blur01 * 7.0 } else { 1.0 }; + // BUG corrigé : le plancher était 2 (pas 1) -> même à blur=0 le curseur + // passait TOUJOURS par le chemin additif multi-tap (poids 1/taps=0.5 chacun), + // et comme prev≠cur au pixel près, les deux copies à 0.5 d'alpha ne se + // recouvraient jamais parfaitement -> curseur en permanence semi-transparent + // (quasi invisible sur fond clair), même sans aucun flou demandé. + let taps = if has_scene { + (1.0 + blur01 * 10.0).round() as u32 // 0 -> 1 (net) ; 1 -> 11 (traînée) + } else { + cfg.mblur_n // fixture/bench : comportement historique inchangé + }; + // L'état est celui de l'instant rendu : la traînée de flou reprend le même + // sprite pour toutes ses copies, un changement d'état en plein mouvement + // n'a pas à laisser une traînée hybride. + let cursor_type = track.type_at(t).map(str::to_string); + let cursor_type = cursor_type.as_deref(); + if taps <= 1 { + self.draw_cur_themed( + &cursor_sprites, + cursor_type, + cur, + sz, + 1.0, + cursor_clip_rect, + ); + } else { + let tp = t - trail_frames / FPS; + let prev = map(track.at(tp), [su0_p, sv0_p], [hu_p, hv_p], s_dst_prev) + .unwrap_or(cur); + // Flou RÉEL, pas des copies discrètes : accumule les N échantillons dans un + // buffer ISOLÉ (transparent), pas directement sur la scène déjà composée. + // BUG précédent : additionner directement sur `self.rtv` revient à AJOUTER + // la couleur du curseur (blanc) à ce qu'il y a déjà dessous — sur un fond + // clair, ajouter du blanc*petit-alpha ne change presque rien de visible + // (déjà proche du blanc) -> curseur quasi invisible. En accumulant d'abord + // dans un buffer à part (parti de zéro, même mécanisme que le motion blur + // écran de `compose_frame_mb`), la somme reste correctement normalisée + // (alpha final ~1 si les échantillons se recouvrent), puis on la composite + // sur la scène par un blend "over" classique — correct quel que soit le fond. + self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); + self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); + let w = 1.0 / taps as f32; + self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); + for k in 0..taps { + let f = k as f32 / (taps - 1) as f32; + self.draw_cur_themed( + &cursor_sprites, + cursor_type, + prev.lerp(cur, f), + sz, + 1.0, + cursor_clip_rect, + ); + } + // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.accum_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + // restaure l'état de composition standard (VS/PS/topologie quad-strip) pour + // le dessin de la webcam qui suit juste après. + self.bind_compose_state(); + } + } + } + } + + // --- webcam : sous-rect SOURCE couvrant la boîte de destination --- + // La coupe est dérivée du ratio RÉEL de la boîte (`cover_crop_uv`), donc la caméra + // n'est jamais étirée quel que soit le rect qu'on lui donne. + // + // Avant, la source était prise PLEIN CADRE pour rectangle/rounded, en supposant que + // « le dst matche le ratio de la source ». C'est vrai du placement par DÉFAUT + // (`fit_cam_aspect` façonne alors le dst), mais faux dès que l'app fournit le rect : + // le preset side-by-side donne à la caméra un slot de colonne au ratio arbitraire + // (cf. `computeCompositeLayout`, branche dual-frame — `webcamRect = webcamSlot`, sans + // aucun ajustement d'aspect), et la caméra y était étirée. L'hypothèse était donc + // portée par l'appelant ; la dériver ici la rend vraie par construction. + // + // Le center-crop carré de square/circle en est un cas particulier (boîte 1:1) — il n'a + // plus besoin d'être traité à part. + let (su0, sv0, su1, sv1) = cover_crop_uv( + [wcw, wch], + [wtw as f32, wth as f32], + w_px[0] / w_px[1].max(0.0001), + ); + // miroir = échanger les bornes u du rect source (flip horizontal). + let (u0, u1) = if lp.webcam_mirror { (su1, su0) } else { (su0, su1) }; + if lp.has_webcam { + // L'ombre portée appartient à la bulle flottante PiP : elle se retire avec elle + // (`shape_fade`), pour qu'au plein écran plus rien n'encadre la caméra. C'est une + // ombre légère NON paramétrable — indépendante du slider Shadow, qui ne pilote plus + // que l'écran (`WEBCAM_SHADOW_OPACITY`, pas `shadow_scale`) — et propre au PiP : les + // blocs side-by-side / top-bottom soudent la caméra à l'écran et n'en portent aucune + // (parité `preset.shadow` web, `null` hors PiP dans `compositeLayout.ts`). + let webcam_is_block = matches!( + scene_preset.as_deref(), + Some("dual-frame") | Some("vertical-stack"), + ); + if cfg.shadow && !webcam_is_block && shape_fade > 0.0 { + let strength = WEBCAM_SHADOW_OPACITY * shape_fade; + self.draw_shadow( + w_dst, + w_px, + w_radius, + WEBCAM_SHADOW_SPREAD_FRAC * frame_min_px, + [0.0, WEBCAM_SHADOW_OFFSET_FRAC * frame_min_px], + strength, + ); + } + self.draw_video( + &LayerCB { + dst: w_dst, + src: [u0, sv0, u1, sv1], + quad_px: w_px, + radius_px: w_radius, + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + src_prev: [u0, sv0, u1, sv1], // src fixe (pas de zoom webcam) + dst_prev: w_dst_prev, + mb: [mb_taps, 1.0, 1.0, 0.0], + ..Default::default() + }, + &wy, + &wuv, + ); + } + + // --- annotations : calque le plus haut, comme dans le DOM de la preview (le calque y est + // monté après la vidéo). Ancrées sur `s_ann`, le rect ÉCRAN SANS ZOOM — c'est le conteneur + // que reçoit l'overlay web (`layout.screenRect`) — et volontairement pas sur le rect de + // sortie, ni sujettes au zoom : dans la preview l'overlay est frère de l'élément qui porte + // la transform, donc les annotations restent en place pendant que le contenu zoome dessous. + // Ce fut `s_dst` tant que le zoom vivait dans la coupe source ; depuis l'issue #179 il vit + // dans la BOÎTE, et `s_dst` emmenait annotations et sous-titres avec lui. + // `source_t`, la même base de temps que les zoom/speed regions : le temps SOURCE du clip, + // pas le compteur de frames. C'est ce qui garde une annotation alignée sur l'image quand + // une speed region répète ou saute des frames. + self.draw_annotations(scene_ref.as_ref(), source_t, s_ann); + Ok(()) + } + + /// Dessine les annotations visibles à `t`. `screen_dst` = rect écran en fractions de sortie. + /// + /// Seule la « figure » (flèche) est rendue à ce stade ; texte, image et flou suivront. Les + /// types non gérés sont ignorés silencieusement plutôt que dessinés de travers : mieux vaut + /// l'absence connue qu'un placeholder qui ferait croire à un bug de style. + unsafe fn draw_annotations(&self, scene: Option<&Scene>, t: f32, screen_dst: [f32; 4]) { + let Some(scene) = scene else { return }; + if scene.annotations.is_empty() { + return; + } + let visible = |a: &crate::scene::SceneAnnotation| { + t >= a.start_sec as f32 && t < a.end_sec as f32 + }; + // Une seule recopie du render target 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. + let needs_copy = scene + .annotations + .iter() + .any(|a| visible(a) && a.kind == "blur" && a.blur.is_some()); + if needs_copy { + // `CopySubresourceRegion` et non `CopyResource` : les deux textures n'ont pas le même + // nombre de niveaux, on ne remplit que le mip 0 puis on laisse le GPU dériver le reste. + self.ctx.CopySubresourceRegion(&self.ann_copy, 0, 0, 0, 0, &self.rt, 0, None); + self.ctx.GenerateMips(&self.ann_copy_srv); + } + // La liste arrive déjà triée par zIndex croissant côté app, donc l'ordre d'itération EST + // l'ordre de peinture — pas de tri par frame. + for annotation in &scene.annotations { + if !visible(annotation) { + continue; + } + let dst = [ + screen_dst[0] + annotation.x * screen_dst[2], + screen_dst[1] + annotation.y * screen_dst[3], + annotation.w * screen_dst[2], + annotation.h * screen_dst[3], + ]; + let quad_px = [dst[2] * self.rw(), dst[3] * self.rh()]; + if quad_px[0] <= 0.0 || quad_px[1] <= 0.0 { + continue; + } + match annotation.kind.as_str() { + "figure" => { + let Some(figure) = annotation.figure.as_ref() else { continue }; + let (segments, half_stroke) = crate::regions::arrow_local_geometry( + &figure.direction, + figure.stroke_width, + quad_px, + ); + let rgba = parse_hex(&figure.color).unwrap_or([1.0, 1.0, 1.0, 1.0]); + self.draw_solid(&LayerCB { + dst, + quad_px, + mode: 9.0, + color: rgba, + 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) = annotation.blur.as_ref() else { continue }; + // Le masque en tracé libre demande une liste de points côté GPU (buffer + // structuré), pas encore faite : on masque alors la BOÎTE ENGLOBANTE. + // + // Ce choix est délibéré et asymétrique. Ne rien dessiner laisserait passer en + // clair, dans le fichier exporté, ce que l'utilisateur a explicitement désigné + // comme à cacher — un masque de confidentialité qui ne masque pas est pire que + // pas de masque, parce qu'il donne confiance à tort. Sur-flouter une marge + // autour de la zone ne trahit personne. + let freehand_fallback = blur.shape == "freehand"; + let is_blur = if blur.style == "blur" { 1.0 } else { 0.0 }; + // `intensity` pilote le rayon du flou, `block_size` la grille de mosaïque — + // deux réglages distincts côté app, un seul paramètre ici selon le style. + let amount = if is_blur > 0.5 { blur.intensity } else { blur.block_size }; + // Le repli du tracé libre passe par le rectangle, pas l'ovale : un ovale + // inscrit dans la boîte englobante en retirerait les coins, donc une partie de + // ce que l'utilisateur a couvert. + let is_oval = if blur.shape == "oval" && !freehand_fallback { 1.0 } else { 0.0 }; + // La teinte n'a de sens qu'en mosaïque : elle sert à marquer visiblement une + // zone caviardée. Un flou teinté ne ressemblerait 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] + }; + self.ctx.PSSetShaderResources(2, Some(&[Some(self.ann_copy_srv.clone())])); + self.draw_solid(&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) = annotation.image_path.as_ref() else { continue }; + if src.is_empty() { + continue; + } + // Cache indexé sur l'ID de l'annotation, pas sur la data URL : celle-ci pèse + // souvent des mégaoctets, et la prendre comme clé de HashMap la ferait hacher + // à chaque frame. La longueur, stockée à côté, sert de garde-fou quand + // l'utilisateur change l'image (une nouvelle image de longueur identique au + // bit près serait manquée jusqu'au rechargement — coût accepté en connaissance). + let key = annotation.id.clone(); + let cached = { + let cache = self.ann_img_cache.borrow(); + cache.get(&key).filter(|(_, _, _, len)| *len == src.len()).cloned() + }; + let Some((srv, iw, ih, _)) = cached.or_else(|| { + match self.load_image_srv(src) { + Ok((srv, w, h)) => { + let entry = (srv, w, h, src.len()); + self.ann_img_cache.borrow_mut().insert(key, entry.clone()); + Some(entry) + } + Err(e) => { + eprintln!("[annotation image] {}: {e}", annotation.id); + None + } + } + }) else { + continue; + }; + if iw == 0 || ih == 0 { + continue; + } + // `object-contain`, comme la preview : mise à l'échelle uniforme pour tenir + // DANS la boîte, centrée. On rétrécit le rect de destination au ratio de + // l'image plutôt que de recadrer la source, ce qui donne exactement ça. + 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]) + }; + let fit = [ + dst[0] + (dst[2] - fit_w) * 0.5, + dst[1] + (dst[3] - fit_h) * 0.5, + fit_w, + fit_h, + ]; + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.draw_solid(&LayerCB { + dst: fit, + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [fit_w * self.rw(), fit_h * self.rh()], + // mode 7 = sprite RGBA avec alpha, déjà utilisé par les thèmes de curseur : + // exactement ce qu'il faut ici, donc aucun shader de plus. `fx` est son + // rect de clip — plein cadre, pour ne rien découper. + 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) = annotation.text.as_ref() else { continue }; + let Some(raster) = self.text_raster.as_ref() else { continue }; + if text.content.trim().is_empty() { + continue; + } + // `font_size_rel` est une fraction de la HAUTEUR DU RECT ÉCRAN (cf. le contrat + // et `annotationScale.ts`) : on la ramène en pixels de sortie ici, avec le même + // produit que la preview applique contre sa propre boîte. + let screen_h_px = screen_dst[3] * self.rh(); + let spec = crate::text::TextSpec { + content: text.content.clone(), + color: parse_hex(&text.color).unwrap_or([1.0, 1.0, 1.0, 1.0]), + // "transparent" ne parse pas en hex : alpha 0 => pas de fond, ce qui est + // exactement la sémantique CSS. + background: parse_hex(&text.background_color).unwrap_or([0.0, 0.0, 0.0, 0.0]), + font_size_px: text.font_size_rel * screen_h_px, + 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 cache = self.text_cache.borrow(); + cache.get(&annotation.id).filter(|(_, k)| *k == key).map(|(srv, _)| srv.clone()) + }; + let Some(srv) = cached.or_else(|| match raster.rasterize(&self.dev, &spec) { + Ok(srv) => { + self.text_cache + .borrow_mut() + .insert(annotation.id.clone(), (srv.clone(), key)); + Some(srv) + } + Err(e) => { + eprintln!("[annotation texte] {}: {e}", annotation.id); + None + } + }) else { + continue; + }; + // Animation d'apparition. Elle est comptée en temps SOURCE (le seul dont on + // dispose ici) : dans une région accélérée, elle défile donc au rythme du + // clip. À vitesse 1 — le cas de toutes les annotations existantes — c'est + // exactement le timing de l'aperçu DOM. + let anim = crate::text_anim::text_animation_state( + text.animation.as_deref(), + (t - annotation.start_sec as f32) * 1000.0, + ); + // Les décalages sont donnés à la hauteur de référence : on les ramène à la + // sortie, comme la taille de police, pour que l'animation ait la même + // amplitude visuelle quelle que soit la résolution. + let anim_px = self.rh() / crate::text_anim::ANIMATION_REFERENCE_HEIGHT; + let (mut ax, mut ay, mut aw, mut ah) = ( + dst[0] + anim.translate_x * anim_px / self.rw(), + dst[1] + anim.translate_y * anim_px / self.rh(), + dst[2], + dst[3], + ); + if (anim.scale - 1.0).abs() > 1e-4 { + // Mise à l'échelle autour du CENTRE de la boîte : un texte qui grossit par + // son coin haut-gauche glisserait en biais au lieu de gonfler sur place. + 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; + } + // Machine à écrire : on ne rogne que la LARGEUR, source et destination + // ensemble, ce qui reproduit le `inset(0 X% 0 0)` de l'aperçu sans redemander + // une rastérisation par caractère. + let reveal = anim.reveal.clamp(0.0, 1.0); + if reveal <= 0.0 { + continue; + } + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.draw_solid(&LayerCB { + dst: [ax, ay, aw * reveal, ah], + src: [0.0, 0.0, reveal, 1.0], + quad_px: [aw * reveal * self.rw(), ah * self.rh()], + // mode 11 : sprite en alpha DÉJÀ prémultiplié (ce que produit D2D). + mode: 11.0, + color: [1.0, 1.0, 1.0, anim.opacity], + ..Default::default() + }); + } + _ => {} + } + } + self.ctx.PSSetShaderResources(2, Some(&[None])); + } + + /// Flou de mouvement (§8) : moyenne de `n` sous-frames aux temps intermédiaires + /// (mêmes textures vidéo, params d'animation à frame+k/n). Résultat laissé dans le RT. + pub unsafe fn compose_frame_mb( + &self, + screen: *const AVFrame, + webcam: *const AVFrame, + frame: u32, + cfg: &Cfg, + ) -> Result<()> { + let n = cfg.mblur_n; + if n <= 1 { + return self.compose_frame(screen, webcam, frame as f32, cfg); + } + // accumulateur à zéro + self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); + let w = 1.0 / n as f32; + for k in 0..n { + let tf = frame as f32 + (k as f32 + 0.5) / n as f32 - 0.5; + self.compose_frame(screen, webcam, tf, cfg)?; // -> self.rt + // accum += rt * (1/n) (blend factor = 1/n, dest = ONE) + self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); + self.upload_cb(&LayerCB::default()); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + // recopie l'accumulateur dans le RT (pour rgb_to_nv12 qui échantillonne rt_srv) + let src: ID3D11Resource = self.accum.cast()?; + let dst: ID3D11Resource = self.rt.cast()?; + self.ctx.CopyResource(&dst, &src); + Ok(()) + } + + /// Rend le RT RGBA vers notre texture NV12 puis copie vers la surface `out_tex`/`slice`. + pub unsafe fn rgb_to_nv12(&self, out_tex: *mut c_void, slice: u32) -> Result<()> { + self.render_nv12(); + let src: ID3D11Resource = self.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); + Ok(()) + } + + /// Alloue (une fois par taille) les ressources du resize export : RGBA intermédiaire + + /// sa propre texture NV12 à `w`×`h`. `w`/`h` doivent être pairs (exigé par NV12 4:2:0, + /// le plan UV fait exactement la moitié) — l'appelant (export_multi côté napi) arrondit. + unsafe fn ensure_resize_target(&self, w: u32, h: u32) -> Result<()> { + if let Some(t) = self.resize_target.borrow().as_ref() { + if t.w == w && t.h == h { + return Ok(()); + } + } + let rd = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut rgba: Option = None; + self.dev.CreateTexture2D(&rd, None, Some(&mut rgba))?; + let rgba = rgba.unwrap(); + let mut rgba_rtv: Option = None; + self.dev.CreateRenderTargetView(&rgba, None, Some(&mut rgba_rtv))?; + let mut rgba_srv: Option = None; + self.dev.CreateShaderResourceView(&rgba, None, Some(&mut rgba_srv))?; + + // NV12 non-array à la taille cible (même contrainte que le NV12 principal). + let nvd = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut nv12: Option = None; + self.dev.CreateTexture2D(&nvd, None, Some(&mut nv12))?; + let nv12 = nv12.unwrap(); + let mk_rtv = |fmt: DXGI_FORMAT| -> Result { + let d = D3D11_RENDER_TARGET_VIEW_DESC { + Format: fmt, + ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 { Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 } }, + }; + let mut rtv: Option = None; + self.dev.CreateRenderTargetView(&nv12, Some(&d), Some(&mut rtv))?; + Ok(rtv.unwrap()) + }; + let nv12_rtv_y = mk_rtv(DXGI_FORMAT_R8_UNORM)?; + let nv12_rtv_uv = mk_rtv(DXGI_FORMAT_R8G8_UNORM)?; + + *self.resize_target.borrow_mut() = Some(ResizeTarget { + w, + h, + rgba_rtv: rgba_rtv.unwrap(), + rgba_srv: rgba_srv.unwrap(), + nv12, + nv12_rtv_y, + nv12_rtv_uv, + }); + Ok(()) + } + + /// Redimensionne (bilinéaire) le RT composé (OUT_W×OUT_H) vers `resize_target.rgba`, avant + /// la conversion NV12 dans `rgb_to_nv12_scaled`. + /// + /// Étirement PLEIN CADRE volontaire, y compris non uniforme quand `target_w`×`target_h` + /// n'a pas le ratio de OUT_W×OUT_H : le fond (wallpaper) doit remplir tout le cadre de + /// sortie quel que soit le ratio choisi — ce n'est PAS lui qu'il faut préserver en "fit". + /// L'écran et la webcam, eux, sont protégés de cet étirement en amont, dans + /// `compose_frame` (rétrécissement inverse de leur rect de destination AVANT ce blit — + /// voir le commentaire sur `undistort` juste avant leur dessin) : ils gardent leur ratio + /// d'origine (letterboxé/pillarboxé sur le fond, qui lui reste plein cadre) sans qu'il + /// faille toucher au viewport ici. + unsafe fn blit_resized(&self, target_w: u32, target_h: u32) -> Result<()> { + self.ensure_resize_target(target_w, target_h)?; + let cache = self.resize_target.borrow(); + let t = cache.as_ref().unwrap(); + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.OMSetRenderTargets(Some(&[Some(t.rgba_rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: target_w as f32, Height: target_h as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + Ok(()) + } + + /// Lit le RT composité (résolu à `target_w`×`target_h`, via le même `blit_resized` + /// réutilisé par `rgb_to_nv12_scaled` pour l'export) vers un `Vec` RGBA8 + /// tightly-packed (`target_w * target_h * 4` octets, ordre R,G,B,A en mémoire — ce + /// que `putImageData(..., 'rgba8')` attend côté JS). + /// + /// Pourquoi un helper dédié plutôt qu'un open-coding dans `live.rs` : tout le + /// pattern GPU→CPU de ce fichier (staging `D3D11_USAGE_STAGING`, `CopyResource`, + /// `Map`/`D3D11_MAP_READ` + copie ligne par ligne qui respecte `RowPitch`) vit déjà + /// dans `dump_nv12`/`dump_raw` — le partager garde la connaissance D3D11 confinée + /// à ce fichier et assure que le live et l'export ne divergent pas sur un détail de + /// copie. La staging est cachée par taille (`live_readback_staging`) — recréée quand + /// `target_w`/`target_h` changent — pour ne pas payer une allocation par frame. + /// + /// Pré-requis : `target_w`/`target_h` ≥ 1. Aucun effet sur le pipeline d'export + /// (les sites d'appel de `rgb_to_nv12_scaled` et `blit_resized` ne sont pas touchés + /// — ce helper réutilise `blit_resized` mais n'est pas sur le chemin d'export). + pub unsafe fn readback_resized( + &self, + target_w: u32, + target_h: u32, + ) -> Result> { + // `ensure_resize_target` (partagé avec l'export) crée INCONDITIONNELLEMENT une + // texture NV12 en plus de la RGBA, même si ce chemin RGBA-only ne s'en sert jamais — + // et NV12 (4:2:0, chroma sous-échantillonnée 2×2) exige des dimensions PAIRES. + // Le canvas Electron (taille device-pixel arbitraire, ex. 910×513) atterrit souvent + // sur une dimension impaire → `CreateTexture2D` de la texture NV12 échouait avec + // E_INVALIDARG (0x80070057), et donc TOUT le readback live (jamais une seule frame + // publiée). On arrondit au pair supérieur ici uniquement — l'export appelle + // `rgb_to_nv12_scaled`/`blit_resized` directement avec ses propres dimensions et + // n'est pas concerné par cet arrondi. + let w = (target_w.max(1) + 1) & !1; + let h = (target_h.max(1) + 1) & !1; + // Dims RÉELLEMENT demandées par l'appelant — le buffer retourné doit rester à cette + // taille exacte (le canvas JS attend `target_w*target_h*4` octets pile), même si le GPU + // travaille en interne à `w`×`h` (arrondi pair) pour satisfaire la contrainte NV12. + let out_w = target_w.max(1); + let out_h = target_h.max(1); + + // 1) Resize GPU exactement comme `rgb_to_nv12_scaled` : remplit le `resize_target` + // RGBA à `w`×`h`. On s'arrête avant la conversion NV12 — on copie le RGBA. + self.blit_resized(w, h)?; + // BUG corrigé : un SRV n'est PAS la ressource (`ID3D11ShaderResourceView` et + // `ID3D11Texture2D` sont des interfaces COM sans rapport de parenté) — un + // `.cast::()` direct sur le SRV échoue avec E_NOINTERFACE + // (0x80004002, confirmé à l'exécution). Il faut passer par `GetResource()` + // (méthode de `ID3D11View`, implémentée par tout SRV/RTV) pour récupérer la + // ressource sous-jacente, ici directement en `ID3D11Resource` — le type que + // `CopyResource` attend de toute façon, donc pas besoin d'aller jusqu'à + // `ID3D11Texture2D`. + let rgba_resource: ID3D11Resource = { + let cache = self.resize_target.borrow(); + let t = cache.as_ref().unwrap(); + t.rgba_srv.GetResource()? + }; + + // 2) Staging texture CPU-readable à la taille cible, recréée paresseusement + // quand la taille change (cache : `live_readback_staging`). + let staging = { + let mut slot = self.live_readback_staging.borrow_mut(); + match slot.as_ref() { + Some((sw, sh, t)) if *sw == w && *sh == h => t.clone(), + _ => { + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + // Même format que `resize_target.rgba` créé dans + // `ensure_resize_target` (R8G8B8A8_UNORM) — la `CopyResource` + // est valide sans conversion GPU. + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + 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 tex: Option = None; + self.dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + let tex = tex.unwrap(); + *slot = Some((w, h, tex.clone())); + tex + } + } + }; + + // 3) GPU → CPU : `CopyResource` resize_target → staging, puis `Map` + copie + // ligne par ligne qui respecte `RowPitch` (cf. `dump_nv12`/`dump_raw`). + // `ID3D11Texture2D` hérite réellement de `ID3D11Resource` (contrairement au + // SRV plus haut) donc ce `.cast()` est valide. + let dst: ID3D11Resource = staging.cast()?; + self.ctx.CopyResource(&dst, &rgba_resource); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + // Crop implicite : on ne lit que les `out_w`×`out_h` premiers pixels de la texture + // (arrondie pair) — le reliquat éventuel (au plus 1px en largeur/hauteur) est ignoré. + let mut out: Vec = vec![0u8; (out_w * out_h * 4) as usize]; + let row_bytes = (out_w * 4) as usize; + for y in 0..out_h as usize { + let src_row = (m.pData as *const u8).add(y * m.RowPitch as usize); + let dst_row = out.as_mut_ptr().add(y * row_bytes); + std::ptr::copy_nonoverlapping(src_row, dst_row, row_bytes); + } + self.ctx.Unmap(&staging, 0); + Ok(out) + } + + /// Readback du RT composité vers CPU **à sa résolution de rendu**, sans aucun resize. + /// + /// Contrairement à `readback_resized` (qui passe par `blit_resized` → un `resize_target` + /// incluant une texture NV12 jamais lue par ce chemin RGBA-only, puis une staging séparée), + /// on copie directement `rt → staging` : la `staging` du compositeur est DÉJÀ dimensionnée + /// à la résolution de rendu (`new_inner`), exactement le patron de `dump_raw`. Depuis la + /// refonte ratio, le RT est rastérisé à la géométrie de sortie ramenée au panneau — soit + /// précisément la taille que la preview veut afficher —, donc le resize de `readback_resized` + /// était devenu une copie identité doublée d'une alloc NV12 inutile, du coût pur à chaque + /// frame. `readback_resized` reste pour le golden test (qui readback à une taille arbitraire). + /// + /// Retourne `(render_w, render_h, pixels)` avec `pixels.len() == render_w * render_h * 4` + /// octets RGBA8 tightly-packed. L'appelant (`live.rs`) publie ces dims dans le packet ; le + /// canvas côté JS se dimensionne dessus (frame auto-descriptive), donc aucun couplage de + /// taille à maintenir des deux côtés. + pub unsafe fn readback_direct(&self) -> Result<(u32, u32, Vec)> { + let (rw, rh) = self.render_dims(); + self.ctx.CopyResource(&self.staging, &self.rt); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&self.staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + // Copie ligne par ligne qui respecte `RowPitch` (la staging peut être paddée par le + // driver) — même idiome que `dump_raw`/`readback_resized`. + let row = (rw * 4) as usize; + let mut out = vec![0u8; row * rh as usize]; + for y in 0..rh as usize { + let src = (m.pData as *const u8).add(y * m.RowPitch as usize); + let dst = out.as_mut_ptr().add(y * row); + std::ptr::copy_nonoverlapping(src, dst, row); + } + self.ctx.Unmap(&self.staging, 0); + Ok((rw, rh, out)) + } + + /// Comme `rgb_to_nv12`, mais redimensionne d'abord (bilinéaire, `ps_tex`/`sampler` déjà + /// utilisés partout ailleurs dans le fichier) le RT composé — toujours rendu en interne à + /// OUT_W×OUT_H, quelle que soit la taille de sortie demandée — vers `target_w`×`target_h` + /// avant la conversion NV12. Identique à `rgb_to_nv12` (donc coût inchangé) quand la cible + /// égale la résolution interne : le live et les exports "Source"/1080p ne paient rien pour + /// cette fonctionnalité. + pub unsafe fn rgb_to_nv12_scaled( + &self, + target_w: u32, + target_h: u32, + out_tex: *mut c_void, + slice: u32, + ) -> Result<()> { + // Raccourci : la cible est déjà la taille à laquelle on vient de rastériser + // → 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 { + 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(); + 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 { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: target_w as f32, Height: target_h as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_y])); + self.ctx.PSSetShader(&self.ps_y, None); + self.ctx.Draw(3, 0); + + self.ctx.OMSetRenderTargets(Some(&[Some(t.nv12_rtv_uv.clone())]), None); + let vp_uv = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: (target_w / 2) as f32, Height: (target_h / 2) as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_uv])); + self.ctx.PSSetShader(&self.ps_uv, None); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + Ok(t.nv12.clone()) + } + + /// 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(()) + } + + /// Convertit le RT RGBA vers notre texture NV12 (§5) : Y pleine réso, UV demi-réso. + pub unsafe fn render_nv12(&self) { + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + + // passe Y : basculer le RT AVANT de binder le SRV (le RGBA RT était encore RTV via + // begin() ; D3D11 rejetterait le SRV d'une ressource encore liée en RTV). + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv_y.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + let vp_y = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_y])); + self.ctx.PSSetShader(&self.ps_y, None); + self.ctx.Draw(3, 0); + + // passe UV (demi-résolution) + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv_uv.clone())]), None); + let vp_uv = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw() / 2.0, Height: self.rh() / 2.0, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_uv])); + self.ctx.PSSetShader(&self.ps_uv, None); + self.ctx.Draw(3, 0); + + // libère le SRV du RT (il redevient RTV au prochain begin()) + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + + /// Debug : dump notre NV12 (Y puis UV entrelacé) en RAW, pour inspecter la conversion. + pub unsafe fn dump_nv12(&self, path: &str) -> Result<()> { + let sd = D3D11_TEXTURE2D_DESC { + Width: OUT_W, + Height: OUT_H, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + }; + let mut stg: Option = None; + self.dev.CreateTexture2D(&sd, None, Some(&mut stg))?; + let stg = stg.unwrap(); + let src: ID3D11Resource = self.nv12.cast()?; + let dstr: ID3D11Resource = stg.cast()?; + self.ctx.CopyResource(&dstr, &src); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&stg, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + let (rw_i, rh_i) = self.render_dims(); + let mut out = Vec::with_capacity((rw_i * rh_i * 3 / 2) as usize); + // plan Y + for y in 0..rh_i as usize { + let row = (m.pData as *const u8).add(y * m.RowPitch as usize); + out.extend_from_slice(std::slice::from_raw_parts(row, rw_i as usize)); + } + // plan UV : commence à RowPitch*Height (offset donné par le pitch), demi-hauteur + let uv_off = m.RowPitch as usize * rh_i as usize; + for y in 0..(rh_i / 2) as usize { + let row = (m.pData as *const u8).add(uv_off + y * m.RowPitch as usize); + out.extend_from_slice(std::slice::from_raw_parts(row, rw_i as usize)); + } + self.ctx.Unmap(&stg, 0); + std::fs::write(path, &out)?; + Ok(()) + } + + /// Recopie le RT en RAM (RGBA tightly-packed) — vérification uniquement. + pub unsafe fn dump_raw(&self, path: &str) -> Result<()> { + self.ctx.CopyResource(&self.staging, &self.rt); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&self.staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + let (rw_i, rh_i) = self.render_dims(); + let mut out = vec![0u8; (rw_i * rh_i * 4) as usize]; + for y in 0..rh_i as usize { + let src = (m.pData as *const u8).add(y * m.RowPitch as usize); + let dst = out.as_mut_ptr().add(y * rw_i as usize * 4); + std::ptr::copy_nonoverlapping(src, dst, rw_i as usize * 4); + } + self.ctx.Unmap(&self.staging, 0); + std::fs::write(path, &out)?; + Ok(()) + } + + /// Blit du RT composité (RGBA) vers un render target externe (backbuffer swapchain), + /// mis à l'échelle dans le viewport `(x,y,w,h)` en pixels — sert la preview (§preview). + /// Passe de copie `ps_tex` : même échantillonnage que `render_nv12`, sans conversion. + /// Le caller a déjà clear le RTV (barres letterbox) avant l'appel. + pub unsafe fn blit_to(&self, rtv: &ID3D11RenderTargetView, x: f32, y: f32, w: f32, h: f32) { + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + self.ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); + let vp = D3D11_VIEWPORT { + TopLeftX: x, TopLeftY: y, Width: w, Height: h, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.upload_cb(&LayerCB::default()); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + + /// Vide le cache de SRV décodeur. À appeler après la fermeture d'un jeu de décodeurs + /// (p.ex. après un export) pour ne pas retenir indéfiniment des textures de pool. + pub fn clear_srv_cache(&self) { + self.srv_cache.borrow_mut().clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + + /// 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` + /// ne demande aucun device — le compilateur seul suffit, et ça tient en quelques + /// millisecondes. + #[test] + fn every_shader_entry_point_compiles() { + let hlsl = include_bytes!("shaders.hlsl"); + for (entry, target) in [ + (&b"vs_main\0"[..], &b"vs_5_0\0"[..]), + (&b"ps_main\0"[..], &b"ps_5_0\0"[..]), + (&b"vs_fs\0"[..], &b"vs_5_0\0"[..]), + (&b"ps_y\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_uv\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_blur\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_tex\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_kawase_down\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_kawase_up\0"[..], &b"ps_5_0\0"[..]), + ] { + let name = String::from_utf8_lossy(&entry[..entry.len() - 1]).to_string(); + unsafe { compile(hlsl, entry, target) } + .unwrap_or_else(|e| panic!("{name} ne compile pas : {e}")); + } + } + +} diff --git a/crates/compositor/src/config.rs b/crates/compositor/src/config.rs new file mode 100644 index 0000000000..9e74a914e6 --- /dev/null +++ b/crates/compositor/src/config.rs @@ -0,0 +1,59 @@ +//! Configurations cumulatives (§9) : chaque cfg ajoute une couche. Le delta de fps +//! entre deux lignes = le coût de la couche ajoutée. Même fixture, mêmes réglages sortie. + +#[derive(Clone)] +pub struct Cfg { + pub name: &'static str, + pub composite: bool, // C1+ : composite 2 sources (sinon décode+encode seul = C0) + pub rounded: bool, // C2+ : coins arrondis (SDF) + pub shadow: bool, // C3+ : ombres portées + pub bg_blur: bool, // C4+ : fond flouté (gaussien séparable) + pub zoom: bool, // C5+ : zoom animé + pub layout_anim: bool, // C6+ : animation de layout A<->B + pub cursor: bool, // C7+ : curseur custom + click bounce + pub mblur_n: u32, // C8 : flou de mouvement — nb de taps du flou par vélocité (1 = off) + pub desc: &'static str, +} + +impl Cfg { + pub fn by_name(name: &str) -> Option { + all().into_iter().find(|c| c.name == name) + } + + /// Le cfg cumulatif complet (C8 : tout composite activé, y compris le + /// flou de mouvement). Sert aux exports qui veulent reproduire la + /// preview à l'identique — l'export MP4 natif (`run_composited` / + /// `run_composited_multi`) le prend aussi et désactive explicitement + /// ce qu'il sait sans effet en mode statique (zoom / layout_anim / + /// mblur). Pour le GIF natif (slice 1), on garde le même point de + /// départ par parité avec l'export MP4 — le bench mesure la + /// différence. + pub fn c8() -> Cfg { + Self::by_name("C8").expect("C8 existe dans `all()`") + } +} + +/// C0..C8, cumulatives. +pub fn all() -> Vec { + let base = Cfg { + name: "C0", + composite: false, + rounded: false, + shadow: false, + bg_blur: false, + zoom: false, + layout_anim: false, + cursor: false, + mblur_n: 1, + desc: "décode + encode, aucun composite", + }; + let c1 = Cfg { name: "C1", composite: true, desc: "+ fond, layout, 2 sources (E1)", ..base.clone() }; + let c2 = Cfg { name: "C2", rounded: true, desc: "+ coins arrondis (E2)", ..c1.clone() }; + let c3 = Cfg { name: "C3", shadow: true, desc: "+ ombres portées (E4)", ..c2.clone() }; + let c4 = Cfg { name: "C4", bg_blur: true, desc: "+ fond flouté (E3)", ..c3.clone() }; + let c5 = Cfg { name: "C5", zoom: true, desc: "+ zoom animé", ..c4.clone() }; + let c6 = Cfg { name: "C6", layout_anim: true, desc: "+ animation de layout", ..c5.clone() }; + let c7 = Cfg { name: "C7", cursor: true, desc: "+ curseur custom (bounce)", ..c6.clone() }; + let c8 = Cfg { name: "C8", mblur_n: 8, desc: "+ flou de mouvement (vélocité, 8 taps)", ..c7.clone() }; + vec![base, c1, c2, c3, c4, c5, c6, c7, c8] +} 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/cursor.rs b/crates/compositor/src/cursor.rs new file mode 100644 index 0000000000..621309e672 --- /dev/null +++ b/crates/compositor/src/cursor.rs @@ -0,0 +1,294 @@ +//! Piste curseur depuis un `.cursor.json` openscreen. Fournit position interpolée +//! et facteur de « click bounce » — consommés par frame (temps fractionnaire), donc +//! le motion blur du curseur vient gratuitement du supersampling temporel. + +use anyhow::{Context, Result}; + +// Paramètres de suivi auto — parité stricte avec +// `src/lib/zoomMath/constants.ts` (AUTO_FOLLOW_PARAMS), partagés +// là-bas entre preview et export pour que la caméra suive le curseur à l'identique. +const AUTO_FOLLOW_MIN_FACTOR: f32 = 0.1; +const AUTO_FOLLOW_MAX_FACTOR: f32 = 0.25; +const AUTO_FOLLOW_RAMP_DISTANCE: f32 = 0.15; +const AUTO_FOLLOW_REFERENCE_MS: f32 = 1000.0 / 40.0; + +#[derive(Clone)] +pub struct CursorTrack { + /// (t_secondes, cx, cy) normalisés dans le cadre screen, triés. + samples: Vec<(f32, f32, f32)>, + /// Même piste après lissage exponentiel adaptatif — ce que le suivi auto du zoom doit + /// consommer. `samples` reste la piste BRUTE : le rendu du curseur lui-même et le click + /// bounce doivent coller à la position réelle, seule la caméra est amortie. + follow_samples: Vec<(f32, f32, f32)>, + /// instants de clic (secondes) dans la fenêtre. + clicks: Vec, + /// CHANGEMENTS d'état du curseur : (instant, `"arrow"` / `"text"` / `"pointer"` / …), triés. + /// Une fonction en escalier, pas une valeur par échantillon : l'état tient sur des secondes + /// entières alors que la position est échantillonnée à ~120 Hz, donc n'enregistrer que les + /// transitions garde cette liste minuscule et rend `type_at` trivial. + types: Vec<(f32, String)>, +} + +/// Interpolation linéaire dans une liste `(t, x, y)` triée ; saturation aux bornes. +fn sample_at(samples: &[(f32, f32, f32)], t: f32) -> Option<(f32, f32)> { + if samples.is_empty() { + return None; + } + if t <= samples[0].0 { + let s = samples[0]; + return Some((s.1, s.2)); + } + if t >= samples[samples.len() - 1].0 { + let s = *samples.last().unwrap(); + return Some((s.1, s.2)); + } + // recherche du segment encadrant + let i = samples.partition_point(|s| s.0 <= t); + let a = samples[i - 1]; + let b = samples[i]; + let f = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + Some((a.1 + (b.1 - a.1) * f, a.2 + (b.2 - a.2) * f)) +} + +/// Port de `advanceFollowFocus` (`cursorFollowUtils.ts`) : lissage exponentiel dont le facteur +/// croît avec la distance à la cible (loin = rattrape vite, près = décélère), corrigé en temps +/// pour être indépendant de la cadence. +/// +/// La version TS est **séquentielle** : elle avance un `prev` d'une frame à l'autre. Rejouer ça +/// par frame ici ferait dépendre l'image du chemin parcouru pour l'atteindre — deux rendus du +/// même instant divergeraient selon qu'on y arrive en lecture ou par un seek, et la preview ne +/// correspondrait plus à l'export. On applique donc le même filtre UNE fois, sur les +/// échantillons de télémétrie eux-mêmes : le résultat est identique en lecture linéaire et reste +/// une pure fonction de `t`. +fn smooth_follow_samples(samples: &[(f32, f32, f32)]) -> Vec<(f32, f32, f32)> { + let mut smoothed: Vec<(f32, f32, f32)> = Vec::with_capacity(samples.len()); + let mut prev: Option<(f32, f32, f32)> = None; + for &(t, x, y) in samples { + let Some((prev_t, px, py)) = prev else { + smoothed.push((t, x, y)); + prev = Some((t, x, y)); + continue; + }; + let dt_ms = (t - prev_t) * 1000.0; + if !(dt_ms > 0.0) { + // Horodatages dupliqués : on garde la valeur déjà lissée plutôt que de diviser par 0. + smoothed.push((t, px, py)); + prev = Some((t, px, py)); + continue; + } + let (dx, dy) = (x - px, y - py); + let distance = (dx * dx + dy * dy).sqrt(); + let ramp = (distance / AUTO_FOLLOW_RAMP_DISTANCE).min(1.0); + let base = AUTO_FOLLOW_MIN_FACTOR + (AUTO_FOLLOW_MAX_FACTOR - AUTO_FOLLOW_MIN_FACTOR) * ramp; + let factor = 1.0 - (1.0 - base).powf(dt_ms / AUTO_FOLLOW_REFERENCE_MS); + let (nx, ny) = (px + dx * factor, py + dy * factor); + smoothed.push((t, nx, ny)); + prev = Some((t, nx, ny)); + } + smoothed +} + +impl CursorTrack { + /// Seul point de construction : garantit que `follow_samples` est toujours dérivé des + /// échantillons courants. Une piste re-lissée (`smoothed`) recalcule donc aussi son suivi, + /// pour que la caméra suive la trajectoire que l'utilisateur voit réellement. + fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec, types: Vec<(f32, String)>) -> CursorTrack { + let follow_samples = smooth_follow_samples(&samples); + CursorTrack { samples, follow_samples, clicks, types } + } + + /// État du curseur au temps `t` : la dernière transition à `t` ou avant. `None` avant la + /// première (enregistrement sans état tagué → l'appelant retombe sur la flèche). + pub fn type_at(&self, t: f32) -> Option<&str> { + let i = self.types.partition_point(|(tc, _)| *tc <= t); + (i > 0).then(|| self.types[i - 1].1.as_str()) + } + + /// Nombre d'échantillons de la piste (utile au diag de chargement). + pub fn sample_count(&self) -> usize { + self.samples.len() + } + + /// Charge la fenêtre [offset_ms, offset_ms + dur_s*1000] et la ramène à t=0. + pub fn load(path: &str, offset_ms: f64, dur_s: f64) -> Result { + let txt = std::fs::read_to_string(path).with_context(|| format!("lecture {path}"))?; + let v: serde_json::Value = serde_json::from_str(&txt)?; + let arr = v["samples"].as_array().context("samples[]")?; + let mut samples = Vec::new(); + let mut clicks = Vec::new(); + let mut types: Vec<(f32, String)> = Vec::new(); + let end = offset_ms + dur_s * 1000.0; + for s in arr { + let tm = s["timeMs"].as_f64().unwrap_or(-1.0); + if tm < offset_ms || tm > end { + continue; + } + let t = ((tm - offset_ms) / 1000.0) as f32; + let cx = s["cx"].as_f64().unwrap_or(0.0) as f32; + let cy = s["cy"].as_f64().unwrap_or(0.0) as f32; + samples.push((t, cx, cy)); + if s["interactionType"].as_str() == Some("click") { + clicks.push(t); + } + // Seules les TRANSITIONS sont retenues — voir `types`. Les échantillons sans + // `cursorType` (macOS ne le tague pas toujours) n'interrompent pas l'état courant : + // c'est une absence d'information, pas un retour à la flèche. + if let Some(ct) = s["cursorType"].as_str() { + if types.last().map(|(_, prev)| prev.as_str()) != Some(ct) { + types.push((t, ct.to_string())); + } + } + } + samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + clicks.sort_by(|a, b| a.partial_cmp(b).unwrap()); + types.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + Ok(CursorTrack::new(samples, clicks, types)) + } + + /// Position lissée au temps `t`, pour le suivi auto du zoom. La télémétrie brute est + /// échantillonnée trop finement pour piloter une caméra directement : la suivre au sample + /// près donne un pan nerveux. Voir `smooth_follow_samples`. + pub fn follow_at(&self, t: f32) -> Option<(f32, f32)> { + sample_at(&self.follow_samples, t) + } + + /// Position (cx, cy) BRUTE au temps `t` (interpolation linéaire), ou None si hors piste. + pub fn at(&self, t: f32) -> Option<(f32, f32)> { + sample_at(&self.samples, t) + } + + /// Facteur d'échelle « click bounce » — parité `getNativeCursorClickBounceScale` (TS, + /// `nativeCursor.ts`) : le curseur PRESSE (rétrécit, 0..38% de la fenêtre d'animation) + /// PUIS REBONDIT (grossit, 38..100%), pas un simple pop qui ne fait que grossir puis + /// redécroître. Seul le clic le plus récent précédant `t` compte (au-delà de la fenêtre, + /// un clic antérieur n'a plus aucun effet — contrairement à l'ancienne décroissance + /// exponentielle à queue infinie qui masquait ce bug). + pub fn bounce(&self, t: f32) -> f32 { + const ANIM_S: f32 = 0.26; // NATIVE_CURSOR_CLICK_ANIMATION_MS (TS) = 260ms + const PRESS_FRAC: f32 = 0.38; + let mut last_tc: Option = None; + for &tc in &self.clicks { + if tc <= t { + last_tc = Some(tc); // clics triés croissant -> garde le plus récent <= t + } else { + break; + } + } + let Some(tc) = last_tc else { return 1.0 }; + let elapsed = (t - tc) / ANIM_S; + if elapsed >= 1.0 { + return 1.0; + } + if elapsed < PRESS_FRAC { + let press = (elapsed / PRESS_FRAC * std::f32::consts::PI).sin(); + 1.0 - press * 0.24 + } else { + let rebound = ((elapsed - PRESS_FRAC) / (1.0 - PRESS_FRAC) * std::f32::consts::PI).sin(); + 1.0 + rebound * 0.16 + } + } + + /// Piste repositionnée par un ressort-amortisseur (parité `cursorPathSmoothing.ts` : + /// resample à 240 Hz + intégration semi-implicite d'Euler). `factor` 0..1 = valeur brute + /// du slider (0 = passthrough, retourne un clone). Les clics restent sur leurs instants + /// bruts (le bounce est temporel, pas positionnel — ne doit pas suivre le lissage). + pub fn smoothed(&self, factor: f32) -> CursorTrack { + if self.samples.len() < 2 || factor <= 0.0 { + return CursorTrack::new(self.samples.clone(), self.clicks.clone(), self.types.clone()); + } + const STEP_S: f32 = 1.0 / 240.0; + let start = self.samples[0].0; + let end = self.samples[self.samples.len() - 1].0; + let step_count = (((end - start) / STEP_S).round() as usize).max(1); + let n = step_count + 1; + let mut times = Vec::with_capacity(n); + let mut raw_x = Vec::with_capacity(n); + let mut raw_y = Vec::with_capacity(n); + for i in 0..n { + let t = if i == n - 1 { end } else { start + i as f32 * STEP_S }; + let (cx, cy) = self.at(t).unwrap_or((0.0, 0.0)); + times.push(t); + raw_x.push(cx); + raw_y.push(cy); + } + let (stiffness, damping, mass) = cursor_spring_config(factor); + let xs = spring_smooth(&raw_x, stiffness, damping, mass, STEP_S); + let ys = spring_smooth(&raw_y, stiffness, damping, mass, STEP_S); + let samples = times.into_iter().zip(xs).zip(ys).map(|((t, x), y)| (t, x, y)).collect(); + // Comme les clics, les changements d'état gardent leurs instants bruts : le lissage + // déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur. + CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) + } +} + +/// Ressort-amortisseur, intégration semi-implicite (symplectique) d'Euler — stable pour ces +/// raideurs à la grille 240 Hz (port direct de `springSmooth` en TS). +fn spring_smooth(targets: &[f32], stiffness: f32, damping: f32, mass: f32, step_s: f32) -> Vec { + let mut out = vec![0.0f32; targets.len()]; + if targets.is_empty() { + return out; + } + let mut x = targets[0]; + let mut v = 0.0f32; + out[0] = x; + for i in 1..targets.len() { + let accel = (-stiffness * (x - targets[i]) - damping * v) / mass; + v += accel * step_s; + x += v * step_s; + out[i] = x; + } + out +} + +/// Port direct de `getCursorSpringConfig` (TS) → (stiffness, damping, mass). N'accepte que +/// 0..1 (plage réelle du slider, cf. `RightPanes.tsx` : `smoothing * 100` sur un slider 0..100). +fn cursor_spring_config(smoothing_factor: f32) -> (f32, f32, f32) { + let clamped = smoothing_factor.clamp(0.0, 2.0); + if clamped <= 0.0 { + return (1000.0, 100.0, 1.0); + } + const LEGACY_MAX: f32 = 0.5; + if clamped <= LEGACY_MAX { + let n = (clamped / LEGACY_MAX).clamp(0.0, 1.0); + return (760.0 - n * 420.0, 34.0 + n * 24.0, 0.55 + n * 0.45); + } + let n = ((clamped - LEGACY_MAX) / (2.0 - LEGACY_MAX)).clamp(0.0, 1.0); + (340.0 - n * 180.0, 58.0 + n * 22.0, 1.0 + n * 0.35) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// L'état du curseur est une fonction en escalier : il tient jusqu'à la transition + /// suivante, il n'est pas interpolé, et avant la première il n'y en a pas. + #[test] + fn cursor_type_holds_until_the_next_transition() { + let track = CursorTrack::new( + vec![(0.0, 0.0, 0.0), (2.0, 1.0, 1.0)], + vec![], + vec![(0.5, "arrow".into()), (1.0, "text".into()), (1.5, "pointer".into())], + ); + + assert_eq!(track.type_at(0.0), None, "avant la première transition"); + assert_eq!(track.type_at(0.5), Some("arrow"), "à l'instant même de la transition"); + assert_eq!(track.type_at(0.9), Some("arrow"), "tient jusqu'à la suivante"); + assert_eq!(track.type_at(1.2), Some("text")); + assert_eq!(track.type_at(99.0), Some("pointer"), "la dernière tient jusqu'à la fin"); + } + + /// Le lissage déplace la trajectoire, pas la chronologie : les états doivent survivre + /// intacts à `smoothed()`, comme les clics. + #[test] + fn smoothing_preserves_cursor_types() { + let track = CursorTrack::new( + vec![(0.0, 0.0, 0.0), (0.5, 0.4, 0.4), (1.0, 1.0, 1.0)], + vec![0.25], + vec![(0.0, "arrow".into()), (0.6, "text".into())], + ); + + let smoothed = track.smoothed(0.4); + assert_eq!(smoothed.type_at(0.1), Some("arrow")); + assert_eq!(smoothed.type_at(0.7), Some("text")); + } +} diff --git a/crates/compositor/src/d3d_linux.rs b/crates/compositor/src/d3d_linux.rs new file mode 100644 index 0000000000..67a701c18e --- /dev/null +++ b/crates/compositor/src/d3d_linux.rs @@ -0,0 +1,163 @@ +//! Backend GPU Linux -- wgpu (Vulkan). +//! +//! Equivalent Linux de `d3d_windows.rs` / `d3d_macos.rs` : meme surface publique +//! (`Backend`, `Gpu`, `create`, `create_backend`, `create_auto`, `probe`, +//! `diagnose`) pour que `pipeline`, `live.rs` et `compositor-view-napi` +//! l'utilisent sans connaitre la plateforme (cf. `lib.rs`, qui re-exporte +//! `crate::d3d` vers `d3d_linux` sous `cfg(target_os = "linux")`). +//! +//! # `Backend::Cpu` sur Linux +//! +//! Contrairement a macOS (ou Metal n'a pas de rasteriseur logiciel), Linux EN A +//! un : Mesa **lavapipe** (`llvmpipe`), le pendant Vulkan de WARP. `probe()` le +//! classe donc en `Backend::Cpu` (le meme repli que WARP cote Windows : notice +//! dans la preview, warning a l'export), et un vrai GPU (RADV, dzn, NVK...) en +//! `Backend::Hardware`. + +use anyhow::{Context, Result}; +use std::sync::OnceLock; + +/// Qui execute le pipeline (symetrie d'API avec `d3d_windows::Backend`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Backend { + /// Vrai GPU (RADV / dzn / NVK / ...) via Vulkan. + Hardware, + /// Mesa lavapipe (`llvmpipe`), rasteriseur logiciel Vulkan. + Cpu, +} + +/// Handle GPU Linux : `wgpu::Device` + `wgpu::Queue` (Arc internes cote wgpu, +/// `.clone()` bon marche). Les champs `device`/`context`/`backend`/ +/// `feature_level` sont alignes sur `d3d_windows::Gpu` / `d3d_macos::Gpu` pour +/// que `live.rs::Player` copie la struct champ par champ sans cfg-fendre le +/// constructeur. +pub struct Gpu { + pub device: wgpu::Device, + /// Pendant de `ID3D11DeviceContext` (D3D11) / `MTLCommandQueue` (Metal) : + /// la file de soumission wgpu. + pub context: wgpu::Queue, + pub backend: Backend, + /// Pas d'equivalent `D3D_FEATURE_LEVEL` en wgpu ; conserve a 0 pour la + /// symetrie d'API (les diagnostics futurs pourront le renseigner). + pub feature_level: u64, +} + +/// `probe()` -- propriete de la machine, mise en cache (la preview et la modale +/// d'export en ont besoin toutes les deux). `None` si aucun adaptateur wgpu +/// (headless sans lavapipe, kernel sans DRM ni ICD logiciel). +static PROBE: OnceLock> = OnceLock::new(); + +pub fn probe() -> Option { + *PROBE.get_or_init(|| create_backend(Backend::Hardware).ok().map(|g| g.backend)) +} + +/// Cree un device wgpu (Vulkan). `_backend` est indicatif : on prend le meilleur +/// adaptateur disponible (HighPerformance) et on reporte son type REEL via +/// `classify` (lavapipe -> `Cpu`, sinon `Hardware`) -- pas de chemin de rendu +/// distinct entre les deux cote Linux, seul le libelle change. +pub fn create_backend(_backend: Backend) -> Result { + pollster::block_on(create_async()) +} + +async fn create_async() -> Result { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + ..Default::default() + }) + .await + .context("aucun adaptateur graphique compatible")?; + let info = adapter.get_info(); + let backend = classify(&info); + let (device, queue) = adapter + .request_device( + &wgpu::DeviceDescriptor { + label: Some("openscreen-linux"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + }, + None, + ) + .await + .context("request_device a echoue")?; + Ok(Gpu { + device, + context: queue, + backend, + feature_level: 0, + }) +} + +/// lavapipe expose "llvmpipe" dans le nom d'adaptateur -- c'est l'equivalent +/// Vulkan de WARP, a ranger sous `Cpu`. +fn classify(info: &wgpu::AdapterInfo) -> Backend { + let n = info.name.to_ascii_lowercase(); + if n.contains("llvmpipe") || n.contains("lavapipe") { + Backend::Cpu + } else { + Backend::Hardware + } +} + +impl Gpu { + /// Chemin de production. Symetrie d'API avec `d3d_windows::Gpu::create_auto` ; + /// `_debug` est le pendant de la couche de debug D3D11 (rien a faire ici, + /// wgpu a `WGPU_VALIDATION` en variable d'env). + pub fn create_auto(_debug: bool) -> Result { + create_backend(Backend::Hardware) + } + + /// Creation hardware-strict (tests et goldens). + pub fn create(_debug: bool) -> Result { + create_backend(Backend::Hardware) + } + + /// Le backend de cette machine, mis en cache. Expose comme fonction ASSOCIEE + /// (`Gpu::probe()`) parce que `compositor-view-napi` l'appelle ainsi. + pub fn probe() -> Option { + probe() + } +} + +/// Message d'echec actionnable (symetrie d'API avec `d3d_windows::diagnose`). +pub fn diagnose(err: &anyhow::Error) -> String { + format!("{err:#}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_cpu_pour_lavapipe() { + let info = wgpu::AdapterInfo { + name: "llvmpipe (LLVM 21.1.8, 256 bits)".into(), + vendor: 0x10005, + device: 0, + device_type: wgpu::DeviceType::Cpu, + driver: "llvmpipe".into(), + driver_info: String::new(), + backend: wgpu::Backend::Vulkan, + }; + assert_eq!(classify(&info), Backend::Cpu); + } + + #[test] + fn classify_hardware_pour_gpu_reel() { + let info = wgpu::AdapterInfo { + name: "Microsoft Direct3D12 (AMD Radeon(TM) Graphics)".into(), + vendor: 0x1002, + device: 0, + device_type: wgpu::DeviceType::IntegratedGpu, + driver: "Dozen".into(), + driver_info: String::new(), + backend: wgpu::Backend::Vulkan, + }; + assert_eq!(classify(&info), Backend::Hardware); + } +} 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 new file mode 100644 index 0000000000..0df8963a4a --- /dev/null +++ b/crates/compositor/src/ffi.rs @@ -0,0 +1,78 @@ +//! 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; +/// `AVERROR_INVALIDDATA` = `-MKTAG('I','N','D','A')`. Contrairement à `AVERROR_EAGAIN`, +/// c'est un FFERRTAG et pas un errno, donc la valeur est la même sur toutes les cibles. +pub const AVERROR_INVALIDDATA: i32 = -1094995529; +/// `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..0addb485dc --- /dev/null +++ b/crates/compositor/src/frame_geometry.rs @@ -0,0 +1,1728 @@ +//! 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], + /// Boîte écran **sans le zoom** : le conteneur auquel les annotations et les + /// sous-titres sont ancrés. + /// + /// C'est `s_dst` avant le `remap_box` du zoom, donc le rect que l'app a résolu + /// (`layout.screenRect`) et que l'overlay web reçoit comme conteneur. Le contrat de + /// `SceneAnnotation` est explicite : « deliberately NOT affected by the zoom crop — the + /// overlay is a sibling of the element carrying the zoom transform, so annotations hold + /// still while the content zooms underneath them ». Tant que le zoom vivait dans la + /// coupe source, `s_dst` tenait ce rôle ; depuis l'issue #179 il vit dans la BOÎTE, donc + /// `s_dst` grandit et se déplace avec lui — et les annotations le suivaient, sous-titres + /// compris, qui se mettaient à zoomer avec l'écran. + pub s_ann: [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, + // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. + s_ann: s_base, + 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), + } + } + + /// La même scène, avec une région de zoom active à `t = 1.5 s`. + fn zoomed_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":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}], + "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("zoomed golden scene") + } + + /// L'ancre des annotations ne bouge PAS avec le zoom, alors que la boîte écran, si. + /// + /// C'est tout le contrat de `SceneAnnotation` : l'overlay web est frère de l'élément qui + /// porte la transform de zoom, donc annotations et sous-titres tiennent en place pendant + /// que le contenu grossit dessous. Tant que le zoom vivait dans la coupe source, `s_dst` + /// jouait ce rôle sans effort ; depuis l'issue #179 il vit dans la BOÎTE, et le natif + /// zoomait les sous-titres avec l'écran. Ce test échoue si `s_ann` se remet à suivre. + #[test] + fn the_annotation_anchor_ignores_the_zoom() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let plain = golden_scene(); + let zoomed = zoomed_golden_scene(); + let a = plan_frame(&golden_input(&plain, &cfg)); + let b = plan_frame(&golden_input(&zoomed, &cfg)); + + assert_ne!( + a.s_dst, b.s_dst, + "le zoom doit bel et bien agir sur la boîte écran (issue #179) — \ + sinon ce test ne prouve rien" + ); + assert_eq!( + a.s_ann, b.s_ann, + "l'ancre des annotations a suivi le zoom : sans zoom {:?}, avec zoom {:?}", + a.s_ann, b.s_ann + ); + // Et sans zoom, l'ancre EST la boîte écran : `s_ann` ne doit pas devenir un rect + // parallèle qui dériverait de `s_dst` pour d'autres raisons (padding, cover, crop). + assert_eq!(a.s_ann, a.s_dst, "sans zoom, ancre et boîte écran coïncident"); + } + + /// **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 new file mode 100644 index 0000000000..a30a430076 --- /dev/null +++ b/crates/compositor/src/gif_export.rs @@ -0,0 +1,1368 @@ +//! Native GIF export pipeline. +//! +//! This is now the ONLY GIF path: the renderer-side `gif.js` exporter it +//! originally sat beside — and the `NATIVE_GIF_EXPORT_ENABLED` flag that +//! chose between them — were deleted when GIF moved over for good. Same +//! compositor as the MP4 path (D3D11 on Windows, Metal on macOS), but the +//! per-frame output is a 256-color GIF89a file written from scratch in +//! pure Rust. +//! +//! ## Why pure Rust, not ffmpeg +//! +//! The compositor's ffmpeg bindings are `avformat` / `avcodec` / `avutil` / +//! `swscale` / `swresample` only — **no `libavfilter`** +//! (see `crates/compositor/Cargo.toml` and `crates/compositor/build.rs`). +//! ffmpeg's `palettegen` + `paletteuse` live in `libavfilter` and aren't +//! buildable here, and ffmpeg's `gif` codec in libavcodec still expects +//! pre-quantized `PAL8` frames — it refuses to do the quantize step +//! itself. So the "ffmpeg GIF muxer" route would have been a +//! write-our-own-palette-and-LZW path either way. The CPU readback +//! (the dominant per-frame cost — see the bench in +//! `crates/poc-d3d/src/bench.rs`) already lands us on CPU regardless, so +//! skipping a swscale round-trip and writing the format directly in this +//! crate costs no extra dependency, stays inside the LGPL-only ffmpeg +//! pin we already have, and keeps the readback/quantize/LZW layers +//! auditable in one file. +//! +//! ## What's in this file +//! +//! - `export_gif` — the orchestrator. Drives `pipeline::walk_composited_timeline`, +//! the SAME clip walk the MP4 exporter uses, so clip iteration, speed +//! segments and output-time decoder advancement have exactly one +//! definition. Per output frame the walk composes, then this module does +//! `Compositor::readback_direct` → palette → optional fused +//! Floyd-Steinberg → `GifWriter::write_frame`. Reports the same `GifStats` +//! shape the MP4 `pipeline::Stats` returns. +//! +//! It previously ran its own loop over the live-preview `Player`, stepping +//! one SOURCE frame per OUTPUT frame — which made a 30 s/60 fps recording +//! export as 6 s of content stretched over 30 s, and could not decode files +//! the MP4 path handled. `tests/export_timing.rs` pins the behaviour. +//! - `GifWriter` — GIF89a format writer: header, optional Netscape 2.0 +//! loop extension, per-frame Graphics Control Extension + Image +//! Descriptor + LZW image data, trailer. Pure std `Write`. +//! - `lzw_compress` — GIF's LZW variant. Codes 0..255 are the palette; +//! code 256 = clear, 257 = EOI; codes 258+ are the string table. +//! Packed LSB-first into the output byte stream. Code size starts at +//! `min_code_size + 1` (9 for 8-bit palette) and grows to 12 as the +//! table fills; when the table hits 4096, a clear code resets it. +//! - `build_palette_median_cut` — 256-color palette via median-cut on +//! the frame's color histogram. Cheap enough at the GIF frame sizes +//! we ship (≤ 480p) and good enough for screen content; the +//! requantize-every-30-frames cadence trades a small per-frame +//! color drift for keeping the palette adapted to the timeline. +//! - `map_to_indices` — brute-force nearest-color search. The hot loop +//! is 4 reads + 3 muls + 2 adds + 1 compare per pixel, small enough +//! for the compiler to autovectorize; a NeuQuant network lookup +//! would be slower at 256 colors and isn't worth its complexity. +//! - `map_to_indices_dithered` — the same search with Floyd-Steinberg +//! error diffusion fused into it (alpha left as the readback emitted +//! it), two row-buffers so the working set is O(width) per row. Fused +//! because the error worth diffusing is `pixel - palette[chosen]`, +//! which doesn't exist until the entry has been picked. +//! +//! ## Honest signal +//! +//! The wall-time is the only honest signal here: GIF's quality is a +//! user-visible trade-off the user already accepted when they picked +//! the format, and 256-color quantization dominates per-frame CPU. The +//! bench in `crates/poc-d3d/src/bench.rs` is the only check that +//! catches a "this is fine in micro-benchmarks but the readback kills +//! the loop" regression. See the `Native GIF export — initial bench` +//! section of `technical-documentation/engineering/rendering-performance.md`. + +use crate::compositor::Compositor; +use crate::config::Cfg; +use crate::d3d::Gpu; +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; +use std::io::{BufWriter, Write}; +use std::path::Path; +use std::time::Instant; + +/// Default output width/height. GIF is 8-bit indexed; smaller frames look +/// better than 1080p under the same palette budget. The user-facing +/// `ExportDialog` can request a different size via `GifExportParams`. +pub const DEFAULT_GIF_WIDTH: u32 = 854; +pub const DEFAULT_GIF_HEIGHT: u32 = 480; +/// Default output framerate. The compositor's decode is decoupled from +/// this; we just subsample frames to hit it. +pub const DEFAULT_GIF_FPS: u32 = 12; + +/// Re-quantize the palette every N frames. The user-visible drift on a +/// screen-recording timeline is small within a few seconds, and +/// rebuilding the histogram + median-cut is O(n) on a 410 k-pixel frame +/// — caching amortizes the cost. 30 frames at 12 fps is one re-quant +/// per 2.5 s, the rough interval at which a recording's colour palette +/// tends to shift. +const PALETTE_REQUANTIZE_EVERY: u64 = 30; + +/// Number of palette entries per frame. GIF supports up to 256 +/// (`2_u16.pow(8)`), which is also what the standard web palette +/// assumes. 256 is the default; the spec allows smaller (4 / 8 / 16 / +/// 32 / 64 / 128) but 256 looks meaningfully better on screen +/// recordings and the cost difference is tiny. +const PALETTE_COLORS: usize = 256; + +/// Wall-time / size / fps summary for a GIF export, shaped like +/// `pipeline::Stats` so the bench and the napi binding can pass it +/// through without a second struct. +pub struct GifStats { + pub frames: u64, + pub wall_s: f64, + pub fps: f64, + /// Duration of the resulting GIF (seconds) = `frames / fps`. Distinct + /// from `wall_s` (real render time). + pub video_duration_s: f64, + /// Size of the GIF file on disk, in bytes. Read after the writer + /// drops so it includes the trailer. + pub file_bytes: u64, +} + +/// Optional knobs for `export_gif`. The same shape as the future +/// `ExportGifParams` block in the TS contract. +#[derive(Debug, Clone)] +pub struct GifExportParams { + pub width: Option, + pub height: Option, + pub fps: Option, + /// `None` or `0` → infinite loop (the historical GIF default). + /// Otherwise finite count. + pub loop_count: Option, + /// Floyd-Steinberg dithering before quantization. Default off — + /// the quantized result without dithering is usually acceptable + /// for screen content, and dithering roughly doubles the per-frame + /// CPU cost. + pub dither: bool, +} + +impl Default for GifExportParams { + fn default() -> Self { + Self { + width: Some(DEFAULT_GIF_WIDTH), + height: Some(DEFAULT_GIF_HEIGHT), + fps: Some(DEFAULT_GIF_FPS), + loop_count: None, // infinite + dither: false, + } + } +} + +/// Drive a multiclip GIF export end-to-end. +/// +/// Deliberately the same shape as `pipeline::run_composited_multi`, and +/// deliberately driven by the same `walk_composited_timeline`: the clip walk, +/// the speed segments and the output-time decoder advancement are the video +/// exporter's, not a second implementation. Only the per-frame sink differs — +/// MP4 hands the composed texture to a hardware NV12 encoder, GIF reads it back +/// to the CPU and quantizes it to 256 colours. +/// +/// A failed run leaves a truncated GIF under exactly the name the user thinks +/// they exported. Remove it rather than leave it lying around — same contract +/// as `discard_partial_output` on the MP4 path. +pub fn export_gif( + clips: &[ClipSource], + out_path: &Path, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + params: &GifExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + let result = export_gif_inner(clips, out_path, gpu, comp, cfg, params, progress); + if result.is_err() { + let _ = std::fs::remove_file(out_path); + } + result +} + +fn export_gif_inner( + clips: &[ClipSource], + out_path: &Path, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + params: &GifExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + if clips.is_empty() { + bail!("export_gif: aucun clip à exporter"); + } + // The caller builds the compositor at the output size (same contract as + // `run_composited_multi` / `ExportParams`), so these must agree with what + // `readback_direct` hands back — asserted per frame below. + let width = params.width.unwrap_or(DEFAULT_GIF_WIDTH); + let height = params.height.unwrap_or(DEFAULT_GIF_HEIGHT); + let fps = params.fps.unwrap_or(DEFAULT_GIF_FPS).max(1); + let dither = params.dither; + + // Set up the GIF writer up front: file + global header. We use a + // per-frame local palette (the standard "high-quality" form: a + // palette tuned to each frame's colours), so the global palette in + // the header is empty. + if let Some(parent) = out_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).ok(); + } + } + let file = File::create(out_path) + .with_context(|| format!("export_gif: create {}", out_path.display()))?; + let mut writer = BufWriter::new(file); + + // GIF frame delay in centiseconds (= 1/100 s). `fps` → + // `100 / fps` cs per frame, rounded to the nearest unit the + // GIF spec supports. u16 caps at 65535 — 10.9 minutes per + // frame, plenty. + // + // ponytail: integer centiseconds can't express every fps exactly + // (12 → 8 cs → 12.5 fps). `video_duration_s` below is computed + // from the delays actually written, so the reported duration never + // disagrees with the file. Fractional accumulation if a viewer ever + // cares about the ~4% drift. + let delay_cs: u16 = (100_u32 / fps).max(1) as u16; + + // Pre-allocate the per-frame index buffer. Reused across + // frames so we don't hit the allocator in the hot loop. + let mut indices: Vec = vec![0u8; (width as usize) * (height as usize)]; + // Optional dither error buffer (one signed channel per + // pixel per channel, 3 channels per pixel, 2 rows of state + // for the FS pass). Allocated once; only touched when + // `dither` is true. + let mut err_cur: Vec = vec![0.0f32; (width as usize) * 3]; + let mut err_next: Vec = vec![0.0f32; (width as usize) * 3]; + // Cached palette: rebuilt on a schedule + // (`PALETTE_REQUANTIZE_EVERY`). + let mut palette_rgb: Vec = vec![0u8; PALETTE_COLORS * 3]; + + let t0 = Instant::now(); + let scene = comp.scene_snapshot(); + let frames = { + let mut gw = GifWriter::new(&mut writer, width as u16, height as u16)?; + gw.write_header()?; + // Netscape 2.0 application extension drives the loop count. + // 0 = infinite; some viewers also treat 0 as infinite, so we + // keep that as the "default". + let loops = match params.loop_count { + None | Some(0) => 0u16, + Some(n) => n, + }; + gw.write_netscape_loop(loops)?; + + let mut screen_decs: HashMap = HashMap::new(); + let mut webcam_decs: HashMap = HashMap::new(); + screen_decs.insert(clips[0].screen.clone(), unsafe { + Decoder::open(&clips[0].screen, gpu)? + }); + + let frames = unsafe { + walk_composited_timeline( + clips, + gpu, + comp, + cfg, + fps as i32, + &scene, + &mut screen_decs, + &mut webcam_decs, + &mut |frame_index| { + // CPU readback of the staged RT (RGBA8 tightly-packed, + // `width * height * 4` bytes). The dominant per-frame cost, + // and the reason GIF can't use the MP4 zero-copy sink. + let (rw, rh, rgba) = comp + .readback_direct() + .map_err(|e| anyhow!("export_gif: readback @ frame {frame_index}: {e:#}"))?; + debug_assert_eq!(rw, width); + debug_assert_eq!(rh, height); + + // Refresh the palette on a schedule. Building the histogram + // and running median-cut is O(unique colors) — fast enough at + // 480p on our 30-frame cadence. + if frame_index % PALETTE_REQUANTIZE_EVERY == 0 { + build_palette_median_cut(&rgba, PALETTE_COLORS, &mut palette_rgb); + } + + // Quantize (with optional dithering). The dither pass diffuses + // the error against the CHOSEN PALETTE ENTRY, so it has to run + // fused with the index mapping — see `map_to_indices_dithered`. + if dither { + map_to_indices_dithered( + &palette_rgb, + &rgba, + width, + height, + &mut err_cur, + &mut err_next, + &mut indices, + ); + } else { + map_to_indices(&palette_rgb, &rgba, &mut indices); + } + + // Per-frame palette (GIF local palette, written by `write_frame`). + gw.write_frame(&indices, &palette_rgb, delay_cs, fps)?; + progress(frame_index + 1); + Ok(()) + }, + // GIF has no audio track, so clip boundaries need no work. + &mut |_, _, _, _| Ok(()), + )? + }; + + gw.finish()?; + frames + }; + // Drop the writer before stat-ing the file so the trailer is + // flushed. + drop(writer); + + let wall_s = t0.elapsed().as_secs_f64(); + let fps_actual = if wall_s > 0.0 { frames as f64 / wall_s } else { 0.0 }; + // From the delays actually written, not from the requested fps — see the + // `delay_cs` note above. + let video_duration_s = frames as f64 * (delay_cs as f64 / 100.0); + let file_bytes = std::fs::metadata(out_path).map(|m| m.len()).unwrap_or(0); + + Ok(GifStats { frames, wall_s, fps: fps_actual, video_duration_s, file_bytes }) +} + + +// ===================================================================== +// GIF89a format writer (pure std::io::Write). +// ===================================================================== +// +// Spec reference: GIF89a Appendix F (LZW) and the Lempel-Ziv-Welch +// variant. Code packing is LSB-first into the byte stream (the LSB of +// each code goes into the LSB of the byte), and bytes are written in +// order. A sub-block of N LZW bytes is preceded by a 1-byte length N +// (1..=255); a 0x00 byte terminates the sub-block stream. The image +// descriptor's LCT size field uses the same `2^N` encoding as the +// header's GCT size field. + +struct GifWriter { + w: W, + width: u16, + height: u16, + /// Set by `finish`, so `Drop` does not append a second trailer. + finished: bool, +} + +impl GifWriter { + fn new(w: W, width: u16, height: u16) -> Result { + if width == 0 || height == 0 { + bail!("gif: dimensions must be > 0 (got {width}x{height})"); + } + Ok(GifWriter { + w, + width, + height, + finished: false, + }) + } + + /// Write the GIF89a header + Logical Screen Descriptor. No global + /// color table: every frame carries a local palette, which the + /// GIF89a spec specifically allows and which gives per-frame + /// colour fidelity that a single global table can't match. + fn write_header(&mut self) -> Result<()> { + self.w.write_all(b"GIF89a")?; + // Logical screen descriptor. + self.w.write_all(&self.width.to_le_bytes())?; + self.w.write_all(&self.height.to_le_bytes())?; + // Packed byte: GCT flag (bit 7) = 0, color resolution + // (bits 4-6) = 7 (8 bits/channel), sort flag (bit 3) = 0, + // GCT size (bits 0-2) = 0 (no GCT). The upper nibble is the + // raw value; the GCT size is `(n + 1)` where the table has + // `2^(n+1)` entries. 0 here = "no GCT" (flag bit is 0 + // anyway). + self.w.write_all(&[0b0_111_0_000])?; + // Background color index (unused, no GCT) and pixel aspect + // ratio (0 = unspecified, the common case). + self.w.write_all(&[0, 0])?; + Ok(()) + } + + /// Write a Netscape 2.0 application extension block driving the + /// GIF loop counter. `loop_count == 0` means infinite — the + /// historical GIF default and what most viewers assume. + fn write_netscape_loop(&mut self, loop_count: u16) -> Result<()> { + self.w.write_all(&[0x21, 0xFF, 0x0B])?; + self.w.write_all(b"NETSCAPE2.0")?; + self.w.write_all(&[0x03, 0x01])?; + self.w.write_all(&loop_count.to_le_bytes())?; + self.w.write_all(&[0x00])?; + Ok(()) + } + + /// Write one animated frame: Graphics Control Extension (delay + /// only — no transparency, no disposal), Image Descriptor, local + /// color table, LZW-compressed index stream. + fn write_frame( + &mut self, + indices: &[u8], + palette_rgb: &[u8], + delay_cs: u16, + _fps: u32, + ) -> Result<()> { + debug_assert_eq!(indices.len(), (self.width as usize) * (self.height as usize)); + debug_assert_eq!(palette_rgb.len(), PALETTE_COLORS * 3); + + // Graphics Control Extension: delay only. The disposal + // method is "leave in place" (0) and the transparent flag + // is off, so a frame with overlapping dimensions is + // composited on top of the previous frame. + self.w.write_all(&[0x21, 0xF9, 0x04])?; + // Packed: 3 reserved (0) | 3 disposal (0 = leave in + // place) | 1 user input (0) | 1 transparent (0). 0x00 + // throughout. + self.w.write_all(&[0x00])?; + self.w.write_all(&delay_cs.to_le_bytes())?; + // Transparent color index (unused — `0` is the conventional + // "no transparency" sentinel). + self.w.write_all(&[0x00])?; + // Block terminator. + self.w.write_all(&[0x00])?; + + // Image Descriptor. + self.w.write_all(&[0x2C])?; + self.w.write_all(&0u16.to_le_bytes())?; // left + self.w.write_all(&0u16.to_le_bytes())?; // top + self.w.write_all(&self.width.to_le_bytes())?; + self.w.write_all(&self.height.to_le_bytes())?; + // Packed: LCT flag (bit 7) = 1, interlace (bit 6) = 0, + // sort (bit 5) = 0, reserved (bits 3-4) = 0, LCT size + // (bits 0-2) = 7 (i.e. 2^(7+1) = 256 entries). + self.w.write_all(&[0b1_0_0_00_111])?; + // Local color table. + self.w.write_all(palette_rgb)?; + + // LZW image data: `LZW minimum code size` byte, then + // sub-blocks of compressed bytes, then a 0x00 terminator. + // LZW min code size is 8 for a 256-color palette. + self.w.write_all(&[8])?; + let mut compressed: Vec = Vec::new(); + lzw_compress(indices, 8, &mut compressed); + write_sub_blocks(&mut self.w, &compressed)?; + self.w.write_all(&[0x00])?; // image data terminator + Ok(()) + } + + /// Write the trailer byte (`0x3B`) and flush. Callers should + /// typically rely on `Drop` instead — this exists for the bench + /// path that wants an explicit "we're done, no more frames" + /// signal. + fn finish(&mut self) -> Result<()> { + if self.finished { + return Ok(()); + } + self.finished = true; + self.w.write_all(&[0x3B])?; + self.w.flush()?; + Ok(()) + } +} + +impl Drop for GifWriter { + fn drop(&mut self) { + // Best-effort trailer for the paths that bail out before calling + // `finish`. Skipped when `finish` already wrote one — two trailer + // bytes are tolerated by most decoders but rejected by strict ones. + // We intentionally don't propagate the result — `Drop` can't return + // errors. The resulting file will be truncated/invalid, which the + // caller will detect on the next read. + if self.finished { + return; + } + let _ = self.w.write_all(&[0x3B]); + let _ = self.w.flush(); + } +} + +/// Write a stream of bytes as a sequence of GIF sub-blocks (max 255 +/// bytes per sub-block, preceded by a 1-byte length, terminated by +/// `0x00`). +fn write_sub_blocks(w: &mut W, data: &[u8]) -> Result<()> { + let mut pos = 0; + while pos < data.len() { + let chunk = (data.len() - pos).min(255); + w.write_all(&[chunk as u8])?; + w.write_all(&data[pos..pos + chunk])?; + pos += chunk; + } + Ok(()) +} + +// ===================================================================== +// LZW encoder (GIF89a variant). +// ===================================================================== +// +// The GIF LZW variant: +// - LZW min code size = log2(max color + 1). For 256 colors: 8. +// - Initial code size = min code size + 1 = 9. +// - Clear code = 2^min_code_size = 256. +// - EOI code = clear code + 1 = 257. +// - First free code = 258. +// - Codes 0..256+1 are the initial table (literal codes plus the +// clear and EOI sentinels); codes 258+ are added as the encoder +// walks the input. +// - Code size bumps from 9 to 12 as the table fills; at 12 bits +// the table holds 4096 codes (0..4095). Adding the 4096th +// "missing pair" triggers a clear code, table reset, and the +// encoder starts over. The bump lands one code LATER than the +// table boundary suggests, because the decoder's table lags +// ours by one entry — see the note at the `code_size += 1`. +// - Codes are packed LSB-first into the byte stream; the +// bit-buffer drains into output bytes as soon as 8 bits have +// accumulated. + +fn lzw_compress(indices: &[u8], min_code_size: u8, out: &mut Vec) { + let clear_code: u16 = 1u16 << min_code_size; + let eoi_code: u16 = clear_code + 1; + let initial_code_size: u8 = min_code_size + 1; + + let mut table: HashMap<(u16, u8), u16> = HashMap::new(); + let mut code_size: u8 = initial_code_size; + let mut next_code: u16 = eoi_code + 1; + let mut bit_buffer: u32 = 0; + let mut bits_in_buffer: u8 = 0; + + // Helper closure: pack `code` at the current `code_size` into + // the bit buffer, draining whole bytes into `out` as they fill + // up. Code packing is LSB-first (the LSB of the code goes into + // the LSB of the current byte), and codes are written into + // `out` in stream order — which is the canonical GIF behaviour. + // Captures `out` mutably; the bit buffer and code size are + // passed in to keep the closure a small mutator rather than a + // re-borrow of the whole function. + let mut emit = |code: u16, code_size: u8, buf: &mut u32, n: &mut u8| { + *buf |= (code as u32) << *n; + *n += code_size; + while *n >= 8 { + out.push((*buf & 0xFF) as u8); + *buf >>= 8; + *n -= 8; + } + }; + + // Always start with a clear code — the decoder also requires + // it. (Empty streams still need the clear + EOI pair.) + emit(clear_code, code_size, &mut bit_buffer, &mut bits_in_buffer); + + if indices.is_empty() { + emit(eoi_code, code_size, &mut bit_buffer, &mut bits_in_buffer); + // Pad the final byte with zeros to a full byte. + if bits_in_buffer > 0 { + out.push(bit_buffer as u8); + } + return; + } + + let mut prefix: u16 = indices[0] as u16; + for &k in &indices[1..] { + let key = (prefix, k); + if let Some(&code) = table.get(&key) { + prefix = code; + continue; + } + // Miss: emit the prefix code at the current size, then + // add the new entry. + emit(prefix, code_size, &mut bit_buffer, &mut bits_in_buffer); + + if next_code <= 4095 { + table.insert(key, next_code); + next_code += 1; + // Bump code_size one entry AFTER the table outgrows + // it, not on the boundary itself. The missing `+ 1` + // is what black-framed every exported GIF. + // + // The decoder builds its table one entry behind the + // encoder: it can only add `(prev, first_byte(cur))` + // once it has read the code that FOLLOWS. So when we + // have just inserted code 511 (`next_code == 512`), + // the decoder still holds 511 entries and still reads + // at 9 bits. Bumping here sends the next code out at + // 10 bits while the decoder reads 9 — the stream + // desyncs a few hundred codes in and every pixel + // after that is garbage. Deferring by one + // (`next_code == 513`) puts both bumps on the same + // code, which is what the reference encoder does too + // (it tests the PRE-insert `free_ent > maxcode` + // after emitting, not before). + if code_size < 12 && next_code == (1u16 << code_size) + 1 { + code_size += 1; + } + } else { + // Table full (next_code is 4096). Emit a clear + // code, reset the table, and start over. The + // decoder will see this clear code and rebuild + // the table the same way. + emit(clear_code, code_size, &mut bit_buffer, &mut bits_in_buffer); + table.clear(); + code_size = initial_code_size; + next_code = eoi_code + 1; + } + prefix = k as u16; + } + + // Flush: emit the final prefix, then EOI, then pad the bit + // buffer to a byte boundary. + emit(prefix, code_size, &mut bit_buffer, &mut bits_in_buffer); + emit(eoi_code, code_size, &mut bit_buffer, &mut bits_in_buffer); + if bits_in_buffer > 0 { + out.push(bit_buffer as u8); + } +} + +// ===================================================================== +// Median-cut palette builder. +// ===================================================================== +// +// The standard Heckbert "Color Image Quantization for Frame Buffer +// Display" algorithm, with two pragmatic choices: +// +// 1. We work on a histogram of distinct colors (not on the raw +// pixel stream). The number of distinct colors on a screen +// recording frame is typically ≪ pixel count (10k–100k +// distinct colors in 410k pixels), which keeps the per-split +// sort cheap. Building the histogram is O(n) on pixel count. +// 2. We split by finding the longest channel axis and bisecting +// at the median (count-weighted) of that axis. A full sort +// per split dominates the cost; we sort by a single key (the +// chosen channel) which is `O(k log k)` per split, summed +// across `num_colors` splits. + +fn build_palette_median_cut(rgba: &[u8], num_colors: usize, out_palette: &mut [u8]) { + debug_assert_eq!(out_palette.len(), num_colors * 3); + debug_assert!(num_colors > 0); + + // 1. Histogram of distinct colors. Bumping the counter is a + // single hashmap insert/update per pixel — O(n) total, with + // cache-friendly bulk iteration over the RGBA buffer. + let mut histogram: HashMap<[u8; 3], u32> = HashMap::new(); + for chunk in rgba.chunks_exact(4) { + let color = [chunk[0], chunk[1], chunk[2]]; + *histogram.entry(color).or_insert(0) += 1; + } + if histogram.is_empty() { + // Shouldn't happen with a real readback, but fall back to + // a black palette if it does. + for chunk in out_palette.chunks_exact_mut(3) { + chunk[0] = 0; + chunk[1] = 0; + chunk[2] = 0; + } + return; + } + + // Collapse the histogram into a sorted vector for the split + // step. We allocate this fresh each call — `requantize_every` + // frames is the only call site, and the cost (one allocation + + // one memcpy from the hashmap) is well under a millisecond at + // 480p. + let entries: Vec<([u8; 3], u32)> = histogram.into_iter().collect(); + + // 2. Repeatedly split the bucket with the longest channel + // range until we have `num_colors` buckets. The split is + // count-weighted: the median of the channel values by + // cumulative count, not by raw position. + let mut buckets: Vec> = vec![entries]; + while buckets.len() < num_colors { + // Find the bucket with the largest total range across + // channels. Ties break by index (earlier split first), + // which is reproducible across machines. + let mut best_idx: usize = 0; + let mut best_range: u32 = 0; + for (i, b) in buckets.iter().enumerate() { + if b.len() < 2 { + continue; + } + let r = channel_range(b, 0); + let g = channel_range(b, 1); + let bl = channel_range(b, 2); + let m = r.max(g).max(bl); + if m > best_range { + best_range = m; + best_idx = i; + } + } + if best_range == 0 { + // All remaining buckets are uniform; no further + // refinement possible. Pad with the average of + // the largest bucket so the palette still has + // the requested entry count (or close to it). + break; + } + // Split `best_idx` along the longest axis. Count-weighted + // median: find the channel value where the cumulative + // count crosses half the bucket's total. + let axis = { + let b = &buckets[best_idx]; + let r = channel_range(b, 0); + let g = channel_range(b, 1); + let bl = channel_range(b, 2); + if r >= g && r >= bl { + 0 + } else if g >= bl { + 1 + } else { + 2 + } + }; + let bucket = buckets.remove(best_idx); + let total: u64 = bucket.iter().map(|(_, c)| *c as u64).sum(); + let half = total / 2; + + // Sort by the chosen axis. A full sort is the right call + // here — the bucket is at most the size of the histogram + // (typically 10k–100k entries), and a single sort is + // cheaper than trying to find the median in O(n) and then + // partitioning, which has worse constants in Rust. + let mut sorted = bucket; + sorted.sort_by_key(|(c, _)| c[axis]); + + // Walk the sorted bucket accumulating counts; the first + // entry past `half` is the split point. + let mut acc: u64 = 0; + let mut split = sorted.len(); + for (i, (_, count)) in sorted.iter().enumerate() { + acc += *count as u64; + if acc >= half { + split = i + 1; + break; + } + } + // split in (0, len) by construction (the bucket has ≥ 2 + // entries and half < total), but guard against an edge + // case where every entry sits on one side of the median. + if split == 0 { + split = 1; + } else if split >= sorted.len() { + split = sorted.len() - 1; + } + let mut right = sorted.split_off(split); + if right.is_empty() { + // Defensive: shouldn't happen with a valid split + // point, but if it does we don't want to lose + // entries. + right = sorted.split_off(sorted.len() - 1); + } + buckets.push(sorted); + buckets.push(right); + } + + // 3. Average each bucket (count-weighted) to get one palette + // entry per bucket. If we have fewer than `num_colors` + // buckets (all-uniform early exit), duplicate the largest + // bucket's average to fill the rest. + for (i, chunk) in out_palette.chunks_exact_mut(3).enumerate() { + let bucket = buckets.get(i).filter(|b| !b.is_empty()); + let (r, g, b) = match bucket { + Some(b) => { + let mut r_sum: u64 = 0; + let mut g_sum: u64 = 0; + let mut b_sum: u64 = 0; + let mut n: u64 = 0; + for (color, count) in b { + r_sum += color[0] as u64 * *count as u64; + g_sum += color[1] as u64 * *count as u64; + b_sum += color[2] as u64 * *count as u64; + n += *count as u64; + } + if n == 0 { + (0u8, 0u8, 0u8) + } else { + ((r_sum / n) as u8, (g_sum / n) as u8, (b_sum / n) as u8) + } + } + None => { + // Pad: reuse the first non-empty bucket's + // average. (`buckets` is non-empty because + // the histogram is non-empty.) + if let Some(b) = buckets.first().filter(|b| !b.is_empty()) { + let mut r_sum: u64 = 0; + let mut g_sum: u64 = 0; + let mut b_sum: u64 = 0; + let mut n: u64 = 0; + for (color, count) in b { + r_sum += color[0] as u64 * *count as u64; + g_sum += color[1] as u64 * *count as u64; + b_sum += color[2] as u64 * *count as u64; + n += *count as u64; + } + if n == 0 { + (0u8, 0u8, 0u8) + } else { + ((r_sum / n) as u8, (g_sum / n) as u8, (b_sum / n) as u8) + } + } else { + (0u8, 0u8, 0u8) + } + } + }; + chunk[0] = r; + chunk[1] = g; + chunk[2] = b; + } +} + +fn channel_range(bucket: &[([u8; 3], u32)], channel: usize) -> u32 { + if bucket.is_empty() { + return 0; + } + let min = bucket.iter().map(|(c, _)| c[channel]).min().unwrap() as u32; + let max = bucket.iter().map(|(c, _)| c[channel]).max().unwrap() as u32; + max - min +} + +// ===================================================================== +// Nearest-color index mapping. +// ===================================================================== +// +// Brute-force squared-distance search over 256 palette entries per +// pixel. The inner loop is `4 reads + 3 muls + 2 adds + 1 compare` +// per (pixel × palette entry) — small enough that the compiler +// autovectorizes the pixel loop on x86-64 (the `pow(2)` distance +// rule is fine because we only compare, not sort by it). A +// NeuQuant-network lookup would walk a per-frame tree (≈ 512-node +// path per pixel), which is **slower** than 256 brute-force +// comparisons on modern CPUs with wide SIMD. +// +// Cost on the 854×480 fixture (410 k pixels × 256 entries) is +// ~100 M simple integer ops, well under one frame on a recent +// CPU. If it ever shows up on the bench, the right fix is +// `std::simd` or a hand-written AVX2 inner loop — both in this +// file, no new deps. + +fn map_to_indices(palette_rgb: &[u8], rgba: &[u8], indices: &mut [u8]) { + let npix = indices.len(); + debug_assert_eq!(rgba.len(), npix * 4); + debug_assert_eq!(palette_rgb.len(), PALETTE_COLORS * 3); + + // Pre-transpose the palette into `[r0..r255, g0..g255, b0..b255]` + // form so the inner loop's three channel reads are contiguous + // and the compiler can pack them into SIMD loads. The cost + // is 768 bytes per frame, written once; the alternative + // (interleaved reads with a `* 3` step) costs the same in + // the hot loop and is harder to vectorize. + let mut pr = [0u8; PALETTE_COLORS]; + let mut pg = [0u8; PALETTE_COLORS]; + let mut pb = [0u8; PALETTE_COLORS]; + for (k, chunk) in palette_rgb.chunks_exact(3).enumerate() { + pr[k] = chunk[0]; + pg[k] = chunk[1]; + pb[k] = chunk[2]; + } + + for i in 0..npix { + let base = i * 4; + let r = rgba[base] as i32; + let g = rgba[base + 1] as i32; + let b = rgba[base + 2] as i32; + // Branchless nearest. The 256-entry loop body is 3 reads + // + 3 subs + 3 muls + 2 adds + 1 compare + 1 conditional + // store — well within autovectorization budget. The + // (distance, index) packing into a single `i32` was + // tried and dropped: the conditional store didn't + // improve (the compiler vectorizes the simple form + // already). + let mut best_idx: usize = 0; + let mut best_dist: i32 = i32::MAX; + for k in 0..PALETTE_COLORS { + let dr = r - pr[k] as i32; + let dg = g - pg[k] as i32; + let db = b - pb[k] as i32; + let dist = dr * dr + dg * dg + db * db; + if dist < best_dist { + best_dist = dist; + best_idx = k; + } + } + indices[i] = best_idx as u8; + } +} + +/// Nearest-palette mapping with Floyd-Steinberg error diffusion, fused. +/// +/// Fused on purpose. A separate dither pass has nothing to diffuse against: +/// quantizing to `round()` of an already-integer channel gives an error of +/// exactly zero for every pixel, so the whole pass is a no-op that still costs +/// a full float traversal. The error that matters is `pixel - palette[chosen]`, +/// which only exists once the palette entry has been picked — hence one loop. +/// +/// Kernel is the standard 7/16 right, 3/16 below-left, 5/16 below, +/// 1/16 below-right. Two row buffers keep the working set at O(width). +#[allow(clippy::too_many_arguments)] +fn map_to_indices_dithered( + palette_rgb: &[u8], + rgba: &[u8], + width: u32, + height: u32, + err_cur: &mut [f32], + err_next: &mut [f32], + indices: &mut [u8], +) { + let w = width as usize; + let h = height as usize; + debug_assert_eq!(indices.len(), w * h); + debug_assert_eq!(palette_rgb.len(), PALETTE_COLORS * 3); + + err_cur.fill(0.0); + err_next.fill(0.0); + + for y in 0..h { + for x in 0..w { + let base = (y * w + x) * 4; + let e = x * 3; + // Channel value carrying the error diffused into this pixel. + let cr = (rgba[base] as f32 + err_cur[e]).clamp(0.0, 255.0); + let cg = (rgba[base + 1] as f32 + err_cur[e + 1]).clamp(0.0, 255.0); + let cb = (rgba[base + 2] as f32 + err_cur[e + 2]).clamp(0.0, 255.0); + + let mut best_idx = 0usize; + let mut best_dist = f32::MAX; + for k in 0..PALETTE_COLORS { + let dr = cr - palette_rgb[k * 3] as f32; + let dg = cg - palette_rgb[k * 3 + 1] as f32; + let db = cb - palette_rgb[k * 3 + 2] as f32; + let dist = dr * dr + dg * dg + db * db; + if dist < best_dist { + best_dist = dist; + best_idx = k; + } + } + indices[y * w + x] = best_idx as u8; + + // THE error: distance to the colour actually written. + let er = cr - palette_rgb[best_idx * 3] as f32; + let eg = cg - palette_rgb[best_idx * 3 + 1] as f32; + let eb = cb - palette_rgb[best_idx * 3 + 2] as f32; + + let mut spread = |slot: &mut [f32], idx: usize, f: f32| { + slot[idx] += er * f; + slot[idx + 1] += eg * f; + slot[idx + 2] += eb * f; + }; + if x + 1 < w { + spread(err_cur, e + 3, 7.0 / 16.0); + spread(err_next, e + 3, 1.0 / 16.0); + } + if x > 0 { + spread(err_next, e - 3, 3.0 / 16.0); + } + spread(err_next, e, 5.0 / 16.0); + } + // Next row becomes current; clear the far row for reuse. + err_cur.copy_from_slice(err_next); + err_next.fill(0.0); + } +} + +// ===================================================================== +// Tests. +// ===================================================================== +// +// These tests run under `cargo test -p openscreen-compositor` and +// don't need a GPU — they exercise the GIF89a writer, the LZW +// encoder, the median-cut palette, the nearest-color mapping, and +// the Floyd-Steinberg dither. The full `export_gif` pipeline +// (Player + GPU + readback) is exercised by the bench, not here. + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// The most basic round-trip: write a 2×2 frame and check the + /// file is well-formed GIF89a. No decode — we just walk the + /// output bytes and confirm the structural shape. + #[test] + fn gif_writer_writes_exactly_one_trailer() { + // `finish` writes 0x3B, and so does `Drop`. Without the guard the file + // ends `3B 3B`, which strict decoders reject. + let mut buf = Vec::new(); + { + let mut gw = GifWriter::new(&mut buf, 2, 2).unwrap(); + gw.write_header().unwrap(); + gw.finish().unwrap(); + } + assert_eq!(buf.last(), Some(&0x3B)); + assert_ne!( + buf[buf.len() - 2], + 0x3B, + "trailer written twice: {:02X?}", + &buf[buf.len() - 2..] + ); + } + + #[test] + fn gif_writer_drop_still_terminates_without_finish() { + // The bail-out paths never call `finish`; `Drop` must still close the file. + let mut buf = Vec::new(); + { + let mut gw = GifWriter::new(&mut buf, 2, 2).unwrap(); + gw.write_header().unwrap(); + } + assert_eq!(buf.last(), Some(&0x3B)); + } + + #[test] + fn gif_writer_writes_minimal_header() { + let mut buf = Vec::new(); + { + let mut gw = GifWriter::new(&mut buf, 2, 2).unwrap(); + gw.write_header().unwrap(); + gw.write_netscape_loop(0).unwrap(); + let palette = vec![0u8; PALETTE_COLORS * 3]; + let indices = vec![0u8; 4]; + gw.write_frame(&indices, &palette, 10, 12).unwrap(); + gw.finish().unwrap(); + } + // Magic. + assert_eq!(&buf[0..6], b"GIF89a"); + // Width / height (LE u16). + assert_eq!(&buf[6..8], &2u16.to_le_bytes()); + assert_eq!(&buf[8..10], &2u16.to_le_bytes()); + // Packed byte: GCT flag = 0, color res = 7, sort = 0, GCT + // size = 0. + assert_eq!(buf[10], 0b0_111_0_000); + // Background + aspect: 0, 0. + assert_eq!(buf[11], 0); + assert_eq!(buf[12], 0); + // Netscape loop extension. + assert_eq!(&buf[13..16], &[0x21, 0xFF, 0x0B]); + assert_eq!(&buf[16..27], b"NETSCAPE2.0"); + // Trailer at the end. + assert_eq!(*buf.last().unwrap(), 0x3B); + } + + /// Reference GIF89a LZW decoder (spec Appendix F), for the + /// round-trip tests below. + /// + /// It exists because "the output is a non-empty byte stream" — + /// all the assertion these tests used to make — passes happily on + /// a stream no decoder can read. The encoder bumped its code size + /// one code too early, every GIF the app exported decoded to black, + /// and the whole suite stayed green: `export_timing.rs` reads the + /// frame PALETTES, which are written outside the LZW data and were + /// perfectly fine. Nothing in the repo ever decoded a pixel. + /// + /// So this is deliberately an INDEPENDENT implementation, written + /// from the spec rather than by mirroring `lzw_compress`: a decoder + /// derived from the encoder would have reproduced the same + /// off-by-one and agreed with it. Its output was cross-checked + /// against macOS ImageIO (`sips -s format png`) on real frames. + /// + /// Table lags the encoder's by one entry by construction: an entry + /// can only be completed once the FOLLOWING code is known. + fn lzw_decompress(data: &[u8], min_code_size: u8) -> Vec { + let clear_code: u16 = 1u16 << min_code_size; + let eoi_code: u16 = clear_code + 1; + let mut code_size: u8 = min_code_size + 1; + + // Literals, then two placeholders so indices line up with the + // clear / EOI codes: `dict.len()` is then exactly the encoder's + // `next_code`. + let fresh = || -> Vec> { + let mut d: Vec> = (0..clear_code).map(|i| vec![i as u8]).collect(); + d.push(Vec::new()); + d.push(Vec::new()); + d + }; + let mut dict = fresh(); + + let mut out: Vec = Vec::new(); + let mut prev: Option = None; + let mut bitpos: usize = 0; + let total_bits = data.len() * 8; + + loop { + assert!( + bitpos + code_size as usize <= total_bits, + "truncated LZW stream at bit {bitpos} of {total_bits}" + ); + // LSB-first: bit i of the code is bit i of the stream. + let mut code: u16 = 0; + for i in 0..code_size as usize { + let bit = (data[(bitpos + i) / 8] >> ((bitpos + i) % 8)) & 1; + code |= (bit as u16) << i; + } + bitpos += code_size as usize; + + if code == clear_code { + dict = fresh(); + code_size = min_code_size + 1; + prev = None; + continue; + } + if code == eoi_code { + break; + } + + // Either a known entry, or the KwKwK case: the code the + // encoder just added, which we can only reconstruct from + // the previous entry plus its own first byte. + let entry: Vec = if (code as usize) < dict.len() { + dict[code as usize].clone() + } else { + let p = prev.unwrap_or_else(|| { + panic!("first code after a clear must be a literal, got {code}") + }); + let mut e = dict[p as usize].clone(); + e.push(dict[p as usize][0]); + e + }; + assert!(!entry.is_empty(), "code {code} resolved to an empty entry"); + out.extend_from_slice(&entry); + + if let Some(p) = prev { + if dict.len() < 4096 { + let mut new_entry = dict[p as usize].clone(); + new_entry.push(entry[0]); + dict.push(new_entry); + if code_size < 12 && dict.len() == (1usize << code_size) { + code_size += 1; + } + } + } + prev = Some(code); + } + out + } + + fn assert_lzw_round_trips(name: &str, pixels: &[u8]) { + let mut compressed = Vec::new(); + lzw_compress(pixels, 8, &mut compressed); + let decoded = lzw_decompress(&compressed, 8); + assert_eq!( + decoded.len(), + pixels.len(), + "{name}: decoded {} pixels, expected {}", + decoded.len(), + pixels.len() + ); + if let Some(i) = (0..pixels.len()).find(|&i| decoded[i] != pixels[i]) { + panic!( + "{name}: first mismatch at pixel {i} (expected {}, got {})", + pixels[i], decoded[i] + ); + } + } + + /// A stream long enough to cross the 9→10 bit boundary must survive a + /// decode. This is THE regression test: the encoder used to bump its + /// code size one code before the decoder does, so everything past + /// roughly the 255th code came back as garbage — which is what made + /// exported GIFs render black. + #[test] + fn lzw_round_trips_across_the_code_size_boundary() { + // Long runs plus slow variation — the shape of a real + // screen-recording frame, and enough misses to walk 9 → 10 → 11. + let pixels: Vec = (0..200_000u32).map(|i| ((i / 97) % 251) as u8).collect(); + assert_lzw_round_trips("gradient-ish", &pixels); + } + + /// Table-full path: 4096 entries, a clear code, and a reset mid-stream. + /// Pseudo-random data fills the table fast and forces several clears. + #[test] + fn lzw_round_trips_through_table_full_clears() { + let mut seed = 0x1234_5678u32; + let pixels: Vec = (0..DEFAULT_GIF_WIDTH * DEFAULT_GIF_HEIGHT) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 24) as u8 + }) + .collect(); + assert_lzw_round_trips("noise (forces clears)", &pixels); + } + + /// The degenerate shapes: uniform frame (one long run), two-colour + /// alternation (table grows without ever repeating), single pixel. + #[test] + fn lzw_round_trips_degenerate_frames() { + assert_lzw_round_trips("uniform", &vec![42u8; 100_000]); + assert_lzw_round_trips( + "alternating", + &(0..50_000).map(|i| (i % 2) as u8).collect::>(), + ); + assert_lzw_round_trips("single pixel", &[7]); + } + + /// The LZW encoder must produce a clear code at the start and + /// an EOI at the end — checked by decoding, since a stream that + /// merely "isn't empty" proves nothing. + #[test] + fn lzw_compress_emits_clear_and_eoi() { + let pixels: Vec = (0..200).map(|i| (i % 4) as u8).collect(); + let mut out = Vec::new(); + lzw_compress(&pixels, 8, &mut out); + assert!(!out.is_empty()); + // The leading clear code (256, 9 bits, LSB-first) occupies the + // first byte and the low bit of the second. + assert_eq!(out[0], 0x00); + assert_eq!(out[1] & 0x01, 0x01); + // And the whole thing decodes back to what went in — which is + // only possible if the EOI is there and the bits line up. + assert_lzw_round_trips("short run", &pixels); + } + + /// LZW on an empty input still emits clear + EOI. This is the + /// documented behaviour and the decoder requires it. + #[test] + fn lzw_compress_empty_still_has_clear_eoi() { + let mut out = Vec::new(); + lzw_compress(&[], 8, &mut out); + assert!(!out.is_empty()); + assert!(lzw_decompress(&out, 8).is_empty()); + } + + /// End-to-end through the container: write a frame with `GifWriter`, + /// then pull the LZW payload back out of the file bytes and decode it. + /// Covers the sub-block chunking and the image-descriptor layout as + /// well as the codec — the encoder and the container have to agree for + /// the pixels to survive. + #[test] + fn written_frame_decodes_back_to_the_same_indices() { + // 64×64 with a diagonal pattern: enough distinct index runs that a + // desynced code size would show up, and >255 compressed bytes so + // the sub-block chunking is exercised. + let (w, h) = (64usize, 64usize); + let indices: Vec = (0..w * h).map(|i| ((i / 7 + i % 13) % 251) as u8).collect(); + let palette = vec![0u8; PALETTE_COLORS * 3]; + + let mut buf = Vec::new(); + { + let mut gw = GifWriter::new(&mut buf, w as u16, h as u16).unwrap(); + gw.write_header().unwrap(); + gw.write_netscape_loop(0).unwrap(); + gw.write_frame(&indices, &palette, 8, 12).unwrap(); + gw.finish().unwrap(); + } + + // Walk to the image descriptor (0x2C), skip its 9-byte body and the + // 256-entry local table, then reassemble the sub-block chain. + let img = buf.iter().position(|&b| b == 0x2C).expect("image descriptor"); + let mut p = img + 10 + PALETTE_COLORS * 3; + assert_eq!(buf[p], 8, "LZW minimum code size"); + p += 1; + let mut payload = Vec::new(); + while buf[p] != 0 { + let len = buf[p] as usize; + payload.extend_from_slice(&buf[p + 1..p + 1 + len]); + p += 1 + len; + } + assert_eq!(lzw_decompress(&payload, 8), indices); + } + + /// Median-cut on a 2-color image should produce 2 distinct + /// palette entries (and pad the rest with the same average). + #[test] + fn median_cut_handles_two_color_image() { + let rgba: Vec = (0..100) + .flat_map(|i| if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }) + .collect(); + let mut palette = vec![0u8; 256 * 3]; + build_palette_median_cut(&rgba, 256, &mut palette); + // The first entries should be near pure red and pure blue; + // the rest pad to the bucket average (whichever the + // algorithm picked first). + let has_red = palette.chunks_exact(3).any(|c| c[0] > 200 && c[1] < 50 && c[2] < 50); + let has_blue = palette.chunks_exact(3).any(|c| c[2] > 200 && c[0] < 50 && c[1] < 50); + assert!(has_red, "median-cut dropped red"); + assert!(has_blue, "median-cut dropped blue"); + } + + /// Median-cut on a uniform image (single color) must not + /// loop forever and must produce a non-empty palette. + #[test] + fn median_cut_uniform_image_does_not_hang() { + let rgba = vec![128u8; 4 * 100]; + let mut palette = vec![0u8; 256 * 3]; + build_palette_median_cut(&rgba, 256, &mut palette); + // Every entry is near gray. + for chunk in palette.chunks_exact(3) { + assert!((chunk[0] as i32 - 128).abs() < 4); + assert!((chunk[1] as i32 - 128).abs() < 4); + assert!((chunk[2] as i32 - 128).abs() < 4); + } + } + + /// Nearest-color mapping: every index is in [0, 256) and the + /// output length matches the input. + #[test] + fn map_to_indices_covers_all_pixels() { + // Use `wrapping_mul` so the test palette stays inside u8 + // without overflowing (the production palette is built by + // median-cut and never overflows, but a test palette + // built from a closed-form expression can). + let palette: Vec = (0..256u32) + .flat_map(|i| { + let i = i as u8; + [i.wrapping_mul(3), i.wrapping_mul(5), i.wrapping_mul(7)] + }) + .collect(); + let rgba: Vec = (0..1000u32) + .flat_map(|i| { + let i = i as u8; + [i, i.wrapping_mul(2), i.wrapping_mul(3), 255] + }) + .collect(); + let mut indices = vec![0u8; 1000]; + map_to_indices(&palette, &rgba, &mut indices); + assert_eq!(indices.len(), 1000); + for &idx in &indices { + assert!((idx as usize) < PALETTE_COLORS); + } + } + + /// The dither path doesn't crash on a 1×1 image (the boundary + /// cases — `x + 1 < w`, `x > 0` — are where off-by-one errors + /// show up) and maps the single pixel into the palette. + #[test] + fn dithered_mapping_handles_one_by_one() { + let rgba = vec![100u8, 150, 200, 255]; + let palette = vec![0u8; PALETTE_COLORS * 3]; + let mut err_cur = vec![0.0f32; 3]; + let mut err_next = vec![0.0f32; 3]; + let mut indices = vec![0u8; 1]; + map_to_indices_dithered( + &palette, + &rgba, + 1, + 1, + &mut err_cur, + &mut err_next, + &mut indices, + ); + assert!((indices[0] as usize) < PALETTE_COLORS); + } + + /// `GifWriter::new` rejects zero dimensions. + #[test] + fn gif_writer_rejects_zero_dimensions() { + let mut buf = Vec::new(); + assert!(GifWriter::new(&mut buf, 0, 100).is_err()); + assert!(GifWriter::new(&mut buf, 100, 0).is_err()); + } + + /// Sub-block writer chops a 600-byte payload into 3 blocks + /// (255 + 255 + 90). The terminator byte is written by the + /// caller, not by `write_sub_blocks` itself — it's a + /// stream-of-sub-blocks, and the terminator's position is a + /// concern of the GIF89a container. + #[test] + fn sub_blocks_chop_at_255() { + let mut buf = Vec::new(); + let payload = vec![0xAAu8; 600]; + write_sub_blocks(&mut Cursor::new(&mut buf), &payload).unwrap(); + // 3 size bytes (255, 255, 90) + 600 payload bytes = 603. + assert_eq!(buf.len(), 603); + assert_eq!(buf[0], 255); + assert_eq!(buf[256], 255); // start of the second block + assert_eq!(buf[512], 90); // start of the third block + // No terminator in `write_sub_blocks` itself — the + // last byte is the last data byte of the third block. + assert_eq!(*buf.last().unwrap(), 0xAA); + } +} diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs new file mode 100644 index 0000000000..d8972e7d1c --- /dev/null +++ b/crates/compositor/src/lib.rs @@ -0,0 +1,137 @@ +//! 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 config; +pub mod cursor; +pub mod ffi; +pub mod frame_geometry; +pub mod gif_export; +pub mod regions; +// Multiplateforme à dessein : n'utilise que libavformat (liée sur les trois +// cibles) et le shim C. Seul Linux l'appelle aujourd'hui, parce que c'est la +// seule plateforme dont la capture passe par `MediaRecorder`, mais rien dedans +// n'est spécifique à Linux. +pub mod remux; +pub mod scene; +pub mod text_anim; +pub mod text_plate; +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; + +#[cfg(target_os = "linux")] +pub mod d3d_linux; +#[cfg(target_os = "linux")] +pub use d3d_linux 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; + +#[cfg(target_os = "linux")] +mod linux_frames; +#[cfg(target_os = "linux")] +use linux_frames as cpu_frames; +#[cfg(target_os = "linux")] +mod linux_decode; + +// 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(target_os = "linux")] +pub mod text_linux; +#[cfg(target_os = "linux")] +pub mod compositor_linux; +#[cfg(target_os = "linux")] +pub mod pipeline_linux; + +#[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; + +#[cfg(target_os = "linux")] +pub use text_linux as text; +#[cfg(target_os = "linux")] +pub use compositor_linux as compositor; +#[cfg(target_os = "linux")] +pub use pipeline_linux as pipeline; + +// `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/linux_decode.rs b/crates/compositor/src/linux_decode.rs new file mode 100644 index 0000000000..e8bb81bc52 --- /dev/null +++ b/crates/compositor/src/linux_decode.rs @@ -0,0 +1,459 @@ +//! Décodeur logiciel ffmpeg — utilisé par la tranche verticale `vk_render` +//! pour ouvrir un MP4 fixture et en extraire la `n`-ième frame en mémoire +//! système, sans aucune dépendance à `D3D11VA`. Côté production, ce sera +//! `pipeline::Decoder::open` côté Windows (qui route par D3D11VA quand FL 11_1 +//! + vidéo disponible, par `vk_frames::VkFrames` sinon) ; ici on isole le +//! chemin « software decode + `vk_frames::present` » pour le démontrer sans +//! toucher `pipeline.rs` (cf. spec §3.4 — `pipeline.rs` est dans WP6). +//! +//! **Sécurité lifetime.** L'`AVFrame` retourné est alloué par `av_frame_alloc` +//! et libéré par `av_frame_free` — le caller doit soit appeler `free_frame()` +//! soit (mieux) laisser `vk_frames::VkFrames::present` la consommer puis +//! réécrire la prochaine. Garder une frame au-delà du prochain `decode_n` +//! libère l'ancienne, exactement comme `cpu_frames::present` côté #162. + +use anyhow::{bail, Context, Result}; +use std::ffi::CString; +use std::ptr; + +use crate::ffi::{ + av_frame_alloc, av_frame_free, av_frame_move_ref, av_frame_unref, av_packet_alloc, + av_packet_free, av_packet_unref, av_read_frame, av_seek_frame, avcodec_alloc_context3, + avcodec_find_decoder, avcodec_flush_buffers, avcodec_free_context, avcodec_open2, + avcodec_parameters_to_context, avcodec_receive_frame, avcodec_send_packet, + avformat_close_input, avformat_find_stream_info, avformat_open_input, AVCodecContext, + AVFormatContext, AVFrame, AVMediaType, AVPacket, AVStream, AVERROR_EAGAIN, AVERROR_EOF, + AVERROR_INVALIDDATA, AVSEEK_FLAG_BACKWARD, +}; + +/// `sn_fmt_stream` est défini dans `crates/compositor/shim.c` — bindgen ne le voit pas +/// (shim.c est compilé séparément par `cc::Build`). On le déclare ici en `extern "C"` +/// comme `pipeline.rs` le fait. La même convention apparaît à plusieurs endroits du +/// crate pour tous les accesseurs du shim. +extern "C" { + fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; +} + +/// `SEEK_SET` constant — la position de seek `av_seek_frame` interprète +/// `timestamp` comme un timestamp absolu (AV_TIME_BASE = microsecondes). +const SEEK_SET: i32 = 0; + +/// Cherche la vidéo du fichier, ouvre le décodeur, et rend un état prêt à +/// décoder. La struct expose `decode_at(frame_idx)` qui seek + décode jusqu'à +/// la frame `frame_idx` (0-indexée depuis le début du flux). +/// +/// Pub (pas `pub(crate)`) parce que `crates/compositor/tests/vk_cross_golden.rs` +/// est un crate externe vis-à-vis de la lib ; le test pilote la tranche. +pub struct SwDecoder { + fmt: *mut AVFormatContext, + dec: *mut AVCodecContext, + stream_idx: i32, + /// Timebase du flux vidéo (en secondes par tick). Permet de convertir un + /// `frame_idx` en timestamp de seek. + stream_timebase: f64, + /// Cadence reelle du flux (avg_frame_rate), PAS 1/time_base. + fps: f64, + /// Packet/frame persistants du pompage SEQUENTIEL (`next_frame`). + pkt: *mut AVPacket, + frame: *mut AVFrame, + sent_eof: bool, + cur_pts: Option, +} + +/// Libère toutes les ressources ffmpeg. `Drop` ne peut pas faillir ; on +/// panique sur une erreur double-free improbable (les handles sont nullifiés +/// après libération, un deuxième `Drop` les trouve à null et n'agit pas). +impl Drop for SwDecoder { + fn drop(&mut self) { + unsafe { + if !self.dec.is_null() { + avcodec_free_context(&mut self.dec); + } + if !self.fmt.is_null() { + avformat_close_input(&mut self.fmt); + } + if !self.frame.is_null() { + av_frame_free(&mut self.frame); + } + if !self.pkt.is_null() { + av_packet_free(&mut self.pkt); + } + } + } +} + +impl SwDecoder { + pub fn open(path: &str) -> Result { + unsafe { Self::open_inner(path) } + } + + unsafe fn open_inner(path: &str) -> Result { + let path_c = CString::new(path).context("chemin NUL inattendu")?; + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let r = avformat_open_input(&mut fmt, path_c.as_ptr(), ptr::null(), ptr::null_mut()); + if r < 0 { + bail!("avformat_open_input({path}) a échoué: {r}"); + } + if fmt.is_null() { + bail!("avformat_open_input({path}) a rendu un fmt null"); + } + let r = avformat_find_stream_info(fmt, ptr::null_mut()); + if r < 0 { + avformat_close_input(&mut fmt); + bail!("avformat_find_stream_info({path}) a échoué: {r}"); + } + // Trouver le premier flux vidéo. `av_find_best_stream` fait ça 1.0. + let stream_idx = crate::ffi::av_find_best_stream( + fmt, + AVMediaType::AVMEDIA_TYPE_VIDEO, + -1, + -1, + ptr::null_mut(), + 0, + ); + if stream_idx < 0 { + avformat_close_input(&mut fmt); + bail!("av_find_best_stream n'a pas trouvé de flux vidéo dans {path}: {stream_idx}"); + } + // Codec params → context → open. AVFormatContext est opaque : `sn_fmt_stream` + // (du `shim.c`) extrait `streams[i]` ; AVStream ne l'est pas, on lit son + // `codecpar` directement. Cf. `pipeline.rs` pour la convention. + let stream = sn_fmt_stream(fmt, stream_idx); + let mut dec = avcodec_alloc_context3(ptr::null()); + if dec.is_null() { + avformat_close_input(&mut fmt); + bail!("avcodec_alloc_context3 a échoué"); + } + let par = (*stream).codecpar; + let r = avcodec_parameters_to_context(dec, par); + if r < 0 { + avcodec_free_context(&mut dec); + avformat_close_input(&mut fmt); + bail!("avcodec_parameters_to_context: {r}"); + } + let codec = avcodec_find_decoder((*par).codec_id); + if codec.is_null() { + avcodec_free_context(&mut dec); + avformat_close_input(&mut fmt); + bail!( + "avcodec_find_decoder n'a pas trouvé de décodeur pour codec_id {}", + (*par).codec_id + ); + } + // Threads de decodage : 0 = « autant que de coeurs », exactement ce que + // `pipeline_windows.rs:526` et `pipeline_macos.rs:178` posent sur leur + // decodeur. Sans ca ffmpeg reste a 1 thread. + (*dec).thread_count = 0; + let r = avcodec_open2(dec, codec, ptr::null_mut()); + if r < 0 { + avcodec_free_context(&mut dec); + avformat_close_input(&mut fmt); + bail!("avcodec_open2: {r}"); + } + // Timebase du flux vidéo — `AVRational { num, den }`. ffmpeg utilise `num` ticks + // par `den` secondes. Le wrapper bindgen expose les deux champs en i32. + let stream_timebase = { + let num = (*stream).time_base.num as f64; + let den = (*stream).time_base.den as f64; + if den == 0.0 { + 1.0 / 60.0 // fallback : suppose 60 fps + } else { + num / den + } + }; + // fps reel du flux : avg_frame_rate d'abord, r_frame_rate en secours, + // 60 en dernier recours. PAS 1/time_base (le time_base est le timescale + // du conteneur, souvent 15360, pas la cadence). + let fps = { + let a = (*stream).avg_frame_rate; + let r = (*stream).r_frame_rate; + if a.num > 0 && a.den > 0 { + a.num as f64 / a.den as f64 + } else if r.num > 0 && r.den > 0 { + r.num as f64 / r.den as f64 + } else { + 60.0 + } + }; + let pkt = av_packet_alloc(); + let frame = av_frame_alloc(); + if pkt.is_null() || frame.is_null() { + avcodec_free_context(&mut dec); + avformat_close_input(&mut fmt); + bail!("av_packet_alloc/av_frame_alloc (pompage sequentiel)"); + } + Ok(SwDecoder { + fmt, + dec, + stream_idx, + stream_timebase, + fps, + pkt, + frame, + sent_eof: false, + cur_pts: None, + }) + } + + /// Rend la frame SUIVANTE du flux, valide jusqu'au prochain appel, ou null a + /// EOF. C'est le pompage `receive_frame`/`read_frame`/`send_packet` classique, + /// identique a `pipeline_windows::Decoder::next` et `pipeline_macos`. Il ne + /// seek PAS : le decodeur garde son etat, donc une lecture sequentielle coute + /// UN packet par frame au lieu d'un re-parcours de demi-GOP. + pub unsafe fn next_frame(&mut self) -> Result<*mut AVFrame> { + loop { + let r = avcodec_receive_frame(self.dec, self.frame); + 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); + } + if r == AVERROR_EOF { + return Ok(ptr::null_mut()); + } + if r != AVERROR_EAGAIN { + bail!("avcodec_receive_frame: {r}"); + } + if self.sent_eof { + return Ok(ptr::null_mut()); + } + let rr = av_read_frame(self.fmt, self.pkt); + if rr < 0 { + // EOF (ou erreur de lecture) : on draine l'encodeur interne. + avcodec_send_packet(self.dec, ptr::null_mut()); + self.sent_eof = true; + } else { + if (*self.pkt).stream_index == self.stream_idx { + let sr = avcodec_send_packet(self.dec, self.pkt); + // AVERROR_INVALIDDATA : packet mal aligne apres un seek, on saute. + // La valeur etait ecrite en dur a -0x2A2A2A2A, soit le tag `****`, + // qui ne designe aucune erreur ffmpeg : le garde ne matchait donc + // jamais et une vraie donnee invalide avortait tout le decodage -- + // exactement le cas du scrub, qui seeke en permanence. + if sr < 0 && sr != AVERROR_INVALIDDATA && sr != AVERROR_EAGAIN { + av_packet_unref(self.pkt); + bail!("avcodec_send_packet: {sr}"); + } + } + av_packet_unref(self.pkt); + } + } + } + + /// Temps source (secondes) de la derniere frame rendue par `next_frame` / + /// `decode_at`, tire du pts REEL et non d'un compteur d'index. + pub fn cur_time_sec(&self) -> Option { + self.cur_pts.map(|pts| pts as f64 * self.stream_timebase) + } + + /// Seek vers la keyframe la plus proche AVANT `frame_idx`, puis décode + /// jusqu'à atteindre la frame demandée. Le seek est résolu par + /// `av_seek_frame` avec `SEEK_SET | BACKWARD` (cherche le keyframe + /// précédent le timestamp demandé). Renvoie une `AVFrame` allouée par + /// `av_frame_alloc` que le caller doit libérer via `free_frame` — + /// ou laisser `vk_frames::VkFrames::present` consommer (qui réécrit + /// `present` avec son carrier, l'ancienne frame devient inaccessible). + /// + /// **Robustesse.** Pour la tranche verticale (`crates/fixture/screen.mp4` + /// qui est un `-c copy` d'un fragment de recording), `av_seek_frame` peut + /// renvoyer un packet dont la première lecture NAL est mal alignée (le + /// moov de la source est en queue, le parser fait de son mieux mais le + /// premier packet après un BACKWARD seek contient parfois un NAL + /// fragmenté). On skippe ces packets avec `send_packet` qui renvoie + /// `AVERROR_INVALIDDATA` plutôt que de paniquer : la prochaine itération + /// lira le packet complet suivant. + pub unsafe fn decode_at(&mut self, frame_idx: u32) -> Result<*mut AVFrame> { + let fps = self.fps; + let target_ts = (frame_idx as f64 / fps) * 1_000_000.0; // AV_TIME_BASE = µs + // `AVSEEK_FLAG_BACKWARD` vaut 1, pas 4 — 4 est `AVSEEK_FLAG_ANY`. La constante + // était écrite en dur à 4 avec un commentaire affirmant le contraire, et c'est + // le seul seek du crate à ne pas passer par `ffi::AVSEEK_FLAG_BACKWARD` (cf. + // pipeline_windows.rs, pipeline_macos.rs, audio.rs). + // + // Sans BACKWARD, ffmpeg se cale sur la première position indexée AU NIVEAU OU + // APRÈS la cible, au lieu de la keyframe qui la précède. La boucle d'avance + // ci-dessous s'arrête dès que `pts >= target`, condition alors satisfaite par la + // toute première frame décodée : elle ne fait plus rien et `decode_at` rend la + // keyframe SUIVANTE. L'erreur est d'un GOP entier. + // + // D'où le symptôme asymétrique signalé : l'écran porte une keyframe toutes les + // ~1,78 s, la webcam toutes les ~6,73 s, donc l'écart y est ~4x plus grand. Et + // comme `live::Player::step` rattrape la webcam par une boucle monotone vers + // l'avant, une fois garée dans le futur elle ne revient jamais — elle fige. + let seek_flags = SEEK_SET | AVSEEK_FLAG_BACKWARD; + let r = av_seek_frame(self.fmt, -1, target_ts as i64, seek_flags); + if r < 0 { + // Repli : rembobiner au début et balayer en avant. + // + // Un WebM de `MediaRecorder` n'a NI Cues NI SeekHead — il est écrit en flux + // et personne ne revient poser l'index —, donc tout seek vers un timestamp + // arbitraire échoue. C'est le cas de tout enregistrement Linux tant qu'il + // n'existe pas de helper de capture natif : la capture y passe par + // getDisplayMedia/MediaRecorder, là où Windows et macOS ont des helpers qui + // écrivent des fichiers indexés. + // + // Rembobiner à 0 reste possible sans index (c'est le début du fichier), et + // la boucle ci-dessous sait déjà avancer jusqu'à `target_ts`. Le coût est + // linéaire, ce qui n'est acceptable que depuis le pompage séquentiel : le + // décodage mesure ~0,07 ms/frame, donc rejoindre la seconde 14 d'un + // enregistrement coûte quelques dizaines de ms au lieu d'échouer. + let rewound = av_seek_frame(self.fmt, -1, 0, seek_flags); + if rewound < 0 { + bail!( + "av_seek_frame(ts={target_ts:.0} µs) a échoué: {r}, et le rembobinage \ + aussi: {rewound}" + ); + } + } + // Flush le décodeur — sans ça, le seek laisse l'état interne avec les + // frames de l'ancien GOP, et la première `receive_frame` peut être + // une frame d'avant le seek. + avcodec_flush_buffers(self.dec); + // Le seek rouvre le flux : le drapeau EOF du pompage sequentiel retombe. + self.sent_eof = false; + + let mut pkt: *mut crate::ffi::AVPacket = ptr::null_mut(); + let mut frame: *mut AVFrame = ptr::null_mut(); + let mut found: *mut AVFrame = ptr::null_mut(); + + let target_ts_seconds = target_ts / 1_000_000.0; + let mut invalid_skips = 0u32; + 'outer: loop { + pkt = av_packet_alloc(); + if pkt.is_null() { + bail!("av_packet_alloc en boucle"); + } + let r = av_read_frame(self.fmt, pkt); + if r < 0 { + // EOF ou erreur : on a épuisé le fichier sans atteindre la cible. + av_packet_free(&mut pkt); + break 'outer; + } + if (*pkt).stream_index != self.stream_idx { + // Pas un packet vidéo — on le jette et on continue. + av_packet_free(&mut pkt); + continue; + } + let send_r = avcodec_send_packet(self.dec, pkt); + av_packet_free(&mut pkt); + if send_r == -0x2A2A2A2A { + // AVERROR_INVALIDDATA — packet mal aligné après un seek. On le + // saute et on continue ; le decodeur attendra un packet propre. + // Valeur ffmpeg = -1094995529 (0xBEEBBEEB), ici écrite comme + // un nombre négatif littéral pour éviter la dépendance `ffi::`. + invalid_skips += 1; + if invalid_skips > 8 { + bail!("plus de 8 packets invalides après seek — fichier ou codec cassé"); + } + continue; + } + if send_r < 0 && send_r != -11 { + bail!("avcodec_send_packet: {send_r}"); + } + frame = av_frame_alloc(); + if frame.is_null() { + bail!("av_frame_alloc en boucle"); + } + loop { + let recv_r = avcodec_receive_frame(self.dec, frame); + if recv_r == 0 { + if found.is_null() { + found = av_frame_alloc(); + if found.is_null() { + bail!("av_frame_alloc pour resultat"); + } + } + // FUITE MÉMOIRE si on l'oublie. `av_frame_move_ref` écrase + // `found` SANS déréférencer ce qu'il contenait — c'est écrit + // noir sur blanc dans libavutil/frame.h : « dst is not + // unreferenced, but directly overwritten without reading or + // deallocating its contents. Call av_frame_unref(dst) + // manually before calling this function to ensure that no + // memory is leaked. » + // + // Cette boucle décode en avant depuis la keyframe jusqu'à la + // cible, donc elle passe ici une fois par frame du GOP. Sans + // ce unref, chaque frame intermédiaire abandonnait ses + // buffers : ~3,1 Mo en 1080p YUV420P, plusieurs dizaines de + // fois par scrub. Symptôme observé : le scrubbing ralentit + // progressivement, puis l'app gèle et meurt. + av_frame_unref(found); + av_frame_move_ref(found, frame); + av_frame_unref(frame); + if (*found).best_effort_timestamp as f64 * self.stream_timebase >= target_ts_seconds { + break 'outer; + } + } else if recv_r == -11 { + break; + } else if recv_r == -541478725 { + // AVERROR_EOF + break 'outer; + } else if recv_r < 0 { + bail!("avcodec_receive_frame: {recv_r}"); + } else { + break; + } + } + av_frame_free(&mut frame); + } + if !frame.is_null() { + av_frame_free(&mut frame); + } + if !pkt.is_null() { + av_packet_free(&mut pkt); + } + if found.is_null() { + bail!("decode_at(frame_idx={frame_idx}) : aucune frame reçue"); + } + let pts = (*found).best_effort_timestamp; + self.cur_pts = if pts == i64::MIN { None } else { Some(pts) }; + Ok(found) + } + + /// Cadence reelle du flux (images/s). Sert a convertir un temps en secondes + /// vers un index de frame pour le seek du preview Linux. + pub fn fps(&self) -> f64 { + self.fps + } + + /// Duree du flux video en secondes (`stream.duration * time_base`), lue via + /// le shim `sn_fmt_stream` (AVFormatContext opaque en bindgen). `None` si + /// indisponible. Pendant Linux de `pipeline_macos::Decoder::available_duration_sec`. + pub fn duration_sec(&self) -> Option { + unsafe { + let stream = sn_fmt_stream(self.fmt, self.stream_idx); + if stream.is_null() { + return None; + } + let duration = (*stream).duration; + if duration > 0 && self.stream_timebase > 0.0 { + let s = duration as f64 * self.stream_timebase; + if s.is_finite() && s > 0.0 { + return Some(s); + } + } + None + } + } + + /// Libère une frame renvoyée par `decode_at`. + pub unsafe fn free_frame(mut frame: *mut AVFrame) { + av_frame_free(&mut frame); + } +} + +// ---------- tests ---------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Le décodeur ne paniquera pas si le fichier n'existe pas — il renvoie + /// `Err`. C'est ce que le test d'intégration attend pour skipper proprement + /// quand `crates/fixture/screen.mp4` est absent. + #[test] + fn open_sur_chemin_inexistant_renvoie_err() { + let r = SwDecoder::open("Z:/does/not/exist.mp4"); + assert!(r.is_err()); + } +} diff --git a/crates/compositor/src/linux_frames.rs b/crates/compositor/src/linux_frames.rs new file mode 100644 index 0000000000..dcb99bedb0 --- /dev/null +++ b/crates/compositor/src/linux_frames.rs @@ -0,0 +1,350 @@ +//! L'axe DECODAGE du backend « CPU-like » Linux : une frame libavcodec en +//! memoire systeme devient deux textures wgpu NV12-split (Y `R8Unorm`, UV +//! entrelacee `Rg8Unorm`), presentees exactement comme si un decodeur materiel +//! les avait produites. +//! +//! Equivalent Linux de `cpu_frames_windows.rs` / `mac_frames.rs`. Meme contrat +//! de « frame seam » (cf. `mac_frames.rs:12-19`) : `compositor::nv12_srvs()` +//! lit quatre champs de l'AVFrame presentee — +//! - `data[0]` : un carrier `Box` (les deux textures wgpu), +//! opaque cote Rust, relu par `compositor_linux::nv12_srvs`, +//! - `data[1]` : 0 (pas d'array), +//! - `width`/`height` : dimensions visibles. +//! `format` est pose a `AV_PIX_FMT_D3D11` comme sur Windows/macOS : un sentinel +//! « buffer GPU natif dans data[0] », jamais inspecte par ffmpeg dans ce pipeline. +//! +//! # Difference avec D3D11/Metal +//! +//! D3D11 a un format NV12 natif (une texture, deux sous-ressources) ; macOS a +//! le CVPixelBuffer IOSurface (zero-copy via CVMetalTextureCache). Vulkan/wgpu +//! n'a pas de format NV12 portable, donc on le decompose en DEUX textures +//! (Y + UV) uploadees par `write_texture`. Le shader WGSL echantillonne les deux +//! et fait le YUV->RGB (cf. `vk_shaders/layer.wgsl`). + +use anyhow::{bail, Result}; +use std::ptr; + +use crate::d3d::Gpu; +use crate::ffi::{ + av_frame_alloc, av_frame_free, av_frame_get_buffer, av_frame_unref, sws_freeContext, + sws_getContext, sws_scale, AVFrame, AVPixelFormat, SwsContext, +}; + +/// `SWS_POINT` (plus proche voisin) : la conversion se fait a dimensions EGALES, +/// aucun reechantillonnage. Valeur figee par l'ABI de libswscale (bindgen ne +/// genere pas les `SWS_*`, ce sont des macros). +const SWS_POINT: i32 = 0x10; + +/// Une frame decodee presentee au compositor sous forme de deux textures wgpu : +/// plane Y (`R8Unorm`, `w x h`) et plane UV entrelacee (`Rg8Unorm`, +/// `(w/2) x (h/2)`). Equivalent NV12-split de la `ID3D11Texture2D` NV12 (D3D11) +/// / du CVPixelBuffer (macOS). +pub(crate) struct VkFrameTex { + pub y: wgpu::Texture, + pub uv: wgpu::Texture, + pub width: u32, + pub height: u32, +} + +#[inline] +fn pack_carrier(tex: Box) -> *mut u8 { + Box::into_raw(tex) as *mut u8 +} + +#[inline] +unsafe fn unpack_carrier<'a>(p: *const u8) -> &'a VkFrameTex { + debug_assert!(!p.is_null()); + &*(p as *const VkFrameTex) +} + +/// Source de frames du backend « CPU-like » Linux. Meme surface que +/// `mac_frames::CpuFrames` (`new` / `present` / `current`) : `pipeline` garde la +/// meme mecanique. Allocation unique de textures reecrites a chaque frame. +pub(crate) struct CpuFrames { + device: wgpu::Device, + queue: wgpu::Queue, + sws: *mut SwsContext, + /// `(w, h, format source)` du contexte swscale courant. + sws_key: (i32, i32, i32), + /// NV12 en memoire systeme : cible de swscale, source de l'upload. + nv12: *mut AVFrame, + tex: Option>, + tex_dims: (u32, u32), + /// La frame remise au compositor. Ne possede aucun pixel : `data[0]` pointe + /// le carrier `VkFrameTex`. + 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 (linux_frames)"); + } + Ok(CpuFrames { + device: gpu.device.clone(), + queue: gpu.context.clone(), + sws: ptr::null_mut(), + sws_key: (0, 0, -1), + nv12, + tex: None, + tex_dims: (0, 0), + present, + }) + } + + /// Convertit `src` (sortie decodeur, memoire systeme) en NV12, l'uploade dans + /// les textures wgpu, et rend la frame de presentation dont `data[0]` est un + /// carrier `Box`. Le pointeur reste valide jusqu'au prochain + /// `present()` — meme contrat que `mac_frames::present`. + pub(crate) unsafe fn present(&mut self, src: *mut AVFrame) -> Result<*mut AVFrame> { + if src.is_null() { + bail!("linux_frames::present: frame source nulle"); + } + let w = (*src).width; + let h = (*src).height; + if w <= 0 || h <= 0 { + bail!("frame decodee sans dimensions ({w}x{h})"); + } + self.ensure_sws(w, h, (*src).format)?; + self.ensure_nv12(w, h)?; + self.ensure_textures(w as u32, h as u32)?; + + 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(), + (*self.nv12).linesize.as_ptr(), + ); + if converted <= 0 { + bail!("sws_scale a converti {converted} lignes"); + } + + self.upload()?; + self.attach_carrier(w, h)?; + // Contrat lu par le compositor : sentinel + timestamps recopies (sinon la + // timeline se croit a t=0). + (*self.present).format = AVPixelFormat::AV_PIX_FMT_D3D11 as i32; + (*self.present).pts = (*src).pts; + (*self.present).best_effort_timestamp = (*src).best_effort_timestamp; + 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(()) + } + + fn ensure_textures(&mut self, w: u32, h: u32) -> Result<()> { + // NV12 impose des dimensions paires pour le chroma : arrondi au-dessus pour + // les textures, `present.width/height` reste aux dimensions visibles (meme + // ecart texture/visible que l'alignement macrobloc D3D11VA, 1080 -> 1088). + let dims = ((w + 1) & !1, (h + 1) & !1); + if let Some(tex) = &self.tex { + if tex.width == dims.0 && tex.height == dims.1 { + return Ok(()); + } + } + let y = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("nv12-y"), + size: wgpu::Extent3d { + width: dims.0, + height: dims.1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let uv = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("nv12-uv"), + size: wgpu::Extent3d { + width: dims.0 / 2, + height: dims.1 / 2, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rg8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.tex = Some(Box::new(VkFrameTex { + y, + uv, + width: dims.0, + height: dims.1, + })); + self.tex_dims = dims; + Ok(()) + } + + /// Upload du NV12 swscale dans les deux textures wgpu. `linesize[0]/[1]` sont + /// les strides memoire (paddes SIMD par swscale), passes tels quels a + /// `bytes_per_row`. + unsafe fn upload(&mut self) -> Result<()> { + let tex = match self.tex.as_ref() { + Some(t) => t, + None => bail!("upload avant ensure_textures"), + }; + let y_stride = (*self.nv12).linesize[0] as usize; + let uv_stride = (*self.nv12).linesize[1] as usize; + let y_size = y_stride * tex.height as usize; + let uv_size = uv_stride * tex.height.div_ceil(2) as usize; + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex.y, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + std::slice::from_raw_parts((*self.nv12).data[0], y_size), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(y_stride as u32), + rows_per_image: Some(tex.height), + }, + wgpu::Extent3d { + width: tex.width, + height: tex.height, + depth_or_array_layers: 1, + }, + ); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex.uv, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + std::slice::from_raw_parts((*self.nv12).data[1], uv_size), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(uv_stride as u32), + rows_per_image: Some(tex.height / 2), + }, + wgpu::Extent3d { + width: tex.width / 2, + height: tex.height / 2, + depth_or_array_layers: 1, + }, + ); + Ok(()) + } + + /// Attache le carrier `Box` a `present.data[0]` et fixe les + /// dimensions visibles (avant padding pair). + unsafe fn attach_carrier(&mut self, w: i32, h: i32) -> Result<()> { + let tex = match self.tex.as_ref() { + Some(t) => t, + None => bail!("attach_carrier avant ensure_textures"), + }; + // Libere un carrier precedent eventuel avant de le remplacer. + if !(*self.present).data[0].is_null() { + let _ = Box::from_raw((*self.present).data[0] as *mut VkFrameTex); + } + (*self.present).data[0] = pack_carrier(Box::new(VkFrameTex { + y: tex.y.clone(), + uv: tex.uv.clone(), + width: tex.width, + height: tex.height, + })); + (*self.present).data[1] = ptr::null_mut(); + (*self.present).width = w; + (*self.present).height = h; + Ok(()) + } + + /// La frame de presentation courante (jamais nulle) — symetrie d'API avec + /// `mac_frames::CpuFrames::current`. + pub(crate) fn current(&self) -> *mut AVFrame { + self.present + } +} + +/// Dimensions (texture, padded pair) du carrier `frame.data[0]`. `(1, 1)` si nul. +pub(crate) unsafe fn carrier_dims(frame: *const AVFrame) -> (u32, u32) { + if (*frame).data[0].is_null() { + return (1, 1); + } + let tex = unpack_carrier((*frame).data[0]); + (tex.width, tex.height) +} + +/// Equivalent Linux de `nv12_srvs` : retourne les deux `TextureView` samplables +/// depuis le carrier `frame.data[0]`. Appele par `compositor_linux`. +pub(crate) unsafe fn nv12_planes( + frame: *const AVFrame, +) -> Result<(wgpu::TextureView, wgpu::TextureView)> { + if (*frame).data[0].is_null() { + bail!("nv12_planes: carrier nul dans data[0]"); + } + let tex = unpack_carrier((*frame).data[0]); + Ok(( + tex.y.create_view(&wgpu::TextureViewDescriptor::default()), + tex.uv.create_view(&wgpu::TextureViewDescriptor::default()), + )) +} + +impl Drop for CpuFrames { + fn drop(&mut self) { + unsafe { + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + if !(*self.present).data[0].is_null() { + let _ = Box::from_raw((*self.present).data[0] as *mut VkFrameTex); + (*self.present).data[0] = ptr::null_mut(); + } + av_frame_free(&mut self.present); + av_frame_free(&mut self.nv12); + } + } +} diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs new file mode 100644 index 0000000000..49199a6999 --- /dev/null +++ b/crates/compositor/src/live.rs @@ -0,0 +1,1966 @@ +//! Vue live : rend le compositing **hors-fenêtre** vers un `Vec` RGBA8 +//! (taille `set_rect`) destiné à être streamé dans un `` Electron via +//! `putImageData`. Option B (canvas) — l'ancienne option A (fenêtre D3D enfant +//! `WS_POPUP` + swapchain) supprimée : la glue TS n'a plus de surface native à +//! embarquer, elle draw chaque frame reçue comme une image bitmap. +//! +//! Pipeline interne : `Player` (decodeur lockstep screen/webcam) + +//! `Compositor::compose_frame` → RT RGBA rastérisé à la GÉOMÉTRIE DE RENDU (depuis la +//! refonte ratio : géométrie de sortie ramenée à la taille du panneau, plus le canvas +//! 16:9 figé d'avant). Le **post-traitement** : +//! - avant : blit du RT vers le backbuffer du swapchain, `Present`. +//! - maintenant : `comp.readback_direct()` copie le RT directement vers la staging +//! `D3D11_USAGE_STAGING` (déjà dimensionnée à la résolution de rendu), `Map`/ +//! `D3D11_MAP_READ`, copie ligne par ligne qui respecte `RowPitch` (même idiome que +//! `dump_nv12`/`dump_raw`), et stocke le `Vec` dans `Shared::latest_frame` pour +//! le `read_frame` napi. Plus de resize intermédiaire (`blit_resized`) : le RT est +//! déjà à la taille voulue, CSS met à l'échelle vers la boîte du panneau côté JS. +//! +//! Modèle de threads : la vue n'a plus de HWND/UI côté thread appelant. Le rendu vit +//! sur un thread dédié — le thread JS/UI n'est jamais bloqué. Les objets COM et la +//! staging restent sur ce thread de rendu ; la frame est publiée via un +//! `Mutex)>>` pour la traversée de threads vers +//! le napi — le `gen` est l'identité de la frame (cf. `LatestFrame`). + +use crate::compositor::{Compositor, LiveParams}; +use crate::regions::speed_at; +use crate::scene::Scene; +use crate::config::{self, Cfg}; +use crate::cursor::CursorTrack; +use crate::d3d::Gpu; +use crate::pipeline::Decoder; +use anyhow::Result; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// "#rrggbb" (ou "rrggbb") → [r, g, b, 1] en 0..1. None si invalide. +fn parse_hex_color(s: &str) -> Option<[f32; 4]> { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 { + return None; + } + let r = u8::from_str_radix(&h[0..2], 16).ok()? as f32 / 255.0; + let g = u8::from_str_radix(&h[2..4], 16).ok()? as f32 / 255.0; + let b = u8::from_str_radix(&h[4..6], 16).ok()? as f32 / 255.0; + Some([r, g, b, 1.0]) +} + +fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64 { + (screen_source_time_sec - webcam_offset_sec).max(0.0) +} + +/// Décodeurs déjà ouverts ET positionnés au bon playhead pour un clip à venir — le résultat +/// d'un préchargement en tâche de fond (voir `open_and_seek_clip`/`maybe_start_prefetch` +/// dans `render_thread`). Appliquer ceci à un `Player` (`apply_prefetched`) ne fait plus +/// aucune E/S : c'est ce qui rend la bascule à la frontière d'un clip instantanée au lieu de +/// payer un `Decoder::open` (ouverture fichier + parsing FFmpeg) synchrone pile au moment de +/// la transition — la pause perceptible observée en usage réel. +struct PrefetchedClip { + sdec: Decoder, + wdec: Decoder, + webcam_offset_sec: f64, + idx: u32, + /// Piste curseur du clip à venir, préchargée ici pour la même raison que les décodeurs : + /// sans ça, la bascule à la frontière restait synchrone sur CE point précis (lecture + + /// parsing JSON du `.cursor.json`, potentiellement des milliers d'échantillons pour un + /// enregistrement long) même après que le préchargement des décodeurs a supprimé le gros + /// de la pause perceptible — un second petit accroc au même endroit, pour la même raison + /// (une E/S synchrone pile à la frontière) qu'on venait de corriger pour les décodeurs. + cursor_track: Option, +} + +/// Ouvre + positionne la paire de décodeurs d'un clip (même travail que +/// `Player::set_active_clip`, mais autonome — sans instance `Player` existante, pour pouvoir +/// tourner sur un thread dédié pendant que le `Player` réel joue encore le clip actif). +unsafe fn open_and_seek_clip( + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + source_time_sec: f64, + gpu: &Gpu, +) -> Result { + let source_time_sec = source_time_sec.max(0.0); + let mut sdec = Decoder::open(screen_path, gpu)?; + let mut wdec = match Decoder::open(webcam_path, gpu) { + Ok(d) => d, + Err(_) => Decoder::open(screen_path, gpu)?, + }; + let sf = sdec.seek_to(source_time_sec)?; + let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?; + if wf.is_null() { + wf = wdec.seek_to(0.0)?; + } + if sf.is_null() { + anyhow::bail!("clip préchargé vide au temps source {source_time_sec:.3}s (screen=\"{screen_path}\")"); + } + let idx = (source_time_sec * sdec.fps()).round().max(0.0) as u32; + let cursor_track = CursorTrack::load(&format!("{screen_path}.cursor.json"), 0.0, 24.0 * 3600.0).ok(); + Ok(PrefetchedClip { sdec, wdec, webcam_offset_sec, idx, cursor_track }) +} + +/// Nombre de paires de décodeurs INACTIVES gardées ouvertes en plus de la paire active. +/// Franchir un clip cross-média rouvre sinon 2 décodeurs (~39 ms) et reseek depuis une image +/// clé (~81 ms) — mesuré ~120 ms/franchissement, le plus gros à-coup du scrub. Les garder +/// ouverts à leur dernière position transforme un RETOUR sur un clip (motif A→B→A ultra +/// fréquent au scrub) en simple reseek, souvent par le chemin rapide `decode_forward`. +/// ponytail: cap fixe. Chaque paire retient son pool de surfaces D3D11VA (VRAM) ; 3 couvre +/// les timelines 2-4 clips, à baisser si la VRAM serre. +const DECODER_POOL_CAP: usize = 3; + +/// Une paire de décodeurs mise de côté, prête à être réactivée sans réouverture. +struct PooledClip { + screen_path: String, + webcam_path: String, + webcam_offset_sec: f64, + clip: PrefetchedClip, +} + +/// Repositionne une paire (écran + webcam) au temps source voulu. `false` = un des deux flux +/// n'a pas de frame utilisable là (position hors flux, EOF non rembobinable) ; l'appelant DOIT +/// alors repartir sur une ouverture fraîche plutôt que de composer une frame vide (« frame sans +/// texture » → preview noire définitive). Partagé par `seek_active` (paire active) et le pool. +unsafe fn seek_pair( + sdec: &mut Decoder, + wdec: &mut Decoder, + source_time_sec: f64, + webcam_offset_sec: f64, +) -> Result { + let sf = sdec.seek_to(source_time_sec)?; + if sf.is_null() { + return Ok(false); + } + let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?; + if wf.is_null() { + wf = wdec.seek_to(0.0)?; + } + if wf.is_null() { + return Ok(false); + } + Ok(true) +} + +/// Bascule cross-média en réutilisant le pool de décodeurs. Réactive la paire cible si elle +/// est déjà en pool (reseek au lieu de rouvrir), sinon ouvre à neuf ; dans les DEUX cas met en +/// pool la paire qu'on QUITTE (au lieu de la fermer), dédupliquée par clé et bornée en LRU. +/// Le vidage du cache de SRV reste à la charge de l'appelant (comme avant) — over-clear est +/// sûr et bon marché, ce qui écarte tout risque « image du clip précédent ». +/// `OPENSCREEN_CLIPSWITCH_TIMING=1` journalise hit/miss + durée. +unsafe fn swap_clip_pooled( + player: &mut Player, + pool: &mut Vec, + request: &ActiveClipRequest, + active_screen: &str, + active_webcam: &str, + active_webcam_offset_sec: f64, +) -> Result<()> { + let timing = std::env::var("OPENSCREEN_CLIPSWITCH_TIMING").is_ok(); + let t0 = std::time::Instant::now(); + let t = request.source_time_sec.max(0.0); + let matches = |p: &PooledClip| { + p.screen_path == request.screen_path + && p.webcam_path == request.webcam_path + && (p.webcam_offset_sec - request.webcam_offset_sec).abs() < 1e-9 + }; + let mut hit = false; + let incoming: PrefetchedClip = match pool.iter().position(&matches) { + Some(i) => { + let mut pooled = pool.remove(i); + // Reseek les décodeurs poolés AVANT de les installer. Échec → on les jette et on + // ouvre à neuf (chemin connu sûr), jamais une frame vide. + if seek_pair(&mut pooled.clip.sdec, &mut pooled.clip.wdec, t, request.webcam_offset_sec)? { + pooled.clip.idx = (t * pooled.clip.sdec.fps()).round().max(0.0) as u32; + hit = true; + pooled.clip + } else { + drop(pooled); + player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)? + } + } + None => player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)?, + }; + let outgoing = player.swap_active(incoming); + // Met la paire quittée en pool : dédup par clé (jamais deux entrées d'un même média), puis + // éviction LRU (le plus ancien, en tête, part en premier). + pool.retain(|p| { + !(p.screen_path == active_screen + && p.webcam_path == active_webcam + && (p.webcam_offset_sec - active_webcam_offset_sec).abs() < 1e-9) + }); + pool.push(PooledClip { + screen_path: active_screen.to_string(), + webcam_path: active_webcam.to_string(), + webcam_offset_sec: active_webcam_offset_sec, + clip: outgoing, + }); + while pool.len() > DECODER_POOL_CAP { + pool.remove(0); + } + if timing { + eprintln!( + "[clipswitch] {} {:.1}ms (t_src={:.2}s, pool={})", + if hit { "POOL_HIT reseek" } else { "OPEN fresh " }, + t0.elapsed().as_secs_f64() * 1000.0, + t, + pool.len(), + ); + } + Ok(()) +} + +/// Lit deux sources en lockstep et compose la frame courante dans le RT du compositeur. +/// Partagé avec la GUI standalone (`app.rs`). +pub struct Player { + sdec: Decoder, + wdec: Decoder, + gpu: Gpu, + webcam_offset_sec: f64, + has_current_frame: bool, + use_current_on_next_step: bool, + idx: u32, +} + +impl Player { + pub unsafe fn open(screen: &str, webcam: &str, gpu: &Gpu) -> Result { + let wdec = match Decoder::open(webcam, gpu) { + Ok(d) => d, + Err(_) => Decoder::open(screen, gpu)?, + }; + Ok(Player { + sdec: Decoder::open(screen, gpu)?, + wdec, + gpu: Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + feature_level: gpu.feature_level, + backend: gpu.backend, + }, + webcam_offset_sec: 0.0, + has_current_frame: false, + use_current_on_next_step: false, + idx: 0, + }) + } + + /// Remplace atomiquement la paire de décodeurs du clip actif. Les nouvelles sources sont + /// ouvertes et positionnées au playhead source courant avant de libérer l'ancienne paire. + /// Synchrone (bloque le thread appelant le temps de l'ouverture) — `render_thread` préfère + /// `apply_prefetched` quand un préchargement en tâche de fond est déjà prêt ; ceci reste le + /// repli correct dans tous les autres cas (changement de clip explicite depuis l'app, + /// préchargement pas encore prêt, etc). + pub unsafe fn set_active_clip( + &mut self, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + source_time_sec: f64, + ) -> Result<()> { + let prefetched = + open_and_seek_clip(screen_path, webcam_path, webcam_offset_sec, source_time_sec, &self.gpu)?; + self.apply_prefetched(prefetched); + Ok(()) + } + + /// Repositionne les décodeurs DÉJÀ ouverts, sans en rouvrir aucun. + /// + /// Pendant de `set_active_clip` pour le cas — dominant — où les FICHIERS n'ont pas + /// changé. L'app raisonne en *segments* (`resolveVisibleClips` découpe les clips aux + /// trims), et deux segments consécutifs d'un même clip pointent sur le même fichier + /// source : seule la fenêtre temporelle diffère. Y répondre par un `set_active_clip` + /// complet, c'est refaire un `Decoder::open` + `avformat_find_stream_info` + une init + /// D3D11VA, deux fois, pour rien. + /// + /// Mesuré : un scrub traversant deux clips a produit 31 bascules — 21 à moins de 500 ms + /// l'une de l'autre — pour deux changements de média réels. + /// Rend `false` quand le repositionnement n'aboutit pas — l'appelant DOIT alors + /// retomber sur `set_active_clip`. + /// + /// Cette sortie existe parce que ce chemin hérite de décodeurs déjà ouverts, donc d'un + /// état : fin de piste atteinte, position hors de la fenêtre, EOF déjà envoyé. + /// `open_and_seek_clip` ne peut pas rencontrer ça (ses décodeurs sont neufs). Une + /// première version marquait la frame comme utilisable sans vérifier les DEUX seeks ; + /// `compose_frame` recevait alors un `AVFrame` vide, `nv12_srvs` échouait avec « frame + /// sans texture D3D11 », et le thread de rendu s'arrêtait définitivement — preview noire + /// jusqu'à recréation de la vue. + pub unsafe fn seek_active(&mut self, source_time_sec: f64) -> Result { + let source_time_sec = source_time_sec.max(0.0); + // Les DEUX flux doivent avoir une frame : `compose_frame` les échantillonne tous les + // deux sans condition, un seul manquant suffit à le faire échouer (d'où le `false` que + // `seek_pair` peut rendre → l'appelant retombe sur l'ouverture complète). + if !seek_pair(&mut self.sdec, &mut self.wdec, source_time_sec, self.webcam_offset_sec)? { + return Ok(false); + } + self.idx = (source_time_sec * self.sdec.fps()).round().max(0.0) as u32; + self.has_current_frame = true; + self.use_current_on_next_step = true; + Ok(true) + } + + /// Bascule instantanément sur une paire de décodeurs déjà ouverte + positionnée — aucune + /// E/S ici, juste l'échange des champs. Utilisé par `set_active_clip` (juste après son + /// propre `open_and_seek_clip`) et directement par `render_thread` quand un préchargement + /// en tâche de fond est déjà prêt au moment de franchir la frontière du clip. + unsafe fn apply_prefetched(&mut self, prefetched: PrefetchedClip) { + // La paire sortante est droppée ici (comportement inchangé pour la lecture libre) ; le + // pool du scrub, lui, récupère la sortante en appelant `swap_active` directement. + let _ = self.swap_active(prefetched); + } + + /// Échange la paire active contre `incoming` (déjà ouverte + positionnée) et REND la paire + /// sortante — pour la mettre en pool au lieu de la fermer. Aucune E/S : juste des champs. + unsafe fn swap_active(&mut self, incoming: PrefetchedClip) -> PrefetchedClip { + let outgoing = PrefetchedClip { + sdec: std::mem::replace(&mut self.sdec, incoming.sdec), + wdec: std::mem::replace(&mut self.wdec, incoming.wdec), + webcam_offset_sec: self.webcam_offset_sec, + idx: self.idx, + // Le curseur est re-dérivé du chemin à la réactivation ; inutile de le trimballer. + cursor_track: None, + }; + self.webcam_offset_sec = incoming.webcam_offset_sec; + self.idx = incoming.idx; + self.has_current_frame = true; + self.use_current_on_next_step = true; + outgoing + } + + /// Ouvre une nouvelle paire de décodeurs positionnée à `source_time_sec`, SANS l'installer + /// (l'appelant l'échange via `swap_active`). Réutilise le device D3D11 du player. + unsafe fn open_clip( + &self, + screen: &str, + webcam: &str, + webcam_offset_sec: f64, + source_time_sec: f64, + ) -> Result { + open_and_seek_clip(screen, webcam, webcam_offset_sec, source_time_sec, &self.gpu) + } + + /// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le + /// franchissement de la fin de fenêtre du clip actif pendant la lecture libre. + pub(crate) unsafe fn screen_time_sec(&self) -> f64 { + self.sdec.cur_time_sec() + } + + /// Compose la frame suivante (→ `comp.rt`). Boucle sur EOF. `false` si fixture vide. + /// + /// L'écran pilote la cadence (1 frame/tick) ; la webcam suit son PROPRE temps source + /// (`screen_time - webcam_offset_sec`), pas un pas 1:1 avec l'écran — BUG corrigé : les + /// deux décodeurs avançaient d'exactement une frame par tick chacun, quelle que soit leur + /// cadence réelle. Écran et webcam sont capturés par des pipelines indépendants (souvent + /// à des fps différents), donc la webcam jouait 2× trop vite dès que sa cadence était + /// inférieure à celle de l'écran. Même logique que `advance_decoder_to` (pipeline.rs), + /// déjà correcte côté export — la preview live ne l'avait jamais reprise. La webcam boucle + /// aussi de façon INDÉPENDANTE à son propre EOF (un clip webcam plus court que l'écran ne + /// doit pas réinitialiser le décodeur écran). + pub unsafe fn step(&mut self, comp: &Compositor, cfg: &Cfg) -> Result { + let use_current = self.use_current_on_next_step; + self.use_current_on_next_step = false; + + let mut sf = if use_current { + self.sdec.cur_frame() + } else { + self.sdec.next()? + }; + if sf.is_null() { + sf = self.sdec.seek_to(0.0)?; + self.idx = 0; + } + if sf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + + let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0); + let mut wf = if use_current { + self.wdec.cur_frame() + } else { + let cur = self.wdec.cur_frame(); + if cur.is_null() { + // Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé. + self.wdec.seek_to(target_webcam_t)? + } else { + // Rattrape la webcam vers `target_webcam_t`, au pire une poignée de frames par + // tick (fps proches) — le garde-fou n'existe que contre un cas pathologique. + let mut wf = cur; + let mut guard = 0u32; + while self.wdec.cur_time_sec() < target_webcam_t { + match self.wdec.next()? { + f if f.is_null() => { + // Fin de la webcam avant l'écran : elle boucle SEULE — l'écran + // garde sa propre position, inchangée. + wf = self.wdec.seek_to(0.0)?; + break; + } + f => wf = f, + } + guard += 1; + if guard > 1000 { + break; + } + } + wf + } + }; + if wf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + + self.has_current_frame = true; + self.sync_time(comp); + comp.compose_frame(sf, wf, self.idx as f32, cfg)?; + self.idx = self.idx.wrapping_add(1); + Ok(true) + } + + /// Positionne `comp` sur le temps source RÉEL (pts) de la frame écran courante, pour que le + /// curseur ET les zoom/full-camera regions du clip actif restent exacts quelle + /// que soit la cadence réelle de l'enregistrement — BUG corrigé : tout dérivait auparavant + /// de `frame / 60.0` (un compteur de frames supposant 60fps pile), qui dérive + /// silencieusement de plus en plus au fil de la lecture dès que le fichier n'est pas + /// exactement à 60fps (30/59.94/etc. sont courants), au lieu de suivre le pts réel du + /// décodeur — exactement la cause du "zoom désynchronisé de la timeline" observé. + unsafe fn sync_time(&self, comp: &Compositor) { + let t = self.sdec.cur_time_sec() as f32; + comp.set_cursor_time(Some(t)); + comp.set_timeline_time(Some(t)); + } + + /// Recompose la frame courante (déjà décodée) — rafraîchit après un changement de param. + pub unsafe fn recompose(&self, comp: &Compositor, cfg: &Cfg) -> Result { + if !self.has_current_frame { + return Ok(false); + } + let sf = self.sdec.cur_frame(); + let wf = self.wdec.cur_frame(); + if sf.is_null() || wf.is_null() { + return Ok(false); + } + self.sync_time(comp); + let f = self.idx.saturating_sub(1); + comp.compose_frame(sf, wf, f as f32, cfg)?; + Ok(true) + } + + /// Seek à `target_sec` (secondes source du clip actif) : keyframe-seek + décodage-avant + /// (`Decoder::seek_to`, même mécanisme robuste que l'export) — remplace l'ancien modèle + /// "compte de frames" qui rewindait tout au frame 0 pour le moindre seek arrière et n'avait + /// aucun raccourci keyframe pour les seeks avant lointains (lent ET, combiné au bug de + /// `set_time`, incorrect au-delà de 6s sur un enregistrement réel). + pub unsafe fn present_frame(&mut self, comp: &Compositor, cfg: &Cfg, target_sec: f64) -> Result { + let sf = self.sdec.seek_to(target_sec)?; + let wf = self + .wdec + .seek_to(webcam_seek_time(target_sec, self.webcam_offset_sec))?; + if sf.is_null() || wf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + self.has_current_frame = true; + self.use_current_on_next_step = false; + self.sync_time(comp); + // "idx" ne sert plus qu'au fallback fixture (jamais lu si une scène est posée) — dérivé + // du temps réel pour rester cohérent si jamais consulté. + self.idx = (target_sec * self.sdec.fps()).round().max(0.0) as u32; + comp.compose_frame(sf, wf, self.idx as f32, cfg)?; + Ok(true) + } +} + +/// Paramètres inspector pilotés depuis l'UI (setParam). Le thread de rendu les applique : +/// booléens/taps → reconstruits dans le `Cfg` ; valeurs continues → `set_live_params`. +#[derive(Clone, Copy, PartialEq)] +struct InspectorParams { + bg_blur: bool, + bg_color: [f32; 4], + shadow_scale: f32, + radius_scale: f32, + mblur_taps: u32, + padding: f32, + webcam_size_scale: f32, + webcam_mirror: bool, + webcam_shape: u32, + cursor_show: bool, + cursor_size_scale: f32, + cursor_bounce_scale: f32, + /// 0..1 : force du lissage ressort-amortisseur de la position (0 = brut). Reconstruit la + /// piste (voir `raw_cursor.smoothed()` dans `render_thread`) plutôt qu'un simple scalaire de + /// dessin — d'où le suivi séparé de sa dernière valeur appliquée. + cursor_smoothing: f32, + /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). + cursor_motion_blur: f32, +} + +impl Default for InspectorParams { + fn default() -> Self { + Self { + bg_blur: false, + bg_color: [0.10, 0.11, 0.14, 1.0], + shadow_scale: 1.0, + radius_scale: 1.0, + mblur_taps: 8, + padding: 0.0, + webcam_size_scale: 1.0, + webcam_mirror: false, + webcam_shape: 3, + cursor_show: true, + cursor_size_scale: 1.0, + cursor_bounce_scale: 1.0, + cursor_smoothing: 0.0, + cursor_motion_blur: 0.0, + } + } +} + +#[derive(Clone)] +struct ActiveClipRequest { + screen_path: String, + webcam_path: String, + webcam_offset_sec: f64, + /// Identité dans le flux `Scene.clips` trié (les chemins ne suffisent pas pour un asset partagé). + clip_index: usize, + /// Playhead exprimé sur l'horloge source écran du nouveau clip. + source_time_sec: f64, +} + +fn same_source_path(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) +} + +/// True when the active clip really has a camera to draw. +/// +/// TWO ways the app says "no camera", and both must be caught here, because the +/// webcam decoder is opened either way — `open_and_seek_clip` falls back to the +/// SCREEN file when the webcam path won't open, so `wdec` always yields frames. +/// Whether those frames are the camera or a second copy of the screen is decided +/// HERE and nowhere else. +/// +/// - the empty string, which is what `sceneDescription.ts` and +/// `NativeCompositorOverlay` send for an asset with no `cameraTrack`; +/// - the screen's own path, the older convention kept working for scenes that +/// still use it. +/// +/// Missing the empty-string case is what put the screen recording inside the PiP +/// box: `"" != "/…/recording.mp4"`, so the box was drawn, and the decoder behind +/// it was the screen fallback. +fn webcam_is_real(webcam_path: &str, screen_path: &str) -> bool { + !webcam_path.trim().is_empty() && !same_source_path(webcam_path, screen_path) +} + +fn scene_clip_matches( + clip: &crate::scene::SceneClip, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> bool { + same_source_path(&clip.screen_path, screen_path) + && same_source_path(&clip.webcam_path, webcam_path) + && (clip.webcam_offset_sec - webcam_offset_sec).abs() <= 1e-6 +} + +fn find_scene_clip_index( + scene: &Scene, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> Option { + scene.clips.iter() + .position(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) + .or_else(|| scene.clips.iter().position(|clip| { + same_source_path(&clip.screen_path, screen_path) + && same_source_path(&clip.webcam_path, webcam_path) + })) +} + +/// Paths and the asset-level webcam offset are identical for multiple cuts of one recording, +/// so path lookup alone always returns clip 0. Prefer the explicit timeline identity. +fn resolve_scene_clip_index( + scene: &Scene, + requested_clip_index: usize, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> Option { + if scene.clips.get(requested_clip_index) + .is_some_and(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) + { + Some(requested_clip_index) + } else { + find_scene_clip_index(scene, screen_path, webcam_path, webcam_offset_sec) + } +} + +fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene { + match scene.clips.get(clip_index) { + Some(clip) => scene.for_clip_window( + clip_index, + clip.source_start_sec, + clip.source_end_sec, + ), + None => scene.clone(), + } +} + +/// Dernière frame readback vers CPU, prête pour le napi `read_frame`. +/// +/// `(gen, w, h, vec)` où `vec.len() == w*h*4` octets RGBA8 tightly-packed (R, G, B, A +/// en mémoire — cf. `Compositor::readback_resized`). `gen` est une génération monotone +/// (≥ 1, `0` réservé à « le consommateur n'a encore rien vu ») incrémentée à CHAQUE +/// publication, càd uniquement quand une nouvelle frame a réellement été composée (le +/// thread de rendu ne republie pas une frame identique — cf. `stepped || first`). Elle +/// est l'IDENTITÉ de la frame : le consommateur (`read_frame`) ne repaie le clone + l'IPC +/// que lorsqu'elle change. `None` = "aucune frame composée pour l'instant" (toutes les +/// lectures avant la 1re frame composée retournent `None` côté napi, jamais un buffer vide). +type LatestFrame = (u64, u32, u32, Vec); + +/// État partagé thread appelant → thread de rendu (commandes sans blocage). +struct Shared { + /// Résolution cible du preview (largeur, hauteur) en pixels devices — ce que la + /// zone canvas Electron affiche. Plus de HWND/HWND-parent : la preview est une + /// image bitmap posée sur un ``, la position CSS est gérée entièrement + /// côté web. Lecture/écriture exclusive via `Mutex`. + preview_size: Mutex<(u32, u32)>, + inspector: Mutex, + /// Temps source (secondes) demandé par l'app pour le clip actif (presentTime/seek), prioritaire + /// sur la lecture libre. En SECONDES (pas un index de frame) : `Player::present_frame` fait un + /// vrai seek keyframe (`Decoder::seek_to`, comme l'export) au lieu de compter des frames — + /// BUG corrigé : l'ancien `set_time` convertissait en index de frame à 60fps fixe PUIS le + /// wrappait modulo `FIXTURE_FRAMES` (360 = 6s) — un reliquat du bench fixture qui faisait + /// boucler silencieusement tout seek au-delà de 6s sur un enregistrement réel, exactement + /// la cause du "zoom timeline désynchronisé" observé. + requested_frame: Mutex>, + /// Changement de sources consommé par le thread de rendu, seul propriétaire des décodeurs. + active_clip_request: Mutex>, + /// scène de l'app (contrat) ; appliquée au compositeur quand `scene_dirty`. + scene: Mutex>, + scene_dirty: AtomicBool, + playing: AtomicBool, + stop: AtomicBool, + /// Dernière frame RGBA8 readback (taille + pixels R,G,B,A tightly-packed). Écrit + /// par le thread de rendu après chaque `compose_frame` réussi, lu par le napi + /// `read_frame` depuis le thread Node principal. `Mutex>` — + /// Option pour distinguer "pas de frame encore composée" (avant le 1er compose, + /// `read_frame` retourne `Ok(None)`) d'un buffer vide (qui n'arrive jamais). + latest_frame: Mutex>, + /// Génération de la dernière frame publiée. Tenue À PART de `latest_frame` : la + /// livraison sans copie vide le slot en le lisant, et dériver la génération d'un slot + /// 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. +/// +/// Plus de fenêtre/OS : le handle ne porte plus de `HWND`. Toute la machinerie Win32 +/// (CreateWindowEx / SetWindowPos / DestroyWindow / register_overlay_class) a été +/// retirée — la preview est désormais purement hors-fenêtre, transportable via +/// mémoire. +pub struct LiveView { + shared: Arc, + thread: Option>, +} + +// `LiveView` ne référence plus aucune ressource Win32 non-`Send`. `Shared` non plus +// (`Mutex`, `AtomicBool`, `Option>`). Le `JoinHandle` est `Send`/`!Sync` +// mais on n'en extrait rien côté napi. Tout ce qui vit dans le thread de rendu +// (compositor, décodeurs, staging, GPU) y reste confiné. +unsafe impl Send for LiveView {} + +impl LiveView { + /// Crée une vue offscreen : pas de HWND/UI côté thread appelant. Démarre juste + /// le thread de rendu qui va composer chaque frame et publier le readback dans + /// `Shared::latest_frame` pour le napi `read_frame`. + /// + /// `w`/`h` sont la **résolution cible du preview** (taille du `` Electron + /// affichant la preview, en pixels device) — anciennement c'était le rect de la + /// fenêtre overlay ; maintenant c'est juste la taille du bitmap RGBA produit. + /// Ajustable à chaud via `set_rect(w, h)`. + pub fn create( + w: u32, + h: u32, + screen: &str, + webcam: &str, + cursor_json: &str, + ) -> Result { + let shared = Arc::new(Shared { + preview_size: Mutex::new((w.max(1), h.max(1))), + inspector: Mutex::new(InspectorParams::default()), + requested_frame: Mutex::new(None), + active_clip_request: Mutex::new(None), + scene: Mutex::new(None), + scene_dirty: AtomicBool::new(false), + playing: AtomicBool::new(true), + stop: AtomicBool::new(false), + latest_frame: Mutex::new(None), + 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.clone(), &s, &wc, &cj) } { + eprintln!("[live] render thread error: {e:#}"); + if let Ok(mut fatal) = sh.fatal.lock() { + *fatal = Some(format!("{e:#}")); + } + } + }); + + Ok(LiveView { shared, thread: Some(thread) }) + } + + /// Met à jour la résolution cible du preview. Force le redimensionnement des + /// ressources GPU de readback (`Compositor::ensure_resize_target` / + /// `live_readback_staging`) au prochain tour du thread de rendu. + /// + /// Signature : `(w, h)` — l'ancienne `(x, y, w, h)` de la fenêtre overlay n'a + /// plus de sens (la position est gérée par CSS côté Electron). `set_rect` côté + /// napi doit s'aligner sur ce 2-param (la largeur/hauteur seule). + pub fn set_rect(&self, w: u32, h: u32) { + if let Ok(mut s) = self.shared.preview_size.lock() { + *s = (w.max(1), h.max(1)); + } + } + + /// Message de l'erreur qui a tué le thread de rendu, `None` tant qu'il tourne. + /// Définitif : le thread ne redémarre pas. + pub fn fatal_error(&self) -> Option { + self.shared.fatal.lock().ok().and_then(|guard| guard.clone()) + } + + /// Récupère la dernière frame readback (gen + taille + RGBA8 tightly-packed). + /// `None` si rien n'a encore été composé (jamais écrit). **Coût : O(w·h)** + /// (copie du `Vec` — nécessaire pour traverser la frontière thread + le + /// FFI vers le Buffer napi). Le `Vec` retourné a `len() == w*h*4`. + /// Préférer `latest_frame_since` sur le chemin chaud : il évite ce clone quand + /// le consommateur possède déjà la génération courante. + pub fn latest_frame(&self) -> Option<(u64, u32, u32, Vec)> { + self.shared + .latest_frame + .lock() + .ok() + .and_then(|guard| guard.as_ref().cloned()) + } + + /// Récupère la dernière frame UNIQUEMENT si sa génération est postérieure à + /// `since_gen`. `None` couvre les DEUX cas où le consommateur n'a rien à peindre : + /// - rien n'a encore été composé (aucune frame publiée), ou + /// - il possède déjà la génération courante (`gen <= since_gen`). + /// Dans ce second cas — l'essentiel du temps d'édition, preview en pause sur une + /// frame figée — on n'exécute PAS le clone `O(w·h)` : c'est tout l'intérêt du + /// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au + /// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`. + pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec)> { + let mut guard = self.shared.latest_frame.lock().ok()?; + match guard.as_ref() { + // Le buffer est EMPORTÉ, pas copié. Le thread de rendu le remplace à chaque + // frame composée et le consommateur garde ses pixels peints sur le canvas : + // personne ne relit jamais la même génération. Le `clone()` d'avant était un + // memcpy `O(w·h)` — 1,5 ms à 1280×720, 3,4 ms à 1920×1080 — payé sur le THREAD + // PRINCIPAL de Node, celui-là même qui doit rester libre pour que React peigne + // la tête de lecture. + // + // Contrepartie assumée : une relecture forcée (`since_gen = 0`) après la + // première ne retrouve rien tant qu'une nouvelle frame n'est pas composée. Sans + // conséquence ici — le seul consommateur ne l'utilise qu'au montage, et un + // redimensionnement provoque de toute façon une recomposition. + Some((gen, ..)) if *gen > since_gen => guard.take(), + _ => None, + } + } + + /// Switch inspector (booléen). + pub fn set_param_bool(&self, key: &str, value: bool) { + if let Ok(mut p) = self.shared.inspector.lock() { + match key { + "backgroundBlur" => p.bg_blur = value, + "webcamMirror" => p.webcam_mirror = value, + "cursorShow" => p.cursor_show = value, + _ => {} + } + } + } + + /// Slider inspector (numérique). Conventions : `shadow`/`roundness`/`webcamSize`/ + /// `cursorSize`/`cursorClickBounce` = échelle (1 = défaut) ; `padding` = 0..1 ; + /// `motionBlur` = 0..1 mappé sur 1..16 taps. + pub fn set_param_num(&self, key: &str, value: f64) { + if let Ok(mut p) = self.shared.inspector.lock() { + let v = value as f32; + match key { + "shadow" => p.shadow_scale = v.max(0.0), + "roundness" => p.radius_scale = v.max(0.0), + "motionBlur" => p.mblur_taps = (1.0 + value.clamp(0.0, 1.0) * 15.0).round() as u32, + "padding" => p.padding = v.clamp(0.0, 1.0), + "webcamSize" => p.webcam_size_scale = v.max(0.05), + "cursorSize" => p.cursor_size_scale = v.max(0.0), + "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), + "cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0), + "cursorMotionBlur" => p.cursor_motion_blur = v.clamp(0.0, 1.0), + _ => {} + } + } + } + + /// Sélection de chaîne : couleur de fond "#rrggbb" ou forme webcam. + pub fn set_param_str(&self, key: &str, value: &str) { + if let Ok(mut p) = self.shared.inspector.lock() { + match key { + "backgroundColor" => { + if let Some(c) = parse_hex_color(value) { + p.bg_color = c; + } + } + "webcamShape" => { + p.webcam_shape = crate::compositor::webcam_shape_code(value); + } + _ => {} + } + } + } + + pub fn set_playing(&self, playing: bool) { + self.shared.playing.store(playing, Ordering::Relaxed); + } + + /// Ce que la vue est en train de faire : lecture libre (`true`) ou pause (`false`). + /// Lu par l'export, qui met les previews en pause le temps d'encoder et doit pouvoir + /// leur rendre CET état plutôt que d'en supposer un (voir `PausedPreviews`). + pub fn playing(&self) -> bool { + self.shared.playing.load(Ordering::Relaxed) + } + + /// Installe la scène de l'app (JSON `SceneDescription`). Parsé ici (hors thread de rendu) ; + /// appliqué au compositeur au prochain tour via le flag `scene_dirty`. JSON invalide → ignoré. + pub fn set_scene(&self, json: &str) { + match Scene::from_json(json) { + Ok(scene) => { + if let Ok(mut s) = self.shared.scene.lock() { + *s = Some(scene); + self.shared.scene_dirty.store(true, Ordering::Relaxed); + } + } + Err(e) => eprintln!("[live] set_scene: JSON invalide: {e:#}"), + } + } + + /// Positionne la vue sur le temps source `seconds` du clip actif — plus de conversion en + /// index de frame ni de wrap fixture ici (voir `requested_frame`). + pub fn set_time(&self, seconds: f64) { + if let Ok(mut r) = self.shared.requested_frame.lock() { + *r = Some(seconds.max(0.0)); + } + } + + /// Programme le remplacement de la paire screen/webcam sur le thread de rendu. L'identité + /// du clip et son playhead source voyagent avec les chemins pour rendre le switch atomique. + pub fn set_active_clip( + &self, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + clip_index: usize, + source_time_sec: f64, + ) { + if let Ok(mut request) = self.shared.active_clip_request.lock() { + *request = Some(ActiveClipRequest { + screen_path: screen_path.to_string(), + webcam_path: webcam_path.to_string(), + webcam_offset_sec, + clip_index, + source_time_sec: source_time_sec.max(0.0), + }); + } + } +} + +impl Drop for LiveView { + fn drop(&mut self) { + // 1. Stoper le thread (il observe `stop` en tête de boucle et sort proprement). + self.shared.stop.store(true, Ordering::SeqCst); + // 2. Join. À la sortie, le thread a relâché toutes ses ressources GPU (compositor, + // décodeurs, resize_target, staging) ; le `Shared` reste vivant tant qu'on n'a + // pas droppé notre `Arc` final. + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + // Plus rien à détruire côté Win32 — pas de HWND. + } +} + +/// A preview's transport — free-run or paused — readable AND writable without touching the +/// GPU. It is the only slice of `LiveView` an export needs: it pauses the previews to free +/// the 3D engine, then hands the transport back. A trait rather than `LiveView` itself so +/// that `PausedPreviews` can be tested with no D3D device (see this file's tests). +pub trait PreviewTransport { + fn playing(&self) -> bool; + fn set_playing(&self, playing: bool); +} + +impl PreviewTransport for LiveView { + fn playing(&self) -> bool { + // Fully-qualified on purpose: inside a trait impl, `self.playing()` resolving to the + // inherent method is a silent coincidence of method resolution, not a guarantee. + LiveView::playing(self) + } + + fn set_playing(&self, playing: bool) { + LiveView::set_playing(self, playing); + } +} + +/// What every preview was doing when an export paused them — enough to give EACH ONE back the +/// state it was found in, instead of resuming them all. +/// +/// That distinction is the fix for a visible bug, not a nicety. While editing, the preview is +/// PAUSED; the renderer only pushes `setPlaying` when the transport actually changes, so +/// nothing ever came along to re-pause a preview an export had resumed on its own authority. +/// It went back to free-running for its own account: its playhead left the moment the app +/// believed was on screen (the zoom then sampled at the wrong source time), and at the first +/// clip boundary it crossed, it swapped its scene over to ANOTHER clip +/// (`scene_for_clip`) — after which the zoom regions of the clip actually being displayed were +/// filtered out of the scene, and no amount of seeking brought them back (the app only pushes +/// `set_active_clip` when ITS active clip changes, and its own had not changed). Only a window +/// reload, which recreates the view, repaired it. +/// +/// `K` is the caller's identity for a view (the napi registry id). Previews created AFTER the +/// pause are absent from the snapshot and are left strictly alone: their transport belongs to +/// whoever created them. +pub struct PausedPreviews { + was_playing: Vec<(K, bool)>, +} + +impl Default for PausedPreviews { + fn default() -> Self { + Self { was_playing: Vec::new() } + } +} + +impl PausedPreviews { + /// Pauses every preview and reports what each one was doing. + pub fn pause<'a, V: PreviewTransport + 'a>(views: impl IntoIterator) -> Self { + let mut was_playing = Vec::new(); + for (key, view) in views { + was_playing.push((key, view.playing())); + view.set_playing(false); + } + Self { was_playing } + } + + /// Gives every preview the snapshot knows about the state it was found in. + pub fn restore<'a, V: PreviewTransport + 'a>(&self, views: impl IntoIterator) { + for (key, view) in views { + if let Some((_, was_playing)) = self.was_playing.iter().find(|(k, _)| *k == key) { + view.set_playing(*was_playing); + } + } + } +} + +/// Un préchargement en cours : quel `next_index` (dans `Scene.clips`) il prépare, et le canal +/// par lequel le thread de fond livre le résultat une fois prêt. +type PendingPrefetch = (usize, std::sync::mpsc::Receiver>); + +/// Combien de secondes avant la fin du clip actif on lance le préchargement du suivant en +/// tâche de fond. Assez large pour couvrir un `Decoder::open` typique (ouverture fichier + +/// `avformat_find_stream_info` + init D3D11VA), assez court pour ne pas garder deux paires de +/// décodeurs ouvertes plus longtemps que nécessaire. +const PREFETCH_LEAD_SEC: f64 = 0.75; + +/// Démarre le préchargement du clip suivant sur un thread dédié dès qu'on entre dans la +/// fenêtre `PREFETCH_LEAD_SEC` avant la fin du clip actif — pour que la bascule à la +/// frontière (`advance_to_next_scene_clip`) trouve les décodeurs déjà ouverts et positionnés +/// au lieu de payer l'E/S + le parsing FFmpeg sur le thread de rendu pile au moment de la +/// transition (la pause perceptible observée en usage réel). No-op si un préchargement est +/// déjà en cours, ou pour une scène à 1 clip (voir `advance_to_next_scene_clip`). +unsafe fn maybe_start_prefetch( + scene: &Scene, + active_clip_index: usize, + screen_time_sec: f64, + gpu: &Gpu, + prefetch: &mut Option, +) { + if scene.clips.len() <= 1 || prefetch.is_some() { + return; + } + let Some(clip) = scene.clips.get(active_clip_index) else { + return; + }; + let remaining = clip.source_end_sec - screen_time_sec; + if !(0.0..PREFETCH_LEAD_SEC).contains(&remaining) { + return; + } + let next_index = if active_clip_index + 1 < scene.clips.len() { + active_clip_index + 1 + } else { + 0 + }; + let next_clip = scene.clips[next_index].clone(); + // Copie légère (COM refcount, pas de nouveau device) — même motif que `Player::open`. + let gpu_clone = Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + feature_level: gpu.feature_level, + backend: gpu.backend, + }; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = unsafe { + open_and_seek_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + &gpu_clone, + ) + }; + // L'appelant a pu abandonner ce préchargement entre-temps (changement de clip + // explicite, scène remplacée) — un receiver droppé fait juste échouer `send` + // silencieusement ; les décodeurs déjà ouverts sont libérés normalement (`Drop`). + let _ = tx.send(result); + }); + *prefetch = Some((next_index, rx)); +} + +/// Bascule le `Player` + le compositeur sur le clip suivant de `scene` (reboucle sur le +/// premier après le dernier). No-op pour une scène à 1 clip (le bouclage léger existant de +/// `Player::step` suffit et coûte moins cher qu'un `set_active_clip` — reopen des décodeurs). +/// +/// Partagée entre le déclenchement PROACTIF (seuil `source_end_sec` franchi) et le filet de +/// sécurité RÉACTIF de `render_thread` (le temps du décodeur a reculé — `Player::step` a +/// bouclé sur l'EOF RÉEL du fichier avant que le seuil ne soit jamais atteint : cas d'un clip +/// NON trimmé dont la dernière frame réelle a un PTS strictement inférieur au +/// `source_end_sec` déclaré, qui égale alors la durée totale du fichier — le seuil `>=` ne se +/// déclenche jamais dans ce cas, d'où le "ça boucle sur le 1er clip" observé malgré le +/// déclenchement proactif). +/// +/// Si `maybe_start_prefetch` a eu le temps de préparer ce même `next_index` à l'avance, la +/// bascule est instantanée (juste un échange de champs, `Player::apply_prefetched`) ; sinon +/// on retombe sur l'ouverture synchrone habituelle (`Player::set_active_clip`) — correct dans +/// tous les cas, juste plus lent quand le préchargement n'a pas eu le temps de finir. +#[allow(clippy::too_many_arguments)] +unsafe fn advance_to_next_scene_clip( + player: &mut Player, + comp: &Compositor, + scene: &Scene, + prefetch: &mut Option, + active_screen_path: &mut String, + active_webcam_path: &mut String, + active_webcam_offset_sec: &mut f64, + active_clip_index: &mut usize, + raw_cursor: &mut Option, + last_smoothing: &mut f32, +) { + if scene.clips.len() <= 1 { + return; + } + let next_index = if *active_clip_index + 1 < scene.clips.len() { + *active_clip_index + 1 + } else { + 0 + }; + let next_clip = &scene.clips[next_index]; + + // N'importe quel préchargement en cours ne concerne plus que CETTE frontière (on vient + // de la franchir, bien ou mal ciblée) — on le consomme s'il correspond, on l'abandonne + // sinon, dans tous les cas il ne doit pas survivre à cet appel. + let ready = prefetch.take().and_then(|(idx, rx)| { + if idx == next_index { rx.try_recv().ok() } else { None } + }); + + // Le curseur préchargé (voir `PrefetchedClip::cursor_track`) doit être extrait AVANT de + // passer `prefetched` (par valeur) à `apply_prefetched`, qui ne s'occupe que des + // décodeurs — sinon ce champ serait silencieusement perdu avec le reste de la struct. + let prefetched_cursor: Option> = match &ready { + Some(Ok(p)) => Some(p.cursor_track.clone()), + _ => None, + }; + + let applied = match ready { + Some(Ok(prefetched)) => { + player.apply_prefetched(prefetched); + Ok(()) + } + Some(Err(e)) => { + eprintln!("[live] préchargement du clip suivant: {e:#} — repli sur ouverture synchrone"); + player.set_active_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + ) + } + None => player.set_active_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + ), + }; + + match applied { + Ok(()) => { + // Jeu de décodeurs remplacé ici aussi (franchissement pendant la lecture libre) : + // même raison qu'au traitement d'`active_clip_request` — le cache de SRV est keyé + // sur l'adresse de la texture, et garder des entrées d'un décodeur fermé fait + // fuir de la VRAM puis, en cas de réutilisation d'adresse, rendre l'image du clip + // précédent. + comp.clear_srv_cache(); + *active_screen_path = next_clip.screen_path.clone(); + *active_webcam_path = next_clip.webcam_path.clone(); + *active_webcam_offset_sec = next_clip.webcam_offset_sec; + *active_clip_index = next_index; + comp.set_scene(Some(scene_for_clip(scene, *active_clip_index))); + // Réutilise le curseur préchargé s'il est disponible (voir plus haut) — sinon + // (préchargement pas encore prêt / raté) on retombe sur la lecture synchrone + // habituelle, comme avant cette optimisation. + *raw_cursor = match prefetched_cursor { + Some(track) => track, + None => { + let cursor_path = format!("{}.cursor.json", active_screen_path); + CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok() + } + }; + match raw_cursor { + Some(track) => comp.set_cursor(track.smoothed(0.0)), + None => comp.clear_cursor(), + } + *last_smoothing = -1.0; + } + Err(e) => eprintln!("[live] auto-advance clip: {e:#}"), + } +} + +/// Taille à laquelle la preview doit rastériser : la **géométrie de sortie** (donc +/// le ratio réel de l'export — la preview doit montrer ce qui sera rendu), ramenée +/// à ce que le canvas affiche réellement. +/// +/// Deux bornes, pour deux raisons distinctes : +/// - jamais plus grand que le **panneau** : les pixels en trop seraient réduits +/// dans la foulée par `readback_resized`, c'est du coût pur (sur un projet 4K +/// ce serait 8 Mpx rastérisés pour un canvas qui en affiche moins d'un) ; +/// - jamais plus grand que la **sortie** : au-delà, la preview serait plus +/// détaillée que l'export, donc mensongère. +/// +/// Sans scène, on ne connaît pas encore le ratio : on prend la taille du panneau +/// telle quelle (aucune composition n'a lieu tant que la scène n'est pas posée). +fn preview_render_size(scene: Option<&Scene>, pw: u32, ph: u32) -> (u32, u32) { + let (pw, ph) = (pw.max(2), ph.max(2)); + let Some(scene) = scene else { + return (pw, ph); + }; + let (ow, oh) = (scene.output.width.max(1) as f64, scene.output.height.max(1) as f64); + // "contain" : le plus grand cadre au ratio de sortie qui tienne dans le panneau. + let scale = (pw as f64 / ow).min(ph as f64 / oh).min(1.0); + // Arrondi via la MÊME règle que `new_sized` : la boucle de rendu compare cette + // taille à `comp.render_size()` (qui renvoie la valeur arrondie) pour décider de + // reconstruire. Sans ce passage par `normalize_render_size`, une cible impaire + // ne serait jamais égalée → reconstruction du compositeur à chaque frame. + Compositor::normalize_render_size((ow * scale).round() as u32, (oh * scale).round() as u32) +} + +/// Boucle de rendu (thread dédié) : décode → compose → resize → readback → publie +/// dans `Shared::latest_frame`. +unsafe fn render_thread( + shared: Arc, + screen: &str, + webcam: &str, + cursor_json: &str, +) -> Result<()> { + // Chemin de PRODUCTION : matériel si possible, backend CPU sinon (voir `create_auto`). + let gpu = Gpu::create_auto(false)?; + let mut comp = Compositor::new(&gpu)?; + // Vue live = le VRAI enregistrement, pas la fenêtre fixture (100s@6s, taillée pour l'ancien + // fixture POC). On charge toute la piste depuis t=0 ; 24h couvre large toute recording réelle. + // Gardée à part (raw_cursor) pour pouvoir régénérer une variante lissée sans relire le + // fichier à chaque changement du slider "smoothing" (voir la boucle plus bas). + let mut raw_cursor = CursorTrack::load(cursor_json, 0.0, 24.0 * 3600.0).ok(); + /// Chemin de la télémétrie curseur actuellement chargée dans `raw_cursor` — évite de + /// relire le même fichier à chaque changement de segment (voir plus bas). + let mut loaded_cursor_path = cursor_json.to_string(); + if let Some(track) = &raw_cursor { + comp.set_cursor(track.smoothed(0.0)); + } + let mut player = Player::open(screen, webcam, &gpu)?; + let mut active_screen_path = screen.to_string(); + let mut active_webcam_path = webcam.to_string(); + let mut active_webcam_offset_sec = 0.0f64; + let mut active_clip_index = 0usize; + // Copie de la Scene complète (tous les clips), tenue à jour à chaque push de l'app — + // permet à la boucle de lecture libre de connaître la fenêtre source + // [source_start_sec, source_end_sec) du clip actif et d'enchaîner elle-même sur le + // clip suivant (voir plus bas), sans dépendre d'un aller-retour JS par frontière de + // clip : la timeline est un niveau d'abstraction AU-DESSUS des clips, elle se lit + // dans son entièreté et l'utilisateur ne doit jamais remarquer la frontière. + let mut full_scene: Option = None; + // Préchargement du clip suivant en cours (voir `maybe_start_prefetch`) — `None` la + // plupart du temps, `Some` seulement dans la fenêtre `PREFETCH_LEAD_SEC` avant une + // frontière de clip. Invalidé (mis à `None`) dès que le contexte qui l'a déclenché + // devient obsolète (nouvelle scène, changement de clip explicite) pour ne jamais risquer + // d'appliquer les décodeurs d'un préchargement qui ne correspond plus à la situation. + let mut prefetch: Option = None; + // Pool de décodeurs INACTIFS gardés ouverts entre franchissements de clip (scrub). Vidé + // quand le thread de rendu meurt (vue détruite / document rechargé). Voir `swap_clip_pooled` + // et `DECODER_POOL_CAP` — c'est le remède au ~120 ms/franchissement mesuré. + let mut decoder_pool: Vec = Vec::new(); + + // config de base = C8 (tous effets) ; le fond flouté est piloté par le param live. + let mut cfg = config::all().pop().expect("au moins une config"); + // Migration D3D : le layout et le zoom viennent de l'app (contrat de scène), pas du planning + // fixture. On désactive l'animation de layout A↔B et le zoom codés en dur de `timeline()` — + // sinon la preview d'un vrai enregistrement joue la « scène fixture » (le bug d'animation vu). + // Layout statique (PiP) par défaut ; les zoom regions / presets seront rebranchés via la scène. + cfg.zoom = false; + cfg.layout_anim = false; + + let mut last = Instant::now(); + let mut acc = 0.0f64; + let mut first = true; + let mut last_preview_size: (u32, u32) = (0, 0); + let mut last_ip: Option = None; + let mut last_smoothing: f32 = -1.0; // force la 1re application (0.0 est une valeur valide) + // La vue live est TOUJOURS pilotée par la scène de l'app. Tant qu'aucune scène n'a été + // appliquée, on refuse de jouer le layout fixture (POC) : un fallback fixture ne ferait que + // MASQUER un scene-push cassé. On attend la scène avant de produire le 1er frame. + let mut scene_applied = false; + + while !shared.stop.load(Ordering::SeqCst) { + // params inspector : booléens/taps → cfg ; valeurs continues → live_params + let ip = *shared.inspector.lock().unwrap(); + let mut clip_changed = false; + let clip_request = shared.active_clip_request.lock().unwrap().take(); + if let Some(request) = clip_request { + // Un changement de clip explicite depuis l'app rend obsolète tout préchargement + // en cours (il visait la suite du clip qu'on est en train de quitter maintenant + // autrement) — sans ça, `advance_to_next_scene_clip` pourrait plus tard appliquer + // des décodeurs qui ne correspondent plus au contexte réel. + prefetch = None; + // Mêmes fichiers que ceux déjà ouverts → seule la fenêtre temporelle change + // (segments d'un même clip séparés par un trim). On repositionne au lieu de + // rouvrir : voir `Player::seek_active` pour ce que ça évite. + let same_media = request.screen_path == active_screen_path + && request.webcam_path == active_webcam_path + && (request.webcam_offset_sec - active_webcam_offset_sec).abs() < 1e-9; + // Le repositionnement n'est tenté que sur médias identiques, et son échec n'est + // JAMAIS fatal : on retombe sur l'ouverture complète, chemin connu comme sûr. + // L'optimisation ne s'applique donc que là où elle fonctionne démontrablement. + let repositioned = same_media && matches!(player.seek_active(request.source_time_sec), Ok(true)); + let switch_result = if repositioned { + Ok(()) + } else { + // Cross-média : passe par le pool de décodeurs — réutilise une paire déjà + // ouverte si possible (reseek au lieu de rouvrir) et met en pool celle qu'on + // quitte, au lieu du couple ouvrir-puis-fermer. Voir `swap_clip_pooled` et la + // mesure de ~120 ms/franchissement qui l'a motivé. + swap_clip_pooled( + &mut player, + &mut decoder_pool, + &request, + &active_screen_path, + &active_webcam_path, + active_webcam_offset_sec, + ) + }; + match switch_result { + Ok(()) => { + // Condition sur `repositioned`, pas sur `same_media` : un repositionnement + // qui a échoué est retombé sur l'ouverture complète, donc des décodeurs + // ONT été fermés et le cache doit être vidé malgré des médias identiques. + if !repositioned { + // Les anciens décodeurs viennent d'être fermés : leurs textures ne + // doivent plus figurer dans le cache de SRV, qui est keyé sur + // l'ADRESSE de la texture. Sans ce vidage, deux défauts se cumulent — + // le cache grandit sans borne et retient les textures via les SRV + // clonés ; et un décodeur neuf peut allouer à une adresse déjà vue, + // donner une collision de clé, et faire rendre l'image du clip + // PRÉCÉDENT. `clear_srv_cache` existait pour ça et n'avait aucun + // appelant. + comp.clear_srv_cache(); + } + active_screen_path = request.screen_path; + active_webcam_path = request.webcam_path; + active_webcam_offset_sec = request.webcam_offset_sec; + let scene = shared.scene.lock().unwrap().clone(); + full_scene = scene.clone(); + if let Some(base_scene) = scene { + if let Some(index) = resolve_scene_clip_index( + &base_scene, + request.clip_index, + &active_screen_path, + &active_webcam_path, + active_webcam_offset_sec, + ) { + active_clip_index = index; + } else { + eprintln!( + "[live] set_active_clip: sources absentes de la scène (screen=\"{}\", webcam=\"{}\")", + active_screen_path, active_webcam_path + ); + } + comp.set_scene(Some(scene_for_clip(&base_scene, active_clip_index))); + scene_applied = true; + } + // Relire la télémétrie curseur seulement si le FICHIER change. Elle était + // rechargée — ouverture disque + parse JSON — à chaque demande de clip, y + // compris quand seul le segment changeait, où le chemin est par + // construction identique. Mesuré : 66 bascules sur un scrub de deux clips, + // donc 66 relectures du même fichier. + let cursor_path = format!("{}.cursor.json", active_screen_path); + if cursor_path != loaded_cursor_path { + loaded_cursor_path = cursor_path.clone(); + raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(); + // Journalisé ICI seulement : sinon la ligne annonce « loaded=ok » à + // chaque changement de segment alors que rien n'a été relu, et le log + // laisse croire à un travail qui n'a plus lieu. + match &raw_cursor { + Some(track) => eprintln!( + "[live] cursor: path={} loaded=ok samples={}", + cursor_path, + track.sample_count(), + ), + None => eprintln!( + "[live] cursor: path={} loaded=FAIL — clear_cursor()", + cursor_path, + ), + } + } + // Appliqué à chaque fois, y compris sans relecture : le compositeur peut + // avoir été reconstruit (changement de taille) et perdu son curseur. + match &raw_cursor { + Some(track) => comp.set_cursor(track.smoothed(0.0)), + None => comp.clear_cursor(), + } + last_smoothing = -1.0; + clip_changed = true; + } + Err(e) => eprintln!("[live] set_active_clip: {e:#}"), + } + } + cfg.bg_blur = ip.bg_blur; + cfg.mblur_n = ip.mblur_taps; + cfg.cursor = ip.cursor_show; + // A clip with no camera must not draw the PiP box — the decoder behind it is the + // screen video, so drawing it duplicates the recording into its own corner. + let has_real_webcam = webcam_is_real(&active_webcam_path, &active_screen_path); + comp.set_live_params(LiveParams { + bg_color: ip.bg_color, + shadow_scale: ip.shadow_scale, + radius_scale: ip.radius_scale, + padding: ip.padding, + webcam_size_scale: ip.webcam_size_scale, + webcam_mirror: ip.webcam_mirror, + webcam_shape: ip.webcam_shape, + cursor_size_scale: ip.cursor_size_scale, + cursor_bounce_scale: ip.cursor_bounce_scale, + cursor_motion_blur: ip.cursor_motion_blur, + has_webcam: has_real_webcam, + }); + // Lissage ressort-amortisseur : re-génère la piste (240 Hz) uniquement quand la valeur + // change (pas à chaque frame — le resample+ressort parcourt tout l'enregistrement). + if let Some(raw) = &raw_cursor { + if ip.cursor_smoothing != last_smoothing { + comp.set_cursor(raw.smoothed(ip.cursor_smoothing)); + last_smoothing = ip.cursor_smoothing; + } + } + // un changement de param doit se voir même en pause (édition live des sliders) : + // on recompose la frame courante dans la branche pause ci-dessous. + let ip_changed = last_ip != Some(ip); + last_ip = Some(ip); + + // scène de l'app : appliquée au compositeur quand elle change (dirty). + let scene_changed = shared.scene_dirty.swap(false, Ordering::Relaxed); + if scene_changed { + // La nouvelle scène peut avoir réordonné/modifié les clips — tout index visé par + // un préchargement en cours n'est plus fiable. + prefetch = None; + let scene = shared.scene.lock().unwrap().clone(); + full_scene = scene.clone(); + let scene = scene.map(|base_scene| { + scene_applied = true; + if let Some(index) = resolve_scene_clip_index( + &base_scene, + active_clip_index, + &active_screen_path, + &active_webcam_path, + active_webcam_offset_sec, + ) { + active_clip_index = index; + } + scene_for_clip(&base_scene, active_clip_index) + }); + comp.set_scene(scene); + } + + // résolution cible du preview (le canvas Electron) → force le recadrage des + // ressources GPU si elle change. BUG évité : sans ce suivi, redimensionner le + // panneau preview PENDANT une pause ne redéclenchait ni recompose ni readback + // (aucune des autres conditions de la branche pause ne couvrait "juste la + // résolution a changé") — le canvas restait figé à l'ancienne taille jusqu'à la + // reprise de lecture ou un autre changement de param/scène. + let (pw, ph) = *shared.preview_size.lock().unwrap(); + let resized = (pw, ph) != last_preview_size; + last_preview_size = (pw, ph); + + // Le compositeur rastérise à la géométrie de SORTIE (ramenée à la taille du + // canvas) et non plus dans un canvas 16:9 figé. Quand cette géométrie change + // — l'utilisateur change de ratio, ou redimensionne le panneau — on + // reconstruit le compositeur. Voir `Compositor::new_sized` pour le choix + // "reconstruire" plutôt que "redimensionner à chaud". + let want = preview_render_size(full_scene.as_ref(), pw, ph); + if want != comp.render_size() { + comp = Compositor::new_sized(&gpu, want.0, want.1)?; + // Le compositeur neuf est vierge : on repasse par les mécanismes + // d'invalidation existants plutôt que de recopier l'état à la main — + // une seule façon d'appliquer la scène, les params et le curseur. + shared.scene_dirty.store(true, Ordering::Relaxed); + last_ip = None; + last_smoothing = -1.0; + first = true; + continue; + } + + // Pas encore de scène → on ne compose RIEN (pas de fixture masquante). On attend + // la scène. Un scene-push cassé reste ainsi visible (preview silencieuse — le + // canvas reste sur sa frame précédente côté JS, ce qui est mieux qu'un fallback + // masquant). + if !scene_applied { + std::thread::sleep(Duration::from_millis(8)); + continue; + } + + // avance : seek app-piloté (presentTime) prioritaire, sinon lecture libre (60 fps) + let requested = shared.requested_frame.lock().unwrap().take(); + let now = Instant::now(); + let dt = (now - last).as_secs_f64().min(0.1); + last = now; + let mut stepped = false; + if let Some(target) = requested { + if player.present_frame(&comp, &cfg, target)? { + stepped = true; + } + acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek + } else if shared.playing.load(Ordering::Relaxed) { + // BUG corrigé : la lecture libre décodait toujours exactement 1 frame par tick de + // 1/60s réel, quelle que soit la speed region active au temps source courant — ni + // l'écran ni la webcam n'accéléraient/ralentissaient jamais en preview live (seul + // l'export, via `speed_segments_for_window`/`advance_decoder_to` dans pipeline.rs, + // retimait correctement). Mod 3 corrige déjà le fps-mismatch webcam/écran (la webcam + // suit le temps source RÉEL de l'écran, pas un pas 1:1) — reprend ici la même idée : + // l'accumulateur de temps réel est mis à l'échelle par le multiplicateur de vitesse + // actif, donc `step()` (qui resynchronise la webcam sur le temps écran courant, + // cf. plus haut) décode plus/moins de frames par seconde réelle selon la région. + let speed = full_scene + .as_ref() + .map(|scene| speed_at(&scene.speed_regions, active_clip_index, player.screen_time_sec())) + .unwrap_or(1.0); + acc += dt * speed; + let step = 1.0 / 60.0; + let mut n = 0; + // Cap proportionnel à la vitesse (borné) : à vitesse élevée, plus de frames doivent + // être décodées par tick réel pour ne pas prendre du retard sur l'accumulateur. + let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64); + while acc >= step && n < max_steps { + // Timeline = niveau d'abstraction AU-DESSUS des clips : dès que le décodeur + // écran atteint la fin de fenêtre du clip actif, on enchaîne nous-mêmes sur + // le clip suivant (ou on reboucle sur le premier après le dernier) — sans + // dépendre d'un `active_clip_request` poussé par le JS en réaction au + // franchissement. Ce round-trip arrivait toujours trop tard : le décodeur + // avait déjà dépassé la fin de la fenêtre, voire atteint l'EOF brut du + // fichier et rebouclé sur lui-même — d'où le "retour au 1er clip" observé. + if let Some(scene) = &full_scene { + // Approche de la frontière : lance (ou laisse tourner) le préchargement + // du clip suivant en tâche de fond, pour que la bascule ci-dessous soit + // instantanée plutôt que de payer un `Decoder::open` synchrone pile au + // moment de la transition — la pause perceptible observée en usage réel. + maybe_start_prefetch( + scene, + active_clip_index, + player.screen_time_sec(), + &gpu, + &mut prefetch, + ); + if let Some(clip) = scene.clips.get(active_clip_index) { + if player.screen_time_sec() >= clip.source_end_sec { + advance_to_next_scene_clip( + &mut player, + &comp, + scene, + &mut prefetch, + &mut active_screen_path, + &mut active_webcam_path, + &mut active_webcam_offset_sec, + &mut active_clip_index, + &mut raw_cursor, + &mut last_smoothing, + ); + } + } + } + let screen_time_before_step = full_scene.as_ref().map(|_| player.screen_time_sec()); + if player.step(&comp, &cfg)? { + stepped = true; + } + // Filet de sécurité : un clip NON trimmé (source_end_sec == durée totale du + // fichier) peut ne jamais franchir le seuil ci-dessus si la dernière frame + // réelle a un PTS strictement inférieur à `source_end_sec` déclaré — `step()` + // finit alors par boucler tout seul sur l'EOF réel (temps qui recule + // brutalement). On détecte ce recul et on corrige immédiatement en enchaînant + // sur le clip suivant, plutôt que de rester bloqué sur le 1er clip. + if let (Some(scene), Some(t_before)) = (&full_scene, screen_time_before_step) { + if player.screen_time_sec() < t_before { + advance_to_next_scene_clip( + &mut player, + &comp, + scene, + &mut prefetch, + &mut active_screen_path, + &mut active_webcam_path, + &mut active_webcam_offset_sec, + &mut active_clip_index, + &mut raw_cursor, + &mut last_smoothing, + ); + } + } + acc -= step; + n += 1; + } + if acc > step { + acc = 0.0; + } + } else if first || ip_changed || scene_changed || clip_changed || resized { + // pause : recompose la frame courante (param / scène / clip / résolution changés). + let _ = player.recompose(&comp, &cfg); + stepped = true; + } + + if stepped || first { + if pw > 0 && ph > 0 { + // Step complet : `compose_frame` (déjà appelé par `step`/`present_frame`/ + // `recompose`) a rastérisé le RT à la géométrie de sortie ramenée au panneau. + // On lit ce RT DIRECTEMENT à sa résolution de rendu (`readback_direct` : copy + // rt → staging → Map/Unmap), sans le resize `blit_resized` qui, depuis la + // refonte ratio, n'était plus qu'une copie identité + une alloc NV12 inutile. + match comp.readback_direct() { + Ok((rw, rh, rgba)) => { + // Publie dans `latest_frame` : on remplace le buffer précédent + // (le canvas ne montre que la dernière frame, peu importe combien + // le renderer en a raté entre deux lectures napi). On incrémente + // la génération sous le MÊME lock que l'écriture du buffer, pour + // qu'un lecteur ne puisse jamais voir un `gen` neuf appairé à un + // buffer périmé (ou l'inverse). `+ 1` depuis la précédente, `1` au + // premier publish. Les dims publiées sont celles du RENDU (`rw`×`rh`) : + // le canvas JS s'y dimensionne (packet auto-descriptif) puis CSS met à + // l'échelle vers la boîte du panneau — plus de resize GPU intermédiaire. + // La génération vient d'un compteur atomique et non du slot : la + // livraison sans copie VIDE le slot en le lisant, et un + // `unwrap_or(1)` repartirait alors de 1 — le consommateur recevrait + // des générations déjà peintes et boucherait. Séquence identique à + // l'ancienne dérivation tant que le slot n'est pas vidé. + let next_gen = shared.frame_gen.fetch_add(1, Ordering::Relaxed) + 1; + if let Ok(mut slot) = shared.latest_frame.lock() { + *slot = Some((next_gen, rw, rh, rgba)); + } + first = false; + } + Err(e) => { + eprintln!("[live] readback_direct: {e:#}"); + std::thread::sleep(Duration::from_millis(8)); + } + } + } + } else { + std::thread::sleep(Duration::from_millis(4)); + } + } + Ok(()) +} + +// ---------- harnais standalone (poc-d3d.exe --live) ---------- +// +// Le harnais crée une fenêtre Win32 simple qui héberge la preview live ; c'est un +// outil de dev, pas un chemin de production (le production est 100 % offscreen et +// passe par `LiveView::create`, cf. plus haut). Sur macOS le harnais n'a pas +// d'équivalent — `poc-d3d` ne tourne que sur Windows — et l'intégralité de cette +// section est cfg-gatée pour que le crate compile sur les deux plateformes. +// +// `run_standalone` reste à portée du module `live` (poc-d3d l'appelle via +// `live::run_standalone`); les helpers internes (`host_proc`, `wide`, +// `client_size`) sont mis dans un sous-module privé pour que les types Win32 ne +// polluent pas le scope module-level. + +#[cfg(windows)] +pub mod standalone_harness { + +use crate::live::LiveView; +use anyhow::Result; +use std::time::Duration; +use windows::core::PCWSTR; +use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; + +extern "system" fn host_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + unsafe { + if msg == WM_DESTROY { + PostQuitMessage(0); + return LRESULT(0); + } + DefWindowProcW(hwnd, msg, wp, lp) + } +} + +/// Test hors Electron : fenêtre hôte top-level (juste pour drainer les messages Windows +/// du main thread) + une `LiveView` offscreen qui produit des frames RGBA8 dans un +/// `` HTML via le harnais d'affichage standalone. Valide le rendu threadé +/// + le readback CPU sans dépendre d'Electron. +pub fn run_standalone(screen: &str, webcam: &str, cursor_json: &str) -> Result<()> { + unsafe { + let hinst = HINSTANCE(GetModuleHandleW(None)?.0); + let cls = wide("PocD3DLiveHost"); + let wc = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(host_proc), + hInstance: hinst, + lpszClassName: PCWSTR(cls.as_ptr()), + hbrBackground: windows::Win32::Graphics::Gdi::HBRUSH(std::ptr::null_mut()), + ..Default::default() + }; + RegisterClassW(&wc); + + let title = wide("poc-d3d — live embed test (offscreen RGBA8 readback)"); + let host = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(cls.as_ptr()), + PCWSTR(title.as_ptr()), + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1280, + 760, + HWND::default(), + HMENU::default(), + hinst, + None, + )?; + + // Résolution preview = client de la fenêtre host. Ajustable au resize du host. + let mut last = (0u32, 0u32); + let (mut w, mut h) = client_size(host); + last = (w, h); + let view = LiveView::create(w, h, screen, webcam, cursor_json)?; + let _ = ShowWindow(host, SW_SHOW); + println!("live embed: vue offscreen créée, thread de rendu démarré"); + println!(" touches : [B] flou de fond (param → D3D) [Espace] pause/lecture"); + + // état des paramètres pilotés au clavier (le MÊME set_param que l'addon napi appelle) + let mut blur = false; + let mut playing = true; + let set_title = |b: bool, p: bool| unsafe { + let t = wide(&format!( + "poc-d3d — live embed · flou: {} · {} (B / Espace)", + if b { "ON" } else { "off" }, + if p { "lecture" } else { "PAUSE" } + )); + let _ = SetWindowTextW(host, PCWSTR(t.as_ptr())); + }; + set_title(blur, playing); + + let mut msg = MSG::default(); + let mut running = true; + while running { + while PeekMessageW(&mut msg, HWND::default(), 0, 0, PM_REMOVE).as_bool() { + if msg.message == WM_QUIT { + running = false; + break; + } + if msg.message == WM_KEYDOWN { + match msg.wParam.0 as u32 { + 0x42 => { + // 'B' : bascule le fond flouté via set_param — chemin param → D3D + blur = !blur; + view.set_param_bool("backgroundBlur", blur); + set_title(blur, playing); + } + 0x20 => { + // Espace : pause/lecture + playing = !playing; + view.set_playing(playing); + set_title(blur, playing); + } + _ => {} + } + } + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + if !running { + break; + } + let (cw, ch) = client_size(host); + if (cw, ch) != last { + view.set_rect(cw, ch); + last = (cw, ch); + w = cw; + h = ch; + } + // Force `first=false` côté render thread : si la preview était en pause + // totale, on n'a pas publié de frame. On laisse le canvas vide ; le harnais + // standalone n'affiche pas réellement les pixels ici (l'embed Electron est + // le consumer réel). On imprime juste une frame de temps en temps pour + // confirmer que la chaîne fonctionne. + if let Some((_gen, fw, fh, _pixels)) = view.latest_frame() { + if (fw, fh) != (w, h) { + // garde-fou : la staging de readback suit `set_rect` côté thread + // de rendu, donc ce serait une désynchro transitoire — acceptable. + } + } + std::thread::sleep(Duration::from_millis(8)); + } + drop(view); + Ok(()) + } +} + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn client_size(hwnd: HWND) -> (u32, u32) { + let mut rc = RECT::default(); + let _ = GetClientRect(hwnd, &mut rc); + ((rc.right - rc.left).max(0) as u32, (rc.bottom - rc.top).max(0) as u32) +} + +} // fin du mod standalone_harness — pas d'équivalent macOS, c'est un harnais dev Windows. + +// Ré-export pour que `live::run_standalone` reste l'API stable appelée par `poc-d3d`. +#[cfg(windows)] +pub use standalone_harness::run_standalone; + +/// Stub no-op pour que le crate compile sur macOS sans laisser de chemin mort +/// derrière le cfg(windows) ci-dessus. À supprimer si un harnais AppKit/Carbon +/// est ajouté dans un commit ultérieur. +#[cfg(target_os = "macos")] +pub fn run_standalone(_screen: &str, _webcam: &str, _cursor_json: &str) -> anyhow::Result<()> { + anyhow::bail!("run_standalone: pas d'équivalent macOS — `poc-d3d` est un outil dev Windows") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn multiclip_scene() -> Scene { + Scene::from_json(r##"{ + "clips": [ + {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":1.25,"hasAudio":true}, + {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":20,"sourceEndSec":24,"webcamOffsetSec":1.25,"hasAudio":true}, + {"screenPath":"/distinct-screen.mp4","webcamPath":"/distinct-webcam.mp4","sourceStartSec":100,"sourceEndSec":104,"webcamOffsetSec":0.5,"hasAudio":true} + ], + "layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[], + "cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}, + "cropByClip":[null,null,null], + "output":{"width":1920,"height":1080,"fps":30} + }"##).expect("multiclip scene") + } + + #[test] + fn explicit_index_disambiguates_clips_sharing_sources() { + let scene = multiclip_scene(); + assert_eq!(find_scene_clip_index(&scene, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(0)); + assert_eq!(resolve_scene_clip_index(&scene, 1, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(1)); + } + + #[test] + fn explicit_index_tracks_a_distinct_asset() { + let scene = multiclip_scene(); + assert_eq!(resolve_scene_clip_index(&scene, 2, "/distinct-screen.mp4", "/distinct-webcam.mp4", 0.5), Some(2)); + } + + #[test] + fn webcam_seek_uses_screen_source_time_and_offset() { + assert_eq!(webcam_seek_time(22.5, 1.25), 21.25); + assert_eq!(webcam_seek_time(0.5, 1.25), 0.0); + } + + #[test] + fn a_clip_without_a_camera_has_no_webcam_to_draw() { + // What the app actually sends for an asset with no `cameraTrack`. Treating + // this as a real camera drew the screen recording inside the PiP box, since + // the webcam decoder falls back to the screen file when the path won't open. + assert!(!webcam_is_real("", "/rec/recording-1.mp4")); + assert!(!webcam_is_real(" ", "/rec/recording-1.mp4")); + // The older sentinel: webcam path == screen path. + assert!(!webcam_is_real("/rec/recording-1.mp4", "/rec/recording-1.mp4")); + assert!(!webcam_is_real("/rec/RECORDING-1.mp4", "/rec/recording-1.mp4")); + } + + #[test] + fn a_clip_with_a_camera_draws_it() { + assert!(webcam_is_real("/rec/recording-1-webcam.webm", "/rec/recording-1.mp4")); + } + + // --- transport handed to an export and back ------------------------------- + // A real `LiveView` needs a D3D device and a decoder; the transport is the only + // part an export touches, so these exercise it through `PreviewTransport` alone. + + /// A preview reduced to its transport flag — no GPU, no render thread. + struct FakePreview(std::cell::Cell); + + impl FakePreview { + fn new(playing: bool) -> Self { + Self(std::cell::Cell::new(playing)) + } + } + + impl PreviewTransport for FakePreview { + fn playing(&self) -> bool { + self.0.get() + } + + fn set_playing(&self, playing: bool) { + self.0.set(playing); + } + } + + /// The regression this exists for: an export used to resume every preview it had + /// paused, so exporting while the editor sat paused left the preview free-running — + /// it drifted off the app's playhead and out of the zoom region the inspector still + /// showed, and only a window reload brought the zoom back. + #[test] + fn an_export_leaves_a_paused_preview_paused() { + let paused = FakePreview::new(false); + let snapshot = PausedPreviews::pause([(7, &paused)]); + assert!(!paused.playing(), "the export must free the GPU while it encodes"); + + snapshot.restore([(7, &paused)]); + assert!(!paused.playing(), "the app never asked for playback — it must still be paused"); + } + + /// The other half of "as found": a preview that WAS playing gets its playback back, + /// which is what the blanket resume happened to get right. + #[test] + fn an_export_gives_a_playing_preview_its_playback_back() { + let playing = FakePreview::new(true); + let snapshot = PausedPreviews::pause([(1, &playing)]); + assert!(!playing.playing(), "paused for the duration of the encode"); + + snapshot.restore([(1, &playing)]); + assert!(playing.playing()); + } + + /// Each preview gets ITS state back, not the majority's. + #[test] + fn each_preview_is_restored_independently() { + let (a, b) = (FakePreview::new(true), FakePreview::new(false)); + let snapshot = PausedPreviews::pause([(1, &a), (2, &b)]); + snapshot.restore([(1, &a), (2, &b)]); + assert_eq!((a.playing(), b.playing()), (true, false)); + } + + /// A preview born mid-export was never paused by it, so the export has no state of + /// its own to hand back — forcing one would overwrite what its creator just pushed. + #[test] + fn a_preview_created_during_an_export_keeps_its_own_transport() { + let existing = FakePreview::new(false); + let snapshot = PausedPreviews::pause([(1, &existing)]); + + let newborn = FakePreview::new(true); + snapshot.restore([(1, &existing), (2, &newborn)]); + assert!(newborn.playing(), "untouched: it is not in the snapshot"); + assert!(!existing.playing()); + } + + /// A preview destroyed during the export simply isn't there to restore — no panic, + /// and the survivors are still handled. + #[test] + fn a_preview_destroyed_during_an_export_is_skipped() { + let (kept, doomed) = (FakePreview::new(true), FakePreview::new(true)); + let snapshot = PausedPreviews::pause([(1, &kept), (2, &doomed)]); + drop(doomed); + snapshot.restore([(1, &kept)]); + assert!(kept.playing()); + } + + // --- taille de rastérisation de la preview --------------------------- + // Ces tests remplacent le filet géométrique qui verrouillait la + // compensation anisotrope : celle-ci n'existe plus (le RT porte la + // géométrie de sortie), donc la logique qui reste à couvrir est le choix + // de la taille. La non-régression pixel, elle, vit dans le golden + // (`tests/output_geometry_golden.rs`). + + fn scene_with_output(w: u32, h: u32) -> Scene { + Scene::from_json(&format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#000000"}},"zoomRegions":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":{w},"height":{h},"fps":null}}}}"## + )) + .expect("scene valide") + } + + /// Sans scène on ne connaît pas encore le ratio de sortie : on prend le + /// panneau tel quel (rien n'est composé tant que la scène n'est pas posée). + #[test] + fn preview_size_without_a_scene_is_the_panel() { + assert_eq!(preview_render_size(None, 800, 450), (800, 450)); + } + + /// Le ratio rendu est celui de la SORTIE, pas celui du panneau — sinon la + /// preview montrerait un cadrage que l'export ne produira pas. + #[test] + fn preview_size_follows_the_output_shape_not_the_panel_shape() { + let portrait = scene_with_output(1080, 1920); + let (w, h) = preview_render_size(Some(&portrait), 1600, 900); + assert!(h > w, "sortie portrait dans un panneau paysage → cadre portrait, obtenu {w}x{h}"); + let got = w as f64 / h as f64; + assert!((got - 1080.0 / 1920.0).abs() < 0.01, "ratio {got}, attendu 0.5625"); + } + + /// Jamais plus grand que le panneau : les pixels en trop seraient réduits + /// dans la foulée par le readback — c'est du coût pur. + #[test] + fn preview_size_never_exceeds_the_panel() { + let uhd = scene_with_output(3840, 2160); + let (w, h) = preview_render_size(Some(&uhd), 960, 540); + assert!(w <= 960 && h <= 540, "{w}x{h} depasse le panneau 960x540"); + } + + /// Jamais plus grand que la sortie : au-delà, la preview serait plus nette + /// que l'export, donc mensongère. + #[test] + fn preview_size_never_exceeds_the_output() { + let small = scene_with_output(640, 360); + let (w, h) = preview_render_size(Some(&small), 3000, 2000); + assert_eq!((w, h), (640, 360)); + } + + /// Anti-régression du bug de reconstruction en boucle : la taille produite + /// doit être un POINT FIXE de `normalize_render_size`. Si ce n'est pas le cas, + /// `want != comp.render_size()` reste vrai indéfiniment et le compositeur se + /// reconstruit à chaque frame (média qui disparaissent, VRAM qui sature). + /// On balaie beaucoup de tailles de panneau : une seule qui produit une + /// dimension impaire suffirait à faire boucler la preview en vrai. + #[test] + fn preview_size_is_always_a_fixed_point_of_the_render_size_rounding() { + let scene = scene_with_output(1920, 1080); + for pw in 200..1400 { + let (w, h) = preview_render_size(Some(&scene), pw, 900); + assert_eq!( + (w, h), + Compositor::normalize_render_size(w, h), + "panneau {pw}x900 → {w}x{h} n'est pas stable → reconstruction en boucle", + ); + } + } +} diff --git a/crates/compositor/src/mac_frames.rs b/crates/compositor/src/mac_frames.rs new file mode 100644 index 0000000000..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_linux.rs b/crates/compositor/src/pipeline_linux.rs new file mode 100644 index 0000000000..2e8af625f9 --- /dev/null +++ b/crates/compositor/src/pipeline_linux.rs @@ -0,0 +1,532 @@ +//! Pipeline Linux (PR #183) : decode software (`linux_decode::SwDecoder`) + +//! upload NV12-split (`linux_frames::CpuFrames`). +//! +//! Equivalent Linux de `pipeline_windows.rs` / `pipeline_macos.rs` : meme +//! surface publique consommee par le code partage (`Decoder`, `ClipSource`, +//! `ExportCodec`, `ExportParams`, `Stats`, `run_composited_multi`). +//! +//! **Export (WP6).** `run_composited_multi` encode + mux un MP4 **vidéo** : +//! encodeur SOFTWARE (`libopenh264` H264 / `libkvazaar` H265 -- les seuls du +//! build LGPL BtbN qui marchent sans device HW, VAAPI/Vulkan-encode = suivi), +//! la frame composée est relue en RGBA (ring de staging à 2, cf. +//! `Compositor::set_readback_depth`) puis convertie +//! YUV420P par `sws_scale`. La marche de timeline est PARTAGÉE +//! (`timeline_walk::walk_composited_timeline`) et le muxer passe par le shim C +//! `sn_fmt_set_pb` (comme Windows/macOS). **L'audio AAC n'est pas encore muxé** +//! (increment suivant : `audio.rs` + `AacEncoder` sont déjà partagés). + +use anyhow::{bail, Result}; +use std::collections::HashMap; +use std::ffi::CString; +use std::ptr; + +use crate::audio::{ + assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, + stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm, +}; +use crate::config::Cfg; +use crate::d3d::Gpu; +use crate::ffi::AVFrame; +use crate::linux_decode::SwDecoder; +use crate::linux_frames::CpuFrames; + +/// `SWS_POINT` (plus proche voisin). Bindgen ne genere pas les `SWS_*` (macros), +/// valeur figee par l'ABI de libswscale -- comme `linux_frames::SWS_POINT`. +const SWS_POINT: i32 = 0x10; + +/// Bilan d'un run d'export. Memes champs que `pipeline_macos::Stats`. +pub struct Stats { + pub frames: u64, + pub wall_s: f64, + pub fps: f64, + pub video_duration_s: f64, +} + +/// Un clip de la timeline. Memes champs que `pipeline_macos::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. Memes variantes que `pipeline_macos::ExportCodec`. +#[derive(Clone, Copy, Debug)] +pub enum ExportCodec { + H264, + H265, +} + +/// Params d'export. Memes champs que `pipeline_macos::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, + } + } +} + +/// Decodeur Linux : software decode (`SwDecoder`) + upload NV12-split +/// (`CpuFrames`). Meme surface que `pipeline_macos::Decoder` +/// (`open`/`seek_to`/`next`/`cur_frame`/`cur_time_sec`/`fps`) pour que `live.rs` +/// le pilote sans connaitre la plateforme. +pub struct Decoder { + sw: SwDecoder, + frames: CpuFrames, + cur: *mut AVFrame, + /// Index de la prochaine frame a decoder (sequentiel). + next_idx: u32, + fps: f64, +} + +// SAFETY : les pointeurs FFI n'ont pas d'affinite thread ; le caller uphold la +// regle « un thread a la fois » (idem `pipeline_macos::Decoder`). +unsafe impl Send for Decoder {} + +impl Decoder { + pub fn open(path: &str, gpu: &Gpu) -> Result { + let sw = SwDecoder::open(path)?; + let fps = sw.fps(); + let frames = CpuFrames::new(gpu)?; + Ok(Decoder { + sw, + frames, + cur: ptr::null_mut(), + next_idx: 0, + fps, + }) + } + + /// Decode la frame a `seconds` (seek), la presente en carrier, la retourne. + pub unsafe fn seek_to(&mut self, seconds: f64) -> Result<*mut AVFrame> { + let idx = (seconds.max(0.0) * self.fps).round() as u32; + self.decode_present(idx) + } + + /// Decode la frame SEQUENTIELLE suivante — pompage `next_frame`, PAS de seek. + /// La frame rendue appartient au decodeur (valide jusqu'au prochain appel), + /// donc elle ne se libere pas ici, contrairement au chemin `decode_at`. + pub unsafe fn next(&mut self) -> Result<*mut AVFrame> { + let raw = self.sw.next_frame()?; + if raw.is_null() { + self.cur = ptr::null_mut(); + return Ok(ptr::null_mut()); + } + let carrier = self.frames.present(raw)?; + self.cur = carrier; + self.next_idx = self.next_idx.saturating_add(1); + Ok(carrier) + } + + unsafe fn decode_present(&mut self, idx: u32) -> Result<*mut AVFrame> { + let raw = self.sw.decode_at(idx)?; + let carrier = self.frames.present(raw)?; + SwDecoder::free_frame(raw); + self.cur = carrier; + self.next_idx = idx + 1; + Ok(carrier) + } + + pub unsafe fn cur_frame(&self) -> *mut AVFrame { + self.cur + } + + /// Temps source (secondes) de la frame courante — pts REEL du decodeur, avec + /// repli sur le compteur d'index si le flux ne porte pas de pts. + pub unsafe fn cur_time_sec(&self) -> f64 { + if let Some(t) = self.sw.cur_time_sec() { + return t.max(0.0); + } + if self.next_idx == 0 || self.fps <= 0.0 { + 0.0 + } else { + (self.next_idx as f64 - 1.0) / self.fps + } + } + + pub unsafe fn fps(&self) -> f64 { + self.fps + } + + /// Duree du flux (secondes). Pendant de + /// `pipeline_macos::Decoder::available_duration_sec` ; consomme par + /// `timeline_walk` pour borner la marche d'export. + pub unsafe fn available_duration_sec(&self) -> Option { + self.sw.duration_sec() + } +} + +/// Encodeur video SOFTWARE (`libopenh264` / `libkvazaar`). Pas de zero-copy HW +/// (VAAPI/Vulkan-encode = suivi) : la frame composee est relue RGBA par +/// l'appelant puis convertie YUV420P par `sws_scale`. Surface +/// `open`/`send_rgba`/`flush` alignee sur le chemin software de +/// `pipeline_macos::VideoEncoder`. +pub struct VideoEncoder { + ctx: *mut crate::ffi::AVCodecContext, + /// AVFrame YUV420P envoyee a l'encodeur. + sw: *mut AVFrame, + /// RGBA (sortie compositeur) -> YUV420P. Cree paresseusement (dims du readback). + sws: *mut crate::ffi::SwsContext, + w: i32, + h: i32, +} + +// SAFETY : pointeurs FFI sans affinite thread ; caller mono-thread (idem Decoder). +unsafe impl Send for VideoEncoder {} + +impl VideoEncoder { + /// Encodeurs software candidats du build LGPL, par codec. La premiere qui + /// ouvre gagne ; `OPENSCREEN_EXPORT_ENCODER=` force un choix. + fn candidate_names(codec: &ExportCodec) -> &'static [&'static str] { + match codec { + ExportCodec::H264 => &["libopenh264"], + ExportCodec::H265 => &["libkvazaar"], + } + } + + pub fn open(codec: &ExportCodec, 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(); + // Liste par defaut, plus l'encodeur force s'il n'y figure pas (ex. h264_vaapi). + let defaults = Self::candidate_names(codec); + let extra: Vec<&str> = forced + .as_deref() + .filter(|f| !defaults.contains(f)) + .into_iter() + .collect(); + for &name in defaults.iter().chain(extra.iter()) { + if forced.as_deref().is_some_and(|f| f != name) { + continue; + } + match unsafe { Self::try_open(name, w, h, fps, bit_rate) } { + Ok(enc) => { + eprintln!("[pipeline] encodeur video : {name} (software YUV420P)"); + return Ok(enc); + } + Err(e) => refused.push(format!("{name}: {e}")), + } + } + match forced { + Some(name) => bail!("OPENSCREEN_EXPORT_ENCODER={name} inutilisable : {}", refused.join(" ; ")), + None => bail!("aucun encodeur video utilisable : {}", refused.join(" ; ")), + } + } + + unsafe fn try_open(name: &str, w: i32, h: i32, fps: i32, bit_rate: i64) -> Result { + use crate::ffi::*; + let cname = CString::new(name)?; + let enc = avcodec_find_encoder_by_name(cname.as_ptr()); + if enc.is_null() { + bail!("absent de ce build ffmpeg"); + } + let mut ctx = avcodec_alloc_context3(enc); + if ctx.is_null() { + bail!("avcodec_alloc_context3"); + } + (*ctx).width = w; + (*ctx).height = h; + (*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_YUV420P; + (*ctx).time_base = AVRational { num: 1, den: fps }; + (*ctx).framerate = AVRational { num: fps, den: 1 }; + (*ctx).bit_rate = bit_rate; + // MP4 : header global dans l'extradata (pas par-paquet). + (*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + if let Err(e) = averr(avcodec_open2(ctx, enc, ptr::null_mut()), "avcodec_open2(enc)") { + avcodec_free_context(&mut ctx); + return Err(e); + } + match alloc_sw_frame(AVPixelFormat::AV_PIX_FMT_YUV420P, w, h) { + Ok(sw) => Ok(VideoEncoder { ctx, sw, sws: ptr::null_mut(), w, h }), + Err(e) => { + avcodec_free_context(&mut ctx); + Err(e) + } + } + } + + /// Envoie une frame composee DEJA RELUE (RGBA) a l'encodeur, en YUV420P. + /// + /// La relecture est sortie d'ici : avec la ring de staging, la frame rendue + /// par `readback_submit` n'est pas celle qui vient d'etre composee mais la + /// precedente, donc l'appelant doit apparier lui-meme la frame et son pts + /// (cf. `run_composited_multi`). + pub unsafe fn send_rgba(&mut self, rgba: &[u8], rw: i32, rh: i32, pts: i64) -> Result<()> { + use crate::ffi::*; + if self.sws.is_null() { + self.sws = sws_getContext( + rw, + rh, + AVPixelFormat::AV_PIX_FMT_RGBA, + self.w, + self.h, + AVPixelFormat::AV_PIX_FMT_YUV420P, + // POINT : le compositeur est dimensionne a la sortie -> pas de + // mise a l'echelle, donc echantillonnage exact (cf. mac_frames). + SWS_POINT, + ptr::null_mut(), + ptr::null_mut(), + ptr::null(), + ); + if self.sws.is_null() { + bail!("sws_getContext {rw}x{rh} RGBA -> {}x{} YUV420P", self.w, self.h); + } + } + averr(av_frame_make_writable(self.sw), "make_writable")?; + // RGBA est un plan unique : data[0] + stride rw*4, les autres nuls. + let src_data: [*const u8; 4] = [rgba.as_ptr(), ptr::null(), ptr::null(), ptr::null()]; + let src_stride: [i32; 4] = [rw * 4, 0, 0, 0]; + let converted = sws_scale( + self.sws, + src_data.as_ptr(), + src_stride.as_ptr(), + 0, + rh, + (*self.sw).data.as_ptr() as *const *mut u8, + (*self.sw).linesize.as_ptr(), + ); + if converted <= 0 { + bail!("sws_scale RGBA->YUV420P : {converted} lignes"); + } + (*self.sw).pts = pts; + averr(avcodec_send_frame(self.ctx, self.sw), "send_frame") + } + + /// Flush : une frame nulle finalise le bitstream de l'encodeur. + pub unsafe fn flush(&mut self) -> Result<()> { + crate::ffi::averr( + crate::ffi::avcodec_send_frame(self.ctx, ptr::null_mut()), + "send_frame_flush", + ) + } +} + +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.sws.is_null() { + crate::ffi::sws_freeContext(self.sws); + } + } + } +} + +/// Alloue une AVFrame systeme au format demande. Symetrique de +/// `pipeline_macos::alloc_sw_frame`. +unsafe fn alloc_sw_frame(pix_fmt: crate::ffi::AVPixelFormat::Type, w: i32, h: i32) -> Result<*mut 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 {w}x{h} pix_fmt={pix_fmt}"); + } + Ok(frame) +} + +/// Draine les paquets de l'encodeur vers le muxer. Symetrique de +/// `pipeline_macos::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 || 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); + } +} + +/// Export multiclip VIDEO (WP6). Encode software + mux MP4. Audio AAC = suivi +/// (`audio.rs`/`AacEncoder` partages, il ne manque que le branchement du 2e flux +/// + l'assemblage PCM par clip, cf. `pipeline_macos::run_composited_multi`). +/// +/// La marche de timeline est PARTAGEE (`walk_composited_timeline`) : elle compose +/// chaque frame de sortie (vitesse/fenetrage/curseur inclus) puis appelle +/// `on_frame(n)`, ou on relit + encode + draine. +pub fn run_composited_multi( + clips: &[ClipSource], + out: &str, + gpu: &Gpu, + comp: &crate::compositor::Compositor, + cfg: &Cfg, + params: &ExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + if clips.is_empty() { + bail!("run_composited_multi: aucun clip a exporter"); + } + let (out_w, out_h) = (params.width, params.height); + let out_fps = params.fps.unwrap_or(30) as i32; + // bitrate proportionnel a la surface (reference : 8 Mbps @ 1920x1080). + let bit_rate = ((out_w as i64 * out_h as i64 * 8_000_000) / (1920 * 1080)).max(2_000_000); + let t0 = std::time::Instant::now(); + + let mut enc = VideoEncoder::open(¶ms.codec, out_w as i32, out_h as i32, out_fps, bit_rate)?; + let ectx = enc.ctx; + + let mut screen_decs: HashMap = HashMap::new(); + let mut webcam_decs: HashMap = HashMap::new(); + + // ---- muxer MP4 (flux video + flux AAC) ---- + let outc = CString::new(out)?; + let mut octx: *mut crate::ffi::AVFormatContext = ptr::null_mut(); + let mut pb: *mut crate::ffi::AVIOContext = ptr::null_mut(); + let ostream; + let opkt; + let mut audio_encoder; + unsafe { + crate::ffi::averr( + crate::ffi::avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + ostream = crate::ffi::avformat_new_stream(octx, ptr::null()); + if ostream.is_null() { + bail!("avformat_new_stream"); + } + crate::ffi::averr( + crate::ffi::avcodec_parameters_from_context((*ostream).codecpar, ectx), + "params_from_ctx", + )?; + (*ostream).time_base = (*ectx).time_base; + crate::ffi::averr( + crate::ffi::avio_open(&mut pb, outc.as_ptr(), crate::ffi::AVIO_FLAG_WRITE as i32), + "avio_open", + )?; + crate::ffi::sn_fmt_set_pb(octx, pb); + // Le flux AAC doit exister AVANT l'en-tete (le muxer y fige sa table de flux). + // Meme si aucun clip n'a d'audio, on ecrit une piste silencieuse -- parite + // avec Windows/macOS, qui muxent toujours l'AAC. + audio_encoder = AacEncoder::open(octx)?; + crate::ffi::averr( + crate::ffi::avformat_write_header(octx, ptr::null_mut()), + "write_header", + )?; + opkt = crate::ffi::av_packet_alloc(); + } + + // Un PCM par clip, assemble apres la marche video (elle seule dit combien de + // frames chaque clip a 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 scene = comp.scene_snapshot(); + // Ring de staging a 2 : l'export ne veut que du debit, une frame de latence + // ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour + // la raison pour laquelle la preview, elle, reste a 1. + comp.set_readback_depth(2)?; + // pts d'encodage : DECOUPLE de l'index de marche `n`, puisque la frame + // recoltee a l'iteration n est celle composee a n-1. Il reste contigu (les + // frames sortent de la ring dans l'ordre de composition), donc le fichier + // produit est identique a celui du chemin synchrone. + let mut encoded_pts: i64 = 0; + let frames = unsafe { + crate::timeline_walk::walk_composited_timeline( + clips, + gpu, + comp, + cfg, + out_fps, + &scene, + &mut screen_decs, + &mut webcam_decs, + &mut |n| { + // Soumet la copie de la frame n SANS l'attendre et recolte la + // precedente : c'est tout le pipelining. Pendant que le CPU + // passe ses ~12,6 ms dans sws_scale + avcodec_send_frame sur la + // frame n-1, le GPU finit la composition et la copie de n. + if let Some((rw, rh, rgba)) = comp.readback_submit()? { + enc.send_rgba(&rgba, rw as i32, rh as i32, encoded_pts)?; + encoded_pts += 1; + drain_encoder(ectx, octx, ostream, opkt)?; + } + // Progression = frames COMPOSEES (inchangee) : la barre ne doit + // pas reculer d'une frame parce que l'encodage a un tour de + // retard. + 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} declare audio mais sans flux decodable; silence", + ), + Err(error) => eprintln!( + "[pipeline] warning: decodage audio clip #{clip_index} echoue ({error:#}); silence", + ), + } + } + Ok(()) + }, + )? + }; + + unsafe { + // Drain de la ring AVANT le flush de l'encodeur : les `depth - 1` + // dernieres copies sont encore en vol, et sans ce drain la derniere + // frame composee ne serait jamais encodee (video amputee d'une frame). + while let Some((rw, rh, rgba)) = comp.readback_take()? { + enc.send_rgba(&rgba, rw as i32, rh as i32, encoded_pts)?; + encoded_pts += 1; + drain_encoder(ectx, octx, ostream, opkt)?; + } + // Le compositeur peut survivre a l'export (l'appelant le possede) : on + // lui rend sa profondeur par defaut plutot que de lui laisser une ring + // a 2 et le buffer de 8 Mo qui va avec. + comp.set_readback_depth(1)?; + enc.flush()?; + drain_encoder(ectx, octx, ostream, opkt)?; + // Audio : le plan part des frames REELLEMENT produites par clip (un clip + // raccourci voit son audio raccourci d'autant), puis un seul encode AAC. + let declared_audio: Vec = clips.iter().map(|c| c.has_audio).collect(); + let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64); + 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); + let mut opkt = opkt; + 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, + }) +} diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs new file mode 100644 index 0000000000..8fec0fb238 --- /dev/null +++ b/crates/compositor/src/pipeline_macos.rs @@ -0,0 +1,1110 @@ +//! 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 { + let f = self.decode_forward_to(seconds, tb_sec)?; + if !f.is_null() { + return Ok(f); + } + // EOF atteint avant la cible (décodeur réactivé depuis le pool, laissé en fin + // de flux) : on retombe sur le seek keyframe complet au lieu de rendre `null` + // — qui forçait une réouverture complète. Voir `pipeline_windows::seek_to`. + } + } + } + + 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_windows.rs b/crates/compositor/src/pipeline_windows.rs new file mode 100644 index 0000000000..bbdab26423 --- /dev/null +++ b/crates/compositor/src/pipeline_windows.rs @@ -0,0 +1,1581 @@ +//! C0 (§9) : décode D3D11VA (screen) → encode h264_amf → mux MP4, sur NOTRE device. +//! Aucun composite. Mesuré au plus extérieur (§10) : Instant (mappe QPC) autour de +//! tout le run, deux lectures seulement. Rien dans la boucle ne peut fausser le fps. + +use crate::audio::{ + assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, + stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm, +}; +use crate::compositor::{Compositor, OUT_H, OUT_W}; +use crate::config::Cfg; +use crate::cpu_frames::CpuFrames; +use crate::cursor::CursorTrack; +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}; +use std::ptr; +use std::time::Instant; +use windows::core::Interface; + +// Macros libav non générées par bindgen (function-like). Valeurs Windows/MSVC. +// `AVERROR(EAGAIN)` dépend de la plateforme (cf. `crate::ffi`) ; ce fichier est +// Windows-only, mais garder une troisième copie de la valeur est ce qui a laissé +// 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). +extern "C" { + fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; + fn sn_fmt_nb_streams(s: *mut AVFormatContext) -> u32; + fn sn_fmt_get_pb(s: *mut AVFormatContext) -> *mut AVIOContext; + fn sn_fmt_set_pb(s: *mut AVFormatContext, p: *mut AVIOContext); +} + +pub struct Stats { + pub frames: u64, + pub wall_s: f64, + pub fps: f64, + /// Durée de la vidéo exportée (secondes) = frames / cadence de sortie. Distincte de + /// `wall_s` (temps de rendu réel) — sert au message de succès ("vidéo de Xs exportée en Ys"). + pub video_duration_s: f64, +} + +/// Garde RAII sur une AVFrame (la libère au Drop). +pub struct FrameGuard(pub *mut AVFrame); +impl Drop for FrameGuard { + fn drop(&mut self) { + unsafe { av_frame_free(&mut self.0) }; + } +} + +/// Décode la n-ième frame d'une source sur NOTRE device (textures échantillonnables). +/// Sert le harnais de composition (S3+), hors mesure. Retourne une frame indépendante. +pub fn decode_frame_n(path: &str, gpu: &Gpu, n: u32) -> Result { + unsafe { decode_frame_n_inner(path, gpu, n) } +} + +unsafe fn decode_frame_n_inner(path: &str, gpu: &Gpu, n: u32) -> Result { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0); + if vidx < 0 { + bail!("aucun flux vidéo"); + } + let stream = sn_fmt_stream(fmt, vidx); + let codecpar = (*stream).codecpar; + let dec = avcodec_find_decoder((*codecpar).codec_id); + let dctx = avcodec_alloc_context3(dec); + averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; + allow_d3d11va_h264_baseline(dctx); + + 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); + averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2")?; + + let pkt = av_packet_alloc(); + let frame = av_frame_alloc(); + let mut got: u32 = 0; + let mut result: *mut AVFrame = ptr::null_mut(); + + 'outer: loop { + let r = av_read_frame(fmt, pkt); + if r == AVERROR_EOF { + avcodec_send_packet(dctx, ptr::null_mut()); + } else { + averr(r, "read_frame")?; + if (*pkt).stream_index != vidx { + av_packet_unref(pkt); + continue; + } + averr(avcodec_send_packet(dctx, pkt), "send_packet")?; + av_packet_unref(pkt); + } + loop { + let r = avcodec_receive_frame(dctx, frame); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + if r == AVERROR_EOF { + break 'outer; + } + break; + } + averr(r, "receive_frame")?; + if got == n { + result = av_frame_clone(frame); // frame indépendante, garde ses refs textures + break 'outer; + } + got += 1; + } + } + + av_frame_free(&mut (frame as *mut _)); + av_packet_free(&mut (pkt as *mut _)); + avcodec_free_context(&mut (dctx as *mut _)); + av_buffer_unref(&mut (hwdev as *mut _)); + avformat_close_input(&mut fmt); + + if result.is_null() { + bail!("frame {n} introuvable"); + } + Ok(FrameGuard(result)) +} + +fn averr(ret: i32, ctx: &str) -> Result<()> { + if ret < 0 { + let mut buf = [0i8; 256]; + unsafe { av_strerror(ret, buf.as_mut_ptr(), buf.len()) }; + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy(); + bail!("{ctx}: {ret} ({msg})"); + } + Ok(()) +} + +/// FFmpeg's DXVA/D3D11VA profile table accepts Constrained Baseline, Main and High, +/// but not plain H.264 Baseline. Chrome MediaRecorder emits plain Baseline even when +/// the bitstream uses the same hardware-decodable subset (no FMO/ASO); without this +/// opt-in FFmpeg rejects the profile before asking the D3D11 driver for a decoder. +/// Keep the mismatch allowance restricted to that exact profile rather than weakening +/// validation for every codec/profile handled by this shared decoder path. +unsafe fn allow_d3d11va_h264_baseline(dctx: *mut AVCodecContext) { + if (*dctx).profile == AV_PROFILE_H264_BASELINE as i32 { + (*dctx).hwaccel_flags |= AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH as i32; + } +} + +// D3D11_TEXTURE2D_DESC.BindFlags (valeurs SDK) +const D3D11_BIND_SHADER_RESOURCE: u32 = 0x8; +const D3D11_BIND_DECODER: u32 = 0x200; + +/// get_format du décodeur : impose la surface D3D11 (§5), sinon ffmpeg retombe en NV12 CPU. +/// Et surtout (§5) : crée un frames-context AVEC BIND_SHADER_RESOURCE, pour que le +/// compositeur HLSL de S3+ échantillonne directement les textures décodeur. +unsafe extern "C" fn get_hw_format( + ctx: *mut AVCodecContext, + mut fmts: *const AVPixelFormat::Type, +) -> AVPixelFormat::Type { + while *fmts != AVPixelFormat::AV_PIX_FMT_NONE { + if *fmts == AVPixelFormat::AV_PIX_FMT_D3D11 { + // frames-context manuel : impose BindFlags (sinon ffmpeg met BIND_DECODER seul, + // et les surfaces ne sont pas échantillonnables → §5). + let frames = av_hwframe_ctx_alloc((*ctx).hw_device_ctx); + if frames.is_null() { + return AVPixelFormat::AV_PIX_FMT_NONE; + } + let fc = (*frames).data as *mut AVHWFramesContext; + (*fc).format = AVPixelFormat::AV_PIX_FMT_D3D11; + (*fc).sw_format = AVPixelFormat::AV_PIX_FMT_NV12; + (*fc).width = (*ctx).coded_width; + (*fc).height = (*ctx).coded_height; + (*fc).initial_pool_size = 32; // DPB H.264 (refs) + frames en vol + let d3dfc = (*fc).hwctx as *mut AVD3D11VAFramesContext; + (*d3dfc).BindFlags = D3D11_BIND_DECODER | D3D11_BIND_SHADER_RESOURCE; + if av_hwframe_ctx_init(frames) < 0 { + av_buffer_unref(&mut (frames as *mut _)); + return AVPixelFormat::AV_PIX_FMT_NONE; + } + (*ctx).hw_frames_ctx = frames; + return AVPixelFormat::AV_PIX_FMT_D3D11; + } + fmts = fmts.add(1); + } + AVPixelFormat::AV_PIX_FMT_NONE +} + +pub fn run_c0(screen: &str, out: &str, gpu: &Gpu) -> Result { + discard_partial_output(out, unsafe { run_c0_inner(screen, out, gpu) }) +} + +/// Un run interrompu laisse le MP4 sans son `moov` : illisible, et portant exactement le nom du +/// fichier que l'utilisateur croit avoir exporté. Le retirer plutôt que le laisser traîner. +/// +/// Posé sur les façades plutôt que sur chaque `?` : les `*_inner` sortent par une trentaine de +/// points, tous concernés de la même façon. +/// +/// ponytail: seul le fichier est nettoyé ; les contextes ffmpeg alloués dans les `*_inner` fuient +/// toujours sur ces sorties-là (il faudrait une garde RAII par pointeur, comme `FrameGuard`). +/// Un export raté est rare et ne boucle pas — à reprendre si ça devient un mode de marche. +fn discard_partial_output(out: &str, result: Result) -> Result { + if result.is_err() { + let _ = std::fs::remove_file(out); + } + result +} + +unsafe fn run_c0_inner(screen: &str, out: &str, gpu: &Gpu) -> Result { + // ---- entrée : demux ---- + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(screen)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "avformat_open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + + let vidx = av_find_best_stream( + fmt, + AVMediaType::AVMEDIA_TYPE_VIDEO, + -1, + -1, + ptr::null_mut(), + 0, + ); + if vidx < 0 { + bail!("aucun flux vidéo"); + } + let stream = sn_fmt_stream(fmt, vidx); + let codecpar = (*stream).codecpar; + + // ---- décodeur D3D11VA sur NOTRE device ---- + let dec = avcodec_find_decoder((*codecpar).codec_id); + if dec.is_null() { + bail!("décodeur introuvable"); + } + let dctx = avcodec_alloc_context3(dec); + averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; + allow_d3d11va_h264_baseline(dctx); + + let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + if hwdev.is_null() { + bail!("av_hwdevice_ctx_alloc"); + } + let hwdc = (*hwdev).data as *mut AVHWDeviceContext; + let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; + // AddRef : ffmpeg Release ce device au teardown. On garde un +1 en fuyant un clone. + let dev_clone = gpu.device.clone(); + (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; + std::mem::forget(dev_clone); + averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; + + (*dctx).hw_device_ctx = av_buffer_ref(hwdev); + (*dctx).get_format = Some(get_hw_format); + averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2(dec)")?; + + // ---- encodeur (ouvert paresseusement à la 1re frame : il lui faut ses dims + hw_frames_ctx) ---- + let mut enc: Option = None; + let mut ectx: *mut AVCodecContext = ptr::null_mut(); + + // ---- sortie : mux MP4 ---- + let mut octx: *mut AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + averr( + avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + let mut ostream: *mut AVStream = ptr::null_mut(); + + let pkt = av_packet_alloc(); + let opkt = av_packet_alloc(); + let frame = av_frame_alloc(); + + let mut frames: u64 = 0; + + // =========== MESURE : plus extérieure possible (§10) =========== + let t0 = Instant::now(); + + // pompe : read → decode → (open enc) → encode → mux + loop { + let r = av_read_frame(fmt, pkt); + if r == AVERROR_EOF { + break; + } + averr(r, "av_read_frame")?; + if (*pkt).stream_index != vidx { + av_packet_unref(pkt); + continue; + } + averr(avcodec_send_packet(dctx, pkt), "send_packet")?; + av_packet_unref(pkt); + + loop { + let r = avcodec_receive_frame(dctx, frame); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + break; + } + averr(r, "receive_frame")?; + + if enc.is_none() { + // config depuis la 1re frame décodée : dims réelles + frames_ctx D3D11 + let opened = VideoEncoder::open( + &ExportCodec::H264, + (*frame).width, + (*frame).height, + 60, + 8_000_000, + (*frame).hw_frames_ctx, + )?; + ectx = opened.ctx; + enc = Some(opened); + + ostream = avformat_new_stream(octx, ptr::null()); + if ostream.is_null() { + bail!("avformat_new_stream"); + } + averr( + avcodec_parameters_from_context((*ostream).codecpar, ectx), + "params_from_ctx", + )?; + (*ostream).time_base = (*ectx).time_base; + let mut pb: *mut AVIOContext = ptr::null_mut(); + averr( + avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), + "avio_open", + )?; + sn_fmt_set_pb(octx, pb); + averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; + } + + (*frame).pts = frames as i64; + enc.as_mut().unwrap().send(frame)?; + drain_encoder(ectx, octx, ostream, opkt)?; + frames += 1; + } + } + + // flush décodeur → encodeur + avcodec_send_packet(dctx, ptr::null_mut()); + loop { + let r = avcodec_receive_frame(dctx, frame); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + break; + } + averr(r, "flush receive_frame")?; + let Some(encoder) = enc.as_mut() else { + bail!("flush : première frame reçue au flush, encodeur jamais ouvert"); + }; + (*frame).pts = frames as i64; + encoder.send(frame)?; + drain_encoder(ectx, octx, ostream, opkt)?; + frames += 1; + } + // flush encodeur + if let Some(encoder) = enc.as_mut() { + encoder.send(ptr::null_mut())?; + drain_encoder(ectx, octx, ostream, opkt)?; + averr(av_write_trailer(octx), "write_trailer")?; + } + + let wall_s = t0.elapsed().as_secs_f64(); + // =========== fin mesure =========== + + // teardown + av_frame_free(&mut (frame as *mut _)); + av_packet_free(&mut (pkt as *mut _)); + av_packet_free(&mut (opkt as *mut _)); + let mut pb = sn_fmt_get_pb(octx); + if !pb.is_null() { + avio_closep(&mut pb); + sn_fmt_set_pb(octx, ptr::null_mut()); + } + avformat_free_context(octx); + // `enc` est libéré par son Drop en fin de portée (voir run_multi_inner). + avcodec_free_context(&mut (dctx as *mut _)); + av_buffer_unref(&mut (hwdev as *mut _)); + avformat_close_input(&mut fmt); + + let fps = frames as f64 / wall_s; + 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 { + fmt: *mut AVFormatContext, + dctx: *mut AVCodecContext, + hwdev: *mut AVBufferRef, + vidx: i32, + pkt: *mut AVPacket, + frame: *mut 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` : sans lui, + /// 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 +// has no OS thread affinity — safe to create on one thread and hand off to another as long +// as it's touched from a single thread at a time (never concurrently), which is exactly the +// live-preview prefetch pattern in `live.rs`: a background thread opens+seeks a `Decoder`, +// then sends it across a channel to the render thread, which alone uses it from then on. +unsafe impl Send for Decoder {} + +impl Decoder { + pub(crate) unsafe fn open(path: &str, gpu: &Gpu) -> Result { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0); + if vidx < 0 { + bail!("aucun flux vidéo dans {path}"); + } + let stream = sn_fmt_stream(fmt, vidx); + let codecpar = (*stream).codecpar; + let dec = avcodec_find_decoder((*codecpar).codec_id); + let dctx = avcodec_alloc_context3(dec); + averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; + allow_d3d11va_h264_baseline(dctx); + + // Backend CPU : on n'attache AUCUN hw_device_ctx et on ne force pas `get_format`, + // donc libavcodec choisit son décodeur logiciel et sort en mémoire système. Passer + // le device WARP à D3D11VA ne marcherait pas de toute façon — WARP n'expose pas + // d'`ID3D11VideoDevice` (`tests/warp_device_cannot_decode.rs`). + let cpu = if gpu.backend == Backend::Cpu { + // `threads = 0` : libavcodec prend le nombre de cœurs. C'est le seul réglage + // qui compte vraiment ici — sans lui le décodage logiciel est mono-thread et + // le benchmark mesurerait surtout ça. + (*dctx).thread_count = 0; + Some(CpuFrames::new(gpu)?) + } else { + None + }; + + let 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 { + fmt, + dctx, + hwdev, + vidx, + pkt: av_packet_alloc(), + 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 { + 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 + /// la playback sans réallouer les décodeurs. La fixture démarre sur un IDR (§11). + pub(crate) unsafe fn rewind(&mut self) -> Result<()> { + averr(av_seek_frame(self.fmt, self.vidx, 0, AVSEEK_FLAG_BACKWARD), "seek")?; + avcodec_flush_buffers(self.dctx); + self.sent_eof = false; + Ok(()) + } + + /// Time_base du flux vidéo (secondes par unité de pts). + unsafe fn tb_sec(&self) -> f64 { + let tb = (*sn_fmt_stream(self.fmt, self.vidx)).time_base; + 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`. Réutilise le décodeur ouvert (pas de réouverture) : c'est LE point de + /// perf multiclip — un seul seek par frontière de clip, décodage séquentiel ensuite, + /// donc le débit par frame ne change pas. Renvoie la frame (ou null à EOF). + pub(crate) unsafe fn seek_to(&mut self, seconds: f64) -> Result<*mut AVFrame> { + let tb_sec = self.tb_sec(); + + // Chemin rapide. Le seek complet ci-dessous jette TOUT l'état du décodeur et repart + // de l'image clé précédente — jusqu'à `gop_size` frames à redécoder (60 sur nos + // captures), et deux fois puisque écran et webcam ont chacun leur décodeur, soit + // ~50 ms par pas de scrub mesurés. Or le cas dominant en édition n'est pas un saut : + // c'est « la frame suivante » (scrub, pas-à-pas) ou « la même frame » (un paramètre + // a changé, la scène est recomposée au même instant). Aucun des deux ne justifie de + // repartir d'une image clé. + // + // Le critère d'arrêt du déroulement est la MÊME expression que celui du seek complet + // (cf. `decode_forward_to`), donc les deux chemins rendent la même frame : c'est une + // optimisation, pas un changement de comportement. + 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. Au-delà du seuil, + // repartir d'une image clé redevient moins cher — un seek coûte en moyenne + // un demi-GOP, soit ~0,5 s sur nos captures. + if cur < seconds && seconds - cur <= SEEK_FORWARD_MAX_SEC { + let f = self.decode_forward_to(seconds, tb_sec)?; + if !f.is_null() { + return Ok(f); + } + // `decode_forward_to` a atteint l'EOF avant la cible — typiquement un + // décodeur réactivé depuis le pool (`live::swap_clip_pooled`), laissé en fin + // de flux (`sent_eof`), qui ne peut plus avancer. On NE rend PAS `null` : ça + // forçait l'appelant à tout ROUVRIR (~190 ms mesurés), le pire à-coup ressenti + // au franchissement. On retombe sur le seek keyframe complet ci-dessous, qui + // rembobine + réarme le décodeur et repart proprement. Si la cible est + // réellement au-delà de l'EOF, ce seek complet rendra `null` lui aussi : + // comportement inchangé pour ce cas. + } + } + } + + let target = if tb_sec > 0.0 { (seconds / tb_sec) as i64 } else { 0 }; + averr(av_seek_frame(self.fmt, self.vidx, target, AVSEEK_FLAG_BACKWARD), "seek_to")?; + 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; + // pas de pts fiable ou pas de time_base → on prend la 1re frame après la keyframe. + 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. Critère d'arrêt identique à celui du seek complet — c'est ce qui + /// garantit que les deux chemins rendent exactement la même frame. + unsafe fn decode_forward_to(&mut self, seconds: f64, tb_sec: f64) -> Result<*mut 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); + } + } + } + + /// Temps (s) de la frame courante, via son pts. 0 si pas de pts fiable. + pub(crate) 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(crate) unsafe fn fps(&self) -> f64 { + let r = (*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. La durée du stream est prioritaire ; + /// `nb_frames / fps` sert de repli pour les conteneurs qui omettent `duration`. + pub(crate) unsafe fn available_duration_sec(&self) -> Option { + let stream = 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 + } + } + + /// Rend la prochaine frame (valide jusqu'au prochain appel), ou null à EOF. + pub(crate) unsafe fn next(&mut self) -> Result<*mut AVFrame> { + loop { + let r = 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 == AVERROR_EOF { + return Ok(ptr::null_mut()); + } + if r != AVERROR_EAGAIN { + averr(r, "receive_frame")?; + } + if self.sent_eof { + return Ok(ptr::null_mut()); + } + let rr = av_read_frame(self.fmt, self.pkt); + if rr == AVERROR_EOF { + avcodec_send_packet(self.dctx, ptr::null_mut()); + self.sent_eof = true; + } else { + averr(rr, "read_frame")?; + if (*self.pkt).stream_index == self.vidx { + averr(avcodec_send_packet(self.dctx, self.pkt), "send_packet")?; + } + av_packet_unref(self.pkt); + } + } + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { + av_frame_free(&mut self.frame); + av_packet_free(&mut self.pkt); + avcodec_free_context(&mut self.dctx); + av_buffer_unref(&mut self.hwdev); + avformat_close_input(&mut self.fmt); + } + } +} + +/// 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 +/// dérouler. Calé sur le demi-GOP de nos captures (GOP=60 à 60 fps) : en deçà, dérouler +/// coûte moins cher que de jeter l'état du décodeur et redécoder depuis la clé précédente. +const SEEK_FORWARD_MAX_SEC: f64 = 0.5; + +unsafe fn make_enc_frames(gpu: &Gpu, w: i32, h: i32) -> Result<(*mut AVBufferRef, *mut AVBufferRef)> { + 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), "enc hwdevice init")?; + + let frames = av_hwframe_ctx_alloc(hwdev); + let fc = (*frames).data as *mut AVHWFramesContext; + (*fc).format = AVPixelFormat::AV_PIX_FMT_D3D11; + (*fc).sw_format = AVPixelFormat::AV_PIX_FMT_NV12; + (*fc).width = w; + (*fc).height = h; + (*fc).initial_pool_size = 32; // l'encodeur AMF garde plusieurs frames en vol + // NV12 array + RENDER_TARGET refusé par ce driver ; NV12 array sans bind aussi. + // Le combo array qui marche (prouvé par C0) = DECODER|SHADER_RESOURCE. On rend dans + // notre propre NV12 simple (RT) puis CopySubresourceRegion vers ce pool. GPU->GPU. + let d3dfc = (*fc).hwctx as *mut AVD3D11VAFramesContext; + (*d3dfc).BindFlags = D3D11_BIND_DECODER | D3D11_BIND_SHADER_RESOURCE; + averr(av_hwframe_ctx_init(frames), "enc frames init")?; + Ok((hwdev, frames)) +} + +/// C1..C8 (§9) : composite 2 sources → encode, effets gatés par `cfg`. Mesuré au plus extérieur (§10). +/// `progress(frames_encodées)` est appelé à chaque frame — no-op côté bench (mesure inchangée), +/// alimente la barre de progression côté GUI. La mesure reste enveloppante (§10) : la sonde est +/// un simple `SendMessage` throttlé (µs), négligeable devant ~8 ms/frame GPU. +pub fn run_composited( + screen: &str, + webcam: &str, + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + progress: &mut dyn FnMut(u64), +) -> Result { + discard_partial_output(out, unsafe { + run_c1_inner(screen, webcam, out, gpu, comp, cfg, progress) + }) +} + +unsafe fn run_c1_inner( + screen: &str, + webcam: &str, + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + progress: &mut dyn FnMut(u64), +) -> Result { + let mut sdec = Decoder::open(screen, gpu)?; + let mut wdec = Decoder::open(webcam, gpu)?; + let (mut enc_hwdev, mut enc_frames) = make_enc_frames(gpu, OUT_W as i32, OUT_H as i32)?; + + let mut enc = VideoEncoder::open( + &ExportCodec::H264, + OUT_W as i32, + OUT_H as i32, + 60, + 8_000_000, + enc_frames, + )?; + let ectx = enc.ctx; + + let mut octx: *mut AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + averr( + avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + let ostream = avformat_new_stream(octx, ptr::null()); + averr(avcodec_parameters_from_context((*ostream).codecpar, ectx), "params_from_ctx")?; + (*ostream).time_base = (*ectx).time_base; + let mut pb: *mut AVIOContext = ptr::null_mut(); + averr(avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), "avio_open")?; + sn_fmt_set_pb(octx, pb); + averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; + + let opkt = av_packet_alloc(); + let mut frames: u64 = 0; + + let t0 = Instant::now(); + loop { + let sf = sdec.next()?; + if sf.is_null() { + break; + } + let wf = wdec.next()?; + if wf.is_null() { + break; + } + comp.compose_frame(sf, wf, frames as f32, cfg)?; + + let outf = av_frame_alloc(); + averr(av_hwframe_get_buffer(enc_frames, outf, 0), "hwframe_get_buffer")?; + let out_tex = (*outf).data[0] as *mut c_void; + let out_slice = (*outf).data[1] as u32; + comp.rgb_to_nv12(out_tex, out_slice)?; + (*outf).pts = frames as i64; + enc.send(outf)?; + drain_encoder(ectx, octx, ostream, opkt)?; + av_frame_free(&mut (outf as *mut _)); + frames += 1; + progress(frames); + } + + enc.send(ptr::null_mut())?; + drain_encoder(ectx, octx, ostream, opkt)?; + averr(av_write_trailer(octx), "write_trailer")?; + + let wall_s = t0.elapsed().as_secs_f64(); + + av_packet_free(&mut (opkt as *mut _)); + let mut pb2 = sn_fmt_get_pb(octx); + if !pb2.is_null() { + avio_closep(&mut pb2); + sn_fmt_set_pb(octx, ptr::null_mut()); + } + avformat_free_context(octx); + // `enc` est libéré par son Drop en fin de portée (voir run_multi_inner). + av_buffer_unref(&mut enc_frames); + av_buffer_unref(&mut enc_hwdev); + + let fps = frames as f64 / wall_s; + Ok(Stats { frames, wall_s, fps, video_duration_s: frames as f64 / 60.0 }) +} + +/// Une source de clip pour l'export multiclip : fichiers screen+webcam + fenêtre source +/// (trim, en secondes). `webcam_offset_sec` : temps source webcam = temps source screen - offset. +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, +} + +/// Export **multiclip** : rend la timeline (clips ordonnés, avec trims) en un seul MP4. +/// Perf (contrainte §multiclip) : décodeurs ouverts une fois par source (cache) et réutilisés +/// entre clips du même asset ; **un seul seek keyframe par frontière de clip** ; décodage +/// séquentiel dans le clip → coût/frame identique au mono-clip (~120fps préservés). +pub fn run_composited_multi( + clips: &[ClipSource], + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + params: &ExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + discard_partial_output(out, unsafe { + run_multi_inner(clips, out, gpu, comp, cfg, params, progress) + }) +} + +/// Codec vidéo de sortie. L'encodeur concret est choisi à l'exécution (voir `candidates`). +/// VP9 a été essayé via un chemin logiciel (libvpx-vp9) mais retiré : trop lent pour être +/// utile en pratique, pas la peine de maintenir ce chemin. Choisir VP9 échoue avec un message +/// clair plutôt que de silencieusement retomber sur H264. +pub enum ExportCodec { + H264, + H265, +} + +/// Un candidat encodeur : son nom ffmpeg, et le format d'entrée qu'on lui présentera. +/// `AV_PIX_FMT_D3D11` = zéro-copie, la texture du compositeur part telle quelle ; tout autre +/// format impose une descente GPU→système par frame (voir `VideoEncoder::send`). +type EncoderCandidate = (&'static str, AVPixelFormat::Type); + +impl ExportCodec { + /// Ordre de préférence, du plus rapide au plus universel. Le premier dont `avcodec_open2` + /// réussit **vraiment** gagne : figurer dans la liste `-encoders` du build ne prouve rien + /// (les back-ends AMF/NVENC sont compilés en dur dans ffmpeg, sans le GPU correspondant + /// derrière), seule l'ouverture négocie avec le driver. + /// + /// - `*_amf` / `*_nvenc` — AMD / NVIDIA, avalent nos textures D3D11 directement. AMF + /// d'abord : c'est le chemin mesuré (§9, C0..C8), le garder en tête laisse les chiffres + /// du banc comparables sur la machine de dev. + /// - `*_mf` — MediaFoundation, c'est-à-dire *n'importe quel* MFT installé : le seul + /// candidat qui ne présuppose aucun vendeur, et celui qui rattrape le MFT logiciel de + /// Windows (VM, RDP, machine sans encodeur), son `hw_encoding` valant `false` par défaut. + /// **En NV12 seulement.** Il annonce `d3d11` et s'ouvre en d3d11, mais meurt ensuite au + /// premier envoi (« Failed to set D3D manager: 80004001 ») quand MediaFoundation lui a + /// résolu le MFT logiciel — mesuré ici. Comme le choix est acté à l'ouverture, ce + /// candidat-là ferait échouer l'export au lieu de glisser au suivant : ne pas le remettre. + /// - `*_qsv` — Intel ; n'accepte pas `AV_PIX_FMT_D3D11`, seulement du NV12 système. + /// ponytail: donc descente GPU→CPU puis remontée CPU→GPU sur Intel, une frame à la fois. + /// Le zéro-copie y demanderait un device QSV dérivé du nôtre et `AV_PIX_FMT_QSV` ; à + /// faire quand on aura une machine Intel pour le mesurer, pas avant. + /// - `libopenh264` / `libkvazaar` — dernier recours 100 % logiciel. **Pas** libx264/libx265 : + /// le ffmpeg vendorisé est le build LGPL, `--disable-libx264 --disable-libx265`. Ces deux + /// là y sont, et sont les seuls encodeurs logiciels H264/H265 dont on dispose. + fn candidates(&self) -> &'static [EncoderCandidate] { + const D3D11: AVPixelFormat::Type = AVPixelFormat::AV_PIX_FMT_D3D11; + const NV12: AVPixelFormat::Type = AVPixelFormat::AV_PIX_FMT_NV12; + const YUV420P: AVPixelFormat::Type = AVPixelFormat::AV_PIX_FMT_YUV420P; + match self { + ExportCodec::H264 => &[ + ("h264_amf", D3D11), + ("h264_nvenc", D3D11), + ("h264_qsv", NV12), + ("h264_mf", NV12), + ("libopenh264", YUV420P), + ], + ExportCodec::H265 => &[ + ("hevc_amf", D3D11), + ("hevc_nvenc", D3D11), + ("hevc_qsv", NV12), + ("hevc_mf", NV12), + ("libkvazaar", YUV420P), + ], + } + } +} + +/// L'encodeur vidéo retenu pour ce run, plus ce qu'il faut pour le nourrir. +/// +/// Le reste du pipeline continue de produire des frames GPU sans savoir qui encode : quand le +/// candidat retenu n'avale pas les textures D3D11, la descente vers la mémoire système se fait +/// ici et nulle part ailleurs. +struct VideoEncoder { + ctx: *mut AVCodecContext, + /// Frame système au format attendu par l'encodeur. Null si zéro-copie D3D11. + sw: *mut AVFrame, + /// Intermédiaire NV12 : `av_hwframe_transfer_data` ne convertit pas, il ne sait descendre + /// que vers le `sw_format` du pool. Non-null seulement si l'encodeur veut du planaire. + nv12: *mut AVFrame, +} + +impl Drop for VideoEncoder { + fn drop(&mut self) { + unsafe { + avcodec_free_context(&mut self.ctx); + if !self.sw.is_null() { + av_frame_free(&mut self.sw); + } + if !self.nv12.is_null() { + av_frame_free(&mut self.nv12); + } + } + } +} + +impl VideoEncoder { + /// Retient le premier candidat que cette machine accepte d'ouvrir, et dit lequel dans les + /// logs. `hw_frames` : le pool D3D11 dans lequel le compositeur rend. + /// + /// `OPENSCREEN_EXPORT_ENCODER=` n'essaie que celui-là. C'est le seul moyen d'exercer + /// les chemins non-AMD depuis une machine AMD, où `h264_amf` gagne toujours au premier tour + /// et laisse la descente mémoire système de `send` jamais exécutée. Le forçage ne retombe + /// délibérément sur rien : un repli silencieux sur AMF ferait croire au test d'être passé. + unsafe fn open( + codec: &ExportCodec, + w: i32, + h: i32, + fps: i32, + bit_rate: i64, + hw_frames: *mut AVBufferRef, + ) -> Result { + let forced = std::env::var("OPENSCREEN_EXPORT_ENCODER").ok(); + let mut refused: Vec = Vec::new(); + for &candidate in codec.candidates() { + 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) => { + refused.push(format!("{name}: {error}")); + continue; + } + }; + // Journalisé sans condition : un rapport de support doit dire qui a encodé. + eprintln!( + "[pipeline] encodeur vidéo : {name} ({}){}", + if encoder.sw.is_null() { "textures D3D11, zéro-copie" } else { "frames système" }, + if refused.is_empty() { + String::new() + } else { + format!(" — écartés : {}", refused.join(" ; ")) + }, + ); + return Ok(encoder); + } + match forced { + // Un nom forcé qui ne figure dans aucune liste ne produit aucun refus : sans ce cas + // le message serait un « aucun encodeur : » suivi de rien, et on chercherait le + // problème du côté du driver plutôt que du côté de la faute de frappe. + 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(" ; "), + ), + } + } + + /// Ouvre un candidat. L'ouverture est la sonde : elle négocie pour de bon avec le driver + /// (c'est elle qui échoue sur une machine sans AMF), et tous les candidats de la liste + /// échouent proprement ici quand ils ne conviennent pas — mesuré sur cette machine : + /// `h264_nvenc` « Operation not permitted », `h264_qsv` « Unknown error ». + /// + /// ponytail: encoder une frame d'essai serait la sonde forte, mais l'essai a été fait et + /// retiré : tirer une frame quelconque du pool n'équivaut pas à une vraie frame composée, + /// et AMF la refusait dans C0 (pool du décodeur) — faux négatif qui coûtait 179→60 fps sur + /// un chemin qui marchait. Le vrai remède serait de sonder avec la 1re frame réelle, avant + /// l'écriture de l'en-tête MP4 ; à faire si un candidat se met à passer l'ouverture pour + /// mourir ensuite. + unsafe fn try_open( + (name, pix_fmt): EncoderCandidate, + w: i32, + h: i32, + fps: i32, + bit_rate: i64, + hw_frames: *mut AVBufferRef, + ) -> Result { + let cname = CString::new(name)?; + let enc = avcodec_find_encoder_by_name(cname.as_ptr()); + if enc.is_null() { + bail!("absent de ce build ffmpeg"); + } + let mut ctx = avcodec_alloc_context3(enc); + if ctx.is_null() { + bail!("avcodec_alloc_context3"); + } + (*ctx).width = w; + (*ctx).height = h; + (*ctx).pix_fmt = pix_fmt; + (*ctx).time_base = AVRational { num: 1, den: fps }; + (*ctx).framerate = AVRational { num: fps, den: 1 }; + (*ctx).bit_rate = bit_rate; + // Toutes nos sorties sont du MP4, qui veut SPS/PPS (et VPS en HEVC) dans l'extradata + // plutôt qu'en ligne dans le flux. Le muxer mov sait à défaut les repêcher dans le + // premier paquet, mais c'est un rattrapage : les encodeurs logiciels qu'on vient + // d'ajouter n'émettent pas d'extradata sans ce drapeau, et personne ici n'a de machine + // pour constater le MP4 bancal qui en sortirait. + (*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + if pix_fmt == AVPixelFormat::AV_PIX_FMT_D3D11 { + (*ctx).hw_frames_ctx = av_buffer_ref(hw_frames); + } + if let Err(error) = averr(avcodec_open2(ctx, enc, ptr::null_mut()), "avcodec_open2(enc)") { + avcodec_free_context(&mut ctx); + return Err(error); + } + + // À partir d'ici le contexte est à nous : le mettre dans la struct d'abord, pour que + // le Drop le libère si l'allocation des tampons échoue. + let mut encoder = VideoEncoder { ctx, sw: ptr::null_mut(), nv12: ptr::null_mut() }; + if pix_fmt != AVPixelFormat::AV_PIX_FMT_D3D11 { + encoder.sw = alloc_sw_frame(pix_fmt, w, h)?; + if pix_fmt != AVPixelFormat::AV_PIX_FMT_NV12 { + encoder.nv12 = alloc_sw_frame(AVPixelFormat::AV_PIX_FMT_NV12, w, h)?; + } + } + Ok(encoder) + } + + /// Envoie une frame du compositeur (texture D3D11) à l'encodeur ; `frame` null = flush. + unsafe fn send(&mut self, frame: *mut AVFrame) -> Result<()> { + if self.sw.is_null() || frame.is_null() { + return averr(avcodec_send_frame(self.ctx, frame), "send_frame"); + } + // L'encodeur garde une référence sur les frames en vol : ne jamais réécrire par-dessus. + averr(av_frame_make_writable(self.sw), "frame_make_writable")?; + let landing = if self.nv12.is_null() { self.sw } else { self.nv12 }; + averr(av_hwframe_transfer_data(landing, frame, 0), "hwframe_transfer_data")?; + if !self.nv12.is_null() { + nv12_to_yuv420p(self.nv12, self.sw); + } + (*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. +unsafe fn alloc_sw_frame(pix_fmt: AVPixelFormat::Type, w: i32, h: i32) -> Result<*mut AVFrame> { + let frame = av_frame_alloc(); + if frame.is_null() { + bail!("av_frame_alloc (tampon encodeur)"); + } + (*frame).format = pix_fmt; + (*frame).width = w; + (*frame).height = h; + if let Err(error) = averr(av_frame_get_buffer(frame, 0), "av_frame_get_buffer (tampon encodeur)") + { + av_frame_free(&mut (frame as *mut _)); + return Err(error); + } + Ok(frame) +} + +/// NV12 (Y puis UV entrelacé) → YUV420P (Y, U, V séparés), pour les encodeurs logiciels qui +/// ne prennent que du planaire. Les `linesize` des deux frames diffèrent : copier plan par +/// plan, ligne par ligne, jamais d'un bloc. +/// +/// ponytail: désentrelacement scalaire, O(w·h) par frame. Négligeable devant l'encodeur +/// logiciel qui suit (des dizaines de ms/frame) — c'est le seul chemin qui l'emprunte. +/// Brancher swscale si ce chemin devient un jour chaud. +unsafe fn nv12_to_yuv420p(src: *mut AVFrame, dst: *mut AVFrame) { + let (w, h) = ((*src).width as usize, (*src).height as usize); + for y in 0..h { + ptr::copy_nonoverlapping( + (*src).data[0].add(y * (*src).linesize[0] as usize), + (*dst).data[0].add(y * (*dst).linesize[0] as usize), + w, + ); + } + // `div_ceil` et non `/ 2` : en largeur ou hauteur impaire le plan chroma compte une colonne + // et une ligne de plus, que la division tronquée laisserait telles que `av_frame_get_buffer` + // les a rendues — un bord vert. D3D11 refuse les textures NV12 impaires, donc le cas n'est + // pas atteignable aujourd'hui ; au même coût, autant que la fonction soit juste seule. + for y in 0..h.div_ceil(2) { + let uv = (*src).data[1].add(y * (*src).linesize[1] as usize); + let u = (*dst).data[1].add(y * (*dst).linesize[1] as usize); + let v = (*dst).data[2].add(y * (*dst).linesize[2] as usize); + for x in 0..w.div_ceil(2) { + *u.add(x) = *uv.add(2 * x); + *v.add(x) = *uv.add(2 * x + 1); + } + } +} + +/// Résolution/cadence/codec de sortie. `fps: None` = dérivé du 1er clip (comportement +/// historique) ; `width`/`height` doivent être pairs (NV12 4:2:0) — l'appelant napi arrondit. +pub struct ExportParams { + pub width: u32, + pub height: u32, + pub fps: Option, + pub codec: ExportCodec, +} + +impl Default for ExportParams { + fn default() -> Self { + Self { width: OUT_W, height: OUT_H, fps: None, codec: ExportCodec::H264 } + } +} + + +unsafe fn run_multi_inner( + clips: &[ClipSource], + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + params: &ExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + if clips.is_empty() { + bail!("aucun clip à exporter"); + } + let (out_w, out_h) = (params.width, params.height); + // décodeurs ouverts une fois par chemin, réutilisés entre clips (screen ≠ webcam → 2 maps + // pour deux &mut indépendants). + let mut screen_decs: HashMap = HashMap::new(); + let mut webcam_decs: HashMap = HashMap::new(); + + // fps de sortie : choix explicite de l'app si fourni, sinon dérivé du 1er clip (recordings + // uniformes) — comportement historique. + screen_decs.insert(clips[0].screen.clone(), Decoder::open(&clips[0].screen, gpu)?); + let out_fps = params + .fps + .unwrap_or_else(|| screen_decs[&clips[0].screen].fps().round().max(1.0) as u32) + as i32; + + // La scène (déjà posée par l'appelant via comp.set_scene) pilote le curseur et le + // fenêtrage par clip ; `walk_composited_timeline` s'en charge. + let scene = comp.scene_snapshot(); + + // ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ---- + // 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); + let mut enc = VideoEncoder::open( + ¶ms.codec, + out_w as i32, + out_h as i32, + out_fps, + bit_rate, + enc_frames, + )?; + let ectx = enc.ctx; + + let mut octx: *mut AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + averr( + avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + let ostream = avformat_new_stream(octx, ptr::null()); + if ostream.is_null() { + bail!("video avformat_new_stream"); + } + averr(avcodec_parameters_from_context((*ostream).codecpar, ectx), "params_from_ctx")?; + (*ostream).time_base = (*ectx).time_base; + // Les deux streams doivent exister avant le header MP4 ; l'AAC reste ouvert pendant le + // rendu puis reçoit le PCM assemblé à partir des comptes de frames réellement produits. + let mut audio_encoder = AacEncoder::open(octx)?; + let mut pb: *mut AVIOContext = ptr::null_mut(); + averr(avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), "avio_open")?; + sn_fmt_set_pb(octx, pb); + averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; + + let opkt = av_packet_alloc(); + let mut clip_frame_counts = vec![0u64; clips.len()]; + let mut clip_pcm: Vec> = + std::iter::repeat_with(|| None).take(clips.len()).collect(); + let t0 = Instant::now(); + + let frames = walk_composited_timeline( + clips, + gpu, + comp, + cfg, + out_fps, + &scene, + &mut screen_decs, + &mut webcam_decs, + &mut |frame_index| { + // 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(()) + }, + &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 #{} déclaré audio mais sans flux décodable; silence conservé", + clip_index, + ), + Err(error) => eprintln!( + "[pipeline] warning: décodage audio du clip #{} échoué ({error:#}); silence conservé", + clip_index, + ), + } + } + Ok(()) + }, + )?; + + comp.set_cursor_time(None); + comp.set_timeline_time(None); + comp.set_scene(scene); + + enc.send(ptr::null_mut())?; + drain_encoder(ectx, octx, ostream, opkt)?; + + let declared_audio: Vec = clips.iter().map(|clip| clip.has_audio).collect(); + let audio_plan = build_audio_concat_plan( + &clip_frame_counts, + &declared_audio, + out_fps as f64, + ); + let assembled_audio = assemble_concatenated_pcm(&clip_pcm, &audio_plan); + audio_encoder.encode(&assembled_audio, octx)?; + + averr(av_write_trailer(octx), "write_trailer")?; + let wall_s = t0.elapsed().as_secs_f64(); + + // teardown (les décodeurs du cache sont droppés en fin de scope). + av_packet_free(&mut (opkt as *mut _)); + let mut pb2 = sn_fmt_get_pb(octx); + if !pb2.is_null() { + avio_closep(&mut pb2); + sn_fmt_set_pb(octx, ptr::null_mut()); + } + avformat_free_context(octx); + // `enc` (donc le contexte encodeur) est libéré par son Drop en fin de portée — après + // ces unref, ce qui est l'ordre voulu : il garde sa propre référence sur le pool. + av_buffer_unref(&mut enc_frames); + av_buffer_unref(&mut enc_hwdev); + + let fps = frames as f64 / wall_s; + Ok(Stats { frames, wall_s, fps, video_duration_s: frames as f64 / out_fps as f64 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// L'ordre EST le contrat : tous les candidats zéro-copie d'abord, ceux qui exigent la + /// mémoire système ensuite (`*_qsv` et `*_mf` sont matériels eux aussi — ce qui les + /// distingue est le format d'entrée, pas le silicium). Un candidat système remonté + /// au-dessus d'un D3D11 coûterait une descente GPU→CPU par frame sur une machine qui n'en + /// a pas besoin, sans que rien n'échoue — donc sans que personne ne le voie. + #[test] + fn les_candidats_vont_du_zero_copie_a_la_memoire_systeme() { + for codec in [ExportCodec::H264, ExportCodec::H265] { + let candidates = codec.candidates(); + let last_d3d11 = candidates + .iter() + .rposition(|&(_, fmt)| fmt == AVPixelFormat::AV_PIX_FMT_D3D11) + .expect("au moins un candidat zéro-copie"); + let first_sw = candidates + .iter() + .position(|&(_, fmt)| fmt != AVPixelFormat::AV_PIX_FMT_D3D11) + .expect("au moins un candidat en mémoire système"); + assert!(last_d3d11 < first_sw, "candidats mal ordonnés : {candidates:?}"); + } + } + + /// Le dernier recours doit être 100 % logiciel, sinon une machine sans encodeur matériel + /// (VM, RDP) n'exporte pas du tout — la régression que cette sélection corrige. + /// libx264/libx265 sont GPL et absents du build LGPL vendorisé : les nommer ferait un + /// filet de sécurité qui n'existe pas. + #[test] + fn le_dernier_recours_est_un_encodeur_logiciel_present_dans_le_build_lgpl() { + let fallbacks: Vec<&str> = [ExportCodec::H264, ExportCodec::H265] + .iter() + .map(|codec| codec.candidates().last().expect("liste non vide").0) + .collect(); + assert_eq!(fallbacks, ["libopenh264", "libkvazaar"]); + for codec in [ExportCodec::H264, ExportCodec::H265] { + for &(name, _) in codec.candidates() { + assert!(!matches!(name, "libx264" | "libx265"), "{name} absent du build LGPL"); + } + } + } + + /// Le désentrelacement chroma est la seule vraie logique du chemin logiciel, et la seule + /// qui puisse se tromper en silence (image verte / couleurs inversées plutôt qu'une + /// erreur). Les deux frames ont des `linesize` différents — c'est exactement ce qu'un + /// `copy` d'un bloc raterait. + /// + /// 5x3 autant que 4x4 : en dimension impaire le plan chroma compte une colonne et une ligne + /// de plus que la moitié, et une division tronquée les laisserait non initialisées. + #[test] + fn nv12_vers_yuv420p_desentrelace_le_chroma() { + for (w, h) in [(4usize, 4usize), (5, 3)] { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + unsafe { + let src = + alloc_sw_frame(AVPixelFormat::AV_PIX_FMT_NV12, w as i32, h as i32).unwrap(); + let dst = + alloc_sw_frame(AVPixelFormat::AV_PIX_FMT_YUV420P, w as i32, h as i32).unwrap(); + + // luma = 10, 11, 12... ligne par ligne ; chroma = U pair, V impair, distinguables. + for y in 0..h { + let row = (*src).data[0].add(y * (*src).linesize[0] as usize); + for x in 0..w { + *row.add(x) = (10 + y * w + x) as u8; + } + } + for y in 0..ch { + let row = (*src).data[1].add(y * (*src).linesize[1] as usize); + for x in 0..cw { + *row.add(2 * x) = (100 + y * cw + x) as u8; // U + *row.add(2 * x + 1) = (200 + y * cw + x) as u8; // V + } + } + + nv12_to_yuv420p(src, dst); + + for y in 0..h { + let row = (*dst).data[0].add(y * (*dst).linesize[0] as usize); + for x in 0..w { + assert_eq!(*row.add(x), (10 + y * w + x) as u8, "luma ({x},{y}) en {w}x{h}"); + } + } + for y in 0..ch { + let u = (*dst).data[1].add(y * (*dst).linesize[1] as usize); + let v = (*dst).data[2].add(y * (*dst).linesize[2] as usize); + for x in 0..cw { + assert_eq!(*u.add(x), (100 + y * cw + x) as u8, "U ({x},{y}) en {w}x{h}"); + assert_eq!(*v.add(x), (200 + y * cw + x) as u8, "V ({x},{y}) en {w}x{h}"); + } + } + + av_frame_free(&mut (src as *mut _)); + av_frame_free(&mut (dst as *mut _)); + } + } + } +} + +unsafe fn drain_encoder( + ectx: *mut AVCodecContext, + octx: *mut AVFormatContext, + ostream: *mut AVStream, + opkt: *mut AVPacket, +) -> Result<()> { + loop { + let r = avcodec_receive_packet(ectx, opkt); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + return Ok(()); + } + averr(r, "receive_packet")?; + (*opkt).stream_index = (*ostream).index; + av_packet_rescale_ts(opkt, (*ectx).time_base, (*ostream).time_base); + averr( + av_interleaved_write_frame(octx, opkt), + "interleaved_write_frame", + ) + .map_err(|e| anyhow!("{e}"))?; + av_packet_unref(opkt); + } +} + +/// Nombre de frames du flux vidéo (borne de la barre de progression export). `nb_frames` +/// si présent (le cas de la fixture MP4), sinon estimé par durée × cadence, sinon fallback. +pub fn probe_frame_count(path: &str) -> Result { + unsafe { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0); + let mut n: u64 = 0; + if vidx >= 0 { + let stream = sn_fmt_stream(fmt, vidx); + let nb = (*stream).nb_frames; + if nb > 0 { + n = nb as u64; + } else { + let afr = (*stream).avg_frame_rate; + let dur = (*stream).duration; + let tb = (*stream).time_base; + if afr.num != 0 && afr.den != 0 && dur > 0 && tb.den != 0 { + let secs = dur as f64 * tb.num as f64 / tb.den as f64; + n = (secs * afr.num as f64 / afr.den as f64).round() as u64; + } + } + } + avformat_close_input(&mut fmt); + if n == 0 { + n = crate::compositor::FIXTURE_FRAMES as u64; + } + Ok(n) + } +} diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs new file mode 100644 index 0000000000..0009397088 --- /dev/null +++ b/crates/compositor/src/regions.rs @@ -0,0 +1,1023 @@ +//! Zoom regions + camera-fullscreen regions — port des enveloppes ease-in/hold/ease-out du +//! web (`zoomRegionUtils.ts` / `cameraFullscreenUtils.ts`) vers le natif, pour que le timing +//! des transitions soit identique en preview ET en export. Inclut le "connected zoom pan" +//! (chaînage lissé entre deux régions rapprochées), le focus "auto" (suivi de la télémétrie +//! curseur) et la rotation 3D (présets iso/left/right, cf. `compositor.rs` pour le rendu du +//! tilt perspective — ce module ne fait que le calcul temporel, pas le rendu GPU). + +use crate::cursor::CursorTrack; +use crate::scene::{SceneCameraFullscreenRegion, SceneSpeedRegion, SceneZoomRegion}; + +/// Quantification commune vidéo/audio : le web retranche exactement 1 ms avant `ceil`. +pub const SPEED_FRAME_EPSILON_SEC: f64 = 0.001; +const MIN_SPEED_SEGMENT_SEC: f64 = 0.0001; + +#[derive(Debug, Clone, Copy)] +pub struct SpeedSegment { + pub start_sec: f64, + pub end_sec: f64, + pub speed: f64, + pub frame_count: u64, +} + +/// Découpe toute la fenêtre gardée en spans contigus ; hors région la vitesse vaut 1×. +/// Les régions sont ordonnées par début et, si un ancien payload en superpose, la première +/// conserve la portion déjà couverte pour ne jamais émettre deux fois le même temps source. +pub fn speed_segments_for_window( + regions: &[SceneSpeedRegion], + source_start_sec: f64, + source_end_sec: f64, + fps: f64, +) -> Vec { + if source_end_sec <= source_start_sec || !fps.is_finite() || fps <= 0.0 { + return Vec::new(); + } + let mut overlapping: Vec<&SceneSpeedRegion> = regions + .iter() + .filter(|r| r.start_sec < source_end_sec && r.end_sec > source_start_sec) + .collect(); + overlapping.sort_by(|a, b| { + a.start_sec + .partial_cmp(&b.start_sec) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut spans = Vec::new(); + let mut cursor = source_start_sec; + for region in overlapping { + let start = region.start_sec.max(source_start_sec).max(cursor); + let end = region.end_sec.min(source_end_sec); + if start > cursor { + push_speed_segment(&mut spans, cursor, start, 1.0, fps); + } + if end > start { + let speed = if region.speed.is_finite() && region.speed > 0.0 { + region.speed + } else { + 1.0 + }; + push_speed_segment(&mut spans, start, end, speed, fps); + cursor = end; + } + } + if cursor < source_end_sec { + push_speed_segment(&mut spans, cursor, source_end_sec, 1.0, fps); + } + spans +} + +/// Multiplicateur de vitesse actif au temps source `t` (temps ABSOLU de la source, même +/// convention que `SceneSpeedRegion.start_sec`/`end_sec` — pas de fenêtre de clip à soustraire). +/// 1.0 hors de toute région. Utilisé par la preview live (`live.rs`) pour moduler le nombre de +/// frames décodées par tick réel — contrairement à `speed_segments_for_window` (export), qui a +/// besoin de pré-découper toute la fenêtre en spans pour connaître le compte de frames total à +/// l'avance, la lecture live avance tick par tick et n'a besoin que de la vitesse "maintenant". +/// +/// BUG corrigé : ignorait `region.clip_index`, filtrant seulement par recouvrement temporel sur +/// la scène BRUTE (non filtrée par clip) — dès qu'un projet a plus d'un clip, deux clips peuvent +/// tout à fait partager la même fenêtre de temps source (chacun démarrant près de t=0 de son +/// propre fichier, cas courant), et la région du MAUVAIS clip matchait alors silencieusement (ou +/// aucune ne matchait quand le clip actif est censé être couvert par une région tournée d'un +/// autre index). Même garde-fou que `Scene::for_clip_window`'s `belongs` (scene.rs) : accepte la +/// région seulement si `clip_index` est absent (vieux payload) OU vaut `active_clip_index`. +pub fn speed_at(regions: &[SceneSpeedRegion], active_clip_index: usize, t: f64) -> f64 { + for region in regions { + let belongs = region.clip_index.map(|i| i == active_clip_index).unwrap_or(true); + if belongs && t >= region.start_sec && t < region.end_sec { + if region.speed.is_finite() && region.speed > 0.0 { + return region.speed; + } + return 1.0; + } + } + 1.0 +} + +fn push_speed_segment( + spans: &mut Vec, + start_sec: f64, + end_sec: f64, + speed: f64, + fps: f64, +) { + let duration = end_sec - start_sec; + if duration <= MIN_SPEED_SEGMENT_SEC { + return; + } + let frames = (((duration - SPEED_FRAME_EPSILON_SEC) / speed) * fps) + .ceil() + .max(0.0) as u64; + spans.push(SpeedSegment { start_sec, end_sec, speed, frame_count: frames }); +} + +// mêmes fenêtres de transition que le web (TRANSITION_WINDOW_MS etc., converties en secondes). +const TRANSITION_WINDOW_S: f32 = 1.01505; +const ZOOM_IN_TRANSITION_WINDOW_S: f32 = TRANSITION_WINDOW_S * 1.5; +const ZOOM_IN_OVERLAP_S: f32 = 0.5; +const FULLSCREEN_LEAD_OUT_WINDOW_S: f32 = TRANSITION_WINDOW_S * 1.5; +// port de `CHAINED_ZOOM_PAN_GAP_MS` / `CONNECTED_ZOOM_PAN_DURATION_MS` (TS). +const CHAINED_ZOOM_PAN_GAP_S: f32 = 1.5; +const CONNECTED_ZOOM_PAN_DURATION_S: f32 = 1.0; + +fn clamp01(v: f32) -> f32 { + v.clamp(0.0, 1.0) +} + +fn sample_cubic_bezier(a1: f32, a2: f32, t: f32) -> f32 { + let o = 1.0 - t; + 3.0 * a1 * o * o * t + 3.0 * a2 * o * t * t + t * t * t +} + +fn sample_cubic_bezier_derivative(a1: f32, a2: f32, t: f32) -> f32 { + let o = 1.0 - t; + 3.0 * a1 * o * o + 6.0 * (a2 - a1) * o * t + 3.0 * (1.0 - a2) * t * t +} + +/// Port direct de `cubicBezier` (TS) : Newton-Raphson puis bissection de repli. +fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 { + let target_x = clamp01(t); + let mut solved_t = target_x; + for _ in 0..8 { + let cur_x = sample_cubic_bezier(x1, x2, solved_t) - target_x; + let cur_d = sample_cubic_bezier_derivative(x1, x2, solved_t); + if cur_x.abs() < 1e-6 || cur_d.abs() < 1e-6 { + break; + } + solved_t -= cur_x / cur_d; + } + let (mut lower, mut upper) = (0.0f32, 1.0f32); + solved_t = clamp01(solved_t); + for _ in 0..10 { + let cur_x = sample_cubic_bezier(x1, x2, solved_t); + if (cur_x - target_x).abs() < 1e-6 { + break; + } + if cur_x < target_x { + lower = solved_t; + } else { + upper = solved_t; + } + solved_t = (lower + upper) * 0.5; + } + sample_cubic_bezier(y1, y2, solved_t) +} + +/// Port de `easeOutScreenStudio` (TS) : cubic-bezier(0.16, 1, 0.3, 1). +fn ease_out_screen_studio(t: f32) -> f32 { + cubic_bezier(0.16, 1.0, 0.3, 1.0, t) +} + +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +/// Port de `computeRegionStrength` (TS, `zoomRegionUtils.ts`) : 0 hors fenêtre, ease-in avant +/// `startSec` (le zoom anticipe légèrement), plein régime pendant la région, ease-out après +/// `endSec`. Les temps reçus sont les temps source échantillonnés par le pipeline, donc ces +/// enveloppes restent alignées quand une speed region répète ou saute des frames. +fn zoom_region_strength(region: &SceneZoomRegion, t: f32) -> f32 { + let start = region.start_sec as f32; + let end = region.end_sec as f32; + let zoom_in_end = start + ZOOM_IN_OVERLAP_S; + let lead_in_start = zoom_in_end - ZOOM_IN_TRANSITION_WINDOW_S; + let lead_out_end = end + TRANSITION_WINDOW_S; + if t < lead_in_start || t > lead_out_end { + return 0.0; + } + if t < zoom_in_end { + let progress = (t - lead_in_start) / ZOOM_IN_TRANSITION_WINDOW_S; + return ease_out_screen_studio(progress); + } + if t <= end { + return 1.0; + } + let progress = clamp01((t - end) / TRANSITION_WINDOW_S); + 1.0 - ease_out_screen_studio(progress) +} + +/// État de zoom complet au temps `t` : échelle, focus, ET tilt 3D (degrés X/Y/Z — rendu en +/// pixel shader par `compositor.rs`, ce module ne fait que le calcul temporel). +pub struct ZoomState { + pub scale: f32, + pub focus: [f32; 2], + pub rotation: [f32; 3], +} + +const IDENTITY_ZOOM: ZoomState = ZoomState { scale: 1.0, focus: [0.5, 0.5], rotation: [0.0, 0.0, 0.0] }; + +/// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1). +fn ease_connected_pan(t: f32) -> f32 { + cubic_bezier(0.1, 0.0, 0.2, 1.0, t) +} + +/// Port de `getRotation3D`/`ROTATION_3D_PRESETS` (TS, `types.ts`) — degrés (rotationX, Y, Z). +/// Angles des présets, en degrés X/Y/Z. +/// +/// Les valeurs d'origine (iso [-10,-16,0], left [0,-22,0], right [0,22,0]) produisaient un quad +/// dont AU MOINS UNE ARÊTE tombait à moins de 0.1° d'un axe de l'image : les deux bords verticaux +/// pour left/right (une rotation Y pure laisse les verticales verticales — c'est de la géométrie, +/// pas un réglage), le bord haut pour iso, dont la remontée due au rotateX était annulée par la +/// division perspective à cette distance-là. +/// +/// Une arête parfaitement verticale qui traverse du texte est indiscernable d'un `overflow: +/// hidden`. C'est ce qui a été rapporté trois fois comme « une troncature de l'enregistrement », +/// alors que le plan était rendu en entier — mesuré au pixel sur un export 1920×1080 : bord droit +/// à 1539, coin calculé à 1540, arrondis présents aux quatre coins. +/// +/// Chaque préset a donc maintenant ses trois composantes, choisies pour qu'aucune arête ne +/// s'approche d'un axe à moins de 2° (cf. `no_preset_has_an_axis_aligned_edge`) tout en gardant +/// l'identité du préset : left penche vers la gauche, right vers la droite, iso est le plus incliné. +fn rotation3d_for(rotation: &Option) -> [f32; 3] { + match rotation.as_deref() { + Some("iso") => [-12.0, -18.0, -2.0], + Some("left") => [-8.0, -16.0, -1.0], + Some("right") => [-8.0, 16.0, 1.0], + _ => [0.0, 0.0, 0.0], + } +} + +fn lerp_rotation3d(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] +} + +/// Les trois segments d'une flèche d'annotation, en unités du viewBox SVG (0..100), repris +/// VERBATIM des tracés de `ArrowSvgs.tsx` : hampe puis deux barbes, toutes à bouts ronds. Garder +/// les mêmes nombres est ce qui garantit que le rendu natif et la preview dessinent la même +/// flèche — inutile de réinventer une géométrie « équivalente ». +pub fn arrow_segments_viewbox(direction: &str) -> [[f32; 4]; 3] { + match direction { + "up" => [[50.0, 20.0, 50.0, 80.0], [50.0, 20.0, 35.0, 35.0], [50.0, 20.0, 65.0, 35.0]], + "down" => [[50.0, 20.0, 50.0, 80.0], [50.0, 80.0, 35.0, 65.0], [50.0, 80.0, 65.0, 65.0]], + "left" => [[80.0, 50.0, 20.0, 50.0], [20.0, 50.0, 35.0, 35.0], [20.0, 50.0, 35.0, 65.0]], + "up-right" => [[25.0, 75.0, 75.0, 25.0], [75.0, 25.0, 53.8, 25.0], [75.0, 25.0, 75.0, 46.2]], + "up-left" => [[75.0, 75.0, 25.0, 25.0], [25.0, 25.0, 25.0, 46.2], [25.0, 25.0, 46.2, 25.0]], + "down-right" => { + [[25.0, 25.0, 75.0, 75.0], [75.0, 75.0, 75.0, 53.8], [75.0, 75.0, 53.8, 75.0]] + } + "down-left" => { + [[75.0, 25.0, 25.0, 75.0], [25.0, 75.0, 46.2, 75.0], [25.0, 75.0, 25.0, 53.8]] + } + // "right" et tout ce qui n'est pas reconnu — même défaut que le schéma côté app. + _ => [[20.0, 50.0, 80.0, 50.0], [80.0, 50.0, 65.0, 35.0], [80.0, 50.0, 65.0, 65.0]], + } +} + +/// Passe les segments du viewBox aux px locaux du quad, et rend la demi-épaisseur du trait. +/// +/// Le SVG n'a pas de `preserveAspectRatio` explicite, donc il vaut `xMidYMid meet` : mise à +/// l'échelle **uniforme** au plus petit côté, centrée. La flèche n'est donc jamais étirée quand la +/// boîte n'est pas carrée, et `strokeWidth` suit la même échelle — c'est pour ça qu'il n'a pas +/// besoin de la convention de proportionnalité du `fontSize` : il est déjà exprimé dans le +/// viewBox, donc déjà relatif à la boîte. +pub fn arrow_local_geometry( + direction: &str, + stroke_width_viewbox: f32, + quad_px: [f32; 2], +) -> ([[f32; 4]; 3], f32) { + let scale = quad_px[0].min(quad_px[1]) / 100.0; + let off = [(quad_px[0] - 100.0 * scale) * 0.5, (quad_px[1] - 100.0 * scale) * 0.5]; + let to_local = |v: [f32; 4]| { + [ + off[0] + v[0] * scale, + off[1] + v[1] * scale, + off[0] + v[2] * scale, + off[1] + v[3] * scale, + ] + }; + let segments = arrow_segments_viewbox(direction); + let half_stroke = (stroke_width_viewbox.max(0.0) * scale) * 0.5; + ([to_local(segments[0]), to_local(segments[1]), to_local(segments[2])], half_stroke) +} + +/// Focus effectif d'une région à `t` : sa position fixe, sauf en mode "auto" où elle suit la +/// télémétrie curseur (port de `getResolvedFocus`, sans le clamp — le crop-window de +/// `compositor.rs` clampe déjà après coup, cf. `su0.clamp(...)`, donc redondant ici). +fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>) -> [f32; 2] { + if region.focus_mode.as_deref() == Some("auto") { + if let Some(track) = cursor { + // `follow_at`, pas `at` : la caméra suit la piste LISSÉE. Suivre la télémétrie brute + // donne un pan nerveux — l'étage de lissage de `cursorFollowUtils.ts` manquait au + // portage. + if let Some((cx, cy)) = track.follow_at(t) { + return [cx, cy]; + } + } + } + [region.focus_x, region.focus_y] +} + +/// Paires de régions adjacentes assez proches pour être chaînées (port de +/// `getConnectedRegionPairs`, TS) : (index courant, index suivant, début transition, fin +/// transition), en secondes. Indices dans `regions` (pas d'id nécessaire — contrairement au +/// web qui matche par `region.id` car il travaille sur des objets isolés, ici tout vient du +/// même slice donc les positions suffisent). +fn connected_pairs(regions: &[SceneZoomRegion]) -> Vec<(usize, usize, f32, f32)> { + let mut order: Vec = (0..regions.len()).collect(); + order.sort_by(|&a, &b| regions[a].start_sec.partial_cmp(®ions[b].start_sec).unwrap()); + let mut pairs = Vec::new(); + for w in order.windows(2) { + let (ci, ni) = (w[0], w[1]); + let gap = regions[ni].start_sec as f32 - regions[ci].end_sec as f32; + if gap <= CHAINED_ZOOM_PAN_GAP_S { + let transition_start = regions[ci].end_sec as f32; + pairs.push((ci, ni, transition_start, transition_start + CONNECTED_ZOOM_PAN_DURATION_S)); + } + } + pairs +} + +/// État de zoom au temps `t` (secondes source du clip actif). Port de +/// `findDominantRegion` (TS) : régions chaînées d'abord (transition puis +/// hold), sinon la région "dominante" indépendante la plus forte (ties → la plus récente). +/// Hors de toute région → identité (échelle 1, focus centre, tilt nul). +pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&CursorTrack>) -> ZoomState { + if regions.is_empty() { + return IDENTITY_ZOOM; + } + let pairs = connected_pairs(regions); + + // 1) transition chaînée : pan lissé de la région courante vers la suivante. + for &(ci, ni, t_start, t_end) in &pairs { + if t < t_start || t > t_end { + continue; + } + let progress = ease_connected_pan(clamp01((t - t_start) / (t_end - t_start).max(1e-3))); + let (cur, next) = (®ions[ci], ®ions[ni]); + let cur_focus = resolve_focus(cur, t, cursor); + let next_focus = resolve_focus(next, t, cursor); + return ZoomState { + scale: lerp(cur.scale, next.scale, progress), + focus: [lerp(cur_focus[0], next_focus[0], progress), lerp(cur_focus[1], next_focus[1], progress)], + rotation: lerp_rotation3d(rotation3d_for(&cur.rotation), rotation3d_for(&next.rotation), progress), + }; + } + + // 2) palier chaîné : entre la fin de la transition et le début officiel de la région + // suivante, celle-ci est déjà pleinement active (anticipe son propre ease-in). + for &(_, ni, _, t_end) in &pairs { + let next = ®ions[ni]; + if t > t_end && t < next.start_sec as f32 { + return ZoomState { + scale: next.scale, + focus: resolve_focus(next, t, cursor), + rotation: rotation3d_for(&next.rotation), + }; + } + } + + // 3) région dominante indépendante — exclut celles déjà couvertes par une transition/palier + // chaîné ci-dessus (sinon leur propre ease-in/out "percerait" à travers la fenêtre chaînée). + let mut best: Option<(usize, f32)> = None; + for (i, r) in regions.iter().enumerate() { + let outgoing_past_end = + pairs.iter().any(|&(ci, _, _, _)| ci == i && t > regions[i].end_sec as f32); + let incoming_before_transition_end = pairs.iter().any(|&(_, ni, _, t_end)| ni == i && t < t_end); + if outgoing_past_end || incoming_before_transition_end { + continue; + } + let s = zoom_region_strength(r, t); + if s <= 0.0 { + continue; + } + let better = match best { + None => true, + Some((bi, bs)) => s > bs || (s == bs && r.start_sec > regions[bi].start_sec), + }; + if better { + best = Some((i, s)); + } + } + match best { + Some((i, strength)) => { + let r = ®ions[i]; + let focus = resolve_focus(r, t, cursor); + let scale = lerp(1.0, r.scale, strength); + // La référence (`zoomTransform.ts`) fait converger le point de focus vers le centre + // de l'écran LINÉAIREMENT : screen(f) = 0.5 + (f - 0.5)(1 - strength). Passer + // `lerp(0.5, f, strength)` comme centre de crop ne donne pas ça — le crop mappant + // screen(f) = 0.5 + (f - centre) * scale, on obtient + // 0.5 + (f - 0.5)(1 - strength) * scale, soit un facteur en trop qui retient le point + // loin du centre en milieu de rampe puis le rattrape. Ce balayage parasite se lit + // comme si une région manuelle suivait le curseur. On inverse donc le mapping pour + // trouver le centre qui produit la trajectoire de référence. + let ease = |f: f32| f - (f - 0.5) * (1.0 - strength) / scale.max(1e-3); + ZoomState { + scale, + focus: [ease(focus[0]), ease(focus[1])], + rotation: lerp_rotation3d([0.0, 0.0, 0.0], rotation3d_for(&r.rotation), strength), + } + } + None => IDENTITY_ZOOM, + } +} + +/// Port de `computeCameraFullscreenRegionStrength` (TS) : progrès EXACTEMENT contenu dans +/// [startSec, endSec] (contrairement au zoom, qui anticipe avant `startSec`) — ease-in depuis +/// 0 pile à `startSec`, plein régime, ease-out jusqu'à 0 pile à `endSec`. Fenêtres bornées à la +/// moitié de la durée de la région pour que les régions courtes s'animent pleinement sans +/// déborder. +fn camera_fullscreen_region_strength(region: &SceneCameraFullscreenRegion, t: f32) -> f32 { + let start = region.start_sec as f32; + let end = region.end_sec as f32; + if t <= start || t >= end { + return 0.0; + } + let half = (end - start) * 0.5; + let lead_in = TRANSITION_WINDOW_S.min(half); + let lead_out = FULLSCREEN_LEAD_OUT_WINDOW_S.min(half); + let lead_in_end = start + lead_in; + let lead_out_start = end - lead_out; + if t < lead_in_end { + let progress = if lead_in > 0.0 { (t - start) / lead_in } else { 1.0 }; + return ease_out_screen_studio(progress); + } + if t <= lead_out_start { + return 1.0; + } + let progress = if lead_out > 0.0 { (end - t) / lead_out } else { 0.0 }; + ease_out_screen_studio(progress) +} + +/// Progrès Full Camera (0..1) au temps `t` : 0 = webcam à sa taille normale, 1 = plein cadre. +/// Régions superposées (ne devrait pas arriver, gardé défensif comme le web) → la plus forte +/// gagne. +pub fn camera_fullscreen_progress_at(regions: &[SceneCameraFullscreenRegion], t: f32) -> f32 { + let mut strongest = 0.0f32; + for r in regions { + let s = camera_fullscreen_region_strength(r, t); + if s > strongest { + strongest = s; + } + } + strongest +} + +// ============ Rotation 3D (tilt perspective, présets iso/left/right) ================ +// Port de `computeRotation3DContainScale` (TS, `types.ts`) — même formule, même ordre de +// composition ("CSS rotateX rotateY rotateZ s'applique droite-à-gauche : Z d'abord, puis Y, +// puis X"). `compositor.rs` s'en sert pour construire le quad tilté (4 coins projetés) rendu +// via un warp bilinéaire inverse en pixel shader (mode 8) — ce module ne fait que la géométrie. + +/// `true` si la rotation est (quasi) neutre — mêmes seuils que `isRotation3DIdentity` (TS). +pub fn is_identity_rotation(r: [f32; 3]) -> bool { + r[0].abs() < 0.01 && r[1].abs() < 0.01 && r[2].abs() < 0.01 +} + +/// Projette un point local (x0,y0,0) par la rotation 3D `rot` (degrés X/Y/Z) puis la +/// perspective `perspective` (distance en px ; <=0 = orthographique). `None` si le point +/// passe derrière le plan de projection (cas pathologique, comme le `return 1` du TS). +fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<(f32, f32)> { + let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); + let (ca, sa) = (a.cos(), a.sin()); + let (cb, sb) = (b.cos(), b.sin()); + let (cg, sg) = (g.cos(), g.sin()); + let (mut px, mut py, mut pz) = (x0, y0, 0.0f32); + // rotateZ + let (zx, zy) = (px * cg - py * sg, px * sg + py * cg); + px = zx; + py = zy; + // rotateY + let (yx, yz) = (px * cb + pz * sb, -px * sb + pz * cb); + px = yx; + pz = yz; + // rotateX + let (xy, xz) = (py * ca - pz * sa, py * sa + pz * ca); + py = xy; + pz = xz; + if perspective > 0.0 { + let denom = perspective - pz; + if denom <= 0.0 { + return None; + } + let f = perspective / denom; + px *= f; + py *= f; + } + Some((px, py)) +} + +/// Les 4 coins d'un quad `width`×`height` réduit de `scale`, projetés. `None` si un coin part +/// derrière le plan de fuite. +fn project_scaled_corners( + width: f32, + height: f32, + scale: f32, + rot: [f32; 3], + perspective: f32, +) -> Option<[(f32, f32); 4]> { + let (hw, hh) = (width * 0.5 * scale, height * 0.5 * scale); + let source = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]; + let mut out = [(0.0f32, 0.0f32); 4]; + for (i, &(x0, y0)) in source.iter().enumerate() { + out[i] = project_corner(x0, y0, rot, perspective)?; + } + Some(out) +} + +/// Demi-étendue des coins projetés sur chaque axe. +fn projected_extents(corners: &[(f32, f32); 4]) -> (f32, f32) { + corners.iter().fold((0.0f32, 0.0f32), |(mx, my), &(x, y)| (mx.max(x.abs()), my.max(y.abs()))) +} + +/// Un écran incliné : ses 4 coins projetés, et la réduction qu'il a fallu pour qu'ils tiennent. +#[derive(Clone, Copy)] +pub struct TiltedQuad { + /// Coins TL, TR, BR, BL en px relatifs au CENTRE du rect d'origine. + pub corners: [(f32, f32); 4], + /// Facteur de containment. Le plan mesure donc `taille_du_rect × scale` dans son PROPRE repère, + /// avant projection — ce qu'il faut connaître pour y poser un rayon de coin à la bonne échelle. + pub scale: f32, +} + +/// Les 4 coins (TL, TR, BR, BL) du quad tilté en 3D, en px relatifs au CENTRE du rect d'origine +/// (0,0 = centre — l'appelant les recentre sur le centre réel à l'écran). `width`/`height` en +/// px = la taille du rect d'origine, aussi utilisée comme référence de perspective (comme le +/// web : la perspective/le containScale sont calculés sur la taille de l'élément lui-même). +pub fn rotated_quad_corners_px(width: f32, height: f32, rot: [f32; 3]) -> TiltedQuad { + // ROTATION_3D_PERSPECTIVE_FACTOR (TS) — à garder synchronisé avec `types.ts`, que la passe 3D + // de l'exporteur canvas lit encore. Distance de fuite = facteur × min(w,h) : plus le facteur + // est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle + // était si faible que l'inclinaison ne se lisait plus (le bord haut d'iso ressortait à 0.08° de + // l'horizontale). + const PERSPECTIVE_FACTOR: f32 = 1.6; + let perspective = width.min(height) * PERSPECTIVE_FACTOR; + let (half_w, half_h) = (width * 0.5, height * 0.5); + + // BUG corrigé : l'échelle de containment était calculée en projetant les coins PLEINE TAILLE, + // puis on projetait les coins RÉDUITS. La division perspective n'étant pas linéaire en la + // taille d'entrée — le `z` d'un coin bouge quand on le rapproche du centre —, réduire d'un + // facteur mesuré sur le grand quad ne suffisait pas : le quad projeté débordait encore, et le + // render target le coupait net (bords droits en haut et à droite d'un écran pourtant penché). + // + // On mesure donc sur les coins RÉELLEMENT projetés et on répète : chaque passe multiplie + // l'échelle par le facteur de débordement observé. Ça converge en deux ou trois tours ; huit + // est une borne large qui coûte quelques multiplications une fois par frame. + let mut scale = 1.0f32; + let mut corners = match project_scaled_corners(width, height, scale, rot, perspective) { + Some(c) => c, + // Un coin derrière le plan de fuite : on rend le quad non tourné plutôt qu'une projection + // absurde (même repli qu'avant). + None => { + return TiltedQuad { + corners: [ + (-half_w, -half_h), + (half_w, -half_h), + (half_w, half_h), + (-half_w, half_h), + ], + scale: 1.0, + }; + } + }; + for _ in 0..8 { + let (max_x, max_y) = projected_extents(&corners); + if max_x <= 0.0 || max_y <= 0.0 { + break; + } + let fit = (half_w / max_x).min(half_h / max_y); + // `fit >= 1` : le quad tient déjà. On ne l'agrandit jamais — le containment ne fait que + // réduire, comme le web. + if fit >= 0.999 { + break; + } + scale *= fit; + match project_scaled_corners(width, height, scale, rot, perspective) { + Some(c) => corners = c, + None => break, + } + } + TiltedQuad { corners, scale } +} + +impl TiltedQuad { + /// Où tombe le point `(fx, fy)` du plan (0..1 depuis son coin haut-gauche), en px relatifs + /// au CENTRE du rect d'origine — même repère que `corners`. + /// + /// C'est la correspondance DIRECTE du warp que le pixel shader du mode 8 parcourt à + /// l'envers : lui part d'un pixel écran et cherche son (s, t) dans le quad, celle-ci part + /// d'un (s, t) et donne le pixel. Bilinéaire des deux côtés — donc tout ce qu'on pose sur + /// le plan incliné par cette fonction retombe exactement sur le contenu que le shader y a + /// dessiné. Sans elle, un recouvrement comme le curseur reste sur le rect droit d'origine + /// pendant que l'image, elle, est penchée. + pub fn point_px(&self, fx: f32, fy: f32) -> (f32, f32) { + let [tl, tr, br, bl] = self.corners; + let top = (tl.0 + (tr.0 - tl.0) * fx, tl.1 + (tr.1 - tl.1) * fx); + let bottom = (bl.0 + (br.0 - bl.0) * fx, bl.1 + (br.1 - bl.1) * fx); + (top.0 + (bottom.0 - top.0) * fy, top.1 + (bottom.1 - top.1) * fy) + } + + /// Demi-largeur / demi-hauteur de la bounding box des coins projetés, en px. + pub fn half_extents_px(&self) -> (f32, f32) { + projected_extents(&self.corners) + } +} + +#[cfg(test)] +mod zoom_focus_tests { + use super::*; + use crate::scene::SceneZoomRegion; + + fn region(scale: f32, focus_x: f32) -> SceneZoomRegion { + SceneZoomRegion { + id: "z1".into(), + clip_index: None, + start_sec: 2.0, + end_sec: 8.0, + scale, + focus_x, + focus_y: 0.5, + focus_mode: Some("manual".into()), + rotation: None, + } + } + + /// Où le point source `f` atterrit à l'écran (0..1) : le crop est centré sur `focus` et + /// couvre `1/scale` de la source, donc l'écran mappe `0.5 + (f - focus) * scale`. + fn screen_x(state: &ZoomState, f: f32) -> f32 { + 0.5 + (f - state.focus[0]) * state.scale + } + + #[test] + fn manual_focus_travels_to_centre_linearly_during_the_ramp() { + // L'invariant de `zoomTransform.ts` : screen(f) = 0.5 + (f - 0.5)(1 - progress). La + // régression corrigée ici ajoutait un facteur `scale`, qui retenait le point loin du + // centre en milieu de rampe puis le rattrapait — lu comme un pan parasite sur une région + // pourtant en mode manuel. + let f = 0.8; + let target_scale = 2.5; + let regions = [region(target_scale, f)]; + // Plusieurs instants de la fenêtre d'ease-in, pour balayer les progressions partielles. + for step in 0..=20 { + let t = 1.0 + step as f32 * 0.15; + let state = zoom_state_at(®ions, t, None); + // `progress` déduit du scale rendu, pour ne pas ré-implémenter l'easing dans le test. + let progress = (state.scale - 1.0) / (target_scale - 1.0); + let expected = 0.5 + (f - 0.5) * (1.0 - progress); + assert!( + (screen_x(&state, f) - expected).abs() < 1e-4, + "t={t} progress={progress} screen={} attendu={expected}", + screen_x(&state, f) + ); + } + } + + #[test] + fn manual_focus_is_dead_centre_at_full_strength() { + let f = 0.8; + let regions = [region(2.5, f)]; + let state = zoom_state_at(®ions, 5.0, None); + assert!((state.scale - 2.5).abs() < 1e-4, "plein régime attendu, scale={}", state.scale); + assert!((screen_x(&state, f) - 0.5).abs() < 1e-4); + assert!((state.focus[0] - f).abs() < 1e-4); + } + + #[test] + fn outside_every_region_the_frame_is_untouched() { + let regions = [region(2.5, 0.8)]; + let state = zoom_state_at(®ions, 0.0, None); + assert_eq!(state.scale, 1.0); + assert_eq!(state.focus, [0.5, 0.5]); + } +} + +#[cfg(test)] +mod arrow_tests { + use super::*; + + /// Ouverture d'une barbe par rapport au fût, en degrés. C'est ce paramètre — plus que la + /// longueur — qui décide si une pointe ressemble à une flèche ou à un crochet. + fn barb_opening_deg(dir: &str, barb: usize) -> f32 { + let segs = arrow_segments_viewbox(dir); + let tip = (segs[barb][0], segs[barb][1]); + // direction du fût vue depuis la pointe : c'est celle de ses deux extrémités qui n'est + // PAS la pointe. + let shaft = segs[0]; + let back = if (shaft[0] - tip.0).abs() + (shaft[1] - tip.1).abs() < 1e-3 { + (shaft[2] - tip.0, shaft[3] - tip.1) + } else { + (shaft[0] - tip.0, shaft[1] - tip.1) + }; + let b = (segs[barb][2] - tip.0, segs[barb][3] - tip.1); + let dot = back.0 * b.0 + back.1 * b.1; + let mag = (back.0.hypot(back.1)) * (b.0.hypot(b.1)); + (dot / mag).clamp(-1.0, 1.0).acos().to_degrees() + } + + #[test] + fn every_arrowhead_opens_at_the_same_angle() { + // La déformation des diagonales ne venait pas que de la taille : leurs barbes ouvraient à + // ~25° du fût quand les cardinales ouvrent à 45°, ce qui donnait une pointe étroite, + // avalée par le fût dès que le trait épaississait. Corriger la longueur seule ne suffisait + // pas — ce test verrouille l'angle, qui est le paramètre réellement visible. + for dir in ["up", "down", "left", "right", "up-right", "up-left", "down-right", "down-left"] + { + for barb in 1..=2 { + let deg = barb_opening_deg(dir, barb); + assert!( + (deg - 45.0).abs() < 1.0, + "{dir} barbe {barb} ouvre à {deg:.1}°, attendu 45°" + ); + } + } + } + + #[test] + fn a_diagonal_head_is_as_large_as_a_cardinal_one() { + // Les barbes diagonales faisaient 15,8 unités contre 21,2 pour les cardinales : une + // flèche en diagonale avait une tête ~25 % plus petite que sa voisine horizontale, ce + // qui se lisait comme une déformation. Ce test interdit la divergence de revenir. + let barb_len = |seg: [f32; 4]| ((seg[2] - seg[0]).powi(2) + (seg[3] - seg[1]).powi(2)).sqrt(); + let cardinal = barb_len(arrow_segments_viewbox("up")[1]); + for dir in ["up-right", "up-left", "down-right", "down-left"] { + for barb in 1..=2 { + let len = barb_len(arrow_segments_viewbox(dir)[barb]); + assert!( + (len - cardinal).abs() < 0.2, + "{dir} barbe {barb} = {len:.2}, cardinale = {cardinal:.2}" + ); + } + } + } + + #[test] + fn the_geometry_is_the_svg_geometry_verbatim() { + // Parité avec `ArrowSvgs.tsx` : si ces nombres divergent, le rendu et la preview + // dessinent deux flèches différentes. + assert_eq!( + arrow_segments_viewbox("right"), + [[20.0, 50.0, 80.0, 50.0], [80.0, 50.0, 65.0, 35.0], [80.0, 50.0, 65.0, 65.0]] + ); + assert_eq!( + arrow_segments_viewbox("up"), + [[50.0, 20.0, 50.0, 80.0], [50.0, 20.0, 35.0, 35.0], [50.0, 20.0, 65.0, 35.0]] + ); + } + + #[test] + fn an_unknown_direction_falls_back_to_right() { + // Même défaut que le schéma côté app, pour qu'une donnée abîmée dessine quelque chose + // de sensé plutôt que rien. + assert_eq!(arrow_segments_viewbox("sideways"), arrow_segments_viewbox("right")); + } + + #[test] + fn a_square_quad_maps_the_viewbox_one_to_one() { + let (segments, half) = arrow_local_geometry("right", 10.0, [100.0, 100.0]); + // échelle 1, aucun centrage à appliquer + assert_eq!(segments[0], [20.0, 50.0, 80.0, 50.0]); + assert!((half - 5.0).abs() < 1e-6); + } + + #[test] + fn a_wide_quad_scales_uniformly_and_centres() { + // `preserveAspectRatio` vaut `xMidYMid meet` par défaut : la flèche tient dans le PLUS + // PETIT côté et se centre — elle n'est jamais étirée. Ici 400x200 -> échelle 2, et + // 200px de marge horizontale à répartir, donc +100 sur les x. + let (segments, half) = arrow_local_geometry("right", 4.0, [400.0, 200.0]); + assert_eq!(segments[0], [100.0 + 40.0, 100.0, 100.0 + 160.0, 100.0]); + // l'épaisseur suit la même échelle uniforme + assert!((half - 4.0).abs() < 1e-6); + } + + #[test] + fn a_tall_quad_centres_vertically() { + let (segments, _) = arrow_local_geometry("up", 4.0, [100.0, 300.0]); + // échelle 1 (plus petit côté = 100), 200px de marge verticale -> +100 sur les y + assert_eq!(segments[0], [50.0, 100.0 + 20.0, 50.0, 100.0 + 80.0]); + } + + #[test] + fn a_negative_stroke_width_cannot_produce_a_negative_half_width() { + let (_, half) = arrow_local_geometry("right", -5.0, [100.0, 100.0]); + assert_eq!(half, 0.0); + } +} + +#[cfg(test)] +mod tilt_tests { + use super::*; + + /// `point_px` doit rendre les coins du plan sur les coins projetés, sans quoi tout ce qu'on + /// pose dessus (le curseur) se décale par rapport à l'image que le shader y a dessinée. + #[test] + fn the_plane_corners_map_to_the_projected_corners() { + let quad = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("iso".into()))); + for (i, (fx, fy)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].into_iter().enumerate() + { + let (x, y) = quad.point_px(fx, fy); + let (ex, ey) = quad.corners[i]; + assert!((x - ex).abs() < 1e-3 && (y - ey).abs() < 1e-3, "coin {i}: {x},{y} != {ex},{ey}"); + } + } + + /// Et le milieu du plan tombe au barycentre des quatre coins — la propriété qui distingue le + /// bilinéaire (ce que fait le shader) d'un simple placement dans la bounding box. + #[test] + fn the_plane_centre_maps_to_the_centroid() { + let quad = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("left".into()))); + let (x, y) = quad.point_px(0.5, 0.5); + let cx = quad.corners.iter().map(|c| c.0).sum::() / 4.0; + let cy = quad.corners.iter().map(|c| c.1).sum::() / 4.0; + assert!((x - cx).abs() < 1e-3 && (y - cy).abs() < 1e-3); + } + + /// La raison d'être du correctif : sur un écran incliné, la position tiltée n'est PAS la + /// position dans le rect droit. Si ces deux-là coïncidaient, le bug d'origine n'existerait + /// pas — et ce test tomberait le jour où quelqu'un remettrait le rect droit. + #[test] + fn a_tilted_plane_moves_the_cursor_off_the_upright_rect() { + let (w, h) = (1920.0f32, 1080.0f32); + let quad = rotated_quad_corners_px(w, h, rotation3d_for(&Some("iso".into()))); + let mut worst: f32 = 0.0; + for (fx, fy) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.5, 0.0)] { + let (x, y) = quad.point_px(fx, fy); + // Le même point posé sur le rect droit, dans le même repère centré. + let (ux, uy) = ((fx - 0.5) * w, (fy - 0.5) * h); + worst = worst.max((x - ux).hypot(y - uy)); + } + assert!(worst > 20.0, "ecart max au rect droit trop faible: {worst}px"); + } + + /// Le contrat du containment : après projection, aucun coin ne sort du rect d'origine. + /// C'est ce qui garantit que le quad incliné n'est jamais coupé par le bord du cadre. + #[test] + fn every_preset_stays_inside_the_original_rect() { + for (name, rot) in [ + ("iso", rotation3d_for(&Some("iso".into()))), + ("left", rotation3d_for(&Some("left".into()))), + ("right", rotation3d_for(&Some("right".into()))), + ] { + // Plusieurs formes de boîte : le débordement dépend du ratio. + for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { + let corners = rotated_quad_corners_px(w, h, rot).corners; + let (max_x, max_y) = projected_extents(&corners); + // Tolérance d'un demi-pixel : l'itération s'arrête à 0.1 % près. + assert!( + max_x <= w * 0.5 + 0.5 && max_y <= h * 0.5 + 0.5, + "{name} {w}x{h} : étendue ({max_x:.1}, {max_y:.1}) dépasse ({:.1}, {:.1})", + w * 0.5, + h * 0.5 + ); + } + } + } + + /// Aucune arête d'un préset ne doit longer un axe de l'image. C'est LE critère qui distingue + /// « un écran incliné » d'« un enregistrement tronqué » : un bord parfaitement vertical qui + /// coupe une phrase se lit comme un `overflow: hidden`, quelle que soit la justesse du reste. + #[test] + fn no_preset_has_an_axis_aligned_edge() { + for (name, rot) in [ + ("iso", rotation3d_for(&Some("iso".into()))), + ("left", rotation3d_for(&Some("left".into()))), + ("right", rotation3d_for(&Some("right".into()))), + ] { + let c = rotated_quad_corners_px(1920.0, 1080.0, rot).corners; + // haut, bas (contre l'horizontale) ; gauche, droite (contre la verticale) + let h_angle = |p: (f32, f32), q: (f32, f32)| (q.1 - p.1).atan2(q.0 - p.0).to_degrees(); + let v_angle = |p: (f32, f32), q: (f32, f32)| (q.0 - p.0).atan2(q.1 - p.1).to_degrees(); + let edges = [ + ("haut", h_angle(c[0], c[1])), + ("bas", h_angle(c[3], c[2])), + ("gauche", v_angle(c[0], c[3])), + ("droite", v_angle(c[1], c[2])), + ]; + for (edge, angle) in edges { + assert!( + angle.abs() >= 2.0, + "{name} : arête {edge} à {angle:.2}° de son axe — ça se lit comme une découpe" + ); + } + } + } + + #[test] + fn a_flat_quad_is_left_alone() { + // Sans rotation, aucun containment ne doit s'appliquer : les coins sont ceux du rect. + let corners = rotated_quad_corners_px(1000.0, 600.0, [0.0, 0.0, 0.0]).corners; + let (max_x, max_y) = projected_extents(&corners); + assert!((max_x - 500.0).abs() < 0.5 && (max_y - 300.0).abs() < 0.5); + } + + #[test] + fn the_tilt_actually_tilts() { + // Garde-fou contre un containment trop zélé qui aplatirait l'effet : les quatre coins + // d'un quad incliné ne peuvent pas rester alignés deux à deux comme un rectangle droit. + let corners = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("iso".into()))).corners; + let top_edge_slope = (corners[1].1 - corners[0].1).abs(); + assert!(top_edge_slope > 1.0, "arête supérieure horizontale : le tilt a disparu"); + } + + // ---- Mapping inverse du mode 8, reproduit à l'identique ------------------------------- + // Le pixel shader retrouve (s,t) dans le quad projeté en résolvant un système quadratique. + // Le miroir ci-dessous est une COPIE de `shaders.hlsl` (mode 8) : même algèbre, même choix + // de racine, mêmes tolérances. Il sert à interroger ce mapping sans GPU — un bord droit qui + // tranche un écran penché ne peut venir que de trois endroits (les coins, ce mapping, le + // rect source), et c'est le seul des trois qu'on ne pouvait pas encore examiner. + + fn cross2(a: (f32, f32), b: (f32, f32)) -> f32 { + a.0 * b.1 - a.1 * b.0 + } + + fn sub(a: (f32, f32), b: (f32, f32)) -> (f32, f32) { + (a.0 - b.0, a.1 - b.1) + } + + /// Point du quad pour un couple (s,t) — la direction que le shader inverse. + fn forward_bilinear(corners: &[(f32, f32); 4], s: f32, t: f32) -> (f32, f32) { + let (c00, c10, c11, c01) = (corners[0], corners[1], corners[2], corners[3]); + let e = sub(c10, c00); + let f = sub(c01, c00); + let g = (c00.0 - c10.0 - c01.0 + c11.0, c00.1 - c10.1 - c01.1 + c11.1); + (c00.0 + e.0 * s + f.0 * t + g.0 * s * t, c00.1 + e.1 * s + f.1 * t + g.1 * s * t) + } + + /// `None` = le shader rend ce pixel TRANSPARENT (donc un trou dans l'écran incliné). + fn shader_inverse_bilinear(corners: &[(f32, f32); 4], p: (f32, f32)) -> Option<(f32, f32)> { + let (c00, c10, c11, c01) = (corners[0], corners[1], corners[2], corners[3]); + let e = sub(c10, c00); + let f = sub(c01, c00); + let g = (c00.0 - c10.0 - c01.0 + c11.0, c00.1 - c10.1 - c01.1 + c11.1); + let h = sub(p, c00); + let k2 = cross2(g, f); + let k1 = cross2(e, f) + cross2(h, g); + let k0 = cross2(h, e); + // Pour une racine `t` candidate, le `s` correspondant — et si le couple tombe dans le quad. + let st_for_root = |t: f32| -> Option<(f32, f32)> { + let denom_x = e.0 + g.0 * t; + let denom_y = e.1 + g.1 * t; + let s = if denom_x.abs() > denom_y.abs() { + (h.0 - f.0 * t) / denom_x + } else { + (h.1 - f.1 * t) / denom_y + }; + ((-0.02..=1.02).contains(&s) && (-0.02..=1.02).contains(&t)).then_some((s, t)) + }; + // Seuil RELATIF. Un prését « left »/« right » est une rotation Y pure : le quad projeté + // est un trapèze symétrique dont `f` et `g` sont tous deux verticaux, donc k2 = 0 + // exactement — au bruit d'arrondi près, et ce bruit vaut quelques centièmes sur des + // produits en 10^6. Un seuil absolu de 0.001 le manquait, l'équation partait dans la + // branche quadratique avec un k2 ≈ 0, et `(-k1 + sqrt(k1²)) / 2k2` y perd toute + // précision : soustraire deux nombres presque égaux ne laisse que du bruit, divisé + // ensuite par un k2 minuscule. C'est ça qui amputait l'écran incliné. + if k2.abs() < 1e-5 * k1.abs() { + let t = if k1.abs() < 1e-6 { 0.0 } else { -k0 / k1 }; + return st_for_root(t); + } + let disc = k1 * k1 - 4.0 * k2 * k0; + if disc < 0.0 { + return None; + } + // Forme stable : `q` évite la soustraction catastrophique, et les deux racines s'en + // déduisent sans jamais retrancher deux quantités voisines. Le signe s'écrit en ternaire + // et non via `signum`, pour coller au shader — où `sign()` vaut 0 en 0 et annulerait `q`. + let sign_k1 = if k1 >= 0.0 { 1.0 } else { -1.0 }; + let q = -0.5 * (k1 + sign_k1 * disc.sqrt()); + let roots = [q / k2, if q.abs() > 0.0 { k0 / q } else { q / k2 }]; + // Les DEUX racines sont essayées : trancher sur `t` seul retenait parfois celle dont le + // `s` tombe hors du quad, et le pixel était alors déclaré dehors alors que l'autre racine + // le plaçait dedans. + st_for_root(roots[0]).or_else(|| st_for_root(roots[1])) + } + + #[test] + fn the_shader_mapping_covers_the_whole_tilted_quad() { + // Le bug rapporté : « une sorte d'overflow hidden qui tronque le screen recording ». Si le + // mapping inverse perd des pixels pourtant intérieurs au quad, le trou a exactement cette + // allure — un bord net, sans rapport avec la géométrie visible. + for (name, rot) in [ + ("iso", rotation3d_for(&Some("iso".into()))), + ("left", rotation3d_for(&Some("left".into()))), + ("right", rotation3d_for(&Some("right".into()))), + ] { + let corners = rotated_quad_corners_px(1920.0, 1080.0, rot).corners; + let mut dropped = Vec::new(); + let n = 64; + for i in 0..=n { + for j in 0..=n { + // On reste à un cheveu des arêtes : le contour exact est une frontière où le + // rejet est légitime. + let s = 0.002 + (i as f32 / n as f32) * 0.996; + let t = 0.002 + (j as f32 / n as f32) * 0.996; + let p = forward_bilinear(&corners, s, t); + match shader_inverse_bilinear(&corners, p) { + None => dropped.push((s, t)), + Some((s2, t2)) => { + // Retrouver le mauvais (s,t) est aussi grave : l'écran afficherait + // alors un morceau de lui-même au mauvais endroit. + if (s2 - s).abs() > 0.01 || (t2 - t).abs() > 0.01 { + dropped.push((s, t)); + } + } + } + } + } + assert!( + dropped.is_empty(), + "{name} : {} points intérieurs perdus par le mapping, p.ex. {:?}", + dropped.len(), + &dropped[..dropped.len().min(5)] + ); + } + } +} diff --git a/crates/compositor/src/remux.rs b/crates/compositor/src/remux.rs new file mode 100644 index 0000000000..e147f8e39e --- /dev/null +++ b/crates/compositor/src/remux.rs @@ -0,0 +1,248 @@ +//! Remux « stream copy » vers le muxer MATROSKA — réécrit un fichier en gardant +//! ses paquets bit-pour-bit, uniquement pour lui donner un index de seek. +//! +//! # Le problème +//! +//! `MediaRecorder` (Chromium) écrit le WebM comme un flux **live** : il n'a pas +//! le droit de revenir en arrière pour remplir un index, donc le fichier final +//! n'a NI `Cues` NI `SeekHead`. Vérifié sur un vrai enregistrement de l'app : +//! les deux magic bytes sont absents du début comme de la fin du fichier. Sans +//! `Cues`, `av_seek_frame` n'a aucun point d'entrée et échoue pour tout +//! timestamp non nul — d'où le repli « rembobine et scanne » linéaire de +//! `linux_decode.rs`. +//! +//! # Le correctif +//! +//! Relire les paquets et les réécrire par le muxer matroska, qui lui connaît la +//! taille finale du fichier et écrit donc `Cues` + `SeekHead` en fin de course +//! (`av_write_trailer` revient au début patcher les offsets). Aucun ré-encodage : +//! les paquets sont copiés tels quels, seuls les timestamps sont rebasés sur la +//! timebase du flux de sortie. Mesuré sur un enregistrement réel de 7,8 Mo : +//! 0,084 s et +378 octets. +//! +//! # Pourquoi `matroska` et pas `webm` +//! +//! Le muxer `webm` REFUSE ce fichier : « Only VP8 or VP9 or AV1 video and Vorbis +//! or Opus audio ... are supported for WebM ». Chromium produit du **H.264 dans +//! du WebM**, une combinaison hors spec que seul lui écrit. On force donc le +//! muxer matroska par son nom (2e argument d'`avformat_alloc_output_context2`), +//! ce qui court-circuite la déduction par extension — le fichier de sortie garde +//! son nom `.webm` alors que son contenu est du Matroska, ce qui est un +//! sur-ensemble strict et décrit le contenu plus honnêtement que ne le faisait +//! `MediaRecorder`. L'extension NE CHANGE PAS : elle alimente le nom du sidecar +//! curseur, le JSON de session et la persistance projet côté TS. +//! +//! # Effet de bord utile : la `Duration` +//! +//! Le muxer matroska recalcule la `Duration` à partir des timestamps réels des +//! paquets, sans lire celle de l'entrée. Confronté à une entrée dont la +//! `Duration` avait été forcée à 999999, il a écrit 16977 — la vraie valeur. Ce +//! remux subsume donc le patch de `Duration` que `webm-duration.ts` applique +//! par ailleurs (`MediaRecorder` ne l'écrit pas non plus). + +use anyhow::{bail, Context, Result}; +use std::ffi::CString; +use std::ptr; + +use crate::ffi::{ + av_interleaved_write_frame, av_packet_alloc, av_packet_free, av_packet_rescale_ts, + av_packet_unref, av_read_frame, av_write_trailer, avcodec_parameters_copy, + avformat_alloc_output_context2, avformat_close_input, avformat_find_stream_info, + avformat_free_context, avformat_new_stream, avformat_open_input, avformat_write_header, + averr, avio_closep, avio_open, sn_fmt_nb_streams, sn_fmt_set_pb, sn_fmt_stream, AVIOContext, + AVFormatContext, AVMediaType, AVPacket, AVIO_FLAG_WRITE, +}; + +/// Bilan d'un remux, remonté jusqu'à la glue TS pour la journalisation. +#[derive(Debug)] +pub struct RemuxStats { + /// Nombre de paquets recopiés (toutes pistes confondues). + pub packets: u64, + /// Nombre de pistes conservées dans la sortie. + pub streams: u32, + /// Durée du remux en secondes. + pub wall_s: f64, +} + +/// Ferme les ressources libav* quel que soit le chemin de sortie (`?` compris). +/// +/// Sans ça, chaque `?` du corps de `remux_to_seekable_matroska` fuiterait un +/// `AVFormatContext` et un descripteur de fichier. `Drop` ne peut pas faillir, +/// donc les erreurs de fermeture sont ignorées — on est déjà en train de rendre +/// une erreur au caller quand ça arrive. +struct RemuxGuard { + ictx: *mut AVFormatContext, + octx: *mut AVFormatContext, + pb: *mut AVIOContext, + pkt: *mut AVPacket, +} + +impl Drop for RemuxGuard { + fn drop(&mut self) { + unsafe { + if !self.pkt.is_null() { + av_packet_free(&mut self.pkt); + } + if !self.ictx.is_null() { + avformat_close_input(&mut self.ictx); + } + if !self.pb.is_null() { + avio_closep(&mut self.pb); + } + if !self.octx.is_null() { + avformat_free_context(self.octx); + self.octx = ptr::null_mut(); + } + } + } +} + +/// Recopie `input` vers `output` par le muxer matroska, sans ré-encoder. +/// +/// `output` DOIT être un chemin temporaire distinct de `input` : le caller +/// (`electron/recording/webm-seek-index.ts`) ne renomme par-dessus l'original +/// qu'une fois le remux terminé, pour qu'un échec laisse l'enregistrement +/// d'origine intact. Écrire directement sur `input` détruirait la seule copie +/// des pixels dès la première erreur d'écriture. +/// +/// Les pistes autres que vidéo/audio/sous-titre sont ignorées : `MediaRecorder` +/// n'en produit pas, et un flux `DATA` ou `ATTACHMENT` inattendu ferait échouer +/// `avformat_write_header` plutôt que de dégrader proprement. +pub fn remux_to_seekable_matroska(input: &str, output: &str) -> Result { + if input == output { + bail!("remux : entrée et sortie identiques ({input}) — le caller doit passer un chemin temporaire"); + } + let t0 = std::time::Instant::now(); + let cin = CString::new(input).context("chemin d'entrée non convertible en CString")?; + let cout = CString::new(output).context("chemin de sortie non convertible en CString")?; + // Nom du muxer, PAS une extension : c'est l'équivalent de `-f matroska`. + let cfmt = CString::new("matroska").expect("littéral sans NUL"); + + let mut guard = RemuxGuard { + ictx: ptr::null_mut(), + octx: ptr::null_mut(), + pb: ptr::null_mut(), + pkt: ptr::null_mut(), + }; + + unsafe { + averr( + avformat_open_input(&mut guard.ictx, cin.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "avformat_open_input", + )?; + averr( + avformat_find_stream_info(guard.ictx, ptr::null_mut()), + "avformat_find_stream_info", + )?; + + averr( + avformat_alloc_output_context2( + &mut guard.octx, + ptr::null(), + cfmt.as_ptr(), + cout.as_ptr(), + ), + "avformat_alloc_output_context2(matroska)", + )?; + if guard.octx.is_null() { + bail!("avformat_alloc_output_context2 n'a pas alloué de contexte matroska"); + } + + // `stream_map[i]` = index de sortie de la piste d'entrée `i`, ou -1 si + // elle est ignorée. Les index de sortie sont réattribués en séquence, + // donc ils ne coïncident pas forcément avec ceux de l'entrée. + let nb_in = sn_fmt_nb_streams(guard.ictx); + let mut stream_map: Vec = vec![-1; nb_in as usize]; + let mut nb_out: i32 = 0; + for i in 0..nb_in { + let istream = sn_fmt_stream(guard.ictx, i as i32); + if istream.is_null() { + continue; + } + let codec_type = (*(*istream).codecpar).codec_type; + if codec_type != AVMediaType::AVMEDIA_TYPE_VIDEO + && codec_type != AVMediaType::AVMEDIA_TYPE_AUDIO + && codec_type != AVMediaType::AVMEDIA_TYPE_SUBTITLE + { + continue; + } + let ostream = avformat_new_stream(guard.octx, ptr::null()); + if ostream.is_null() { + bail!("avformat_new_stream a rendu NULL pour la piste {i}"); + } + averr( + avcodec_parameters_copy((*ostream).codecpar, (*istream).codecpar), + "avcodec_parameters_copy", + )?; + // Le codec_tag est propre au conteneur d'origine ; le garder ferait + // écrire à matroska un tag qu'il ne reconnaît pas. 0 = « au muxer de + // choisir », c'est ce que fait `ffmpeg -c copy`. + (*(*ostream).codecpar).codec_tag = 0; + stream_map[i as usize] = nb_out; + nb_out += 1; + } + if nb_out == 0 { + bail!("remux : aucune piste vidéo/audio/sous-titre dans {input}"); + } + + averr( + avio_open(&mut guard.pb, cout.as_ptr(), AVIO_FLAG_WRITE as i32), + "avio_open", + )?; + sn_fmt_set_pb(guard.octx, guard.pb); + averr( + avformat_write_header(guard.octx, ptr::null_mut()), + "avformat_write_header", + )?; + + guard.pkt = av_packet_alloc(); + if guard.pkt.is_null() { + bail!("av_packet_alloc a rendu NULL"); + } + + let mut packets: u64 = 0; + loop { + let r = av_read_frame(guard.ictx, guard.pkt); + if r < 0 { + // Fin de fichier ou flux tronqué : dans les deux cas on écrit le + // trailer sur ce qu'on a. Un enregistrement coupé net (crash, + // batterie) reste lisible et devient seekable jusqu'à sa coupure. + break; + } + let in_idx = (*guard.pkt).stream_index; + let out_idx = stream_map + .get(in_idx as usize) + .copied() + .unwrap_or(-1); + if out_idx < 0 { + av_packet_unref(guard.pkt); + continue; + } + let istream = sn_fmt_stream(guard.ictx, in_idx); + let ostream = sn_fmt_stream(guard.octx, out_idx); + if istream.is_null() || ostream.is_null() { + av_packet_unref(guard.pkt); + continue; + } + av_packet_rescale_ts(guard.pkt, (*istream).time_base, (*ostream).time_base); + (*guard.pkt).stream_index = out_idx; + // `pos` décrit un offset dans le fichier d'ENTRÉE ; le laisser + // induirait le muxer en erreur sur la sortie. + (*guard.pkt).pos = -1; + let w = av_interleaved_write_frame(guard.octx, guard.pkt); + // `av_interleaved_write_frame` prend possession du paquet (il le + // déréférence lui-même), d'où l'absence d'`av_packet_unref` ici. + averr(w, "av_interleaved_write_frame")?; + packets += 1; + } + + // C'est CE trailer qui écrit `Cues` puis revient patcher `SeekHead`. + averr(av_write_trailer(guard.octx), "av_write_trailer")?; + + Ok(RemuxStats { + packets, + streams: nb_out as u32, + wall_s: t0.elapsed().as_secs_f64(), + }) + } +} diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs new file mode 100644 index 0000000000..2de5695108 --- /dev/null +++ b/crates/compositor/src/scene.rs @@ -0,0 +1,614 @@ +//! Contrat de scène côté Rust — miroir exact de `SceneDescription` (TS, `src/native/sceneDescription.ts`). +//! L'app sérialise le document en JSON ; le natif le parse ici puis calcule la composition par frame, +//! ce qui **remplace le `timeline()` fixture** (placements A↔B + zoom codés en dur). Le natif possède +//! toute la maths par-frame (géométrie du layout, easing du zoom, application des effets) ; ce module +//! ne fait que le modèle de données + le parse. La conversion JS (camelCase) est gérée par serde. + +use serde::Deserialize; + +/// Un clip de la timeline (fichiers screen+webcam + fenêtre source). = `CompositorClipInput` (TS). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneClip { + pub screen_path: String, + pub webcam_path: String, + pub source_start_sec: f64, + pub source_end_sec: f64, + /// temps source webcam = temps source screen − ceci. + pub webcam_offset_sec: f64, + /// Une source sans piste audio décodable garde sa durée via du silence natif. + #[serde(default)] + pub has_audio: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebcamPosition { + pub cx: f32, + pub cy: f32, +} + +/// Placement de la webcam (preset + réglages). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneLayout { + /// "picture-in-picture" | "dual-frame" | "vertical-stack" | "no-webcam". + pub preset: String, + /// échelle taille webcam (1 = défaut PiP du compositeur). + pub webcam_size: f32, + /// "rectangle" | "circle" | "square" | "rounded" — la forme RÉSOLUE par le layout, pas le + /// réglage brut de l'utilisateur : seul le PiP honore le sélecteur de forme, les layouts en + /// bloc découpent toujours un rectangle (côté app, cf. `computeCompositeLayout`). + pub webcam_shape: String, + pub webcam_mirror: bool, + /// position normalisée (0..1) du centre webcam, ou None → défaut du preset. + pub webcam_position: Option, + /// la webcam rétrécit pendant un zoom actif. + pub webcam_reactive_zoom: bool, + /// Rect webcam résolu côté app (0..1 fractions du cadre de sortie), en PARITÉ EXACTE avec + /// `computeCompositeLayout` (TS). Permet à TS et Rust de partager la même source de vérité : + /// le natif ne dérive PLUS ses propres placements pour PiP/dual-frame/vertical-stack — il + /// consomme ce rect directement et applique par-dessus les ajustements purement par-frame + /// (`webcam_size_scale`, `reactive_scale`, Full Camera). + /// + /// `#[serde(default)]` : champ ajouté après coup ; les anciens JSON (et les tests) omettent + /// ce champ, ce qui active le fallback `preset_placements` Rust historique (PiP codé en dur). + #[serde(default)] + pub webcam_rect: Option, + /// Rect ÉCRAN résolu côté app (mêmes fractions 0..1 du cadre de sortie que `webcam_rect`). + /// Déjà paddé et déjà au ratio du crop — le natif le consomme TEL QUEL, sans `padding_scale` + /// ni `fit_dst_to_aspect`. Sans lui, le natif gardait sa boîte écran codée en dur + /// (`preset_placements`) tout en respectant la boîte caméra de l'app : les deux ne + /// s'accordaient plus et la caméra du preset side-by-side sortait du cadre. + /// + /// `#[serde(default)]` : ancien payload / tests → None → fallback `preset_placements`. + #[serde(default)] + pub screen_rect: Option, + /// Rayon des coins de l'écran, en FRACTION du petit côté de sa propre boîte, quand le preset + /// en impose un (les layouts en bloc encadrent écran et caméra à l'identique). None → slider + /// Roundness. Une fraction, pas des px : cf. `SceneEffects::roundness_frac`. + #[serde(default)] + pub screen_radius_frac: Option, + /// L'écran doit-il REMPLIR sa boîte quitte à être rogné (`object-fit: cover`) plutôt que d'y + /// tenir en entier ? Vrai pour les layouts en bloc, dont la boîte écran est un slot au ratio + /// arbitraire : c'est ce que `computeCompositeLayout` renvoie sous `screenCover` et que + /// `frameRenderer` applique déjà côté web. Sans ce drapeau le natif étirait la source pour + /// remplir le slot — d'autant plus visible sur un clip recadré, le crop éloignant encore le + /// ratio de la source de celui du slot. + /// + /// `#[serde(default)]` : absent → `false` → comportement "contain" historique. + #[serde(default)] + pub screen_cover: bool, + /// Un layout résolu PAR CLIP visible, aligné par index sur `Scene::clips` / `crop_by_clip`. + /// Les champs scalaires ci-dessus sont ceux du PREMIER clip (repli pour un payload sans ce + /// tableau, et valeur de départ tant qu'aucun clip n'est actif). + /// + /// Par clip parce que la FORME de la source écran l'est : un clip est un enregistrement + /// d'écran + une caméra et un son optionnels, et rien n'impose à deux clips d'avoir été + /// enregistrés à la même taille ni au même ratio. Le crop n'est qu'une manière de plus de + /// faire varier cette forme — un 16:9 recadré en 9:16 doit se disposer exactement comme un + /// enregistrement nativement en 9:16. À ne pas confondre avec le ratio de la SCÈNE, global. + /// + /// `for_clip_window` recopie l'entrée du clip composé dans les champs scalaires, si bien que + /// `compose_frame` continue de lire un seul `layout` sans branche supplémentaire. + #[serde(default)] + pub layout_by_clip: Vec>, + /// Rayon des coins de la CAMÉRA, en fraction du petit côté de SA boîte — même règle que + /// `screen_radius_frac`, issu du même appel `computeCompositeLayout`. C'est la seule façon + /// que « le bloc encadre écran et caméra à l'identique » soit vrai : sans lui l'écran prenait + /// le rayon de l'app pendant que la caméra gardait la table Rust indépendante + /// (`min * 0.5 | 0.3 | 0.12`, non bornée), donc deux moitiés d'un même bloc arrondies par + /// deux formules différentes. + /// + /// `#[serde(default)]` : ancien payload / tests → None → table Rust historique. + #[serde(default)] + pub webcam_radius_frac: Option, +} + +/// La moitié du layout qui dépend de la FORME de la source, résolue pour un clip. +/// Voir `SceneLayout::layout_by_clip`. +/// +/// Les rayons sont des fractions du petit côté de LEUR boîte, exactement comme les champs +/// scalaires `screen_radius_frac`/`webcam_radius_frac` — aucune longueur ne traverse ce +/// contrat en pixels, par clip ou non. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedClipLayout { + pub screen_rect: SceneRect, + #[serde(default)] + pub webcam_rect: Option, + #[serde(default)] + pub screen_radius_frac: Option, + #[serde(default)] + pub webcam_radius_frac: Option, + #[serde(default)] + pub webcam_shape: Option, + #[serde(default)] + pub screen_cover: bool, +} + +/// Rect normalisé 0..1 du cadre de sortie : x, y en haut-gauche ; width, height. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneRect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// Effets de cadre (padding, blur, ombre, coins, motion blur). +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneEffects { + /// 0..1 inset supplémentaire de l'écran. + pub padding: f32, + pub blur: bool, + /// 0..1 force de l'ombre. + pub shadow: f32, + /// Slider Roundness, en FRACTION du petit côté du cadre de sortie. + /// + /// Toute longueur qui traverse ce contrat est une fraction, jamais un nombre de pixels, et + /// c'est porteur : le compositeur rastérise la preview dans un cadre contain-fitté petit et + /// l'export à la pleine résolution, donc « un pixel » ne désigne pas la même chose des deux + /// côtés de la frontière. Des valeurs absolues la traversaient et signifiaient en silence + /// « px du render target » — d'où le cercle PiP dégénéré en preview alors que l'export était + /// juste, et l'ombre proportionnellement plus faible en 4K qu'en 1080p. Une fraction n'a + /// pas d'unité à confondre : le natif multiplie par ce que sa référence mesure ici et + /// maintenant. + pub roundness_frac: f32, + /// 0..1 flou de mouvement. + pub motion_blur: f32, +} + +/// Fond derrière l'écran (parsé depuis `settings.wallpaper`). +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SceneBackground { + Color { color: String }, + Gradient { + #[serde(rename = "angleDeg")] + angle_deg: f32, + stops: Vec, + }, + Image { path: String }, +} + +/// Une annotation de la timeline (temps en secondes, source du clip). +/// +/// Espace de coordonnées — à respecter au rendu : `x`/`y`/`w`/`h` sont des fractions du **rect +/// écran**, pas du cadre de sortie (le calque web reçoit `layout.screenRect` comme conteneur), et +/// elles ne subissent **pas** le crop de zoom : l'overlay est frère de l'élément qui porte la +/// transform, donc les annotations restent en place pendant que le contenu zoome dessous. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotation { + #[serde(default)] + pub id: String, + /// Voir `SceneZoomRegion::clip_index`. + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, + /// "text" | "image" | "figure" | "blur". + pub kind: String, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + /// Ordre de peinture ; l'app envoie déjà la liste triée croissante. + #[serde(default)] + pub z_index: i32, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub image_path: Option, + #[serde(default)] + pub figure: Option, + #[serde(default)] + pub blur: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotationText { + pub content: String, + /// Chaînes CSS, parsées ici comme la couleur de fond (`parse_hex`) ; "transparent" = pas de + /// remplissage. + pub color: String, + pub background_color: String, + /// Taille de police en **fraction de la hauteur du rect écran**, comme tout le reste de ce + /// contrat : à multiplier par la hauteur du rect en pixels de sortie. La preview applique le + /// même produit contre sa propre boîte (`annotationScale.ts`), donc preview et rendu + /// s'accordent à n'importe quelle résolution. + pub font_size_rel: f32, + pub font_family: String, + pub font_weight: String, + pub font_style: String, + pub text_decoration: String, + pub text_align: String, + #[serde(default)] + pub animation: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotationFigure { + /// "up" | "down" | "left" | "right" | "up-right" | "up-left" | "down-right" | "down-left". + pub direction: String, + pub color: String, + pub stroke_width: f32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotationBlur { + /// "blur" | "mosaic". + pub style: String, + /// "rectangle" | "oval" | "freehand". + pub shape: String, + /// "white" | "black". + pub color: String, + pub intensity: f32, + pub block_size: f32, + /// Fractions du rect écran, même espace que le rect. + #[serde(default)] + pub freehand_points: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SceneAnnotationPoint { + pub x: f32, + pub y: f32, +} + +/// Une zone de zoom de la timeline (temps en secondes). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneZoomRegion { + /// Identifiant stable — nécessaire pour apparier les régions adjacentes (connected pan). + /// `#[serde(default)]` : champ ajouté après coup. + #[serde(default)] + pub id: String, + /// Index du clip dont les temps source portent cette région. `None` garde la compatibilité + /// avec les payloads antérieurs et déclenche le repli par chevauchement de fenêtre source. + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, + /// échelle cible (>1 = zoom avant). + pub scale: f32, + pub focus_x: f32, + pub focus_y: f32, + /// "manual" | "auto" (suit la télémétrie curseur) | null (= manual). + #[serde(default)] + pub focus_mode: Option, + /// "iso" | "left" | "right" | null. + pub rotation: Option, +} + +/// Une zone de vitesse portée par le temps source d'un clip. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneSpeedRegion { + /// Index du clip dont les temps source portent cette région (voir `SceneZoomRegion`). + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, + pub speed: f64, +} + +/// Une zone "Full Camera" de la timeline (temps en secondes) : la caméra PREND tout le cadre +/// pendant cette fenêtre (plein écran net — ni marge, ni arrondi, ni masque, ni fond derrière). +/// Pas de champs au-delà des bornes temporelles (miroir de `CameraFullscreenRegion`, TS). +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCameraFullscreenRegion { + /// Index du clip dont les temps source portent cette région (voir `SceneZoomRegion`). + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, +} + +/// Rendu du curseur. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCursor { + pub show: bool, + /// échelle directe (1 = défaut). + pub size: f32, + pub smoothing: f32, + pub motion_blur: f32, + pub click_bounce: f32, + pub clip_to_bounds: bool, + /// id du thème (jeu de sprites) — informatif ici : le natif consomme `cursor_sprites`. + pub theme: String, + /// Sprites par état de curseur (`"arrow"`, `"text"`, `"pointer"`, `"resize-ew"`, …), chemins + /// absolus résolus côté app (compositorViewService, même mécanisme que le wallpaper image). + /// Le thème choisi n'y fournit que les états qu'il possède ; l'app complète le reste avec + /// l'art intégrée, si bien qu'ici la table est TOUJOURS complète ou vide. + /// + /// Vide → curseur math dot+ring, qui n'est qu'un filet de sécurité : ce n'est PAS à quoi + /// ressemble un pointeur système, et l'afficher en temps normal était le bug « le curseur + /// par défaut est un point dans un cercle ». + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub cursor_sprites: std::collections::HashMap, +} + +/// Un sprite de curseur : image + point de pivot. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCursorSprite { + /// Chemin absolu d'un PNG/JPEG déchiffrable par le crate `image`. + pub path: String, + /// Pivot en FRACTION de l'image (0..1), pas en pixels : le sprite est redimensionné au + /// réglage « taille du curseur », et seule une fraction survit à cette mise à l'échelle. + /// Un pivot centré (0.5, 0.5) imposé à tous les sprites décalait la pointe d'autant plus + /// que le curseur était agrandi — le bug que ce champ corrige. + pub hotspot_x: f32, + pub hotspot_y: f32, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCrop { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneOutput { + pub width: u32, + pub height: u32, + /// null = fps du 1er clip. + pub fps: Option, +} + +/// Tout ce dont le natif a besoin pour composer la scène, sérialisé depuis un document. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Scene { + pub clips: Vec, + pub layout: SceneLayout, + pub effects: SceneEffects, + pub background: SceneBackground, + pub zoom_regions: Vec, + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub annotations: Vec, + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub speed_regions: Vec, + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub camera_fullscreen_regions: Vec, + pub cursor: SceneCursor, + /// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS). + #[serde(default)] + pub crop_by_clip: Vec>, + /// État de rendu interne, positionné par `for_clip_window` (jamais envoyé par l'app). + #[serde(skip)] + pub(crate) active_clip_index: usize, + pub output: SceneOutput, +} + +impl Scene { + /// Parse le JSON produit par `buildSceneDescription` (TS). + pub fn from_json(json: &str) -> anyhow::Result { + Ok(serde_json::from_str(json)?) + } + + /// Copie de scène limitée aux régions du clip actif. `clipIndex` est l'identité fiable + /// lorsque plusieurs clips réutilisent les mêmes temps source ; son absence retombe sur le + /// chevauchement avec la fenêtre source pour accepter les anciens payloads. + pub(crate) fn for_clip_window( + &self, + clip_index: usize, + source_start_sec: f64, + source_end_sec: f64, + ) -> Scene { + let belongs = |region_clip_index: Option, start_sec: f64, end_sec: f64| { + let overlaps_window = end_sec > source_start_sec && start_sec < source_end_sec; + overlaps_window && region_clip_index.map(|i| i == clip_index).unwrap_or(true) + }; + let mut scene = self.clone(); + scene.zoom_regions.retain(|region| { + belongs(region.clip_index, region.start_sec, region.end_sec) + }); + scene.speed_regions.retain(|region| { + belongs(region.clip_index, region.start_sec, region.end_sec) + }); + scene.camera_fullscreen_regions.retain(|region| { + belongs(region.clip_index, region.start_sec, region.end_sec) + }); + scene.annotations.retain(|annotation| { + belongs(annotation.clip_index, annotation.start_sec, annotation.end_sec) + }); + // Le layout dépend de la FORME de la source du clip (dimensions natives × crop), qui + // varie d'un clip à l'autre. On installe donc celui du clip composé dans les champs + // scalaires : `compose_frame` continue de lire un seul `layout`, sans jamais avoir à + // savoir qu'il en existe un par clip. Absent (payload ancien) → on garde les scalaires. + if let Some(Some(l)) = scene.layout.layout_by_clip.get(clip_index).cloned() { + scene.layout.screen_rect = Some(l.screen_rect); + scene.layout.webcam_rect = l.webcam_rect; + scene.layout.screen_radius_frac = l.screen_radius_frac; + scene.layout.webcam_radius_frac = l.webcam_radius_frac; + scene.layout.screen_cover = l.screen_cover; + if let Some(shape) = l.webcam_shape { + scene.layout.webcam_shape = shape; + } + } + scene.active_clip_index = clip_index; + scene + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_minimal_scene_json() { + let json = r##"{ + "clips": [{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":0,"hasAudio":true}], + "layout": {"preset":"picture-in-picture","webcamSize":1.5,"webcamShape":"circle","webcamMirror":true,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects": {"padding":0.5,"blur":true,"shadow":0.8,"roundnessFrac":0.0222,"motionBlur":0.0}, + "background": {"kind":"gradient","angleDeg":135,"stops":["#eaebed","#bcc0c6"]}, + "zoomRegions": [{"clipIndex":0,"startSec":1.0,"endSec":3.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"iso"}], + "speedRegions": [{"clipIndex":0,"startSec":1.0,"endSec":2.0,"speed":2.0}], + "cursor": {"show":true,"size":1,"smoothing":0.5,"motionBlur":0.2,"clickBounce":1,"clipToBounds":false,"theme":"default"}, + "cropByClip": [null], + "output": {"width":1920,"height":1080,"fps":null} + }"##; + let scene = Scene::from_json(json).expect("parse"); + assert_eq!(scene.clips.len(), 1); + assert_eq!(scene.clips[0].screen_path, "/s.mp4"); + assert_eq!(scene.layout.preset, "picture-in-picture"); + assert!(scene.layout.webcam_mirror); + assert!((scene.effects.roundness_frac - 0.0222).abs() < 1e-6); + match scene.background { + SceneBackground::Gradient { angle_deg, ref stops } => { + assert_eq!(angle_deg, 135.0); + assert_eq!(stops.len(), 2); + } + _ => panic!("expected gradient"), + } + assert_eq!(scene.zoom_regions[0].scale, 2.0); + assert_eq!(scene.zoom_regions[0].clip_index, Some(0)); + assert_eq!(scene.speed_regions[0].speed, 2.0); + assert!(scene.clips[0].has_audio); + assert_eq!(scene.crop_by_clip.len(), 1); + assert_eq!(scene.output.width, 1920); + } + + #[test] + fn parses_color_and_image_backgrounds() { + let color = r##"{"clips":[],"layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#123456"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1280,"height":720,"fps":30}}"##; + let s = Scene::from_json(color).expect("parse color"); + match s.background { + SceneBackground::Color { ref color } => assert_eq!(color, "#123456"), + _ => panic!("expected color"), + } + assert_eq!(s.output.fps, Some(30.0)); + } + + #[test] + fn parses_webcam_rect_payload() { + // webcamRect est une fraction 0..1 du cadre de sortie ; sa présence doit désactiver + // le fallback `preset_placements` Rust côté `compose_frame` (voir `compositor.rs`). + let json = r##"{ + "clips": [], + "layout": { + "preset": "picture-in-picture", + "webcamSize": 0.25, + "webcamShape": "rounded", + "webcamMirror": false, + "webcamPosition": null, + "webcamReactiveZoom": false, + "webcamRect": { "x": 0.8125, "y": 0.8125, "width": 0.1666667, "height": 0.1666667 } + }, + "effects": {"padding": 0, "blur": false, "shadow": 0, "roundnessFrac": 0.0222, "motionBlur": 0}, + "background": {"kind":"color","color":"#000000"}, + "zoomRegions": [], + "cursor": {"show": true, "size": 1, "smoothing": 0, "motionBlur": 0, "clickBounce": 1, "clipToBounds": false, "theme": "default"}, + "cropByClip": [], + "output": {"width": 1920, "height": 1080, "fps": null} + }"##; + let s = Scene::from_json(json).expect("parse w/ webcamRect"); + let r = s + .layout + .webcam_rect + .expect("webcam_rect doit être présent pour ce payload"); + // bornes + ratio cohérent avec `computeCompositeLayout` (TS) pour le preset PiP @25%. + assert!((0.0..=1.0).contains(&r.x) && (0.0..=1.0).contains(&r.y)); + assert!(r.width > 0.0 && r.width <= 1.0); + assert!((r.width - r.height).abs() < 1e-5); + } + + #[test] + fn webcam_rect_field_optional_in_payload() { + // L'ancien payload sans `webcamRect` doit toujours parser sans erreur (le champ est + // `#[serde(default)]`) ; `webcam_rect` est alors None → fallback `preset_placements`. + let json = r##"{"clips":[],"layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#000000"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1920,"height":1080,"fps":null}}"##; + let s = Scene::from_json(json).expect("parse sans webcam_rect"); + assert!(s.layout.webcam_rect.is_none()); + assert_eq!(s.layout.preset, "picture-in-picture"); + } +} + +#[cfg(test)] +mod annotation_tests { + use super::*; + + /// Enveloppe minimale valide + les annotations passées en paramètre. + fn scene_json(annotations: &str) -> String { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#000000"}},"zoomRegions":[],"annotations":{annotations},"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + } + + #[test] + fn an_older_payload_without_annotations_still_parses() { + // `#[serde(default)]` : tout JSON produit avant ce champ doit continuer à charger. + let json = scene_json("[]").replace(r#""annotations":[],"#, ""); + let scene = Scene::from_json(&json).expect("parse sans annotations"); + assert!(scene.annotations.is_empty()); + } + + #[test] + fn parses_a_text_annotation_with_its_style() { + let json = scene_json( + r##"[{"id":"ann1","clipIndex":0,"startSec":1.0,"endSec":3.0,"kind":"text","x":0.25,"y":0.5,"w":0.4,"h":0.1,"zIndex":2,"text":{"content":"Bonjour","color":"#ffffff","backgroundColor":"transparent","fontSizeRel":0.0296,"fontFamily":"Inter","fontWeight":"bold","fontStyle":"normal","textDecoration":"none","textAlign":"center","animation":"fade"}}]"##, + ); + let scene = Scene::from_json(&json).expect("parse texte"); + let ann = &scene.annotations[0]; + assert_eq!(ann.kind, "text"); + assert_eq!(ann.clip_index, Some(0)); + assert_eq!(ann.z_index, 2); + assert!((ann.x - 0.25).abs() < 1e-6 && (ann.h - 0.1).abs() < 1e-6); + let text = ann.text.as_ref().expect("payload texte"); + assert_eq!(text.content, "Bonjour"); + assert_eq!(text.text_align, "center"); + assert_eq!(text.animation.as_deref(), Some("fade")); + assert!(ann.figure.is_none() && ann.blur.is_none()); + } + + #[test] + fn parses_figure_and_blur_payloads() { + let json = scene_json( + r##"[{"id":"f","startSec":0.0,"endSec":1.0,"kind":"figure","x":0.1,"y":0.1,"w":0.2,"h":0.2,"zIndex":0,"figure":{"direction":"up-left","color":"#34B27B","strokeWidth":6}}, + {"id":"b","startSec":0.0,"endSec":1.0,"kind":"blur","x":0.0,"y":0.0,"w":0.5,"h":0.5,"zIndex":1,"blur":{"style":"mosaic","shape":"freehand","color":"black","intensity":8,"blockSize":16,"freehandPoints":[{"x":0.1,"y":0.2},{"x":0.3,"y":0.4}]}}]"##, + ); + let scene = Scene::from_json(&json).expect("parse figure+blur"); + let figure = scene.annotations[0].figure.as_ref().expect("payload figure"); + assert_eq!(figure.direction, "up-left"); + assert!((figure.stroke_width - 6.0).abs() < 1e-6); + let blur = scene.annotations[1].blur.as_ref().expect("payload blur"); + assert_eq!(blur.shape, "freehand"); + let points = blur.freehand_points.as_ref().expect("points"); + assert_eq!(points.len(), 2); + assert!((points[1].x - 0.3).abs() < 1e-6); + } + + #[test] + fn for_clip_window_keeps_only_the_annotations_of_the_composed_clip() { + // Même règle que les zoom/speed/camera regions : bon clip ET recouvrement de la fenêtre. + let json = scene_json( + r##"[{"id":"keep","clipIndex":0,"startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}, + {"id":"other-clip","clipIndex":1,"startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}, + {"id":"out-of-window","clipIndex":0,"startSec":50.0,"endSec":51.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}]"##, + ); + let scene = Scene::from_json(&json).expect("parse"); + let filtered = scene.for_clip_window(0, 0.0, 10.0); + assert_eq!( + filtered.annotations.iter().map(|a| a.id.as_str()).collect::>(), + vec!["keep"] + ); + } +} diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl new file mode 100644 index 0000000000..b9d438d7d7 --- /dev/null +++ b/crates/compositor/src/shaders.hlsl @@ -0,0 +1,541 @@ +// Compositeur — un draw par calque (quad). NV12->RGB maison (E1), coins arrondis SDF (E2). +// Tout écrit depuis les maths (§7), rien repris de l'ancien paradigme. + +cbuffer Layer : register(b0) +{ + 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, 4 = curseur + 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é) +}; + +struct VSOut +{ + float4 pos : SV_Position; + float2 uv : TEXCOORD0; // coords d'échantillonnage source + float2 local : TEXCOORD1; // coords pixel dans le quad (pour SDF) + float2 pout : TEXCOORD2; // position 0..1 sortie (pour la vélocité par pixel) +}; + +VSOut vs_main(uint vid : SV_VertexID) +{ + float2 c = float2(vid & 1, (vid >> 1) & 1); // strip: (0,0)(1,0)(0,1)(1,1) + float2 p = dst.xy + c * 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 = src.xy + c * (src.zw - src.xy); + o.local = c * quad_px; + o.pout = p; + return o; +} + +Texture2D texY : register(t0); +Texture2D texUV : register(t1); +Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) +SamplerState samp : register(s0); + +// BT.709 limited -> RGB (§7 E1), matrice en dur, range mesuré en S1. +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 saturate(rgb); +} + +float3 sample_yuv(float2 uv) +{ + float y = texY.Sample(samp, uv); + float2 cbcr = texUV.Sample(samp, uv); + return yuv709_limited(y, cbcr); +} + +// SDF segment à bouts ronds — la primitive des flèches d'annotation, dont les tracés SVG sont +// trois segments `stroke-linecap="round"` (cf. ArrowSvgs.tsx). +float sd_segment(float2 p, float2 a, float2 b) +{ + float2 pa = p - a; + float2 ba = b - a; + float h = saturate(dot(pa, ba) / max(dot(ba, ba), 1e-6)); + return length(pa - ba * h); +} + +// SDF rectangle à coins arrondis (§7 E2) : <0 dedans. +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. +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); // arêtes parallèles : quad dégénéré + 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). Le max des demi-plans suffit près +// des arêtes mais donne un coin en pointe ; ici on veut aussi la distance juste au coin, puisque +// c'est elle qui devient l'arrondi une fois le rayon retranché. +float sd_convex_quad(float2 p, float2 v0, float2 v1, float2 v2, float2 v3) +{ + float2 v[5] = { v0, v1, v2, v3, v0 }; + float inside = -1e9; + float border = 1e9; + [unroll] for (int k = 0; k < 4; k++) + { + float2 a = v[k]; + float2 e = v[k + 1] - 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, v[k + 1])); + } + return (inside < 0.0) ? -border : border; +} + +// (s, t, ok) du warp inverse du mode 8 pour une racine `t` donnée : `ok` = 1 quand le couple +// tombe dans le quad projeté (même marge 0.02 qu'ailleurs). Les deux racines doivent être +// essayées — trancher sur `t` seul retient parfois celle dont le `s` sort du quad, et le pixel +// est alors déclaré dehors alors que l'autre racine le plaçait dedans. +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, partagé +// par le mode 8 (écran incliné) et le mode 13 (curseur posé sur ce même écran). Les deux doivent +// résoudre exactement la même équation, sinon le curseur glisse par rapport au contenu — d'où +// une seule implémentation plutôt que deux copies. +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; + // Seuil RELATIF. Les présets « left »/« right » sont une rotation Y pure : le quad + // projeté est un trapèze symétrique dont `f` et `g` sont tous deux verticaux, donc + // k2 = 0 EXACTEMENT — au bruit d'arrondi près, et ce bruit vaut quelques centièmes sur + // des produits en 10^6. Un seuil absolu de 0.001 le manquait : l'équation passait dans la + // branche quadratique avec k2 ≈ 0, où `(-k1 + sqrt(k1²)) / 2k2` ne renvoie que du bruit — + // soustraire deux nombres presque égaux, puis diviser par presque rien. La quasi-totalité + // du quad était rejetée, ce qui se voyait comme un écran incliné tranché net. + 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); + // Forme stable : `q` n'oppose jamais deux quantités voisines, et les deux racines + // s'en déduisent exactement. `sign()` est évité parce qu'il vaut 0 en 0, ce qui + // annulerait `q` là où la formule reste parfaitement définie. + 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; +} + +float4 ps_main(VSOut i) : SV_Target +{ + // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Même warp que le mode 8, mais + // échantillonnant la texture du curseur en alpha DROIT (comme le mode 7) au lieu de la + // vidéo NV12. Le curseur remplace un pointeur qui faisait partie de l'image capturée : il + // doit donc subir la même inclinaison qu'elle, sinon il se lit comme un autocollant plat + // collé par-dessus la scène. Corriger sa seule position ne suffisait pas. + // fx.xy/fx.zw = coins TL/TR (px locaux) ; src_prev.xy/.zw = BR/BL ; dst_prev = rect de clip + // « Clip to canvas » en espace sortie. + if (mode > 12.5) + { + if (i.pout.x < dst_prev.x || i.pout.x > dst_prev.x + dst_prev.z || + i.pout.y < dst_prev.y || i.pout.y > dst_prev.y + dst_prev.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du sprite projeté + } + float4 s = texImg.Sample(samp, saturate(float2(r.x, r.y))); + float a = s.a * color.a; + return float4(s.rgb * a, a); + } + + // mode 8 : écran tilté en 3D (zoom regions "rotation" : iso/left/right). `dst`/`quad_px` + // couvrent la BOUNDING BOX des 4 coins projetés (calculée côté CPU, `regions.rs`) ; ce + // shader retrouve où tombe chaque pixel DANS le quad tilté (warp bilinéaire inverse — pas + // de perspective-correct exact, mais indiscernable à l'œil pour un tilt de 10-22°) et + // échantillonne la vidéo à l'UV correspondant, sinon transparent (hors du quad projeté). + // fx.xy/fx.zw = coins TL/TR (px locaux, 0..quad_px) ; src_prev.xy/.zw = coins BR/BL. + // mode 11 : texte d'annotation, rastérisé par Direct2D (voir text.rs). D2D écrit sur une + // surface DXGI en alpha PRÉMULTIPLIÉ, donc contrairement au mode 7 (sprite curseur, alpha + // droit) il ne faut SURTOUT pas re-multiplier ici : les bords adoucis des glyphes + // deviendraient deux fois trop transparents et le texte paraîtrait délavé. + // `color.a` reste l'opacité globale (fondu d'animation). + // + // mode 12 : ombre du quad PROJETÉ. Même pénombre que le mode 2, mais portée par le + // quadrilatère incliné au lieu d'un rect droit — une ombre droite derrière un écran penché ne + // se lit pas comme son ombre, mais comme une seconde surface posée derrière. Les coins + // arrivent dans la même convention que le mode 8 (fx = TL/TR, src_prev = BR/BL, px locaux) ; + // mb.y = étalement de la pénombre en px. + if (mode > 11.5) + { + float2 quad[5] = { fx.xy, fx.zw, src_prev.xy, src_prev.zw, fx.xy }; + // Coins arrondis du même rayon que le plan (`radius_px`). Une ombre à coins vifs derrière + // un écran aux coins arrondis dépasse en pointe à chaque coin — visible, et d'autant plus + // que le rayon monte. On rentre donc chaque arête de `r`, et retrancher `r` à la distance + // du quadrilatère ainsi obtenu redonne un arrondi exactement tangent aux deux arêtes. + float r = max(radius_px, 0.0); + float2 v[4]; + [unroll] 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]; // arête précédente + float2 ec = quad[k + 1] - quad[k]; // arête courante + float2 np = float2(ep.y, -ep.x) / max(length(ep), 1e-6); + float2 nc = float2(ec.y, -ec.x) / max(length(ec), 1e-6); + // Chaque arête rentrée de r : n·x = n·a - r. Leur intersection est le coin rentré. + 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(mb.y, 1e-3); + float a = color.a * (1.0 - smoothstep(0.0, spread, d)); + return float4(color.rgb * a, a); + } + + if (mode > 10.5) + { + float4 s = texImg.Sample(samp, i.uv); + return s * color.a; + } + + // mode 10 : annotation « flou » — masque la zone en réutilisant l'image DÉJÀ composée, qui + // arrive dans `texImg` (recopie du render target : on ne peut pas échantillonner la cible sur + // laquelle on dessine). `i.pout` donne directement l'UV de sortie, donc aucun mapping à + // refaire. fx.x = 0 mosaïque / 1 flou ; fx.y = taille de bloc px (mosaïque) ou rayon px + // (flou) ; fx.z = 0 rectangle / 1 ovale ; fx.w = 1 si le masque doit être teinté. + if (mode > 9.5) + { + // Masque de forme, en coords locales normalisées du quad. + float2 n = i.local / max(quad_px, 1e-6); + float cov = 1.0; + if (fx.z > 0.5) + { + // Ovale inscrit : distance au centre en unités de demi-axes, adoucie sur ~1px. + float2 d = (n - 0.5) * 2.0; + float r = length(d); + float aa = 2.0 / max(min(quad_px.x, 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 (fx.x > 0.5) + { + // Flou : on échantillonne un niveau de mip de l'image composée. `log2(rayon)` donne + // le niveau dont un texel couvre à peu près le rayon demandé, et le filtrage + // trilinéaire lisse la transition entre deux niveaux quand le rayon varie. + // + // Un noyau de quelques taps espacés du rayon ne floute PAS : il superpose autant de + // copies décalées, ce qui se voit comme du texte fantôme. Atteindre un vrai lissage + // par taps demanderait un tap par pixel de rayon ; la pyramide de mips donne le même + // résultat à coût constant, et c'est le GPU qui l'a construite. + float lod = log2(max(fx.y, 1.0)); + rgb = texImg.SampleLevel(samp, i.pout, lod).rgb; + } + else + { + // Mosaïque : on quantifie l'UV 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 = dst.zw / max(quad_px, 1e-6); + float2 block = max(fx.y, 1.0) * px_uv; + float2 origin = dst.xy; + float2 q = origin + (floor((i.pout - origin) / block) + 0.5) * block; + // `SampleLevel(..., 0)` et non `Sample` : l'UV quantifié est une marche d'escalier, + // donc 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.SampleLevel(samp, q, 0.0).rgb; + } + + if (fx.w > 0.5) + { + // Teinte blanc/noir : la couleur choisie, mêlée à moitié, garde la forme lisible sans + // effacer complètement ce qu'il y a dessous. + rgb = lerp(rgb, color.rgb, 0.5); + } + float a = cov * color.a; + return float4(rgb * a, a); // prémultiplié + } + + // mode 9 : annotation « figure » — une flèche. Parité EXACTE avec `ArrowSvgs.tsx`, dont + // chaque direction est un tracé de trois segments à bouts ronds dans un viewBox 0..100 : + // une hampe et deux barbes. Trois `sd_segment` et un `min` reproduisent donc la forme telle + // quelle, pas une approximation. Les extrémités arrivent déjà converties en px locaux du + // quad (échelle uniforme centrée, comme le `preserveAspectRatio` par défaut du SVG). + // fx = hampe (a.xy, b.xy), src_prev = barbe 1, dst_prev = barbe 2 ; mb.y = demi-épaisseur px. + if (mode > 8.5) + { + float d = sd_segment(i.local, fx.xy, fx.zw); + d = min(d, sd_segment(i.local, src_prev.xy, src_prev.zw)); + d = min(d, sd_segment(i.local, dst_prev.xy, 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 = saturate(mb.y - d + 0.5) * color.a; + return float4(color.rgb * a, a); // prémultiplié, comme tous les autres modes + } + + if (mode > 7.5) + { + float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du quad projeté + } + float2 uv = float2(lerp(src.x, src.z, saturate(r.x)), lerp(src.y, src.w, saturate(r.y))); + // Coins arrondis DANS LE REPÈRE DU PLAN (`dst_prev.xy` = sa taille avant projection) : + // le rayon reste constant le long du bord, alors qu'un arrondi calculé dans la bbox + // s'étirerait avec la perspective. Sans cet arrondi, un écran penché a des arêtes de + // couteau qui coupent le contenu en pleine phrase, et ça se lit comme une troncature + // plutôt que comme une inclinaison. + // + // Inconditionnel, rayon 0 COMPRIS : `sd_round_rect` dégénère alors en SDF de rectangle et + // le feather de 1.5 px subsiste, ce qui est précisément ce qui fait lire une arête inclinée + // comme une arête. Sous l'ancienne garde `radius_px > 0`, un slider Roundness à 0 laissait + // la couverture du plan au seul test binaire `r.z < 0.5` ci-dessus : des marches d'escalier + // en escalier franc, soit la troncature même que cette branche existe pour éviter (d'où le + // symptôme « le tilt 3D est tronqué, mais pas au-dessus d'un certain arrondi »). L'ombre du + // mode 12 applique déjà son `max(radius_px, 0.0)` sans garde, pour la même raison. + float2 plane_px = 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(radius_px, 0.0)); + float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); + return float4(sample_yuv(uv) * tilt_a, tilt_a); // prémultiplié, comme les autres modes + } + + // mode 7 : sprite curseur thème (PNG alpha droite, arrow.png etc.). Prémultiplie ici + // (le blend state attend du prémultiplié 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 -> aucun effet). + if (mode > 6.5) + { + if (i.pout.x < fx.x || i.pout.x > fx.x + fx.z || i.pout.y < fx.y || i.pout.y > fx.y + fx.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float4 s = texImg.Sample(samp, i.uv); + float a = s.a * color.a; // color.a = opacité globale (fade éventuel) + 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. + if (mode > 5.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. + if (mode > 4.5) + { + float2 dir = fx.xy; + float denom = max(abs(dir.x) + abs(dir.y), 1e-4); + float t = saturate(0.5 + dot(i.pout - 0.5, dir) / denom); + float3 g = lerp(color.rgb, src.xyz, t); + return float4(g, 1.0); // opaque, prémultiplié (a=1) + } + + // mode 4 : curseur custom (dot + ring, dessiné depuis les maths). color = teinte. + // fx = rect de clip "Clip to canvas" (mêmes conventions que le mode 7 ci-dessus). + if (mode > 3.5) + { + if (i.pout.x < fx.x || i.pout.x > fx.x + fx.z || i.pout.y < fx.y || i.pout.y > fx.y + fx.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float2 p = i.local - quad_px * 0.5; + float r = length(p); + float R = quad_px.x * 0.5; + float aa = 1.5; + float dot_r = R * 0.34; + float ring_r = R * 0.72; + float ring_w = R * 0.09; + float dot = 1.0 - smoothstep(dot_r - aa, dot_r + aa, r); + float ring = smoothstep(ring_r - ring_w - aa, ring_r - ring_w, r) + * (1.0 - smoothstep(ring_r + ring_w, ring_r + ring_w + aa, r)); + // liseré sombre fin sous le dot pour le contraste sur fond clair + float halo = (1.0 - smoothstep(dot_r + aa, dot_r + aa + 2.5, r)) * (1.0 - dot); + float a = saturate(dot + ring) * color.a; + float3 rgb = color.rgb * (dot + ring) + float3(0, 0, 0) * halo; + a = saturate(a + halo * 0.35 * 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 (mode > 1.5) + { + // `quad_px`/`spread` sont en px de SORTIE : le render target porte la géométrie de + // sortie, donc aucune pré-déformation n'est nécessaire. (Historiquement le canvas + // était figé en 16:9 et étiré en fin de pipeline, d'où un facteur anisotrope transporté + // dans `mb.yz` que ce shader devait annuler — le halo ressortait elliptique sans lui.) + float spread = fx.x; + float2 halfsz = quad_px * 0.5 - spread; + float2 p = i.local - quad_px * 0.5; + float d = sd_round_rect(p, halfsz, radius_px); + float a = color.a * (1.0 - smoothstep(0.0, spread, d)); + return float4(color.rgb * a, a); + } + + float3 rgb; + if (mode < 0.5) + { + // flou de mouvement par vélocité (§8) : pour CE pixel sortie, uv à la frame + // précédente = même pixel remappé par (dst_prev, src_prev). On floute le long + // de uv_prev->uv_now (capture translation ET zoom). Early-out si immobile. + float2 uv_now = i.uv; + float2 localp = (i.pout - dst_prev.xy) / dst_prev.zw; + float2 uv_prev = src_prev.xy + localp * (src_prev.zw - src_prev.xy); + float2 duv = uv_now - uv_prev; + int taps = (int) mb.x; + if (taps <= 1 || dot(duv, duv) < 1e-9) + { + rgb = sample_yuv(uv_now); + } + else + { + float3 acc = 0.0; + [loop] for (int k = 0; k < 16; k++) + { + if (k >= taps) break; + float t = (float) k / (float) (taps - 1); + acc += sample_yuv(uv_prev + duv * t); + } + rgb = acc / (float) taps; + } + } + else + { + rgb = color.rgb; + } + + float alpha = color.a; + if (radius_px > 0.0) + { + // `quad_px` est en px de SORTIE (le render target porte la géométrie de sortie) et + // `radius_px` est un rayon réel en px de sortie : la SDF isotrope les compare dans le + // même espace, le coin est donc rond par construction. (Avant, le canvas figé en 16:9 + // était étiré en fin de pipeline et il fallait pré-déformer par `mb.yz` pour que le + // cercle ne ressorte pas elliptique.) + float2 halfsz = quad_px * 0.5; + float2 p = i.local - quad_px * 0.5; + float d = sd_round_rect(p, halfsz, radius_px); + alpha *= 1.0 - smoothstep(0.0, 1.5, d); // ~1.5px feather (§7 fwidth-like) + } + return float4(rgb * alpha, alpha); // prémultiplié +} + +// ============ RGB -> NV12 (§5) : deux passes vers les plans d'une texture NV12 ============ +// VS plein écran (triangle unique) qui expose l'UV. +struct FSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; +FSOut vs_fs(uint vid : SV_VertexID) +{ + 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; +} + +Texture2D rgbTex : register(t0); +SamplerState sampNV : register(s0); + +// BT.709 limited, RGB(0..1) -> Y' et Cb,Cr (inverse de yuv709_limited). +float rgb2y(float3 c) { return (16.0 + 219.0 * (0.2126*c.r + 0.7152*c.g + 0.0722*c.b)) / 255.0; } +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 (128.0 + 224.0 * float2(cb, cr)) / 255.0; +} + +float ps_y(FSOut i) : SV_Target // plan Y (R8), pleine résolution +{ + return rgb2y(rgbTex.Sample(sampNV, i.uv).rgb); +} +float2 ps_uv(FSOut i) : SV_Target // plan UV (R8G8), demi-résolution (bilinéaire moyenne) +{ + return rgb2uv(rgbTex.Sample(sampNV, i.uv).rgb); +} + +// ============ Flou gaussien séparable (§7 E3) ============ +// fx.x = sigma (px), fx.y = pas de texel (1/dim), fx.zw = direction (1,0)|(0,1). +#define BLUR_R 24 +float4 ps_blur(FSOut i) : SV_Target +{ + float sigma = max(fx.x, 0.001); + float2 step = fx.y * fx.zw; + float4 acc = 0.0; + float wsum = 0.0; + [unroll] + for (int k = -BLUR_R; k <= BLUR_R; k++) + { + float w = exp(-0.5 * (k * k) / (sigma * sigma)); + acc += rgbTex.Sample(sampNV, i.uv + k * step) * w; + wsum += w; + } + return acc / wsum; +} +// simple copie/échantillonnage d'une texture RGBA (pour redessiner le fond flouté) +float4 ps_tex(FSOut i) : SV_Target { return rgbTex.Sample(sampNV, i.uv); } + +// ============ Dual-Kawase (fond flouté rapide) ============ +// fx.xy = texel de la texture SOURCE (1/w, 1/h), fx.z = offset. 5 taps (down) / 8 taps (up), +// bilinéaires, à résolution décroissante -> bien moins de samples qu'un gaussien large. +float4 ps_kawase_down(FSOut i) : SV_Target +{ + float2 hp = fx.xy * 0.5 * 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; +} +float4 ps_kawase_up(FSOut i) : SV_Target +{ + float2 hp = fx.xy * 0.5 * 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; +} 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_anim.rs b/crates/compositor/src/text_anim.rs new file mode 100644 index 0000000000..6a30bd0a00 --- /dev/null +++ b/crates/compositor/src/text_anim.rs @@ -0,0 +1,164 @@ +//! Animations d'apparition du texte d'annotation. +//! +//! Port VERBATIM de `src/lib/annotationTextAnimation.ts` : même durée, mêmes easings, mêmes +//! amplitudes. Les sept animations étaient déjà nommées dans le schéma, traduites dans les treize +//! langues et transportées jusqu'ici par la scène — mais rien ne les jouait. Reprendre les +//! constantes du TS plutôt que d'en réinventer garantit qu'un projet fait à l'époque de l'aperçu +//! DOM s'anime toujours pareil. + +/// Les décalages ci-dessous sont exprimés en px À CETTE HAUTEUR : l'appelant les met à l'échelle +/// de la sortie, exactement comme la taille de police (cf. `annotationScale.ts`). En pixels +/// absolus, la même animation sauterait de deux fois plus haut dans un rendu 4K que dans l'aperçu. +pub const ANIMATION_REFERENCE_HEIGHT: f32 = 1080.0; + +pub const TEXT_ANIMATION_DURATION_MS: f32 = 700.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TextAnimationState { + pub opacity: f32, + pub scale: f32, + pub translate_x: f32, + pub translate_y: f32, + /// Fraction du bloc révélée depuis la gauche (machine à écrire). 1 = tout visible. + pub reveal: f32, +} + +impl TextAnimationState { + /// L'état « rien à animer » — aussi celui d'une animation inconnue ou absente. + pub const IDLE: TextAnimationState = + TextAnimationState { opacity: 1.0, scale: 1.0, translate_x: 0.0, translate_y: 0.0, reveal: 1.0 }; +} + +fn clamp01(v: f32) -> f32 { + v.clamp(0.0, 1.0) +} + +fn ease_out_cubic(v: f32) -> f32 { + let t = clamp01(v); + 1.0 - (1.0 - t).powi(3) +} + +fn ease_out_back(v: f32) -> f32 { + let t = clamp01(v); + const C1: f32 = 1.70158; + const C3: f32 = C1 + 1.0; + 1.0 + C3 * (t - 1.0).powi(3) + C1 * (t - 1.0).powi(2) +} + +/// État de l'animation `animation` après `elapsed_ms` depuis le début de l'annotation. +pub fn text_animation_state(animation: Option<&str>, elapsed_ms: f32) -> TextAnimationState { + let name = animation.unwrap_or("none"); + if name == "none" { + return TextAnimationState::IDLE; + } + let progress = clamp01(elapsed_ms.max(0.0) / TEXT_ANIMATION_DURATION_MS); + let eased = ease_out_cubic(progress); + match name { + "fade" => TextAnimationState { opacity: eased, ..TextAnimationState::IDLE }, + "rise" => TextAnimationState { + opacity: eased, + translate_y: (1.0 - eased) * 18.0, + ..TextAnimationState::IDLE + }, + "pop" => TextAnimationState { + opacity: eased, + scale: ease_out_back(progress).max(0.72), + ..TextAnimationState::IDLE + }, + "slide-left" => TextAnimationState { + opacity: eased, + translate_x: (1.0 - eased) * -28.0, + ..TextAnimationState::IDLE + }, + "typewriter" => TextAnimationState { reveal: progress, ..TextAnimationState::IDLE }, + "pulse" => TextAnimationState { + scale: 1.0 + (progress * std::f32::consts::PI).sin() * 0.06, + ..TextAnimationState::IDLE + }, + // Un nom inconnu (projet plus récent que ce binaire) montre le texte tel quel plutôt que + // de le faire disparaître. + _ => TextAnimationState::IDLE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_animation_shows_the_text_as_is() { + for at in [0.0, 350.0, 5000.0] { + assert_eq!(text_animation_state(None, at), TextAnimationState::IDLE); + assert_eq!(text_animation_state(Some("none"), at), TextAnimationState::IDLE); + } + } + + #[test] + fn an_unknown_name_shows_the_text_rather_than_hiding_it() { + // Le pire comportement serait une opacité 0 : l'annotation disparaîtrait sans explication. + assert_eq!(text_animation_state(Some("kenburns"), 0.0), TextAnimationState::IDLE); + } + + #[test] + fn every_animation_settles_on_the_plain_text() { + // Propriété qui compte le plus : passée la durée, aucune animation ne laisse de trace. + for name in ["fade", "rise", "pop", "slide-left", "typewriter", "pulse"] { + let end = text_animation_state(Some(name), TEXT_ANIMATION_DURATION_MS + 1.0); + assert!((end.opacity - 1.0).abs() < 1e-3, "{name} : opacité {}", end.opacity); + assert!((end.scale - 1.0).abs() < 1e-3, "{name} : échelle {}", end.scale); + assert!(end.translate_x.abs() < 1e-3 && end.translate_y.abs() < 1e-3, "{name} : décalé"); + assert!((end.reveal - 1.0).abs() < 1e-3, "{name} : révélation {}", end.reveal); + } + } + + #[test] + fn fade_and_rise_start_invisible_and_below() { + let fade = text_animation_state(Some("fade"), 0.0); + assert_eq!(fade.opacity, 0.0); + let rise = text_animation_state(Some("rise"), 0.0); + assert_eq!(rise.opacity, 0.0); + assert!((rise.translate_y - 18.0).abs() < 1e-3, "part de 18px plus bas"); + } + + #[test] + fn slide_left_enters_from_the_right() { + // Signe négatif = le texte commence décalé vers la gauche et revient, comme le TS. + let s = text_animation_state(Some("slide-left"), 0.0); + assert!((s.translate_x + 28.0).abs() < 1e-3, "translate_x = {}", s.translate_x); + } + + #[test] + fn pop_overshoots_then_comes_back() { + // easeOutBack dépasse 1 avant de retomber : c'est ce qui donne le « pop ». + let mid = (0..=100) + .map(|i| text_animation_state(Some("pop"), i as f32 * 7.0).scale) + .fold(0.0f32, f32::max); + assert!(mid > 1.0, "aucun dépassement : échelle max {mid}"); + assert!(text_animation_state(Some("pop"), 0.0).scale >= 0.72, "plancher du TS respecté"); + } + + #[test] + fn pulse_swells_in_the_middle_and_never_moves() { + let mid = text_animation_state(Some("pulse"), TEXT_ANIMATION_DURATION_MS * 0.5); + assert!((mid.scale - 1.06).abs() < 1e-3, "échelle {}", mid.scale); + assert_eq!(mid.opacity, 1.0); + assert_eq!(mid.translate_x, 0.0); + } + + #[test] + fn typewriter_reveals_linearly_and_only_from_the_left() { + for (at, expected) in [(0.0, 0.0), (175.0, 0.25), (350.0, 0.5), (700.0, 1.0)] { + let s = text_animation_state(Some("typewriter"), at); + assert!((s.reveal - expected).abs() < 1e-3, "à {at}ms : {}", s.reveal); + // Le texte reste opaque : c'est la largeur qui se dévoile, pas l'alpha (l'aperçu DOM + // faisait exactement ça avec un `inset()`). + assert_eq!(s.opacity, 1.0); + } + } + + #[test] + fn a_negative_elapsed_time_is_the_start_not_the_end() { + // Peut arriver d'une frame calculée juste avant le début de l'annotation. + assert_eq!(text_animation_state(Some("fade"), -50.0).opacity, 0.0); + } +} diff --git a/crates/compositor/src/text_linux.rs b/crates/compositor/src/text_linux.rs new file mode 100644 index 0000000000..64c590c743 --- /dev/null +++ b/crates/compositor/src/text_linux.rs @@ -0,0 +1,485 @@ +//! Rasterisation de texte Linux (PR #183) -- `cosmic-text` (rustybuzz + swash + +//! fontdb) au lieu de DirectWrite (Windows) / CoreText (macOS). +//! +//! Equivalent Linux de `text_windows.rs` / `text_macos.rs` : meme surface +//! publique (`TextSpec` + `cache_key`, `TextRasterizer::new()`, +//! `rasterize(&self, gpu, spec)`) pour que `compositor` (cfg-re-exporte) et +//! `text_anim` (partage) l'utilisent sans connaitre la plateforme. +//! +//! **Difference de format.** macOS/Windows bakent la couleur dans une texture +//! BGRA premultipliee (CoreText/Direct2D). Ici on produit un **atlas de +//! couverture R8** (alpha) que le shader WGSL (`layer.wgsl` mode 11) teinte par +//! `layer.color` -- meme resultat visuel, et le contrat d'iso-render porte sur +//! la GEOMETRIE (`frame_geometry::plan_frame`), pas sur la rasterisation texte +//! (dont l'ecart d'antialiasing est deja exclu des goldens cross-backend). + +use anyhow::{bail, Result}; +use std::cell::RefCell; + +use cosmic_text::{Attrs, Buffer, FontSystem, Metrics, Shaping, SwashCache}; + +use crate::d3d::Gpu; + +/// Tout ce dont le rendu d'un texte depend. Meme structure et meme `cache_key` +/// que `text_windows::TextSpec` / `text_macos::TextSpec` : la cle est partagee +/// entre plateformes, donc deux specs identiques produisent la meme texture. +#[derive(Clone, PartialEq)] +pub struct TextSpec { + pub content: String, + /// RGBA 0..1 (deja parse depuis la chaine CSS cote appelant). + pub color: [f32; 4], + /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). + pub background: [f32; 4], + pub font_size_px: f32, + pub font_family: String, + pub bold: bool, + pub italic: bool, + pub underline: bool, + /// "left" | "center" | "right". + pub align: String, + /// Taille de la boite en px de sortie. + pub box_px: [u32; 2], +} + +impl TextSpec { + /// FNV-1a sur les memes octets, dans le meme ordre, que + /// `text_macos::TextSpec::cache_key` / `text_windows` -- la policy est + /// partagee (cache cross-plateforme coherent). + pub fn cache_key(&self) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |bytes: &[u8]| { + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + }; + mix(self.content.as_bytes()); + mix(self.font_family.as_bytes()); + mix(&self.font_size_px.to_bits().to_le_bytes()); + for c in self.color.iter().chain(self.background.iter()) { + mix(&c.to_bits().to_le_bytes()); + } + mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); + mix(self.align.as_bytes()); + mix(&self.box_px[0].to_le_bytes()); + mix(&self.box_px[1].to_le_bytes()); + h + } +} + +/// Le resultat d'une rasterisation : la texture R8 de couverture + ses dims. +pub struct RasterizedGlyphs { + pub view: wgpu::TextureView, + pub width: u32, + pub height: u32, +} + +/// Le rasterizer Linux. `fontdb` lit `/usr/share/fonts` a la construction du +/// `FontSystem`. Etat (font_system, swash_cache) en `RefCell` pour que +/// `rasterize(&self, ...)` matche la signature `&self` des autres plateformes +/// (le compositor tient un `Option` et l'appelle sur `&self`). +pub struct TextRasterizer { + font_system: RefCell, + swash_cache: RefCell, +} + +impl TextRasterizer { + pub fn new() -> Result { + Ok(TextRasterizer { + font_system: RefCell::new(FontSystem::new()), + swash_cache: RefCell::new(SwashCache::new()), + }) + } + + /// Rasterise `spec` dans une texture R8Unorm (couverture alpha) et rend sa + /// view. `gpu` fournit le device/queue wgpu (passe au rasterize comme cote + /// macOS). Le cache par `cache_key()` est gere par le caller (compositor). + pub fn rasterize(&self, gpu: &Gpu, spec: &TextSpec) -> Result { + let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1)); + let atlas = self.build_atlas(spec)?; + + Self::upload(gpu, &atlas, w, h) + } + + /// La moitie CPU de [`Self::rasterize`] : shaping + placement des glyphes + /// dans un atlas R8 de `spec.box_px`. + /// + /// Separee du GPU EXPRES. Le placement des glyphes est de l'arithmetique + /// pure, et c'est exactement la ou le portage s'etait trompe (origine au + /// coin au lieu de la ligne de base, `line_top` ajoute au X, signe de + /// `placement.top` inverse). Tant que ce code vivait derriere un `&Gpu`, il + /// etait intestable sans peripherique. Il ne l'est plus. + pub fn build_atlas(&self, spec: &TextSpec) -> Result> { + let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1)); + if spec.content.is_empty() { + bail!("text_linux::rasterize: texte vide"); + } + + let mut font_system = self.font_system.borrow_mut(); + let mut swash_cache = self.swash_cache.borrow_mut(); + + let font_size = spec.font_size_px.max(1.0); + let line_height = font_size * 1.4; // heuristique standard. + let metrics = Metrics::new(font_size, line_height); + let mut buffer = Buffer::new(&mut font_system, metrics); + buffer.set_size(Some(w as f32), Some(h as f32)); + + let mut attrs = Attrs::new(); + attrs = attrs.family(cosmic_text::Family::Name(&spec.font_family)); + if spec.bold { + attrs = attrs.weight(cosmic_text::Weight::BOLD); + } + if spec.italic { + attrs = attrs.style(cosmic_text::Style::Italic); + } + if spec.underline { + attrs = attrs.underline(cosmic_text::UnderlineStyle::Single); + } + buffer.set_text(&spec.content, &attrs, Shaping::Advanced, None); + // L'alignement se pose PAR LIGNE, apres set_text (qui reconstruit les + // lignes) et avant le shaping. Sans ca tout le texte sort ferre a + // gauche alors que le defaut de l'editeur est « center ». + let align = match spec.align.as_str() { + "center" => Some(cosmic_text::Align::Center), + "right" | "end" => Some(cosmic_text::Align::Right), + "justify" => Some(cosmic_text::Align::Justified), + // `None` laisse cosmic-text suivre la direction du script, ce qui + // est le bon defaut pour "left"/"start" et pour une valeur inconnue. + _ => None, + }; + for line in &mut buffer.lines { + line.set_align(align); + } + buffer.shape_until_scroll(&mut font_system, false); + + // CENTRAGE VERTICAL. L'overlay web pose `alignItems: center` sur le + // conteneur de l'annotation, et Windows reproduit ca avec + // `DWRITE_PARAGRAPH_ALIGNMENT_CENTER` (text_windows.rs:175-176). macOS + // ne le fait pas, et le portage Linux avait copie macOS : le texte + // collait en haut de sa boite. Les deux references natives divergent + // reellement ici ; c'est le web qui porte l'intention produit. + // + // La hauteur du bloc est prise sur la DERNIERE ligne posee plutot que + // sur un compte de lignes x line_height : cosmic-text peut replier une + // ligne logique en plusieurs runs, donc compter les runs surestimerait + // des que le texte deborde en largeur. + let text_h = buffer + .layout_runs() + .map(|run| run.line_top + run.line_height) + .fold(0.0f32, f32::max); + // `max(0)` : un texte plus haut que sa boite reste ancre en haut plutot + // que de sortir par le dessus, ou il serait entierement rogne. + let y_offset = (((h as f32) - text_h) * 0.5).max(0.0).round() as i32; + + // Atlas R8 : on n'ecrit que le canal alpha (couverture). Le tint par + // `spec.color` se fait cote shader (mode 11). + let mut atlas: Vec = vec![0u8; (w * h) as usize]; + for run in buffer.layout_runs() { + for glyph in run.glyphs.iter() { + // L'ORIGINE EST LA LIGNE DE BASE, PAS LE COIN. C'est la + // convention de cosmic-text : `Buffer::draw` appelle + // `glyph.physical((0., run.line_y), 1.0)` et son rasteriseur + // pose ensuite le haut du bitmap a `y - placement.top`. + // + // Le portage passait `(0.0, 0.0)` — donc sans ligne de base — + // et ajoutait `run.line_top`, une quantite VERTICALE, au X. + // Resultat mesure sur « Agjo Hxy » en 40px : aucune ligne de + // base commune, le 'A' 14 px SOUS le 'o', et sur du multi-ligne + // la 2e ligne redessinee sur les memes rangees que la 1re mais + // decalee de `line_top` px vers la droite. + let physical = glyph.physical((0.0, run.line_y), 1.0); + let img = swash_cache.get_image(&mut font_system, physical.cache_key); + let glyph_x = physical.x; + let glyph_y = physical.y; + let Some(img) = img else { continue }; + let placement = img.placement; + let (img_w, img_h) = (placement.width, placement.height); + if img_w == 0 || img_h == 0 { + continue; + } + let stride = match img.content { + cosmic_text::SwashContent::Mask => img_w as usize, + cosmic_text::SwashContent::Color => img_w as usize * 4, + _ => continue, + }; + let alpha_offset = if matches!(img.content, cosmic_text::SwashContent::Color) { + 3 + } else { + 0 + }; + let bpp = if alpha_offset == 0 { 1 } else { 4 }; + // `placement.top` est la hauteur de l'encre AU-DESSUS de la + // ligne de base, donc la premiere rangee du bitmap est a + // `baseline - top`. Le signe etait inverse. + let ink_top = glyph_y - placement.top + y_offset; + let ink_left = glyph_x + placement.left; + for row in 0..img_h as i32 { + let dest_y = ink_top + row; + if dest_y < 0 || dest_y >= h as i32 { + continue; + } + // `placement.left` est negatif sur les glyphes qui debordent + // a gauche de leur avance ('j' en DejaVu Sans : -3). Sans ce + // rattrapage, `dest_x as usize` enroule en release et l'encre + // se retrouve collee au bord droit de la rangee PRECEDENTE ; + // en debug c'est une panique d'overflow. On saute plutot les + // colonnes SOURCE hors cadre. + let (dest_x, skip_cols) = if ink_left < 0 { + (0i32, (-ink_left) as usize) + } else { + (ink_left, 0usize) + }; + if dest_x >= w as i32 || skip_cols >= img_w as usize { + continue; + } + let copy_len = ((img_w as usize - skip_cols) as i32) + .min(w as i32 - dest_x) + .max(0) as usize; + if copy_len == 0 { + continue; + } + let src_row = &img.data[(row as usize) * stride..(row as usize + 1) * stride]; + let atlas_row_start = (dest_y as usize) * w as usize; + for col in 0..copy_len { + let atlas_idx = atlas_row_start + (dest_x as usize + col); + let src_idx = (col + skip_cols) * bpp + alpha_offset; + if src_idx < src_row.len() { + // `max` et non affectation : deux glyphes peuvent se + // chevaucher (accents, ligatures, italiques) et + // ecraser ferait disparaitre l'encre du premier. + atlas[atlas_idx] = atlas[atlas_idx].max(src_row[src_idx]); + } + } + } + } + } + + Ok(atlas) + } + + /// Televerse un atlas R8 deja construit et rend sa view. + fn upload(gpu: &Gpu, atlas: &[u8], w: u32, h: u32) -> Result { + let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("text-atlas"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + gpu.context.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &atlas, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w), + rows_per_image: Some(h), + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Ok(RasterizedGlyphs { + view, + width: w, + height: h, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(content: &str, align: &str) -> TextSpec { + TextSpec { + content: content.to_owned(), + color: [1.0, 1.0, 1.0, 1.0], + background: [0.0, 0.0, 0.0, 0.0], + font_size_px: 40.0, + // Vide = cosmic-text prend la police par defaut du systeme. Nommer + // une famille precise rendrait le test dependant des polices + // installees sur la machine qui l'execute. + font_family: String::new(), + bold: false, + italic: false, + underline: false, + align: align.to_owned(), + box_px: [400, 200], + } + } + + /// Rangees d'atlas contenant de l'encre, pour la tranche `x0..x1`. + fn ink_rows(atlas: &[u8], w: usize, x0: usize, x1: usize) -> Vec { + let h = atlas.len() / w; + (0..h) + .filter(|y| (x0..x1.min(w)).any(|x| atlas[y * w + x] > 16)) + .collect() + } + + fn ink_cols(atlas: &[u8], w: usize) -> Vec { + let h = atlas.len() / w; + (0..w) + .filter(|x| (0..h).any(|y| atlas[y * w + x] > 16)) + .collect() + } + + #[test] + fn glyphs_of_different_heights_share_one_baseline() { + // LE test de ce fichier. Le portage posait chaque glyphe contre le HAUT + // de sa propre boite d'encre au lieu de la ligne de base commune, avec + // en prime le signe de `placement.top` inverse. Mesure d'alors sur + // « Agjo Hxy » en 40px : le 'A' finissait 14 px SOUS le 'o'. + // + // On compare le bas de l'encre d'un 'H' (qui descend jusqu'a la ligne + // de base) et celui d'un 'x' (idem). S'ils partagent une ligne de base, + // leurs dernieres rangees d'encre coincident a l'antialiasing pres. + let raster = TextRasterizer::new().expect("rasterizer"); + let atlas = raster.build_atlas(&spec("Hx", "left")).expect("atlas"); + let w = 400usize; + + let cols = ink_cols(&atlas, w); + assert!(!cols.is_empty(), "aucune encre : le texte n'a pas ete rasterise"); + // Coupe entre les deux glyphes : le plus grand trou horizontal. + let split = cols + .windows(2) + .max_by_key(|p| p[1] - p[0]) + .map(|p| (p[0] + p[1]) / 2) + .expect("deux glyphes attendus"); + + let left = ink_rows(&atlas, w, cols[0], split); + let right = ink_rows(&atlas, w, split, *cols.last().unwrap() + 1); + assert!(!left.is_empty() && !right.is_empty(), "un des deux glyphes est vide"); + + let (h_bottom, x_bottom) = (*left.last().unwrap(), *right.last().unwrap()); + assert!( + h_bottom.abs_diff(x_bottom) <= 2, + "pas de ligne de base commune : bas du 'H' = {h_bottom}, bas du 'x' = {x_bottom}" + ); + } + + #[test] + fn a_second_line_sits_below_the_first() { + // L'autre moitie du meme bug : `run.line_top` etait ajoute au X, donc la + // 2e ligne se dessinait sur les MEMES rangees que la 1re, decalee vers + // la droite. Ici elle doit etre strictement plus bas, et commencer a peu + // pres a la meme abscisse. + let raster = TextRasterizer::new().expect("rasterizer"); + let atlas = raster.build_atlas(&spec("ab\ncd", "left")).expect("atlas"); + let w = 400usize; + + let rows = ink_rows(&atlas, w, 0, w); + assert!(rows.len() > 4, "trop peu d'encre pour deux lignes"); + // Un trou vertical separe les deux lignes. + let gap = rows + .windows(2) + .max_by_key(|p| p[1] - p[0]) + .expect("deux lignes attendues"); + assert!( + gap[1] - gap[0] > 2, + "les deux lignes se chevauchent : aucune separation verticale trouvee" + ); + } + + /// Chaque texte par defaut d'annotation produit-il de l'encre sur CETTE + /// machine ? + /// + /// L'app propose un texte localise a la creation ("Hello", "你好", + /// "مرحبا"...). Si la police systeme ne couvre pas le script, cosmic-text + /// rend du tofu ou rien du tout, et l'utilisateur voit une annotation vide + /// qu'il n'a pas ecrite. Le test ne PEUT pas garantir la couverture d'une + /// machine inconnue — il documente ce qui manque sur celle qui l'execute, + /// ce qui est exactement l'information utile quand quelqu'un rapporte + /// « mon annotation est invisible ». + #[test] + fn the_localised_default_texts_render_on_this_machine() { + let raster = TextRasterizer::new().expect("rasterizer"); + let samples = [ + ("en", "Hello"), ("fr", "Bonjour"), ("es", "Hola"), ("it", "Ciao"), + ("pt-BR", "Olá"), ("ru", "Привет"), ("tr", "Merhaba"), ("vi", "Xin chào"), + ("ar", "مرحبا"), ("ja-JP", "こんにちは"), ("ko-KR", "안녕하세요"), + ("zh-CN", "你好"), ("zh-TW", "你好"), + ]; + let mut blank = Vec::new(); + for (locale, text) in samples { + let atlas = raster.build_atlas(&spec(text, "center")).expect("atlas"); + let ink = atlas.iter().filter(|byte| **byte > 16).count(); + println!(" {locale:6} {text:12} -> {ink} px d'encre"); + if ink == 0 { + blank.push(locale); + } + } + assert!( + blank.is_empty(), + "aucune police installee ne couvre: {blank:?} — l'annotation par defaut y serait invisible" + ); + } + + #[test] + fn one_short_line_is_centred_vertically_in_its_box() { + // L'overlay web pose `alignItems: center` et Windows fait pareil + // (DWRITE_PARAGRAPH_ALIGNMENT_CENTER) ; macOS non, et le portage avait + // copie macOS. Une ligne de 40px dans une boite de 200px doit laisser a + // peu pres autant de vide au-dessus qu'en dessous. + let raster = TextRasterizer::new().expect("rasterizer"); + let atlas = raster.build_atlas(&spec("Hx", "center")).expect("atlas"); + let (w, h) = (400usize, 200usize); + let rows = ink_rows(&atlas, w, 0, w); + assert!(!rows.is_empty(), "aucune encre"); + + let (top, bottom) = (rows[0], *rows.last().unwrap()); + let above = top as i32; + let below = (h - 1 - bottom) as i32; + assert!( + (above - below).abs() <= 12, + "texte non centre verticalement : {above}px au-dessus, {below}px en dessous" + ); + } + + #[test] + fn centering_moves_the_ink_off_the_left_edge() { + // `spec.align` n'etait jamais applique : tout sortait ferre a gauche + // alors que le defaut de l'editeur est « center ». + let raster = TextRasterizer::new().expect("rasterizer"); + let w = 400usize; + let left = ink_cols(&raster.build_atlas(&spec("hi", "left")).expect("atlas"), w); + let centered = ink_cols(&raster.build_atlas(&spec("hi", "center")).expect("atlas"), w); + + assert!(!left.is_empty() && !centered.is_empty()); + assert!( + centered[0] > left[0] + 20, + "le centrage n'a pas bouge le texte : gauche debute a {}, centre a {}", + left[0], + centered[0] + ); + } + + #[test] + fn a_glyph_overhanging_to_the_left_does_not_wrap_around() { + // 'j' a un `placement.left` negatif en DejaVu Sans. Avant, `dest_x as + // usize` enroulait : l'encre atterrissait au bord DROIT de la rangee + // precedente en release, et paniquait en debug. Ce test tourne en debug + // sous `cargo test`, donc il attrape la panique directement. + let raster = TextRasterizer::new().expect("rasterizer"); + let atlas = raster.build_atlas(&spec("jazz", "left")).expect("pas de panique"); + let w = 400usize; + // Rien ne doit avoir atterri contre le bord droit d'une rangee. + let h = atlas.len() / w; + let right_edge_ink = (0..h).filter(|y| atlas[y * w + (w - 1)] > 16).count(); + assert_eq!(right_edge_ink, 0, "de l'encre a enroule jusqu'au bord droit"); + } +} diff --git a/crates/compositor/src/text_macos.rs b/crates/compositor/src/text_macos.rs new file mode 100644 index 0000000000..2dea29fbf2 --- /dev/null +++ b/crates/compositor/src/text_macos.rs @@ -0,0 +1,848 @@ +//! 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. `CFAttributedString` avec police (`kCTFontAttributeName`), couleur +//! (`kCTForegroundColorAttributeName`), soulignement (`kCTUnderlineStyleAttributeName`) +//! et alignement (`kCTParagraphStyleAttributeName`). +//! 3. `CTFramesetterSuggestFrameSizeWithConstraints` mesure le bloc mis en page, puis +//! `block_layout` en déduit le cadre (centré verticalement) et la plaque de fond +//! (`spec.background`, alpha 0 = transparent) qui l'habille. +//! 4. `CTFramesetterCreateFrame` sur ce cadre, puis `CTFrameDraw`. +//! 5. `MTLTexture` BGRA8Unorm + `replace_region` depuis le buffer CPU. +//! +//! `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 CGContextSetRGBFillColor(ctx: CFTypeRef, r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat); + fn CGContextAddPath(ctx: CFTypeRef, path: CFTypeRef); + fn CGContextFillPath(ctx: CFTypeRef); + fn CGPathCreateWithRect(rect: CGRect, transform: *const c_void) -> CFTypeRef; + fn CGPathCreateWithRoundedRect( + rect: CGRect, + corner_width: CGFloat, + corner_height: CGFloat, + 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 CTFramesetterSuggestFrameSizeWithConstraints( + framesetter: CFTypeRef, + string_range: CFRange, + frame_attributes: CFTypeRef, + constraints: CGSize, + fit_range: *mut CFRange, + ) -> CGSize; + 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) }; + } +} + +// --------------------------------------------------------------------------- +// Modèle de boîte du bloc de texte +// --------------------------------------------------------------------------- + +/// Marge et rayon de la plaque : `crate::text_plate`, partagé avec le rendu Direct2D. +/// Les deux plateformes DOIVENT lire les mêmes nombres — cf. l'en-tête de ce module. +fn plate_padding(font_px: CGFloat) -> (CGFloat, CGFloat) { + let (x, y) = crate::text_plate::padding(font_px as f32); + (x as CGFloat, y as CGFloat) +} + +/// `CTTextAlignment` : 0 = left, 1 = right, 2 = center (3 = justified, 4 = natural). +fn ct_alignment(align: &str) -> u8 { + match align { + "left" => 0, + "right" => 1, + _ => 2, + } +} + +/// Où poser le cadre de mise en page et la plaque de fond dans une boîte `box_w`×`box_h`, +/// une fois le bloc mesuré à `text_w`×`text_h`. +/// +/// Repère **CoreGraphics** : origine en BAS à gauche, `y` croissant vers le haut. Le +/// bitmap, lui, range sa ligne 0 en HAUT — d'où la conversion `box_h - haut - hauteur`, +/// faite ici une fois pour toutes plutôt que dispersée dans les appels de dessin. +/// +/// Deux choses que la version précédente ne faisait pas : +/// +/// * **centrage vertical.** `CTFrameDraw` remplit son cadre du haut vers le bas ; avec un +/// cadre couvrant toute la boîte, les lignes se collaient en haut et laissaient le reste +/// vide. Une bande de sous-titres fait 22 % de la hauteur de l'image +/// (`CAPTION_BAND_HEIGHT_PCT`, volontairement généreuse pour absorber deux lignes), donc +/// « le reste » représentait ~180 px sur 238 en 1080p. C'est l'énorme marge basse. +/// Windows n'avait pas le problème : `DWRITE_PARAGRAPH_ALIGNMENT_CENTER`. +/// * **plaque ajustée au texte.** Le fond couvrait la boîte entière, là où le `` du +/// DOM, le renderer canvas et Direct2D (qui remplit `DWRITE_TEXT_METRICS`) l'ajustent +/// tous au bloc mis en page. +/// +/// Le cadre garde toute la largeur utile (`box_w` moins la marge de plaque) : c'est sur +/// elle que CoreText applique l'alignement de paragraphe, exactement comme DirectWrite. +/// L'inset horizontal joue le rôle du `p-2` que l'overlay DOM posait sur le conteneur — il +/// réserve la place de la marge de plaque, pour qu'un texte aligné à gauche ou à droite ne +/// la voie pas rognée par le bord de la boîte. +fn block_layout( + box_w: CGFloat, + box_h: CGFloat, + text_w: CGFloat, + text_h: CGFloat, + align: u8, + font_px: CGFloat, +) -> (CGRect, CGRect) { + let (pad_x, pad_y) = plate_padding(font_px); + let avail_w = layout_width(box_w, font_px); + + // Un cadre haut d'exactement `text_h` perd parfois sa dernière ligne sur un arrondi de + // la mesure. On l'étend d'un pixel vers le BAS — donc en abaissant l'origine `y`, pas + // en montant le sommet — pour que le haut du texte ne bouge pas d'un poil. + const GUARD: CGFloat = 1.0; + let top = ((box_h - text_h) * 0.5).max(0.0); + let frame_x = (box_w - avail_w) * 0.5; + let frame = CGRect { + origin: CGPoint { + x: frame_x, + y: box_h - top - text_h - GUARD, + }, + size: CGSize { + width: avail_w, + height: text_h + GUARD, + }, + }; + + // La plaque épouse le bloc, marge comprise, sans jamais déborder de la boîte : au-delà + // elle serait coupée net par le bord de la texture et perdrait ses coins arrondis. + let plate_w = (text_w + pad_x * 2.0).min(box_w); + let plate_h = (text_h + pad_y * 2.0).min(box_h); + let slack_x = (box_w - plate_w).max(0.0); + let plate_x = match align { + // À gauche, les lignes commencent au bord gauche du cadre ; à droite, elles + // finissent au bord droit. La plaque déborde de `pad_x` du côté concerné. + 0 => frame_x - pad_x, + 1 => frame_x + avail_w + pad_x - plate_w, + _ => slack_x * 0.5, + } + .clamp(0.0, slack_x); + let plate_y = (box_h - top - text_h - pad_y).clamp(0.0, (box_h - plate_h).max(0.0)); + + ( + frame, + CGRect { + origin: CGPoint { + x: plate_x, + y: plate_y, + }, + size: CGSize { + width: plate_w, + height: plate_h, + }, + }, + ) +} + +/// Largeur offerte aux lignes — `crate::text_plate::layout_width`, en `CGFloat`. +fn layout_width(box_w: CGFloat, font_px: CGFloat) -> CGFloat { + crate::text_plate::layout_width(box_w as f32, font_px as f32) as CGFloat +} + +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"); + } + + // PAS de flip du CTM, 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. Le sommet du cadre 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, w as CGFloat, h as CGFloat); + + 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_w: CGFloat, + box_h: CGFloat, + ) -> 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 --- + let alignment: u8 = ct_alignment(&spec.align); + 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"))?; + + // `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 whole = CFRange { + location: 0, + length: 0, + }; + + // --- mesure du bloc mis en page --- + // Hauteur non contrainte (`CGFLOAT_MAX`) : on veut la place que le texte PREND, pas + // celle qu'on lui offre. Un texte plus haut que la boîte est ensuite recadré sur + // elle, ce qui le rend coupé en bas plutôt que centré et coupé des deux côtés. + let font_px = spec.font_size_px.max(1.0) as CGFloat; + let avail_w = layout_width(box_w, font_px); + let mut fit = whole; + let measured = CTFramesetterSuggestFrameSizeWithConstraints( + framesetter.get(), + whole, + std::ptr::null(), + CGSize { + width: avail_w, + height: CGFloat::MAX, + }, + &mut fit as *mut CFRange, + ); + // Arrondi au pixel supérieur : la mesure revient parfois une fraction sous la + // réalité, et il en faut peu pour rogner la dernière ligne. + let text_w = measured.width.ceil().clamp(0.0, avail_w); + let text_h = measured.height.ceil().max(0.0); + + let (frame_rect, plate_rect) = + block_layout(box_w, box_h, text_w, text_h, alignment, font_px); + + // --- plaque de fond, sous le texte --- + if spec.background[3] > 0.0 && plate_rect.size.width > 0.0 && plate_rect.size.height > 0.0 + { + let radius = crate::text_plate::radius( + font_px as f32, + plate_rect.size.width as f32, + plate_rect.size.height as f32, + ) as CGFloat; + let plate = CFOwned::new(CGPathCreateWithRoundedRect( + plate_rect, + radius, + radius, + std::ptr::null(), + )) + .ok_or_else(|| anyhow!("CGPathCreateWithRoundedRect NULL"))?; + CGContextSetRGBFillColor( + ctx, + spec.background[0] as CGFloat, + spec.background[1] as CGFloat, + spec.background[2] as CGFloat, + spec.background[3] as CGFloat, + ); + CGContextAddPath(ctx, plate.get()); + CGContextFillPath(ctx); + } + + let path = CFOwned::new(CGPathCreateWithRect(frame_rect, std::ptr::null())) + .ok_or_else(|| anyhow!("CGPathCreateWithRect NULL"))?; + let frame = CFOwned::new(CTFramesetterCreateFrame( + framesetter.get(), + whole, + path.get(), + std::ptr::null(), + )) + .ok_or_else(|| anyhow!("CTFramesetterCreateFrame NULL"))?; + + CTFrameDraw(frame.get(), ctx); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(content: &str) -> TextSpec { + TextSpec { + content: content.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: "center".into(), + box_px: [256, 256], + } + } + + /// Rastérise et rend les octets BGRA, ou `None` si la machine n'a pas de device Metal. + fn raster_bgra(spec: &TextSpec) -> Option<(Vec, usize, usize)> { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return None; + }; + let raster = TextRasterizer::new().expect("TextRasterizer::new"); + let tex = unsafe { raster.rasterize(&gpu, spec) }.expect("rasterize"); + let (w, h) = (spec.box_px[0] as usize, spec.box_px[1] as usize); + 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, + ); + Some((px, w, h)) + } + + /// Boîte englobante de l'encre (alpha > 8) : `(x0, y0, x1, y1)`, bornes incluses. + fn ink_bounds(px: &[u8], w: usize, h: usize) -> (usize, usize, usize, usize) { + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for y in 0..h { + for x in 0..w { + if px[(y * w + x) * 4 + 3] > 8 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + assert!(x0 <= x1 && y0 <= y1, "aucune encre : rien n'a été rastérisé"); + (x0, y0, x1, y1) + } + + /// Le texte n'est pas retourné. 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 un vrai bug de ce fichier. + /// + /// Il compare les deux moitiés de la boîte ENGLOBANTE, pas de la boîte de sortie : + /// depuis le centrage vertical, un texte bien orienté n'est plus majoritairement dans + /// la moitié haute de la texture. `H` sur la première ligne et `.` sur la seconde rend + /// le bloc très dissymétrique, donc le miroir se voit immédiatement. + #[test] + fn text_is_not_mirrored_vertically() { + let Some((px, w, h)) = raster_bgra(&spec("HHHH\n.")) else { + return; + }; + let (_, y0, _, y1) = ink_bounds(&px, w, h); + 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 mid = (y0 + y1) / 2; + let (upper, lower) = (ink(y0..mid), ink(mid..y1 + 1)); + assert!( + upper > lower * 3, + "texte retourné : encre haut={upper}, bas={lower} (les `HHHH` sont sur la 1re ligne)" + ); + } + + /// Le bug rapporté : une ligne de sous-titre se collait en haut de sa bande et laissait + /// ~180 px de vide en dessous. La bande fait 22 % de la hauteur de l'image, donc la + /// boîte est toujours bien plus haute que le texte — le bloc doit y être centré. + /// + /// La mesure porte sur la PLAQUE, pas sur les glyphes : l'encre ne remplit jamais sa + /// hauteur de ligne (au-dessus des capitales et sous les jambages il reste du vide, + /// en quantités inégales), donc ses marges ne sont pas symétriques même parfaitement + /// centrées. La plaque, elle, est le bloc mis en page. + #[test] + fn a_single_line_is_centred_in_a_tall_box() { + let mut s = spec("Bonjour tout le monde"); + s.background = [0.0, 0.0, 0.0, 1.0]; + // Une vraie bande de sous-titres en 1080p : 80 % de large, 22 % de haut. + s.box_px = [1536, 238]; + let Some((px, w, h)) = raster_bgra(&s) else { + return; + }; + let (x0, y0, x1, y1) = ink_bounds(&px, w, h); + let (top, bottom) = (y0 as i64, (h - 1 - y1) as i64); + let (left, right) = (x0 as i64, (w - 1 - x1) as i64); + assert!( + (top - bottom).abs() <= 1, + "bloc non centré verticalement : {top} px au-dessus, {bottom} px en dessous" + ); + assert!( + (left - right).abs() <= 1, + "bloc non centré horizontalement : {left} px à gauche, {right} px à droite" + ); + // Et le vide restant est réparti, pas empilé en bas comme avant le correctif. + assert!(top > 20, "la boîte fait {h} px de haut : le bloc devrait flotter dedans"); + } + + /// La plaque de fond épouse le bloc de texte au lieu de remplir la boîte. Sans ça, une + /// bande de sous-titres est un pavé opaque de 22 % de la hauteur de l'image. + #[test] + fn the_background_plate_hugs_the_text_not_the_box() { + let mut s = spec("Bonjour"); + s.background = [0.0, 0.0, 0.0, 1.0]; + s.box_px = [1536, 238]; + let Some((px, w, h)) = raster_bgra(&s) else { + return; + }; + let (x0, y0, x1, y1) = ink_bounds(&px, w, h); + let (plate_w, plate_h) = (x1 - x0 + 1, y1 - y0 + 1); + assert!( + plate_h < h / 2, + "la plaque couvre {plate_h} px sur {h} : elle remplit encore la boîte" + ); + assert!( + plate_w < w / 2, + "la plaque couvre {plate_w} px sur {w} : elle remplit encore la boîte" + ); + // Le fond est opaque : les coins de la boîte doivent rester vides. + for (cx, cy) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] { + assert_eq!( + px[(cy * w + cx) * 4 + 3], + 0, + "coin ({cx}, {cy}) peint : la plaque déborde du bloc" + ); + } + } + + /// La plaque laisse respirer le texte : `0.1em` en haut/bas, `0.2em` à gauche/droite, + /// le modèle de boîte partagé avec l'overlay DOM et le renderer canvas. + #[test] + fn the_plate_keeps_a_margin_around_the_glyphs() { + let mut s = spec("Bonjour"); + s.box_px = [1536, 238]; + let Some((glyphs, w, h)) = raster_bgra(&s) else { + return; + }; + let (gx0, _, gx1, _) = ink_bounds(&glyphs, w, h); + + s.background = [0.0, 0.0, 0.0, 1.0]; + let Some((plate, _, _)) = raster_bgra(&s) else { + return; + }; + let (px0, _, px1, _) = ink_bounds(&plate, w, h); + + assert!( + px0 < gx0 && px1 > gx1, + "la plaque ({px0}..{px1}) ne dépasse pas les glyphes ({gx0}..{gx1})" + ); + } + + /// Un texte plus haut que sa boîte se coupe en BAS. Le centrer puis le rogner des deux + /// côtés mangerait la première ligne, qui est celle qu'on veut lire. + #[test] + fn an_overflowing_text_starts_at_the_top() { + let mut s = spec("Un texte tres long qui deborde largement de la boite prevue pour lui"); + s.box_px = [240, 90]; + let Some((px, w, h)) = raster_bgra(&s) else { + return; + }; + let (_, y0, _, _) = ink_bounds(&px, w, h); + assert!( + y0 < h / 4, + "le débordement ne part pas du haut : première ligne d'encre à y={y0} sur {h}" + ); + } + + /// Géométrie pure — pas de GPU, pas de CoreText. + #[test] + fn block_layout_centres_the_frame_and_sizes_the_plate() { + let (frame, plate) = block_layout(1536.0, 238.0, 500.0, 56.0, 2, 48.0); + // Cadre centré : autant de vide au-dessus qu'en dessous (repère CG, y vers le haut). + let above = 238.0 - (frame.origin.y + frame.size.height); + let below = frame.origin.y; + assert!((above - below).abs() <= 1.5, "cadre décentré : {above} / {below}"); + // Plaque = bloc + 0.2em/0.1em, centrée elle aussi. + assert!((plate.size.width - (500.0 + 2.0 * 9.6)).abs() < 0.01); + assert!((plate.size.height - (56.0 + 2.0 * 4.8)).abs() < 0.01); + assert!((plate.origin.x - (1536.0 - plate.size.width) * 0.5).abs() < 0.01); + } + + #[test] + fn block_layout_never_lets_the_plate_leave_the_box() { + for align in [0u8, 1, 2] { + // Bloc plus large et plus haut que la boîte : la plaque doit se contenter d'elle. + let (_, plate) = block_layout(200.0, 60.0, 400.0, 200.0, align, 48.0); + assert!(plate.origin.x >= 0.0, "align={align} : x={}", plate.origin.x); + assert!(plate.origin.y >= 0.0, "align={align} : y={}", plate.origin.y); + assert!(plate.origin.x + plate.size.width <= 200.0 + 0.01, "align={align}"); + assert!(plate.origin.y + plate.size.height <= 60.0 + 0.01, "align={align}"); + } + } +} diff --git a/crates/compositor/src/text_plate.rs b/crates/compositor/src/text_plate.rs new file mode 100644 index 0000000000..0e88ca9a2c --- /dev/null +++ b/crates/compositor/src/text_plate.rs @@ -0,0 +1,94 @@ +//! Le modèle de boîte de la plaque de fond d'un bloc de texte, partagé par les deux +//! rastériseurs. +//! +//! `text_windows.rs` (Direct2D) et `text_macos.rs` (CoreText) dessinent la même chose avec +//! deux API qui n'ont rien en commun ; ce qu'elles PEUVENT partager, ce sont les trois +//! nombres qui décident de l'allure du bloc. Ils vivent ici parce que c'est exactement le +//! genre de constante qui dérive en silence quand elle est recopiée : rien dans un rendu +//! Windows ne signale qu'une marge macOS a bougé, et personne ne compare les deux à l'œil. +//! +//! Les valeurs viennent du modèle de boîte de référence de l'app — le `` que +//! l'overlay DOM posait derrière le texte et son jumeau canvas +//! (`src/lib/exporter/annotationRenderer.ts`) : `padding: 0.1em 0.2em`, `border-radius: 4px` +//! à la taille de police par défaut des sous-titres. +//! +//! Tout est exprimé en **em**, jamais en pixels : `font_size_px` est déjà mis à l'échelle de +//! la sortie par l'appelant (`font_size_rel * hauteur_du_rect_écran`), donc une marge en em +//! reste juste en 720p comme en 4K, là où une constante en pixels ne vaudrait qu'à une seule +//! résolution. + +/// Marge interne horizontale de la plaque, en em. +const PAD_X_EM: f32 = 0.2; +/// Marge interne verticale, en em. Plus serrée que l'horizontale : la hauteur de ligne +/// apporte déjà du blanc au-dessus des capitales et sous les jambages, la largeur non. +const PAD_Y_EM: f32 = 0.1; +/// Rayon des coins, en em. La référence dit « 4 px » à la taille de police par défaut des +/// sous-titres (48 px sur une frame haute de 1080). +const RADIUS_EM: f32 = 4.0 / 48.0; + +/// Marge interne `(horizontale, verticale)` de la plaque, en pixels de sortie. +pub fn padding(font_px: f32) -> (f32, f32) { + let f = font_px.max(1.0); + (f * PAD_X_EM, f * PAD_Y_EM) +} + +/// Rayon des coins de la plaque, en pixels de sortie. `plate_w`/`plate_h` le bornent à la +/// moitié du plus petit côté : au-delà, Direct2D comme CoreGraphics rendent une forme +/// dégénérée plutôt qu'un rectangle arrondi. +pub fn radius(font_px: f32, plate_w: f32, plate_h: f32) -> f32 { + (font_px.max(1.0) * RADIUS_EM) + .min(plate_w * 0.5) + .min(plate_h * 0.5) + .max(0.0) +} + +/// Largeur offerte aux lignes dans une boîte large de `box_w`. +/// +/// La boîte est rentrée de la marge de plaque, comme le `p-2` que l'overlay DOM posait sur +/// le conteneur : sans ça, un texte aligné à gauche ou à droite colle au bord et sa plaque +/// se fait rogner du côté où elle devrait respirer. La mesure du bloc et le cadre de mise en +/// page doivent TOUS DEUX passer par ici — mesurer sur une largeur et composer sur une autre +/// coupe les lignes ailleurs que là où la plaque a été dimensionnée. +pub fn layout_width(box_w: f32, font_px: f32) -> f32 { + (box_w - padding(font_px).0 * 2.0).max(1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Les valeurs de référence, à la taille de police par défaut des sous-titres. Ce test + /// existe pour qu'un changement de marge soit un choix explicite et non un effet de bord. + #[test] + fn the_reference_box_model_at_the_default_caption_size() { + let (pad_x, pad_y) = padding(48.0); + assert!((pad_x - 9.6).abs() < 1e-4, "0.2em de 48 px"); + assert!((pad_y - 4.8).abs() < 1e-4, "0.1em de 48 px"); + assert!((radius(48.0, 400.0, 60.0) - 4.0).abs() < 1e-4, "4 px à 48 px de police"); + } + + /// Les marges suivent la police, donc la résolution de sortie : le même bloc rendu deux + /// fois plus grand doit avoir des marges deux fois plus grandes, pas les mêmes. + #[test] + fn the_padding_scales_with_the_font() { + let (x1, y1) = padding(48.0); + let (x2, y2) = padding(96.0); + assert!((x2 - x1 * 2.0).abs() < 1e-4); + assert!((y2 - y1 * 2.0).abs() < 1e-4); + } + + #[test] + fn the_radius_never_degenerates_the_plate() { + // Plaque plus mince que le rayon nominal : il se rabat sur la moitié du petit côté. + assert!((radius(200.0, 300.0, 6.0) - 3.0).abs() < 1e-4); + assert!(radius(48.0, 0.0, 0.0) >= 0.0); + } + + #[test] + fn the_layout_width_never_collapses() { + assert!((layout_width(1000.0, 48.0) - (1000.0 - 19.2)).abs() < 1e-4); + // Boîte plus étroite que ses propres marges : une largeur nulle ou négative ferait + // boucler la mise en page au lieu de simplement déborder. + assert!(layout_width(4.0, 200.0) >= 1.0); + } +} diff --git a/crates/compositor/src/text_windows.rs b/crates/compositor/src/text_windows.rs new file mode 100644 index 0000000000..93a5f6df8c --- /dev/null +++ b/crates/compositor/src/text_windows.rs @@ -0,0 +1,313 @@ +//! Rastérisation du texte des annotations, via DirectWrite (mise en page) et Direct2D (dessin) +//! sur une surface DXGI partagée avec D3D11. +//! +//! Pourquoi DirectWrite et pas un rasterizer de glyphes maison : l'app expédie en 13 langues, +//! dont l'arabe, et le texte d'une annotation est saisi librement. Le façonnage (ligatures, +//! bidirectionnel, CJK, sélection de police de repli) est un travail que DirectWrite fait +//! correctement et qu'on ne réécrira pas. Le device D3D11 est déjà créé avec +//! `D3D11_CREATE_DEVICE_BGRA_SUPPORT` (cf. `d3d.rs`, dont le commentaire anticipait cet usage), +//! donc l'interop ne coûte aucun changement de pipeline. +//! +//! Pourquoi ça ne coûte rien par frame : le texte d'une annotation est un contenu STATIQUE. On le +//! rastérise une fois dans une texture, et la boucle de rendu ne fait plus qu'un quad texturé de +//! plus. Le travail cher — façonnage, mise en page, rendu des glyphes — sort du chemin chaud, et +//! le cache est invalidé sur le contenu, le style et la taille de boîte, JAMAIS sur la +//! transformation : déplacer, redimensionner ou animer une annotation n'est pas une raison de +//! re-rastériser (c'est l'affaire du vertex shader). + +use anyhow::{bail, Result}; +use windows::core::Interface; +use windows::Win32::Graphics::Direct2D::Common::{ + D2D1_ALPHA_MODE_PREMULTIPLIED, D2D1_COLOR_F, D2D1_PIXEL_FORMAT, D2D_POINT_2F, D2D_RECT_F, +}; +use windows::Win32::Graphics::Direct2D::{ + D2D1CreateFactory, ID2D1Factory, D2D1_DRAW_TEXT_OPTIONS_NONE, D2D1_FACTORY_TYPE_SINGLE_THREADED, + D2D1_FEATURE_LEVEL_DEFAULT, D2D1_RENDER_TARGET_PROPERTIES, D2D1_RENDER_TARGET_TYPE_DEFAULT, + D2D1_RENDER_TARGET_USAGE_NONE, D2D1_ROUNDED_RECT, +}; +use windows::Win32::Graphics::Direct3D11::{ + ID3D11Device, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, + D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, +}; +use windows::Win32::Graphics::DirectWrite::{ + DWriteCreateFactory, IDWriteFactory, DWRITE_FACTORY_TYPE_SHARED, + DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_ITALIC, DWRITE_FONT_STYLE_NORMAL, + DWRITE_FONT_WEIGHT_BOLD, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_PARAGRAPH_ALIGNMENT_CENTER, + DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_TEXT_ALIGNMENT_TRAILING, + DWRITE_TEXT_METRICS, DWRITE_TEXT_RANGE, +}; +use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_B8G8R8A8_UNORM; +use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC; +use windows::Win32::Graphics::Dxgi::IDXGISurface; + +/// Tout ce dont le rendu d'un texte dépend. Sert aussi de clé de cache : deux specs égales +/// donnent la même texture, donc `cache_key` couvre exactement ces champs. +#[derive(Clone, PartialEq)] +pub struct TextSpec { + pub content: String, + /// RGBA 0..1 (déjà parsé depuis la chaîne CSS côté appelant). + pub color: [f32; 4], + /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). + pub background: [f32; 4], + pub font_size_px: f32, + pub font_family: String, + pub bold: bool, + pub italic: bool, + pub underline: bool, + /// "left" | "center" | "right". + pub align: String, + /// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne). + pub box_px: [u32; 2], +} + +impl TextSpec { + /// FNV-1a sur les champs. Utilisé pour décider s'il faut re-rastériser ; volontairement + /// insensible à tout ce qui n'affecte pas les pixels (position, opacité d'animation…). + pub fn cache_key(&self) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |bytes: &[u8]| { + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + }; + mix(self.content.as_bytes()); + mix(self.font_family.as_bytes()); + mix(&self.font_size_px.to_bits().to_le_bytes()); + for c in self.color.iter().chain(self.background.iter()) { + mix(&c.to_bits().to_le_bytes()); + } + mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); + mix(self.align.as_bytes()); + mix(&self.box_px[0].to_le_bytes()); + mix(&self.box_px[1].to_le_bytes()); + h + } +} + +/// Chaîne UTF-16 terminée par un zéro, pour les API Win32 qui prennent un `PCWSTR`. +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +pub struct TextRasterizer { + d2d: ID2D1Factory, + dwrite: IDWriteFactory, +} + +impl TextRasterizer { + pub fn new() -> Result { + unsafe { + // SINGLE_THREADED : tout le rendu du compositeur vit sur un seul thread, et le mode + // multithread ajoute un verrou par appel pour rien. + let d2d: ID2D1Factory = + D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, None)?; + let dwrite: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?; + Ok(TextRasterizer { d2d, dwrite }) + } + } + + /// Rastérise `spec` dans une texture neuve et rend sa SRV. Le fond éventuel est dessiné + /// derrière le texte, ajusté aux métriques de la mise en page — comme le CSS, où + /// `backgroundColor` est porté par le `` et épouse donc le texte, pas la boîte — + /// avec la marge et les coins arrondis de `crate::text_plate`, partagés avec CoreText. + pub unsafe fn rasterize( + &self, + dev: &ID3D11Device, + spec: &TextSpec, + ) -> Result { + let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1)); + if spec.content.is_empty() { + bail!("texte vide"); + } + + // B8G8R8A8 : le format qu'exige une cible de rendu D2D. DXGI expose déjà les composantes + // dans l'ordre RGBA au shader, donc rien à ré-échanger côté HLSL. + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut tex: Option = None; + dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + let tex = tex.unwrap(); + + let surface: IDXGISurface = tex.cast()?; + let props = D2D1_RENDER_TARGET_PROPERTIES { + r#type: D2D1_RENDER_TARGET_TYPE_DEFAULT, + pixelFormat: D2D1_PIXEL_FORMAT { + format: DXGI_FORMAT_B8G8R8A8_UNORM, + // PREMULTIPLIED : ce que D2D produit sur une surface DXGI. Le shader ne doit donc + // PAS re-multiplier par l'alpha (cf. mode 11 dans shaders.hlsl). + alphaMode: D2D1_ALPHA_MODE_PREMULTIPLIED, + }, + // 96 dpi = 1 unité D2D pour 1 pixel : la mise en page se fait donc directement en + // pixels de sortie, ce qui rend `font_size_px` littéral. + dpiX: 96.0, + dpiY: 96.0, + usage: D2D1_RENDER_TARGET_USAGE_NONE, + minLevel: D2D1_FEATURE_LEVEL_DEFAULT, + }; + let rt = self.d2d.CreateDxgiSurfaceRenderTarget(&surface, &props)?; + + let family = wide(&spec.font_family); + let locale = wide(""); + let format = self.dwrite.CreateTextFormat( + windows::core::PCWSTR(family.as_ptr()), + None, + if spec.bold { DWRITE_FONT_WEIGHT_BOLD } else { DWRITE_FONT_WEIGHT_NORMAL }, + if spec.italic { DWRITE_FONT_STYLE_ITALIC } else { DWRITE_FONT_STYLE_NORMAL }, + DWRITE_FONT_STRETCH_NORMAL, + spec.font_size_px.max(1.0), + windows::core::PCWSTR(locale.as_ptr()), + )?; + format.SetTextAlignment(match spec.align.as_str() { + "left" => DWRITE_TEXT_ALIGNMENT_LEADING, + "right" => DWRITE_TEXT_ALIGNMENT_TRAILING, + _ => DWRITE_TEXT_ALIGNMENT_CENTER, + })?; + // Centrage vertical : l'overlay web met `alignItems: center` sur le conteneur. + format.SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER)?; + + let text: Vec = spec.content.encode_utf16().collect(); + // La boîte de mise en page est rentrée de la marge de plaque (cf. `text_plate`), et + // le texte se dessine à `pad_x` : sans cet inset, un texte aligné à gauche ou à + // droite colle au bord de la boîte et sa plaque se fait rogner du côté où elle + // devrait respirer. + let font_px = spec.font_size_px.max(1.0); + let (pad_x, pad_y) = crate::text_plate::padding(font_px); + let layout_w = crate::text_plate::layout_width(w as f32, font_px); + let layout = self + .dwrite + .CreateTextLayout(&text, &format, layout_w, h as f32)?; + if spec.underline { + layout.SetUnderline( + true, + DWRITE_TEXT_RANGE { startPosition: 0, length: text.len() as u32 }, + )?; + } + + let color = D2D1_COLOR_F { + r: spec.color[0], + g: spec.color[1], + b: spec.color[2], + a: spec.color[3], + }; + let brush = rt.CreateSolidColorBrush(&color, None)?; + + rt.BeginDraw(); + rt.Clear(Some(&D2D1_COLOR_F { r: 0.0, g: 0.0, b: 0.0, a: 0.0 })); + if spec.background[3] > 0.0 { + let mut m = DWRITE_TEXT_METRICS::default(); + layout.GetMetrics(&mut m)?; + let bg = D2D1_COLOR_F { + r: spec.background[0], + g: spec.background[1], + b: spec.background[2], + a: spec.background[3], + }; + let bg_brush = rt.CreateSolidColorBrush(&bg, None)?; + // Le texte commence à `pad_x + m.left`, donc la plaque à `m.left` — l'inset de la + // boîte de mise en page et la marge de plaque s'annulent exactement, quel que soit + // l'alignement. Elle est ensuite bornée à la boîte : au-delà, elle serait coupée + // net par le bord de la texture et perdrait ses coins arrondis. + let rect = D2D_RECT_F { + left: m.left.max(0.0), + top: (m.top - pad_y).max(0.0), + right: (m.left + m.width + pad_x * 2.0).min(w as f32), + bottom: (m.top + m.height + pad_y).min(h as f32), + }; + let radius = crate::text_plate::radius( + font_px, + (rect.right - rect.left).max(0.0), + (rect.bottom - rect.top).max(0.0), + ); + rt.FillRoundedRectangle( + &D2D1_ROUNDED_RECT { + rect, + radiusX: radius, + radiusY: radius, + }, + &bg_brush, + ); + } + rt.DrawTextLayout( + D2D_POINT_2F { x: pad_x, y: 0.0 }, + &layout, + &brush, + D2D1_DRAW_TEXT_OPTIONS_NONE, + ); + // `EndDraw` rapporte une perte de device par son HRESULT plutôt qu'en échouant tout de + // suite : on le propage pour que l'appelant sache que la texture n'est pas exploitable. + rt.EndDraw(None, None)?; + + let mut srv: Option = None; + dev.CreateShaderResourceView(&tex, None, Some(&mut srv))?; + Ok(srv.unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(content: &str) -> TextSpec { + TextSpec { + content: content.into(), + color: [1.0, 1.0, 1.0, 1.0], + background: [0.0, 0.0, 0.0, 0.0], + font_size_px: 32.0, + font_family: "Inter".into(), + bold: true, + italic: false, + underline: false, + align: "center".into(), + box_px: [400, 120], + } + } + + #[test] + fn identical_specs_share_a_cache_key() { + assert_eq!(spec("Bonjour").cache_key(), spec("Bonjour").cache_key()); + } + + #[test] + fn the_key_changes_with_anything_that_changes_the_pixels() { + let base = spec("Bonjour").cache_key(); + let mut other = spec("Bonsoir"); + assert_ne!(other.cache_key(), base, "contenu"); + other = spec("Bonjour"); + other.font_size_px = 33.0; + assert_ne!(other.cache_key(), base, "taille"); + other = spec("Bonjour"); + other.italic = true; + assert_ne!(other.cache_key(), base, "style"); + other = spec("Bonjour"); + other.color = [1.0, 0.0, 0.0, 1.0]; + assert_ne!(other.cache_key(), base, "couleur"); + other = spec("Bonjour"); + other.align = "left".into(); + assert_ne!(other.cache_key(), base, "alignement"); + other = spec("Bonjour"); + // La taille de boîte compte : elle décide des retours à la ligne, donc des pixels. + other.box_px = [401, 120]; + assert_ne!(other.cache_key(), base, "boîte"); + } + + #[test] + fn the_key_is_stable_across_unicode_content() { + // Le façonnage est délégué à DirectWrite ; la clé ne doit pas pour autant se briser sur + // du non-ASCII (l'app expédie en 13 langues). + for text in ["مرحبا", "こんにちは", "Grüße", "Здравствуйте"] { + assert_eq!(spec(text).cache_key(), spec(text).cache_key()); + } + assert_ne!(spec("مرحبا").cache_key(), spec("こんにちは").cache_key()); + } +} 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/src/vk_shaders/blur.wgsl b/crates/compositor/src/vk_shaders/blur.wgsl new file mode 100644 index 0000000000..c5f8f88e64 --- /dev/null +++ b/crates/compositor/src/vk_shaders/blur.wgsl @@ -0,0 +1,132 @@ +// Tranche verticale WP4 — Kawase blur (mode 9+10 du HLSL) porté en WGSL. +// +// Le Kawase blur est une approximation gaussienne en 6 passes : down 3x +// (RT→½→¼→⅛) puis up 3x (⅛→¼→½→RT). Chaque passe est un 5-tap linéaire +// à offset 2.2 px (cf. HLSL `ps_kawase_down` / `ps_kawase_up`). Le résultat +// est visuellement équivalent à un flou gaussien ~30-50 px (selon la +// taille de la pyramide) à un coût constant 6×5 = 30 taps — vs 49 taps +// pour une passe gaussienne équivalente. Cf. HLSL `Compositor::blur_bg`. +// +// Bindings : la passe de down lit d'une texture RGBA8 et écrit dans +// une texture RGBA8 plus petite ; la passe d'up fait l'inverse. Toutes +// les passes partagent le même bind group layout, seule la constante +// `texel_offset` (dans LayerCB `fx`) change entre les passes. + +struct Layer { + dst: vec4, + src: vec4, + quad_px: vec2, + radius_px: f32, + mode: f32, + color: vec4, + fx: vec4, // .x = texel offset (2.2 pour Kawase) + src_prev: vec4, + dst_prev: vec4, + mb: vec4, +} + +@group(0) @binding(0) var layer: Layer; +@group(0) @binding(1) var tex: texture_2d; +@group(0) @binding(2) var samp: sampler; + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { + // Fullscreen triangle — un seul triangle couvre tout l'écran, plus + // efficace qu'un quad en termes de pixels shaders émis. + let pos = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + var o: VsOut; + // `pos[vid]` UNE SEULE FOIS, puis on réutilise `p`. Ce n'est pas du style : + // indexer deux fois le même tableau local avec un indice dynamique fait + // émettre à naga 24 du SPIR-V INVALIDE. Son `spilled_composites` est indexé + // par le handle de l'expression de base, donc le second accès écrase l'entrée + // du premier : un seul `OpVariable` est émis, et les `OpStore`/`OpAccessChain` + // du premier accès référencent un id sans définition. Le module viole alors + // VUID-VkShaderModuleCreateInfo-pCode-08737, donc le comportement du pilote + // est indéfini — RADV déréférence l'id fantôme et segfault à la création du + // pipeline, lavapipe survit par chance. Cf. gfx-rs/wgpu#7048, corrigé par + // #7239 (wgpu 25) ; il n'existe pas de patch 24.0.x, donc tant qu'on est sur + // wgpu 24 c'est au shader de ne pas déclencher le bug. + let p = pos[vid]; + o.pos = vec4(p, 0.0, 1.0); + o.uv = p * 0.5 + vec2(0.5, 0.5); + // Note : on inverse Y parce que wgpu NDC y-up mais l'image source est + // y-down (cf. le Y-flip dans le VS du layer.wgsl principal). + o.uv.y = 1.0 - o.uv.y; + return o; +} + +// Triangle plein écran pour une COPIE 1:1, séparé de `vs_main` à dessein. +// +// Les deux mappings sont aujourd'hui identiques, et c'est précisément le piège : +// `vs_main` appartient à la chaîne Kawase, qui enchaîne SIX passes. Une +// inversion en Y y serait invisible (six inversions se compensent), donc rien +// dans cette chaîne ne défend l'orientation. Une copie unique, elle, la porte +// entière. Dupliquer les quatre lignes coûte moins qu'une traînée de curseur +// retournée le jour où quelqu'un ajuste la convention du Kawase. +// +// Orientation : en NDC wgpu (calqué sur D3D/Metal) y=+1 est le HAUT de la cible +// et v=0 la PREMIÈRE ligne de la texture, donc v doit croître quand y décroît. +// Vérifié par `compose_linux_trainee_de_curseur`, qui échoue en trouvant la +// traînée dans la bande miroir si on écrit `0.5 + p.y * 0.5`. +@vertex +fn vs_fullscreen(@builtin(vertex_index) vid: u32) -> VsOut { + let pos = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + // Un seul accès indexé, cf. la note naga 24 / RADV dans `vs_main`. + let p = pos[vid]; + var o: VsOut; + o.pos = vec4(p, 0.0, 1.0); + o.uv = vec2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5); + return o; +} + +// Copie telle quelle. La source est en alpha PRÉMULTIPLIÉ (tout le compositeur +// l'est), donc le blend « over » de la pipeline la composite sans reconversion. +@fragment +fn fs_copy(i: VsOut) -> @location(0) vec4 { + return textureSample(tex, samp, i.uv); +} + +// Kawase down : 5-tap linéaire à offset `texel_offset` en coords source. +// `texel_offset` est 2.2 typiquement (le spread mesuré du filtre). +@fragment +fn fs_kawase_down(i: VsOut) -> @location(0) vec4 { + let o = layer.fx.x; + let c = textureSample(tex, samp, i.uv).rgb; + let s1 = textureSample(tex, samp, i.uv + vec2( o, o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + let s2 = textureSample(tex, samp, i.uv + vec2(-o, o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + let s3 = textureSample(tex, samp, i.uv + vec2( o, -o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + let s4 = textureSample(tex, samp, i.uv + vec2(-o, -o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + return vec4((c + s1 + s2 + s3 + s4) * 0.2, layer.color.a); +} + +// Kawase up : interpolation linéaire entre la texture de destination +// (`tex`) et l'échantillon à offset `texel_offset` dans la même texture. +// C'est l'algorithme Kawase « up » original — moins connu que le down +// mais c'est ce qui donne le look "soft glow" mesuré sur le banc. +// +// On interpole entre la valeur au centre et les 4 voisins à offset `o`. +@fragment +fn fs_kawase_up(i: VsOut) -> @location(0) vec4 { + let o = layer.fx.x; + let c = textureSample(tex, samp, i.uv).rgb; + let s1 = textureSample(tex, samp, i.uv + vec2( o, o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + let s2 = textureSample(tex, samp, i.uv + vec2(-o, o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + let s3 = textureSample(tex, samp, i.uv + vec2( o, -o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + let s4 = textureSample(tex, samp, i.uv + vec2(-o, -o) / vec2(layer.quad_px.x, layer.quad_px.y)).rgb; + // Pondération (1.0 centre, 0.5 chaque voisin) — 1+4×0.5 = 3.0, /3 = 1/3 par + // échantillon. Le rendu Kawase up est plus doux que le down. + return vec4((c + (s1 + s2 + s3 + s4) * 0.5) / 3.0, layer.color.a); +} diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl new file mode 100644 index 0000000000..6fb2a73ed8 --- /dev/null +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -0,0 +1,443 @@ +// Tranche verticale WP3 — port 1:1 des modes 0 (vidéo NV12) et 1 (couleur pleine) +// du `ps_main` HLSL (`crates/compositor/src/shaders.hlsl`). Le mode 2 (ombre +// portée) partage la même SDF et le même feather que les autres modes, donc on +// l'inclut aussi pour parité. +// +// Les constantes YUV (BT.709 limited) sont reprises à l'identique du HLSL : +// Yf = (Y * 255 − 16) / 219 +// Cb = (UV.x * 255 − 128) / 224 +// Cr = (UV.y * 255 − 128) / 224 +// R = Yf + 1.5748 · Cr +// G = Yf − 0.1873 · Cb − 0.4681 · Cr +// B = Yf + 1.8556 · Cb +// Mesuré en S1 (cf. doc §7 E1). Une déviation > 0/255 entre HLSL et WGSL ici +// indiquerait une différence de précision fp32 ; IEEE-754 round-to-nearest est +// identique sur les deux backends. +// +// Le rendu est en alpha PRÉMULTIPLIÉ (cf. commentaire HLSL), convention qu'on +// retrouve dans tous les autres modes du compositeur (texte, curseur, ombre). + +struct Layer { + dst: vec4, // x,y,w,h sortie 0..1 (origine haut-gauche) + src: vec4, // u0,v0,u1,v1 source 0..1 + quad_px: vec2, // taille du quad en px de sortie (pour la SDF isotrope) + radius_px: f32, + mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté + color: vec4, + fx: vec4, // mode 2 : spread ombre en px ; modes 8/12/13 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) + src_prev: vec4, // modes 8/12/13 : coins BR,BL du quad projeté ; mode 9 : barbe 1 + dst_prev: vec4, // mode 8 : taille du plan en px AVANT projection (le rayon y vit) ; mode 13 : rect de clip ; mode 9 : barbe 2 + mb: vec4, // mode 12 : mb.y = spread de la pénombre en px ; mode 9 : mb.y = demi-épaisseur du trait en px +} + +@group(0) @binding(0) var layer: Layer; +@group(0) @binding(1) var texY: texture_2d; // R8Unorm, sample .r +@group(0) @binding(2) var texUV: texture_2d; // Rg8Unorm, sample .rg +@group(0) @binding(3) var samp: sampler; + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, // UV d'échantillonnage source + @location(1) local: vec2, // pixel local dans le quad (SDF) + @location(2) pout: vec2, // position 0..1 sortie +}; + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { + // strip 4 vertices : (0,0)(1,0)(0,1)(1,1) + let c = vec2(f32(vid & 1u), f32((vid >> 1u) & 1u)); + let p = layer.dst.xy + c * layer.dst.zw; + let ndc = vec2(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0); + var o: VsOut; + o.pos = vec4(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; +} + +fn yuv709_limited(y: f32, cbcr: vec2) -> vec3 { + let Yf = (y * 255.0 - 16.0) / 219.0; + let Cb = (cbcr.x * 255.0 - 128.0) / 224.0; + let Cr = (cbcr.y * 255.0 - 128.0) / 224.0; + return clamp(vec3( + Yf + 1.5748 * Cr, + Yf - 0.1873 * Cb - 0.4681 * Cr, + Yf + 1.8556 * Cb, + ), vec3(0.0), vec3(1.0)); +} + +fn sample_yuv(uv: vec2) -> vec3 { + let y = textureSample(texY, samp, uv).r; + let cbcr = textureSample(texUV, samp, uv).rg; + return yuv709_limited(y, cbcr); +} + +// SDF rectangle à coins arrondis (< 0 dedans). Identique au HLSL. +fn sd_round_rect(p: vec2, halfsz: vec2, r: f32) -> f32 { + let q = abs(p) - halfsz + vec2(r); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +// ---- Primitives du tilt 3D (modes 8 et 12), portees de `shaders.metal` ---- + +// SDF segment a bouts ronds. +fn sd_segment(p: vec2, a: vec2, b: vec2) -> f32 { + let pa = p - a; + let ba = b - a; + let h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0); + return length(pa - ba * h); +} + +// Intersection de deux droites donnees par (normale, offset) : n.x = d. Cramer. +fn line_cross(n1: vec2, d1: f32, n2: vec2, d2: f32) -> vec2 { + let det = n1.x * n2.y - n1.y * n2.x; + if abs(det) < 1e-6 { + return vec2(0.0, 0.0); + } + return vec2(d1 * n2.y - d2 * n1.y, d2 * n1.x - d1 * n2.x) / det; +} + +// Contribution d'une arete a la SDF du quad : .x = distance signee au demi-plan +// porte par l'arete (>0 dehors), .y = distance au SEGMENT. +fn quad_edge(p: vec2, a: vec2, b: vec2) -> vec2 { + let e = b - a; + // Division par la longueur plutot que `normalize` : une arete degeneree + // donnerait un NaN qui effacerait le calque entier. + let n = vec2(e.y, -e.x) / max(length(e), 1e-6); + return vec2(dot(p - a, n), sd_segment(p, a, b)); +} + +// Distance signee EXACTE a un quadrilatere convexe (<0 dedans). La boucle `k` +// du MSL est deroulee : elle indexait un tableau local avec un indice runtime, +// ce que naga 24 traduit en SPIR-V invalide (cf. `blur.wgsl`). +fn sd_convex_quad(p: vec2, v0: vec2, v1: vec2, v2: vec2, v3: vec2) -> f32 { + let e0 = quad_edge(p, v0, v1); + let e1 = quad_edge(p, v1, v2); + let e2 = quad_edge(p, v2, v3); + let e3 = quad_edge(p, v3, v0); + let inside = max(max(e0.x, e1.x), max(e2.x, e3.x)); + let border = min(min(e0.y, e1.y), min(e2.y, e3.y)); + if inside < 0.0 { + return -border; + } + return border; +} + +// Coin d'un quad rentre de `r` : intersection des deux aretes adjacentes, +// chacune decalee de `r` vers l'interieur. Meme deroulement que ci-dessus. +fn inset_corner(prev: vec2, cur: vec2, next: vec2, r: f32) -> vec2 { + let ep = cur - prev; + let ec = next - cur; + // TL->TR->BR->BL tourne dans le sens horaire en y-bas, donc (e.y, -e.x) sort du quad. + let np = vec2(ep.y, -ep.x) / max(length(ep), 1e-6); + let nc = vec2(ec.y, -ec.x) / max(length(ec), 1e-6); + return line_cross(np, dot(prev, np) - r, nc, dot(cur, nc) - r); +} + +// (s, t, ok) du warp inverse du mode 8 pour une racine `t` donnee. +fn quad_st_for_root(t: f32, e: vec2, f: vec2, g: vec2, h: vec2) -> vec3 { + let denom_x = e.x + g.x * t; + let denom_y = e.y + g.y * t; + var s: f32; + if abs(denom_x) > abs(denom_y) { + s = (h.x - f.x * t) / denom_x; + } else { + s = (h.y - f.y * t) / denom_y; + } + // Tolerance de 2 % reprise telle quelle du MSL : sans elle une rangee de + // pixels du bord tombe hors du quad par arrondi et l'ecran se liseree. + var ok = 0.0; + if s >= -0.02 && s <= 1.02 && t >= -0.02 && t <= 1.02 { + ok = 1.0; + } + return vec3(s, t, ok); +} + +// (s, t, ok) du point `P` dans le quad c00->c10->c11->c01 : le warp bilineaire INVERSE. +fn quad_inverse_bilinear(P: vec2, c00: vec2, c10: vec2, c11: vec2, c01: vec2) -> vec3 { + let e = c10 - c00; + let f = c01 - c00; + let g = c00 - c10 - c01 + c11; + let h = P - c00; + let k2 = g.x * f.y - g.y * f.x; + let k1 = e.x * f.y - e.y * f.x + h.x * g.y - h.y * g.x; + let k0 = h.x * e.y - h.y * e.x; + // Quad quasi affine (rotation Y pure, p.ex.) : le terme quadratique s'evanouit + // et resoudre la quadratique diviserait par ~0. + if abs(k2) < 1e-5 * abs(k1) { + var t = 0.0; + if abs(k1) >= 1e-6 { + t = -k0 / k1; + } + return quad_st_for_root(t, e, f, g, h); + } + let disc = k1 * k1 - 4.0 * k2 * k0; + if disc < 0.0 { + return vec3(0.0, 0.0, 0.0); + } + // Forme stable de la quadratique : additionner deux termes de meme signe evite + // l'annulation catastrophique que `(-k1 +- sqrt(disc)) / (2 k2)` produit quand + // `disc` approche `k1^2`. `sign()` de WGSL rend 0 en 0, la ou le ternaire MSL + // rend +1 : d'ou le signe explicite. + var sgn = 1.0; + if k1 < 0.0 { + sgn = -1.0; + } + let q = -0.5 * (k1 + sgn * sqrt(disc)); + let r0 = quad_st_for_root(q / k2, e, f, g, h); + var t1 = q / k2; + if abs(q) > 0.0 { + t1 = k0 / q; + } + let r1 = quad_st_for_root(t1, e, f, g, h); + if r0.z > 0.5 { + return r0; + } + return r1; +} + +@fragment +fn fs_main(i: VsOut) -> @location(0) vec4 { + var rgb: vec3; + var alpha: f32; + + if layer.mode < 0.5 { + // Mode 0 — vidéo NV12 + flou de mouvement par vélocité (§8), port 1:1 du + // HLSL/MSL. Pour CE pixel de sortie, l'UV qu'il occupait à la frame + // précédente se retrouve en le remappant par (dst_prev, src_prev) : on + // floute le long de ce segment, ce qui capture la translation ET le zoom + // du calque sans avoir à transporter un champ de vitesse. + let taps = i32(layer.mb.x); + // `taps` d'abord : un draw qui a oublié `dst_prev` le laisse à zéro, et + // la division par `dst_prev.zw` produirait des UV infinis. Dégrader vers + // le chemin net est le seul échec acceptable pour un effet cosmétique. + if taps <= 1 || layer.dst_prev.z <= 0.0 || layer.dst_prev.w <= 0.0 { + rgb = sample_yuv(i.uv); + } else { + let localp = (i.pout - layer.dst_prev.xy) / layer.dst_prev.zw; + let uv_prev = layer.src_prev.xy + localp * (layer.src_prev.zw - layer.src_prev.xy); + let duv = i.uv - uv_prev; + if dot(duv, duv) < 1e-9 { + rgb = sample_yuv(i.uv); + } else { + // Borne 16 en dur, identique au HLSL et au MSL : `taps` vient d'un + // uniform et une boucle sans borne statique ne se déroule pas. + // L'échelle de l'inspector s'arrête pile à 16 (1 + 15·blur), donc + // c'est `taps` qui coupe, jamais la borne. + var acc = vec3(0.0); + let step = 1.0 / f32(taps - 1); + for (var k: i32 = 0; k < 16; k = k + 1) { + if k >= taps { break; } + acc = acc + sample_yuv(uv_prev + duv * (f32(k) * step)); + } + rgb = acc / f32(taps); + } + } + } else if layer.mode < 1.5 { + // Mode 1 — couleur pleine. + rgb = layer.color.rgb; + } else if layer.mode > 4.5 && layer.mode < 5.5 { + // Mode 5 -- gradient lineaire : color (c0) -> src.rgb (c1) le long de + // la direction fx.xy (sin, -cos de l'angle). Parite avec le HLSL/MSL. + let t = clamp(dot(i.pout - vec2(0.5), layer.fx.xy) + 0.5, 0.0, 1.0); + rgb = mix(layer.color.rgb, layer.src.rgb, t); + } else if layer.mode > 10.5 && layer.mode < 11.5 { + // Mode 11 : texte. texY est l'atlas R8 (couverture alpha au canal .r, + // produit par text_cosmic::TextRasterizer), teinte par layer.color. + // Sortie en alpha premultiplie, comme les autres modes. + let cov = textureSample(texY, samp, i.uv).r; + let a = layer.color.a * cov; + return vec4(layer.color.rgb * a, a); + } else if layer.mode > 8.5 && layer.mode < 9.5 { + // Mode 9 -- annotation « figure » : une fleche. Parite EXACTE avec + // `ArrowSvgs.tsx`, dont chaque direction est un trace de trois segments a + // bouts ronds dans un viewBox 0..100 : une hampe et deux barbes. Trois + // `sd_segment` et un `min` reproduisent la forme telle quelle, pas une + // approximation. Les extremites arrivent deja converties en px locaux du + // quad par `regions::arrow_local_geometry` (echelle uniforme centree, + // comme le `preserveAspectRatio` par defaut du SVG), donc ce shader n'a + // aucune geometrie a deviner. + // + // fx = hampe (a.xy, b.xy), src_prev = barbe 1, dst_prev = barbe 2 ; + // mb.y = demi-epaisseur en px. + var 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 crenelage, et une fleche + // fine ne disparait pas quand la demi-epaisseur descend sous le pixel. + let a = clamp(layer.mb.y - d + 0.5, 0.0, 1.0) * layer.color.a; + return vec4(layer.color.rgb * a, a); + } else if layer.mode > 9.5 && layer.mode < 10.5 { + // Mode 10 -- annotation « flou » : masque la zone en reutilisant l'image + // DEJA composee, qui arrive sur texY (recopie mipmappee du render target + // -- on ne peut pas echantillonner la cible sur laquelle on dessine). + // `i.pout` donne directement l'UV de sortie, donc aucun mapping a refaire. + // + // fx.x = 0 mosaique / 1 flou ; fx.y = taille de bloc px (mosaique) ou + // rayon px (flou) ; fx.z = 0 rectangle / 1 ovale ; fx.w = 1 si teinte. + let n = i.local / max(layer.quad_px, vec2(1e-6)); + var cov = 1.0; + if layer.fx.z > 0.5 { + // Ovale inscrit : distance au centre en unites de demi-axes, adoucie + // sur ~1px. + let dc = (n - vec2(0.5)) * 2.0; + let r = length(dc); + let 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 vec4(0.0, 0.0, 0.0, 0.0); + } + var masked: vec3; + if layer.fx.x > 0.5 { + // Flou : on echantillonne un niveau de mip de l'image composee. + // `log2(rayon)` donne le niveau dont un texel couvre a peu pres le + // rayon demande, et le filtrage trilineaire lisse la transition entre + // deux niveaux quand le rayon varie. + // + // Un noyau de quelques taps espaces du rayon ne floute PAS : il + // superpose autant de copies decalees, ce qui se voit comme du texte + // fantome. Atteindre un vrai lissage par taps demanderait un tap par + // pixel de rayon ; la pyramide de mips donne le meme resultat a cout + // constant, et c'est le GPU qui l'a construite. + let lod = log2(max(layer.fx.y, 1.0)); + masked = textureSampleLevel(texY, samp, i.pout, lod).rgb; + } else { + // Mosaique : on quantifie l'UV sur une grille de `fx.y` px, alignee + // sur le quad pour que les blocs ne rampent pas quand l'annotation + // bouge. + let px_uv = layer.dst.zw / max(layer.quad_px, vec2(1e-6)); + let block = max(layer.fx.y, 1.0) * px_uv; + let origin = layer.dst.xy; + let q = origin + (floor((i.pout - origin) / block) + vec2(0.5)) * block; + // Niveau 0 explicite : l'UV quantifie est une marche d'escalier, donc + // ses derivees explosent en bord de bloc et le choix automatique de + // mip ramollirait justement les aretes qui font la mosaique. + masked = textureSampleLevel(texY, samp, q, 0.0).rgb; + } + if layer.fx.w > 0.5 { + // Teinte blanc/noir : la couleur choisie, melee a moitie, garde la + // forme lisible sans effacer completement ce qu'il y a dessous. + masked = mix(masked, layer.color.rgb, 0.5); + } + let a = cov * layer.color.a; + return vec4(masked * a, a); + } else if layer.mode > 6.5 && layer.mode < 7.5 { + // Mode 7 -- sprite curseur (PNG RGBA, alpha droite) echantillonne sur + // texY (comme le mode 11 y lie son atlas). `fx` = rect de clip "Clip to + // canvas" [x,y,w,h] en sortie 0..1 (= s_dst si actif, sinon un rect + // englobant : sans effet). Sortie en alpha premultiplie. + 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 vec4(0.0, 0.0, 0.0, 0.0); + } + let s = textureSample(texY, samp, i.uv); + let ca = s.a * layer.color.a; + return vec4(s.rgb * ca, ca); + } else if layer.mode > 5.5 && layer.mode < 6.5 { + // Mode 6 -- fond image (wallpaper RGBA) cover-fit, echantillonne sur + // texY. `src` porte le rect UV cover-fit (calcule cote Rust). Opaque : + // le fond couvre tout le cadre. + return vec4(textureSample(texY, samp, i.uv).rgb, 1.0); + } else if layer.mode > 7.5 && layer.mode < 8.5 { + // Mode 8 -- ecran tilte (rotation 3D des zoom regions). Le quad projete est + // dessine dans sa BBOX (le VS ne sait tracer qu'un rect) et chaque fragment + // remonte au (s,t) du plan par warp bilineaire inverse. + // + // PAS de test de clip sur `dst_prev` : en mode 8 `dst_prev.xy` porte + // `plane_px`, la taille du plan en PIXELS (~1600), la ou `i.pout` vit dans + // [0,1]. Un clip la-dessus serait vrai partout et n'afficherait rien. + let 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 vec4(0.0, 0.0, 0.0, 0.0); // hors du quad projete + } + // La coupe source s'applique ICI : `r` est une position DANS le plan (0..1), + // pas une coordonnee de texture. Echantillonner `r` directement ignorerait le + // crop utilisateur et le zoom. + let uv = vec2( + 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 REPERE DU PLAN : le rayon reste constant le long du + // bord, la ou un arrondi calcule dans la bbox s'etirerait avec la perspective. + // Inconditionnel, rayon 0 compris -- `sd_round_rect` degenere en SDF de + // rectangle et le feather de 1,5 px subsiste, ce qui fait lire une arete + // inclinee COMME une arete plutot que comme un escalier. + let plane_px = layer.dst_prev.xy; + let p = vec2(r.x, r.y) * plane_px - plane_px * 0.5; + let d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); + let 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` a zero, donc s'en servir rendrait un plan totalement transparent. + return vec4(sample_yuv(uv) * tilt_a, tilt_a); + } else if layer.mode > 11.5 && layer.mode < 12.5 { + // Mode 12 -- ombre du quad projete. La penombre suit le QUADRILATERE, pas son + // rect englobant : un rect droit derriere un ecran incline se lit comme une + // seconde surface, pas comme son ombre. + // + // `fx`/`src_prev` portent les COINS (en px locaux a la bbox, comme `i.local`), + // et le spread vit dans `mb.y` -- pas dans `fx.x` comme au mode 2. + let tl = layer.fx.xy; + let tr = layer.fx.zw; + let br = layer.src_prev.xy; + let bl = layer.src_prev.zw; + // Coins arrondis du meme rayon que le plan : une ombre a coins vifs derriere un + // ecran arrondi depasse en pointe a chaque coin, d'autant plus que le rayon monte. + let r = max(layer.radius_px, 0.0); + let v0 = inset_corner(bl, tl, tr, r); + let v1 = inset_corner(tl, tr, br, r); + let v2 = inset_corner(tr, br, bl, r); + let v3 = inset_corner(br, bl, tl, r); + let d = sd_convex_quad(i.local, v0, v1, v2, v3) - r; + let spread = max(layer.mb.y, 1e-3); + let a = layer.color.a * (1.0 - smoothstep(0.0, spread, d)); + return vec4(layer.color.rgb * a, a); + } else if layer.mode > 12.5 && layer.mode < 13.5 { + // Mode 13 -- sprite de curseur POSE sur l'ecran incline : ses quatre coins + // ont traverse la meme projection que la video, et le fragment remonte a sa + // position dans le sprite par le meme warp inverse que le mode 8. + // + // Le rect de clip « Clip to canvas » est ici dans `dst_prev` (en sortie + // 0..1, [x,y,w,h]) et NON dans `fx` comme au mode 7 : `fx` porte les coins. + 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 vec4(0.0, 0.0, 0.0, 0.0); + } + let 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 vec4(0.0, 0.0, 0.0, 0.0); + } + // Sprite RGBA a alpha DROITE sur texY (comme le mode 7 y lie le sien) : + // on premultiplie ici. + let s = textureSample(texY, samp, clamp(vec2(r.x, r.y), vec2(0.0), vec2(1.0))); + let ca = s.a * layer.color.a; + return vec4(s.rgb * ca, ca); + } else { + // Mode 2 — ombre portée (SDF d'un quad arrondi élargi de `fx.x`). + let spread = layer.fx.x; + let halfsz = layer.quad_px * 0.5 - vec2(spread); + let p = i.local - layer.quad_px * 0.5; + let d = sd_round_rect(p, halfsz, layer.radius_px); + let a = layer.color.a * (1.0 - smoothstep(0.0, spread, d)); + return vec4(layer.color.rgb * a, a); + } + + alpha = layer.color.a; + + if layer.radius_px > 0.0 { + // Feather ~1.5 px sur le bord du quad — parité exacte avec le HLSL + // (`smoothstep(0.0, 1.5, d)`). Le shader HLSL inclut `quad_px` en px de + // SORTIE ; on reproduit la même chose ici. + let halfsz = layer.quad_px * 0.5; + let p = i.local - layer.quad_px * 0.5; + let d = sd_round_rect(p, halfsz, layer.radius_px); + alpha *= 1.0 - smoothstep(0.0, 1.5, d); + } + + return vec4(rgb * alpha, alpha); // alpha prémultiplié +} diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs new file mode 100644 index 0000000000..9aa60d928b --- /dev/null +++ b/crates/compositor/tests/compose_linux.rs @@ -0,0 +1,1470 @@ +//! Verifie que le port Linux reconciliie sur v1.8.0 REND une frame : +//! `d3d::Gpu` -> `compositor::Compositor` -> `pipeline::Decoder` (les modules +//! Linux, via les alias cfg) -> `compose_frame` (geometrie partagee +//! `plan_frame`) -> `readback_direct`. Bypass le render-thread de `live.rs` +//! pour isoler la chaine de rendu elle-meme. +//! +//! Opt-in (rend sur GPU) : `OPENSCREEN_LINUX_COMPOSE=1` + la fixture +//! `crates/fixture/screen.mp4`. Sinon skip (le teardown Vulkan/Mesa segfault a +//! l'exit apres le rendu -- verifier via la sortie, pas l'exit code). + +// Linux UNIQUEMENT, comme `warp_device_cannot_decode.rs` l'est a Windows. Les +// fichiers de `tests/` sont compiles quelle que soit la plateforme : sans cette +// porte, `cargo check` sous Windows resout `pipeline::Decoder` vers +// `pipeline_windows::Decoder`, qui est `pub(crate)` -- et le check Windows casse +// sur un test qui ne s'y executera jamais. +#![cfg(target_os = "linux")] + +use std::path::Path; + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::pipeline::{ + run_composited_multi, ClipSource, Decoder, ExportCodec, ExportParams, +}; +use openscreen_compositor::scene::Scene; + +const FIXTURE: &str = "../fixture/screen.mp4"; +const W: u32 = 960; +const H: u32 = 540; + +/// Ecrit un PPM P6 dans `OPENSCREEN_VK_OUT` (defaut `target`) pour inspection. +fn write_ppm(name: &str, w: u32, h: u32, rgba: &[u8]) { + use std::io::Write; + let out = std::env::var("OPENSCREEN_VK_OUT").unwrap_or_else(|_| "target".into()); + let _ = std::fs::create_dir_all(&out); + let path = format!("{out}/{name}.ppm"); + let mut f = std::fs::File::create(&path).expect("create ppm"); + write!(f, "P6\n{w} {h}\n255\n").unwrap(); + let mut rgb = vec![0u8; (w * h * 3) as usize]; + for (d, s) in rgb.chunks_exact_mut(3).zip(rgba.chunks_exact(4)) { + d.copy_from_slice(&s[0..3]); + } + f.write_all(&rgb).unwrap(); + println!("wrote {path}"); +} + +#[test] +fn compose_linux_rend_une_frame() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux: opt-in (OPENSCREEN_LINUX_COMPOSE=1 + fixture). Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + // Scene : fond gradient + padding (l'ecran est inset -> le fond floute se + // voit tout autour) pour valider visuellement le blur du background. + let scene_json = r##"{"clips":[],"layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0.18,"blur":true,"shadow":0,"roundnessFrac":0.05,"motionBlur":0},"background":{"kind":"gradient","angleDeg":45,"stops":["#ff3b6b","#3b6bff"]},"zoomRegions":[],"annotations":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1920,"height":1080,"fps":30}}"##; + comp.set_scene(Some(Scene::from_json(scene_json).expect("scene json"))); + + let (w, h, rgba) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let mut cfg = Cfg::c8(); + cfg.bg_blur = true; + // webcam = screen (mon compose coeur ne dessine que l'ecran). + comp.compose_frame(sf, sf, 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + + let n = (rgba.len() / 4) as f32; + let mut sum = 0u64; + for px in rgba.chunks_exact(4) { + sum += px[0] as u64; + } + let mean_r = sum as f32 / n; + println!("compose_linux : {w}x{h} bytes={} mean_R={:.1}", rgba.len(), mean_r); + + // PPM P6 pour inspection visuelle. + let out = std::env::var("OPENSCREEN_VK_OUT").unwrap_or_else(|_| "target".into()); + let _ = std::fs::create_dir_all(&out); + let ppm = format!("{out}/compose_linux.ppm"); + { + use std::io::Write; + let mut f = std::fs::File::create(&ppm).expect("create ppm"); + write!(f, "P6\n{w} {h}\n255\n").unwrap(); + let mut rgb = vec![0u8; (w * h * 3) as usize]; + for (d, s) in rgb.chunks_exact_mut(3).zip(rgba.chunks_exact(4)) { + d.copy_from_slice(&s[0..3]); + } + f.write_all(&rgb).unwrap(); + } + println!("wrote {ppm}"); + + assert_eq!(rgba.len(), (W * H * 4) as usize); + assert!( + mean_r > 5.0 && mean_r < 250.0, + "mean R={mean_r} hors plage plausible (5..250) — frame vide ?" + ); +} + +// --------------------------------------------------------------------------- +// Rotation 3D (modes 8 et 12) +// --------------------------------------------------------------------------- + +/// Scene « ecran seul sur fond plat magenta », avec ou sans preset de rotation. +/// Le fond est une couleur SATUREE que l'enregistrement d'ecran de la fixture ne +/// produit nulle part : c'est ce qui permet de separer l'ecran du fond au pixel +/// pres, donc de mesurer la forme reellement dessinee. +fn tilt_scene_json(rotation: &str, shadow: u32, roundness: f32) -> String { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.2,"blur":false,"shadow":{shadow},"roundnessFrac":{roundness},"motionBlur":0}},"background":{{"kind":"color","color":"#ff00ff"}},"zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":6,"scale":1.0,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}],"annotations":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) +} + +/// `true` si le pixel n'est PAS le fond magenta. Seuil large : le feather des +/// bords et le degrade du sampler ne doivent pas compter comme du fond. +fn not_bg(px: &[u8]) -> bool { + !(px[0] > 200 && px[1] < 60 && px[2] > 200) +} + +/// Pour chaque colonne, la premiere ligne non-fond. `None` = colonne entierement +/// de fond. C'est la trace du BORD HAUT de ce qui est dessine : horizontale pour +/// un ecran droit, oblique pour un ecran incline. +fn top_edge(rgba: &[u8], w: u32, h: u32) -> Vec> { + (0..w) + .map(|x| { + (0..h).find(|&y| { + let i = ((y * w + x) * 4) as usize; + not_bg(&rgba[i..i + 4]) + }) + }) + .collect() +} + +/// Ecart max du bord haut, mesure sur les colonnes centrales uniquement : aux +/// deux extremites le bord haut d'un quad incline bascule sur le bord LATERAL, +/// ce qui ajouterait une variation qui n'est pas celle qu'on veut mesurer. +fn top_edge_swing(edge: &[Option]) -> u32 { + let n = edge.len(); + let seen: Vec = edge[n / 4..3 * n / 4].iter().flatten().copied().collect(); + match (seen.iter().min(), seen.iter().max()) { + (Some(&lo), Some(&hi)) => hi - lo, + _ => 0, + } +} + +/// Ecran incline (mode 8). Rend DEUX fois la meme scene, seule la rotation +/// change, et compare la silhouette obtenue. +/// +/// L'assertion porte sur la GEOMETRIE, pas sur la presence d'un fichier : le +/// bord haut de l'ecran droit est horizontal a moins de 2 px pres, celui de +/// l'ecran incline balaie des dizaines de lignes. Un mode 8 non branche cote +/// Rust, un warp inverse faux, ou un `quad_st_for_root` qui rejetterait tout +/// casse l'une des trois bornes. +#[test] +fn compose_linux_ecran_tilte() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux tilt: opt-in (OPENSCREEN_LINUX_COMPOSE=1 + fixture). Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let mut cfg = Cfg::c8(); + cfg.shadow = false; + let (w, h, upright, tilted) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + // `frame` = 90 -> source_t = 3 s, au coeur de la region [0, 6] : la rampe + // d'entree est finie, la rotation est a pleine force. + let render = |json: String| { + let scene = Scene::from_json(&json).expect("scene json"); + // Le padding transite par les live_params, pas la scene brute. + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.compose_frame(sf, sf, 90.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + let (w, h, upright) = render(tilt_scene_json("null", 0, 0.0)); + let tilted: Vec<(&str, Vec)> = ["iso", "left", "right"] + .iter() + .map(|p| (*p, render(tilt_scene_json(&format!("\"{p}\""), 0, 0.0)).2)) + .collect(); + (w, h, upright, tilted) + }; + + write_ppm("compose_linux_tilt_upright", w, h, &upright); + + let up_swing = top_edge_swing(&top_edge(&upright, w, h)); + let up_area = upright.chunks_exact(4).filter(|p| not_bg(p)).count(); + println!("compose_linux tilt : {w}x{h} droit bord_haut={up_swing}px aire={up_area}"); + + // Garde-fou du detecteur lui-meme : si le fond magenta ne separait pas + // proprement l'ecran, le bord de la reference droite ne serait pas plat et + // toute la mesure serait du bruit. + assert!( + up_swing <= 2, + "reference droite : bord haut non horizontal ({up_swing} px) — le detecteur de fond derape" + ); + + // Les TROIS presets. Ils ne donnent pas le meme quadrilatere : iso penche le + // plus, left/right sont dominés par leur rotateY, donc leur quad approche le + // cas quasi affine que `quad_inverse_bilinear` traite par une branche a part. + for (preset, tilted) in &tilted { + write_ppm(&format!("compose_linux_tilt_{preset}"), w, h, tilted); + let swing = top_edge_swing(&top_edge(tilted, w, h)); + let area = tilted.chunks_exact(4).filter(|p| not_bg(p)).count(); + println!("compose_linux tilt {preset} : bord_haut={swing}px aire={area}"); + + // Chaque preset combine un rotateX et un rotateZ non nuls : sur une largeur + // d'ecran de ~600 px le bord haut ne peut pas rester horizontal. + assert!( + swing >= 15, + "{preset} : bord haut plat a {swing} px — mode 8 pas dessine (rect droit ?)" + ); + // Le containment reduit le plan pour qu'il tienne dans le rect d'origine : + // l'aire couverte baisse. La borne basse attrape le cas « mode 8 ne rend + // rien » (quad_inverse_bilinear qui rejette tout, alpha a zero...). + assert!( + area > up_area * 4 / 10 && area < up_area * 95 / 100, + "{preset} : aire {area} hors de (0.40, 0.95) x {up_area} — mode 8 vide ou inopérant" + ); + } +} + +/// Ombre du quad projete (mode 12). L'ombre doit suivre le QUADRILATERE : si +/// elle retombait sur le mode 2 (rect arrondi axis-aligned), sa bordure exterieure +/// serait horizontale en haut. On isole l'ombre en soustrayant le meme rendu sans +/// ombre, puis on mesure la pente de la bordure de la zone assombrie. +#[test] +fn compose_linux_ombre_du_quad_tilte() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux ombre tiltee: opt-in. Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let (w, h, sans, avec) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let render = |json: String, shadow: bool| { + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + let mut cfg = Cfg::c8(); + cfg.shadow = shadow; + comp.compose_frame(sf, sf, 90.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + // Rayon non nul : c'est la seule facon d'exercer `inset_corner`/`line_cross` + // (le rentrant des coins de l'ombre) et l'arrondi en repere PLAN du mode 8. + let (w, h, sans) = render(tilt_scene_json("\"iso\"", 0, 0.04), false); + let (_, _, avec) = render(tilt_scene_json("\"iso\"", 1, 0.04), true); + (w, h, sans, avec) + }; + + write_ppm("compose_linux_tilt_shadow", w, h, &avec); + + // Masque de l'ombre : pixels du FOND assombris par le calque 12. On ignore + // l'ecran lui-meme (l'ombre passe dessous, il n'y change rien). + let mut mask = vec![false; (w * h) as usize]; + let mut count = 0usize; + for p in 0..(w * h) as usize { + let i = p * 4; + let dark = sans[i] as i32 - avec[i] as i32 > 12 && !not_bg(&sans[i..i + 4]); + mask[p] = dark; + count += dark as usize; + } + // Bordure HAUTE de la penombre, colonne par colonne. + let edge: Vec> = (0..w) + .map(|x| (0..h).find(|&y| mask[(y * w + x) as usize])) + .collect(); + let swing = top_edge_swing(&edge); + println!("compose_linux ombre tiltee : {count} px assombris, bordure haute swing={swing}px"); + + assert!(count > 3000, "ombre absente ({count} px assombris) — mode 12 pas dessine ?"); + // Un repli sur le mode 2 donnerait une bordure haute rigoureusement plate. + assert!( + swing >= 15, + "bordure haute de l'ombre plate a {swing} px — l'ombre est un rect droit, pas le quad projete" + ); +} + +/// Curseur pose sur l'ecran incline (mode 13). Le curseur est place HORS du +/// centre : c'est la que le plan incline le deplace vraiment. Au centre, la +/// position tiltee et la position droite coincident et le test ne prouverait rien. +/// +/// L'assertion est que le sprite BOUGE quand on incline. Un repli sur le +/// placement droit (mode 7) laisserait les deux barycentres au meme endroit ; un +/// mode 13 absent ferait disparaitre le curseur (compte a zero). +#[test] +fn compose_linux_curseur_sur_ecran_tilte() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux curseur tilte: opt-in. Skip."); + return; + } + // Sprite vert 16x16 opaque (le meme que le test du mode 7). + const SPRITE: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAGElEQVR4nGNk+MdAEmAhTfmohlENQ0kDAGoRATwbkCdPAAAAAElFTkSuQmCC"; + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let track_path = std::env::temp_dir().join("os_cursor_track_tilt.json"); + std::fs::write( + &track_path, + r#"{"samples":[{"timeMs":3000,"cx":0.22,"cy":0.24,"cursorType":"arrow"}]}"#, + ) + .expect("write track"); + let track = CursorTrack::load(track_path.to_str().unwrap(), 0.0, 6.0).expect("CursorTrack::load"); + comp.set_cursor(track); + comp.set_cursor_time(Some(3.0)); + + let scene_json = |rotation: &str| { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#ff00ff"}},"zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":6,"scale":1.0,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}],"annotations":[],"cursor":{{"show":true,"size":4,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default","cursorSprites":{{"arrow":{{"path":"{SPRITE}","hotspotX":0.5,"hotspotY":0.5}}}}}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + }; + + let (w, h, upright, tilted) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let render = |json: String| { + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.compose_frame(sf, sf, 90.0, &Cfg::c8()).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + let (w, h, upright) = render(scene_json("null")); + let (_, _, tilted) = render(scene_json("\"iso\"")); + (w, h, upright, tilted) + }; + + write_ppm("compose_linux_tilt_cursor", w, h, &tilted); + + // Barycentre des pixels verts du sprite. + let centroid = |rgba: &[u8]| -> (f32, f32, usize) { + let (mut sx, mut sy, mut n) = (0.0f32, 0.0f32, 0usize); + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + if rgba[i + 1] > 180 && rgba[i] < 120 && rgba[i + 2] < 120 { + sx += x as f32; + sy += y as f32; + n += 1; + } + } + } + (sx / n.max(1) as f32, sy / n.max(1) as f32, n) + }; + let (ux, uy, un) = centroid(&upright); + let (tx, ty, tn) = centroid(&tilted); + let shift = ((tx - ux).powi(2) + (ty - uy).powi(2)).sqrt(); + println!( + "compose_linux curseur tilte : droit=({ux:.1},{uy:.1}) n={un} \ + incline=({tx:.1},{ty:.1}) n={tn} deplacement={shift:.1}px" + ); + + assert!(un > 50, "curseur droit absent (n={un}) — la scene de reference est cassee"); + assert!(tn > 50, "curseur absent sous rotation (n={tn}) — mode 13 pas dessine"); + assert!( + shift >= 12.0, + "curseur deplace de {shift:.1}px seulement — il est reste sur le rect droit (mode 7 ?)" + ); +} + +/// Curseur : sprite thematise (mode 7) dessine au centre. Sprite VERT (data URI +/// PNG) distinct du fond sombre et de l'ecran, pour l'affirmer sans ambiguite. +#[test] +fn compose_linux_dessine_le_curseur() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux curseur: opt-in (OPENSCREEN_LINUX_COMPOSE=1 + fixture). Skip."); + return; + } + + // Sprite vert 16x16 opaque en data URI (decode_data_uri -> crate image). + const SPRITE: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAGElEQVR4nGNk+MdAEmAhTfmohlENQ0kDAGoRATwbkCdPAAAAAElFTkSuQmCC"; + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + // Scene : curseur visible (size 3 pour un sprite bien lisible), sprite "arrow". + let scene_json = format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0.03,"motionBlur":0}},"background":{{"kind":"color","color":"#101015"}},"zoomRegions":[],"annotations":[],"cursor":{{"show":true,"size":3,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default","cursorSprites":{{"arrow":{{"path":"{SPRITE}","hotspotX":0.5,"hotspotY":0.5}}}}}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ); + comp.set_scene(Some(Scene::from_json(&scene_json).expect("scene json"))); + + // Piste curseur : un echantillon au centre (0.5, 0.5). `load` lit un fichier. + let track_path = std::env::temp_dir().join("os_cursor_track.json"); + std::fs::write( + &track_path, + r#"{"samples":[{"timeMs":0,"cx":0.5,"cy":0.5,"cursorType":"arrow"}]}"#, + ) + .expect("write track"); + let track = CursorTrack::load(track_path.to_str().unwrap(), 0.0, 2.0).expect("CursorTrack::load"); + comp.set_cursor(track); + comp.set_cursor_time(Some(0.0)); + + let (w, h, rgba) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let cfg = Cfg::c8(); + comp.compose_frame(sf, sf, 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + + // Le sprite vert doit apparaitre franchement (G haut, R/B bas). + let green = rgba + .chunks_exact(4) + .filter(|p| p[1] > 180 && p[0] < 120 && p[2] < 120) + .count(); + println!("compose_linux curseur : {w}x{h} pixels verts={green}"); + + let out = std::env::var("OPENSCREEN_VK_OUT").unwrap_or_else(|_| "target".into()); + let _ = std::fs::create_dir_all(&out); + let ppm = format!("{out}/compose_linux_cursor.ppm"); + { + use std::io::Write; + let mut f = std::fs::File::create(&ppm).expect("create ppm"); + write!(f, "P6\n{w} {h}\n255\n").unwrap(); + let mut rgb = vec![0u8; (w * h * 3) as usize]; + for (d, s) in rgb.chunks_exact_mut(3).zip(rgba.chunks_exact(4)) { + d.copy_from_slice(&s[0..3]); + } + f.write_all(&rgb).unwrap(); + } + println!("wrote {ppm}"); + + assert!(green > 50, "sprite curseur vert absent (verts={green}) — mode 7 ?"); +} + +/// Fond image (mode 6 wallpaper) : un PNG orange en data URI remplit le fond +/// (cover-fit) autour de l'ecran inset (padding). Distinct de l'ecran et du +/// gris par defaut, pour l'affirmer sans ambiguite. +#[test] +fn compose_linux_fond_image() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux fond image: opt-in. Skip."); + return; + } + const BG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAKklEQVR4nO3NwQ0AAAQAMRJ721wswa83wDWn47X63QMAAAAAAAAAAIC7FhLfAfuIQEbyAAAAAElFTkSuQmCC"; + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let scene_json = format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.4,"blur":false,"shadow":0,"roundnessFrac":0.05,"motionBlur":0}},"background":{{"kind":"image","path":"{BG}"}},"zoomRegions":[],"annotations":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ); + let scene = Scene::from_json(&scene_json).expect("scene json"); + // Le padding (et les autres effets) transitent par les live_params, pas la + // scene brute -> sans ca l'ecran remplit tout le cadre et masque le fond. + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + + let (w, h, rgba) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let cfg = Cfg::c8(); + comp.compose_frame(sf, sf, 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + // Orange (255,128,0) : R haut, G moyen, B bas. + let orange = rgba + .chunks_exact(4) + .filter(|p| p[0] > 200 && p[1] > 90 && p[1] < 170 && p[2] < 70) + .count(); + println!("compose_linux fond image : {w}x{h} pixels orange={orange}"); + + let out = std::env::var("OPENSCREEN_VK_OUT").unwrap_or_else(|_| "target".into()); + let _ = std::fs::create_dir_all(&out); + { + use std::io::Write; + let mut f = std::fs::File::create(format!("{out}/compose_linux_bgimage.ppm")).expect("ppm"); + write!(f, "P6\n{w} {h}\n255\n").unwrap(); + let mut rgb = vec![0u8; (w * h * 3) as usize]; + for (d, s) in rgb.chunks_exact_mut(3).zip(rgba.chunks_exact(4)) { + d.copy_from_slice(&s[0..3]); + } + f.write_all(&rgb).unwrap(); + } + assert!(orange > 2000, "fond image absent (orange={orange}) — mode 6 ?"); +} + +/// Flou de mouvement par VELOCITE du calque ecran (mode 0 du shader). +/// +/// La velocite vient d'un zoom en pleine rampe : `plan_frame` calcule alors un +/// `s_dst_prev` different de `s_dst`, et le shader floute chaque pixel le long +/// du segment qui relie son UV d'avant a son UV d'aujourd'hui. +/// +/// La MEME frame decodee est composee deux fois, seul `effects.motionBlur` +/// change — toute difference mesuree ne peut donc venir que de l'effet. Deux +/// assertions, parce que « les deux images different » ne dirait pas dans quel +/// SENS : on verifie aussi que la version floutee a moins de detail haute +/// frequence. Un cablage errone de `src_prev`/`dst_prev` ferait bien differer +/// les images, mais pas forcement dans ce sens-la. +#[test] +fn compose_linux_flou_de_velocite_ecran() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux flou de velocite: opt-in. Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + // La region de zoom demarre a 2 s ; sa rampe d'entree commence ~1 s plus tot + // (`ZOOM_IN_TRANSITION_WINDOW_S`). A t = 66/60 = 1,1 s on est donc en pleine + // montee : `plan_frame` y donne s_dst 1,629 contre s_dst_prev 1,559, soit + // 4,5 % d'echelle en une frame — largement de quoi etaler le calque. + let scene_of = |mblur: f32| { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":{mblur}}},"background":{{"kind":"color","color":"#101015"}},"zoomRegions":[{{"id":"z1","startSec":2,"endSec":4,"scale":2.5,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":null}}],"annotations":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + }; + + // UN SEUL `seek_to` : les deux rendus partagent la meme AVFrame, donc le + // decodeur ne peut pas introduire de difference qu'on prendrait pour l'effet. + let (sharp, blurred) = unsafe { + let sf = dec.seek_to(1.1).expect("Decoder::seek_to"); + let cfg = Cfg::c8(); + let render = |mblur: f32| { + comp.set_scene(Some(Scene::from_json(&scene_of(mblur)).expect("scene json"))); + comp.compose_frame(sf, sf, 66.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + }; + (render(0.0), render(1.0)) + }; + + write_ppm("compose_linux_mb_screen_off", W, H, &sharp); + write_ppm("compose_linux_mb_screen_on", W, H, &blurred); + + let mut diff_sum = 0u64; + for (a, b) in sharp.chunks_exact(4).zip(blurred.chunks_exact(4)) { + for c in 0..3 { + diff_sum += (a[c] as i32 - b[c] as i32).unsigned_abs() as u64; + } + } + let mean_diff = diff_sum as f32 / (W * H * 3) as f32; + + // Detail haute frequence : somme des gradients voisins sur le canal vert. + let sharpness = |img: &[u8]| -> f32 { + let g = |x: u32, y: u32| img[((y * W + x) * 4 + 1) as usize] as i32; + let mut acc = 0u64; + for y in 0..H - 1 { + for x in 0..W - 1 { + acc += (g(x + 1, y) - g(x, y)).unsigned_abs() as u64; + acc += (g(x, y + 1) - g(x, y)).unsigned_abs() as u64; + } + } + acc as f32 / ((W - 1) * (H - 1) * 2) as f32 + }; + let (s_sharp, s_blur) = (sharpness(&sharp), sharpness(&blurred)); + println!( + "compose_linux flou de velocite : mean_diff={mean_diff:.2} gradient net={s_sharp:.2} floute={s_blur:.2}" + ); + + // Mesure observee : mean_diff 12,5 et gradient 7,9 -> 3,0. Les seuils gardent + // de la marge tout en restant loin du « ca a bouge d'un poil ». + assert!( + mean_diff > 4.0, + "motionBlur 0 vs 1 rend (quasi) la MEME image (mean_diff={mean_diff:.3}) — mb/src_prev/dst_prev non cables ?" + ); + assert!( + s_blur < s_sharp * 0.7, + "le rendu floute n'est pas plus doux (gradient {s_blur:.2} vs {s_sharp:.2}) — le flou ne suit pas la velocite" + ); +} + +/// Meme flou de velocite, mais sur le calque CAMERA — un draw distinct, avec son +/// propre `src_prev` (qui doit suivre le cover-crop et le miroir) et son propre +/// `dst_prev`. +/// +/// La velocite vient d'une region « Full Camera » en pleine ouverture : la boite +/// camera passe de 0,526 a 0,579 de large en une frame pendant que `s_dst` ne +/// bouge PAS d'un pouce. C'est ce qui rend le test concluant — une difference +/// mesuree dans la boite camera ne peut pas venir du calque ecran, et +/// l'assertion sur le coin haut-gauche (hors boite) le verifie explicitement. +#[test] +fn compose_linux_flou_de_velocite_camera() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux flou de velocite camera: opt-in. Skip."); + return; + } + let webcam_fixture = "../fixture/webcam.mp4"; + if !Path::new(webcam_fixture).is_file() { + eprintln!("compose_linux flou de velocite camera: pas de fixture webcam. Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open screen"); + let mut cam = Decoder::open(webcam_fixture, &gpu).expect("Decoder::open webcam"); + + // `webcamMirror: true` : le miroir inverse les bornes u de `src`, et + // `src_prev` doit inverser les MEMES. S'il gardait l'ancien [0,0,1,1] la + // reprojection viserait une zone de texture jamais affichee. + let scene_of = |mblur: f32| { + format!( + r##"{{"clips":[],"layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":true,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":{mblur}}},"background":{{"kind":"color","color":"#101015"}},"zoomRegions":[],"cameraFullscreenRegions":[{{"startSec":2,"endSec":4}}],"annotations":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + }; + + let (sharp, blurred) = unsafe { + let sf = screen.seek_to(2.1).expect("seek screen"); + let wf = cam.seek_to(2.1).expect("seek webcam"); + let cfg = Cfg::c8(); + let render = |mblur: f32| { + let scene = Scene::from_json(&scene_of(mblur)).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + // frame 126 = t 2,1 s : la camera est a mi-ouverture. + comp.compose_frame(sf, wf, 126.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + }; + (render(0.0), render(1.0)) + }; + + write_ppm("compose_linux_mb_camera_off", W, H, &sharp); + write_ppm("compose_linux_mb_camera_on", W, H, &blurred); + + let mean_diff = |x0: u32, x1: u32, y0: u32, y1: u32| -> f32 { + let mut sum = 0u64; + for y in y0..y1 { + for x in x0..x1 { + let i = ((y * W + x) * 4) as usize; + for c in 0..3 { + sum += (sharp[i + c] as i32 - blurred[i + c] as i32).unsigned_abs() as u64; + } + } + } + sum as f32 / ((x1 - x0) * (y1 - y0) * 3) as f32 + }; + // Boite camera a t = 2,1 s : [0,411 ; 0,403 ; 0,579 ; 0,579] de la sortie, + // soit x 394..950 et y 217..530 en pixels — retrecie ici pour rester loin des + // bords adoucis. Le coin haut-gauche, lui, ne montre que l'ecran. + let inside = mean_diff(420, 920, 245, 505); + let outside = mean_diff(0, 300, 0, 150); + println!("compose_linux flou de velocite camera : dans la boite={inside:.2} hors boite={outside:.4}"); + + assert!( + inside > 4.0, + "la camera n'est pas floutee (diff={inside:.3}) — src_prev/dst_prev du calque webcam non cables ?" + ); + assert!( + outside < 0.01, + "l'ecran a bouge aussi (diff={outside:.3}) — le test ne prouve alors rien sur la camera" + ); +} + +/// Trainee fantome du curseur (accumulation temporelle, pas un mode de shader). +/// +/// Le curseur traverse le cadre ; a `cursor.motionBlur = 1` `plan_cursor` rend +/// 11 taps entre sa position d'il y a 8 frames (8/60 s) et sa position courante. +/// On compare au meme rendu sans trainee : la seule difference possible etant le +/// curseur, un exces de vert la ou le curseur N'EST PAS (mais est PASSE) est la +/// signature de la trainee. +/// +/// « Exces de vert » = G - max(R,B), pas G brut : une copie a 1/taps d'opacite +/// sur un fond CLAIR fait a peine monter le vert (le fond y est deja) mais fait +/// nettement chuter le rouge et le bleu. Mesurer G seul rendrait le test +/// dependant de ce qui passe sous le curseur dans la video. +/// +/// Le trajet est volontairement hors de l'axe median (cy = 0,28) : une passe de +/// composition qui retournerait `accum` verticalement enverrait la trainee dans +/// la bande miroir, ce que la seconde assertion interdit. A cy = 0,5 le defaut +/// serait invisible. +#[test] +fn compose_linux_trainee_de_curseur() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux trainee curseur: opt-in. Skip."); + return; + } + const SPRITE: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAGElEQVR4nGNk+MdAEmAhTfmohlENQ0kDAGoRATwbkCdPAAAAAElFTkSuQmCC"; + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + // Piste : deplacement horizontal regulier cx 0,1 -> 0,9 en 0,4 s, a cy fixe. + // Assez rapide pour que les 8/60 s de recul de la trainee separent nettement + // les deux extremites (~256 px a 960 de large) : sans quoi la trainee se + // superpose au curseur lui-meme et on ne pourrait plus les distinguer. + let mut samples = String::new(); + for k in 0..=8 { + let (ms, cx) = (k * 50, 0.1 + 0.1 * k as f32); + if k > 0 { + samples.push(','); + } + samples.push_str(&format!( + r#"{{"timeMs":{ms},"cx":{cx},"cy":0.28,"cursorType":"arrow"}}"# + )); + } + let track_path = std::env::temp_dir().join("os_cursor_trail_track.json"); + std::fs::write(&track_path, format!(r#"{{"samples":[{samples}]}}"#)).expect("write track"); + let track = CursorTrack::load(track_path.to_str().unwrap(), 0.0, 2.0).expect("CursorTrack::load"); + comp.set_cursor(track); + comp.set_cursor_time(Some(0.35)); + + let scene_of = |mblur: f32| { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#101015"}},"zoomRegions":[],"annotations":[],"cursor":{{"show":true,"size":3,"smoothing":0,"motionBlur":{mblur},"clickBounce":0,"clipToBounds":false,"theme":"default","cursorSprites":{{"arrow":{{"path":"{SPRITE}","hotspotX":0.5,"hotspotY":0.5}}}}}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + }; + + let (sharp, trail) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let cfg = Cfg::c8(); + let render = |mblur: f32| { + let scene = Scene::from_json(&scene_of(mblur)).expect("scene json"); + // `cursor.motionBlur` ET `cursor.size` transitent par les LiveParams, + // pas par la scene brute : sans ca `plan_cursor` verrait toujours 0. + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.compose_frame(sf, sf, 15.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + }; + (render(0.0), render(1.0)) + }; + + write_ppm("compose_linux_cursor_trail_off", W, H, &sharp); + write_ppm("compose_linux_cursor_trail_on", W, H, &trail); + + // A t = 0,35 s le curseur est en cx 0,8 (x ~ 768 px) et 8/60 s plus tot en + // cx ~ 0,533 (x ~ 512 px) ; le sprite fait 51 px de cote a size 3 (34/1080 + // de frame_min_px, x3), donc le curseur COURANT occupe x = 742..794. La + // fenetre ci-dessous couvre le milieu du trajet, franchement a sa gauche : + // sans trainee il n'y a rien du tout. La bande miroir est son reflet par + // rapport a l'axe horizontal de l'image (cy = 0,28 est hors de cet axe + // exprès), donc un `accum` composite a l'envers y atterrirait. + let greener = |x0: u32, x1: u32, y0: u32, y1: u32| -> usize { + let excess = |img: &[u8], i: usize| { + img[i + 1] as i32 - (img[i] as i32).max(img[i + 2] as i32) + }; + let mut n = 0; + for y in y0..y1 { + for x in x0..x1 { + let i = ((y * W + x) * 4) as usize; + if excess(&trail, i) - excess(&sharp, i) > 10 { + n += 1; + } + } + } + n + }; + let on_path = greener(530, 700, 130, 172); + let mirrored = greener(530, 700, 368, 410); + println!("compose_linux trainee curseur : sur le trajet={on_path} bande miroir={mirrored}"); + + // La fenetre fait 170x42 = 7140 px et la trainee la remplit entierement. + // Le seuil a 4000 laisse de la marge tout en refusant une trainee qui ne + // couvrirait qu'un bout du trajet. + assert!( + on_path > 4000, + "pas de trainee au milieu du trajet ({on_path} px plus verts) — le curseur n'est dessine qu'a sa position courante" + ); + assert!( + mirrored < 50, + "trainee dans la bande MIROIR ({mirrored} px) — la passe de composition d'accum retourne l'image en Y" + ); +} + +/// Export (WP6) : ~1s de la fixture -> MP4 H264 software. Verifie que la marche +/// de timeline + l'encodeur + le muxer produisent un fichier non trivial. Le +/// contenu est re-validable par ffprobe (cf. la commande dans le run manuel). +#[test] +fn export_linux_mp4() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("export_linux: opt-in (OPENSCREEN_LINUX_COMPOSE=1 + fixture). Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + // Petite sortie : l'export est un smoke test, pas un bench. + let comp = Compositor::new_sized(&gpu, 640, 360).expect("Compositor::new_sized"); + + let out = std::env::var("OPENSCREEN_EXPORT_OUT") + .unwrap_or_else(|_| std::env::temp_dir().join("os_export_linux.mp4").to_string_lossy().into()); + let clips = vec![ClipSource { + screen: FIXTURE.to_string(), + webcam: FIXTURE.to_string(), + source_start_sec: 0.0, + source_end_sec: 1.0, + webcam_offset_sec: 0.0, + has_audio: true, + }]; + let params = ExportParams { + width: 640, + height: 360, + fps: Some(30), + codec: ExportCodec::H264, + }; + + let mut last = 0u64; + let stats = run_composited_multi( + &clips, + &out, + &gpu, + &comp, + &Cfg::c8(), + ¶ms, + &mut |n| last = n, + ) + .expect("run_composited_multi"); + println!( + "export_linux : {} frames, {:.1} fps encode, {:.2}s video, progress={last} -> {out}", + stats.frames, stats.fps, stats.video_duration_s + ); + + assert!(stats.frames > 0, "aucune frame exportee"); + let meta = std::fs::metadata(&out).expect("mp4 metadata"); + assert!(meta.len() > 2000, "mp4 trop petit ({} octets) — muxer ?", meta.len()); +} + +/// Rend une frame qui exerce EN MEME TEMPS les trois corrections de cette +/// serie : ombre portee (ecran + camera), cover-crop de la webcam sous un +/// masque CERCLE (le cas ou l'etirement etait le plus violent : la boite est +/// forcee carree, donc une camera 16:9 s'ecrasait de 1,78x), et une annotation +/// texte avec un fond. +/// +/// Opt-in comme les autres tests de ce fichier ; ecrit un PPM a inspecter. +#[test] +fn compose_linux_ombre_webcam_ronde_et_texte() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux ombre/webcam/texte: opt-in. Skip."); + return; + } + let webcam_fixture = "../fixture/webcam.mp4"; + if !Path::new(webcam_fixture).is_file() { + eprintln!("compose_linux ombre/webcam/texte: pas de fixture webcam. Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open screen"); + let mut cam = Decoder::open(webcam_fixture, &gpu).expect("Decoder::open webcam"); + + // `shadow: 1` + camera en cercle + une annotation texte visible a t=1s. + let scene_json = r##"{"clips":[],"layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"circle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0.14,"blur":false,"shadow":1,"roundnessFrac":0.04,"motionBlur":0},"background":{"kind":"gradient","angleDeg":45,"stops":["#1f2933","#3b6bff"]},"zoomRegions":[],"annotations":[{"id":"a1","kind":"text","x":0.08,"y":0.08,"w":0.5,"h":0.14,"startSec":0,"endSec":10,"zIndex":1,"text":{"content":"Ombre + fond","color":"#ffffff","backgroundColor":"#e0245e","fontSizeRel":0.09,"fontFamily":"","fontWeight":"normal","fontStyle":"normal","textDecoration":"none","textAlign":"center"}}],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1920,"height":1080,"fps":30}}"##; + let parsed = Scene::from_json(scene_json).expect("scene json"); + // Cf. le commentaire dans compose_linux_forme_webcam_cercle : sans les + // LiveParams, la scene est parsee et ignoree. + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + + let (w, h, rgba) = unsafe { + let sf = screen.seek_to(1.0).expect("seek screen"); + let wf = cam.seek_to(1.0).expect("seek webcam"); + let mut cfg = Cfg::c8(); + cfg.shadow = true; + comp.compose_frame(sf, wf, 1.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + + let out = std::env::var("OPENSCREEN_VK_OUT").unwrap_or_else(|_| "target".into()); + let _ = std::fs::create_dir_all(&out); + let ppm = format!("{out}/compose_linux_shadow_webcam_text.ppm"); + { + use std::io::Write; + let mut f = std::fs::File::create(&ppm).expect("create ppm"); + write!(f, "P6\n{w} {h}\n255\n").unwrap(); + let mut rgb = vec![0u8; (w * h * 3) as usize]; + for (d, s) in rgb.chunks_exact_mut(3).zip(rgba.chunks_exact(4)) { + d.copy_from_slice(&s[0..3]); + } + f.write_all(&rgb).unwrap(); + } + println!("wrote {ppm}"); + + // L'annotation a un fond ROSE (#e0245e) : il doit exister des pixels + // nettement rouges-magenta dans le quart haut-gauche, ce qui n'etait pas le + // cas quand la plaque n'etait pas dessinee du tout. + let mut plate_px = 0usize; + for y in 0..(h / 3) { + for x in 0..(w / 2) { + let i = ((y * w + x) * 4) as usize; + let (r, g_, b) = (rgba[i] as i32, rgba[i + 1] as i32, rgba[i + 2] as i32); + if r > 140 && g_ < 90 && b > 40 && b < 140 { + plate_px += 1; + } + } + } + assert!( + plate_px > 200, + "fond d'annotation introuvable ({plate_px} px roses) — la plaque n'est pas dessinee" + ); +} + +/// Forme de la webcam : `rectangle` contre `circle`. +/// +/// Le masque n'est pas un mode de shader dedie — il sort de `radius_px`, que +/// `plan_frame` met a la moitie du cote pour `circle`. Ce test le MESURE au +/// lieu de le supposer. +/// +/// La methode : rendre trois fois la meme scene — sans camera, camera +/// rectangle, camera cercle — et diffe chacune des deux dernieres contre la +/// premiere. Le diff EST l'empreinte de la camera, masque compris, sans avoir +/// a deviner ou `plan_frame` l'a posee ni a distinguer la camera du fond. Un +/// disque remplit pi/4 ~= 0,785 de sa boite englobante, un rectangle la +/// remplit entierement : le taux de remplissage separe les deux sans ambiguite. +#[test] +fn compose_linux_forme_webcam_cercle() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux forme webcam: opt-in. Skip."); + return; + } + let webcam_fixture = "../fixture/webcam.mp4"; + if !Path::new(webcam_fixture).is_file() { + eprintln!("compose_linux forme webcam: pas de fixture webcam. Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open screen"); + let mut cam = Decoder::open(webcam_fixture, &gpu).expect("Decoder::open webcam"); + + let scene = |preset: &str, shape: &str| { + format!( + r##"{{"clips":[],"layout":{{"preset":"{preset}","webcamSize":1.6,"webcamShape":"{shape}","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.1,"blur":false,"shadow":0,"roundnessFrac":0.0,"motionBlur":0}},"background":{{"kind":"color","color":"#00ff00"}},"zoomRegions":[],"annotations":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + }; + + let mut render = |preset: &str, shape: &str| -> Vec { + let parsed = Scene::from_json(&scene(preset, shape)).expect("scene json"); + // OBLIGATOIRE. `compose_frame` lit les LiveParams, PAS la scene brute : + // la forme webcam, le padding et les effets y transitent. Sans cette + // ligne la scene est parsee mais ignoree, et le test mesure la forme par + // defaut ("rounded") en croyant mesurer celle qu'il a demandee. + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek screen"); + let wf = cam.seek_to(1.0).expect("seek webcam"); + let mut cfg = Cfg::c8(); + cfg.shadow = false; + comp.compose_frame(sf, wf, 1.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + + let none = render("no-webcam", "rectangle"); + let rect = render("picture-in-picture", "rectangle"); + let circle = render("picture-in-picture", "circle"); + write_ppm("compose_linux_webcam_rect", W, H, &rect); + write_ppm("compose_linux_webcam_circle", W, H, &circle); + + // Empreinte = pixels qui changent quand la camera apparait. + let footprint = |with: &[u8]| -> Vec { + with.chunks_exact(4) + .zip(none.chunks_exact(4)) + .map(|(a, b)| { + (a[0] as i32 - b[0] as i32).abs() + + (a[1] as i32 - b[1] as i32).abs() + + (a[2] as i32 - b[2] as i32).abs() + > 24 + }) + .collect() + }; + // Taux de remplissage de la boite englobante de l'empreinte. + let fill = |mask: &[bool], label: &str| -> f32 { + let (mut x0, mut y0, mut x1, mut y1) = (W, H, 0u32, 0u32); + let mut n = 0u32; + for y in 0..H { + for x in 0..W { + if mask[(y * W + x) as usize] { + x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); n += 1; + } + } + } + assert!(x1 > x0 && y1 > y0, "{label} : aucune empreinte de camera"); + let (bw, bh) = (x1 - x0 + 1, y1 - y0 + 1); + let ar = bw as f32 / bh as f32; + let f = n as f32 / (bw * bh) as f32; + println!("{label} : boite {bw}x{bh} (AR {ar:.3}), {n} px, remplissage {f:.3}"); + f + }; + + let rect_fill = fill(&footprint(&rect), "rectangle"); + let circle_fill = fill(&footprint(&circle), "cercle"); + + assert!(rect_fill > 0.95, "le rectangle devrait remplir sa boite ({rect_fill:.3})"); + // pi/4 = 0,785 ; on tolere l'antialiasing du SDF sur le pourtour. + assert!( + (circle_fill - 0.785).abs() < 0.06, + "le masque cercle ne rogne pas comme un disque (remplissage {circle_fill:.3}, attendu ~0.785)" + ); +} + +/// Un enregistrement SANS camera ne doit rien dessiner dans la boite PiP. +/// +/// Le cas est reproduit tel quel : le decodeur « webcam » recoit la frame de +/// l'ECRAN, ce que `open_and_seek_clip` fait en production des que le chemin +/// webcam est vide ou illisible. Seul `LiveParams::has_webcam` distingue alors +/// une vraie camera d'une seconde copie de l'ecran, et ce backend ne le lisait +/// pas : l'enregistrement d'ecran apparaissait dans sa propre vignette. +/// +/// L'assertion est une egalite stricte avec le rendu « no-webcam » : pas un +/// seuil, parce qu'il ne s'agit pas de mesurer une empreinte plus petite mais +/// de verifier qu'il n'y en a aucune. +#[test] +fn compose_linux_sans_camera_ne_dessine_pas_de_vignette() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux sans camera: opt-in. Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open screen"); + + let scene_json = r##"{"clips":[],"layout":{"preset":"picture-in-picture","webcamSize":1.6,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0.1,"blur":false,"shadow":0,"roundnessFrac":0.0,"motionBlur":0},"background":{"kind":"color","color":"#00ff00"},"zoomRegions":[],"annotations":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1920,"height":1080,"fps":30}}"##; + + let mut render = |has_webcam: bool| -> Vec { + let parsed = Scene::from_json(scene_json).expect("scene json"); + let mut lp = openscreen_compositor::compositor::live_params_from_scene(&parsed); + lp.has_webcam = has_webcam; + comp.set_live_params(lp); + comp.set_scene(Some(parsed)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek screen"); + let mut cfg = Cfg::c8(); + cfg.shadow = false; + // La frame ecran passee AUSSI comme webcam : le repli exact de + // `open_and_seek_clip` quand il n'y a pas de fichier camera. + comp.compose_frame(sf, sf, 1.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + + let with_camera = render(true); + let without_camera = render(false); + write_ppm("compose_linux_sans_camera", W, H, &without_camera); + + let differing = with_camera + .chunks_exact(4) + .zip(without_camera.chunks_exact(4)) + .filter(|(a, b)| { + (a[0] as i32 - b[0] as i32).abs() + + (a[1] as i32 - b[1] as i32).abs() + + (a[2] as i32 - b[2] as i32).abs() + > 24 + }) + .count(); + println!("vignette ecran-dans-la-camera : {differing} px"); + assert!( + differing > 1000, + "le rendu de controle ne dessine aucune vignette — le test ne prouve rien ({differing} px)" + ); + + // Reference : le preset qui ne veut pas de camera du tout. Il pose le meme + // rectangle d'ecran (`plan_frame` : « no-webcam » et « picture-in-picture » + // partagent `full_screen`), donc seule la vignette peut les separer. + let no_webcam_preset = { + let parsed = Scene::from_json(&scene_json.replace( + r#""preset":"picture-in-picture""#, + r#""preset":"no-webcam""#, + )) + .expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek screen"); + let mut cfg = Cfg::c8(); + cfg.shadow = false; + comp.compose_frame(sf, sf, 1.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + let residual = without_camera + .chunks_exact(4) + .zip(no_webcam_preset.chunks_exact(4)) + .filter(|(a, b)| { + (a[0] as i32 - b[0] as i32).abs() + + (a[1] as i32 - b[1] as i32).abs() + + (a[2] as i32 - b[2] as i32).abs() + > 24 + }) + .count(); + assert_eq!( + residual, 0, + "sans camera, le rendu devrait etre celui du preset no-webcam ({residual} px d'ecart)" + ); +} + +// --------------------------------------------------------------------------- +// Annotations : figure (fleche), flou/mosaique, image, et animations du texte. +// +// Ces quatre familles existaient dans le schema et arrivaient jusqu'au +// compositeur, mais le chemin Linux ne dessinait QUE le texte -- tout le reste +// etait ignore en silence. Les tests ci-dessous les MESURENT sur le GPU au lieu +// de supposer qu'un draw ajoute suffit : la methode est toujours la meme, rendre +// deux fois la meme scene (avec et sans l'annotation) et lire l'empreinte dans +// le diff. Elle ne demande de connaitre ni ou `plan_frame` a pose l'ecran, ni +// quelle couleur la video porte a cet endroit. +// --------------------------------------------------------------------------- + +/// Scene de base des tests d'annotation : fond uni, pas d'effets, une liste +/// d'annotations injectee telle quelle. +fn annotation_scene(annotations: &str) -> String { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.1,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#00ff00"}},"zoomRegions":[],"annotations":[{annotations}],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) +} + +/// Indices des pixels qui different entre deux rendus. C'est l'empreinte exacte +/// de ce que l'annotation a ajoute. +fn changed_pixels(a: &[u8], b: &[u8]) -> Vec { + a.chunks_exact(4) + .zip(b.chunks_exact(4)) + .enumerate() + .filter(|(_, (p, q))| { + // Seuil 6/255 : au-dessus du bruit de quantification du YUV->RGB, + // bien en-dessous de tout trait ou masque reel. + (0..3).any(|c| (p[c] as i32 - q[c] as i32).abs() > 6) + }) + .map(|(i, _)| i) + .collect() +} + +/// Boite englobante (x0, y0, x1, y1) inclusive d'une liste d'indices de pixels. +fn bbox(px: &[usize], w: u32) -> (u32, u32, u32, u32) { + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32); + for &i in px { + let (x, y) = (i as u32 % w, i as u32 / w); + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + (x0, y0, x1, y1) +} + +/// Une fleche est un TRACE, pas un aplat — et elle suit sa direction. +/// +/// Deux pieges que ce test ferme. Le premier : ne rien dessiner du tout, ce que +/// faisait le chemin Linux. Le second, plus sournois : dessiner le quad entier +/// (un mode inconnu tombe sur la branche « ombre » du shader et remplit la +/// boite), ce qui se voit comme un rectangle colore et non comme une fleche. +/// L'aire couverte les separe : trois segments d'epaisseur fixe ne peuvent pas +/// remplir la moitie de leur boite. +#[test] +fn compose_linux_annotation_fleche() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux annotation fleche: opt-in. Skip."); + return; + } + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let mut render = |annotations: &str| -> Vec { + let parsed = Scene::from_json(&annotation_scene(annotations)).expect("scene json"); + // Cf. compose_linux_forme_webcam_cercle : sans les LiveParams la scene + // est parsee puis ignoree, et le test mesure les valeurs par defaut. + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + // Le 3e argument de `compose_frame` est un NUMERO DE FRAME (source_t = + // frame / 60), pas des secondes. `set_timeline_time` fixe directement + // l'instant que lit la fenetre temporelle des annotations -- sans lui, + // une annotation a `startSec: 1` ne serait tout simplement pas visible, + // et un test d'animation mesurerait sa propre erreur de cadrage. + comp.set_timeline_time(Some(1.0)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek"); + comp.compose_frame(sf, std::ptr::null(), 60.0, &Cfg::c8()).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + + let figure = |direction: &str| { + format!( + r##"{{"id":"f1","kind":"figure","x":0.2,"y":0.2,"w":0.4,"h":0.4,"startSec":0,"endSec":10,"zIndex":1,"figure":{{"direction":"{direction}","color":"#ff0000","strokeWidth":8}}}}"## + ) + }; + let none = render(""); + let right = render(&figure("right")); + let up = render(&figure("up")); + + let right_px = changed_pixels(&none, &right); + let up_px = changed_pixels(&none, &up); + assert!( + right_px.len() > 200, + "aucune fleche dessinee ({} px changes) — le mode 9 n'atteint pas le shader", + right_px.len() + ); + + // La boite fait 0.4 x 0.4 du rect ecran ; la fleche s'y inscrit en carre + // (preserveAspectRatio). Un trait de 8/100 d'epaisseur sur trois segments + // couvre nettement moins de la moitie de ce carre. + let (x0, y0, x1, y1) = bbox(&right_px, W); + let box_area = ((x1 - x0 + 1) * (y1 - y0 + 1)) as f64; + let fill = right_px.len() as f64 / box_area; + assert!( + fill < 0.5, + "la fleche remplit {fill:.2} de sa boite — c'est un aplat, pas un trace" + ); + + // La direction est vraiment lue : « right » et « up » sont deux tracés + // differents, donc leurs empreintes ne peuvent pas coincider. + let common = right_px + .iter() + .collect::>() + .intersection(&up_px.iter().collect::>()) + .count(); + let overlap = common as f64 / right_px.len().min(up_px.len()) as f64; + assert!( + overlap < 0.75, + "« right » et « up » se recouvrent a {overlap:.2} — la direction est ignoree" + ); +} + +/// Le flou floute vraiment, et la mosaique fait des blocs. +/// +/// Mesure sur l'ENERGIE HAUTE FREQUENCE (somme des ecarts entre voisins +/// horizontaux) dans la zone masquee. Compter des pixels changes ne suffirait +/// pas : un masque qui recopierait la frame telle quelle changerait aussi des +/// pixels au bord et passerait. Ce qu'on veut prouver, c'est que le detail a +/// DISPARU — c'est la seule propriete qui rend l'annotation utile. +#[test] +fn compose_linux_annotation_flou_et_mosaique() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux annotation flou: opt-in. Skip."); + return; + } + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let mut render = |annotations: &str| -> Vec { + let parsed = Scene::from_json(&annotation_scene(annotations)).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + // Le 3e argument de `compose_frame` est un NUMERO DE FRAME (source_t = + // frame / 60), pas des secondes. `set_timeline_time` fixe directement + // l'instant que lit la fenetre temporelle des annotations -- sans lui, + // une annotation a `startSec: 1` ne serait tout simplement pas visible, + // et un test d'animation mesurerait sa propre erreur de cadrage. + comp.set_timeline_time(Some(1.0)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek"); + comp.compose_frame(sf, std::ptr::null(), 60.0, &Cfg::c8()).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + + // Boite bien a l'interieur du rect ecran, sur de la video (pas sur le fond). + let blur_ann = |style: &str, amount: f32| { + format!( + r##"{{"id":"b1","kind":"blur","x":0.25,"y":0.25,"w":0.5,"h":0.5,"startSec":0,"endSec":10,"zIndex":1,"blur":{{"style":"{style}","shape":"rectangle","color":"white","intensity":{amount},"blockSize":{amount}}}}}"## + ) + }; + let none = render(""); + let blurred = render(&blur_ann("blur", 24.0)); + let mosaic = render(&blur_ann("mosaic", 16.0)); + + let changed = changed_pixels(&none, &blurred); + assert!( + changed.len() > 2000, + "le flou n'a rien change ({} px) — le mode 10 n'atteint pas le shader", + changed.len() + ); + let (x0, y0, x1, y1) = bbox(&changed, W); + + // Energie haute frequence sur le canal vert, a l'interieur de la zone, en + // s'ecartant du bord (les 2 px de bord melangent masque et image nette). + let hf = |px: &[u8]| -> f64 { + let mut sum = 0f64; + let mut n = 0usize; + for y in (y0 + 2)..=(y1 - 2) { + for x in (x0 + 2)..(x1 - 2) { + let i = ((y * W + x) * 4) as usize; + let j = i + 4; + sum += (px[i + 1] as i32 - px[j + 1] as i32).abs() as f64; + n += 1; + } + } + sum / n.max(1) as f64 + }; + let sharp_hf = hf(&none); + let blur_hf = hf(&blurred); + assert!( + sharp_hf > 1.0, + "la fixture est trop plate a cet endroit ({sharp_hf:.2}) pour mesurer un flou" + ); + assert!( + blur_hf < sharp_hf * 0.5, + "detail toujours present sous le flou : {blur_hf:.2} contre {sharp_hf:.2} sans masque" + ); + + // Mosaique : a l'interieur d'un bloc les pixels sont IDENTIQUES, donc la + // proportion de voisins strictement egaux explose par rapport a l'image + // nette. C'est la signature d'un aplat par blocs, qu'un simple flou n'a pas. + let flat_ratio = |px: &[u8]| -> f64 { + let (mut eq, mut n) = (0usize, 0usize); + for y in (y0 + 2)..=(y1 - 2) { + for x in (x0 + 2)..(x1 - 2) { + let i = ((y * W + x) * 4) as usize; + let j = i + 4; + if px[i..i + 3] == px[j..j + 3] { + eq += 1; + } + n += 1; + } + } + eq as f64 / n.max(1) as f64 + }; + let sharp_flat = flat_ratio(&none); + let mosaic_flat = flat_ratio(&mosaic); + assert!( + mosaic_flat > sharp_flat + 0.3, + "pas de blocs : {mosaic_flat:.2} de voisins egaux contre {sharp_flat:.2} sans masque" + ); +} + +/// Une image d'annotation tient dans sa boite SANS etre etiree. +/// +/// C'est le meme defaut que celui corrige pour la webcam : coller la source au +/// rect deforme tout ce qui n'a pas exactement son rapport. On rend une image +/// 4:1 dans une boite qui ne l'est pas, et on mesure le rapport de l'empreinte +/// — il doit rester 4:1, quelle que soit la boite. +#[test] +fn compose_linux_annotation_image() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux annotation image: opt-in. Skip."); + return; + } + // Aplat magenta 400x100 : un rapport 4:1 franc, et une couleur que la + // fixture ne porte pas. + let img_path = std::env::temp_dir().join("openscreen-annotation-4x1.png"); + let img = image::RgbaImage::from_pixel(400, 100, image::Rgba([255, 0, 255, 255])); + img.save(&img_path).expect("ecrire le png de test"); + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let mut render = |annotations: &str| -> Vec { + let parsed = Scene::from_json(&annotation_scene(annotations)).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + // Le 3e argument de `compose_frame` est un NUMERO DE FRAME (source_t = + // frame / 60), pas des secondes. `set_timeline_time` fixe directement + // l'instant que lit la fenetre temporelle des annotations -- sans lui, + // une annotation a `startSec: 1` ne serait tout simplement pas visible, + // et un test d'animation mesurerait sa propre erreur de cadrage. + comp.set_timeline_time(Some(1.0)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek"); + comp.compose_frame(sf, std::ptr::null(), 60.0, &Cfg::c8()).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + + let none = render(""); + // Boite carree en fraction du rect ecran — donc PAS carree en pixels, et + // dans tous les cas pas 4:1. + let with_image = render(&format!( + r##"{{"id":"i1","kind":"image","x":0.25,"y":0.3,"w":0.4,"h":0.4,"startSec":0,"endSec":10,"zIndex":1,"imagePath":"{}"}}"##, + img_path.display() + )); + + let changed = changed_pixels(&none, &with_image); + assert!( + changed.len() > 500, + "aucune image dessinee ({} px changes)", + changed.len() + ); + let (x0, y0, x1, y1) = bbox(&changed, W); + let (bw, bh) = ((x1 - x0 + 1) as f64, (y1 - y0 + 1) as f64); + let aspect = bw / bh; + assert!( + (aspect - 4.0).abs() < 0.25, + "image etiree : empreinte {bw}x{bh} (rapport {aspect:.2}), attendu 4:1" + ); + + // Et c'est bien l'image qui est peinte, pas un aplat de la couleur du bord : + // le centre doit etre magenta. + let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2); + let i = ((cy * W + cx) * 4) as usize; + let (r, g_, b) = (with_image[i] as i32, with_image[i + 1] as i32, with_image[i + 2] as i32); + assert!( + r > 200 && g_ < 80 && b > 200, + "centre de l'empreinte non magenta : ({r}, {g_}, {b})" + ); + let _ = std::fs::remove_file(&img_path); +} + +/// Les animations d'apparition du texte sont JOUEES. +/// +/// `text_anim.rs` etait porte, teste unitairement et transporte par la scene, +/// mais aucun appelant Linux ne l'invoquait : une annotation animee s'affichait +/// simplement d'un bloc. On rend la MEME frame video a deux instants differents +/// de l'animation en deplaçant `startSec` — le seul ecart possible entre les +/// deux rendus est donc l'animation elle-meme. +#[test] +fn compose_linux_animation_texte() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux animation texte: opt-in. Skip."); + return; + } + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let mut render = |annotations: &str| -> Vec { + let parsed = Scene::from_json(&annotation_scene(annotations)).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + // Le 3e argument de `compose_frame` est un NUMERO DE FRAME (source_t = + // frame / 60), pas des secondes. `set_timeline_time` fixe directement + // l'instant que lit la fenetre temporelle des annotations -- sans lui, + // une annotation a `startSec: 1` ne serait tout simplement pas visible, + // et un test d'animation mesurerait sa propre erreur de cadrage. + comp.set_timeline_time(Some(1.0)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek"); + comp.compose_frame(sf, std::ptr::null(), 60.0, &Cfg::c8()).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + + // `startSec` deplace l'instant DANS l'animation sans toucher la frame video : + // a t=1.0s, start=1.0 donne 0 ms ecoulees, start=0.0 en donne 1000 (fini). + let text = |animation: &str, start: f32| { + format!( + r##"{{"id":"t1","kind":"text","x":0.1,"y":0.1,"w":0.6,"h":0.2,"startSec":{start},"endSec":10,"zIndex":1,"text":{{"content":"Hello","color":"#ffffff","backgroundColor":"transparent","fontSizeRel":0.12,"fontFamily":"","fontWeight":"bold","fontStyle":"normal","textDecoration":"none","textAlign":"center","animation":"{animation}"}}}}"## + ) + }; + let none = render(""); + let settled = render(&text("fade", 0.0)); + let ink_settled = changed_pixels(&none, &settled).len(); + assert!( + ink_settled > 200, + "aucun texte rendu ({ink_settled} px) — le test ne mesure rien" + ); + + // Fondu a 0 ms : opacite 0, donc RIEN ne doit apparaitre. Sans l'animation + // le texte serait deja a pleine opacite et ce compte vaudrait `ink_settled`. + let starting = render(&text("fade", 1.0)); + let ink_starting = changed_pixels(&none, &starting).len(); + assert!( + ink_starting < ink_settled / 10, + "le fondu n'est pas applique : {ink_starting} px a 0 ms contre {ink_settled} px a la fin" + ); + + // Machine a ecrire a mi-course (350 ms sur 700) : la moitie gauche du bloc + // est revelee, donc l'empreinte est nettement plus etroite qu'a la fin. + let typed_full = render(&text("typewriter", 0.0)); + let typed_half = render(&text("typewriter", 0.65)); + let full_px = changed_pixels(&none, &typed_full); + let half_px = changed_pixels(&none, &typed_half); + assert!(!half_px.is_empty(), "la machine a ecrire n'a rien revele a mi-course"); + let full_right = bbox(&full_px, W).2; + let half_right = bbox(&half_px, W).2; + assert!( + half_right < full_right, + "le texte n'est pas revele progressivement : bord droit {half_right} a mi-course \ + contre {full_right} a la fin" + ); +} diff --git a/crates/compositor/tests/export_timing.rs b/crates/compositor/tests/export_timing.rs new file mode 100644 index 0000000000..ac67d07bbf --- /dev/null +++ b/crates/compositor/tests/export_timing.rs @@ -0,0 +1,210 @@ +//! Frame-timing regression net for the export paths. +//! +//! The bug class this exists to catch is silent: an exporter that advances its +//! decoder by one SOURCE frame per OUTPUT frame emits the right number of +//! frames while covering only `out_fps / source_fps` of the recording. Frame +//! count alone therefore proves nothing — the content has to be checked. +//! +//! So the fixture is a 4 s / 60 fps clip whose colour changes every second +//! (red → green → blue → white). A correct export spans all four colours; a +//! mis-advanced one stays red for its whole length. +//! +//! Needs a D3D11 GPU and the generated media, so it is opt-in: set +//! OPENSCREEN_TEST_MEDIA to a directory holding `screen_colors.mp4` and +//! `webcam_gray.mp4`. Without it every test here skips (no CI builds this +//! crate today — see the Rust-CI gap noted in the PR). +//! +//! Regenerate the media with the vendored ffmpeg: +//! for c in red green blue white; do ffmpeg -f lavfi \ +//! -i "color=c=$c:size=640x360:duration=1:rate=60" -c:v libopenh264 \ +//! -g 60 -pix_fmt yuv420p seg_$c.mp4; done +//! ffmpeg -f concat -safe 0 -i concat.txt -c copy screen_colors.mp4 +//! ffmpeg -f lavfi -i "color=c=gray:size=320x240:duration=4:rate=60" \ +//! -c:v libopenh264 -g 60 -pix_fmt yuv420p webcam_gray.mp4 + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::gif_export::{self, GifExportParams}; +use openscreen_compositor::pipeline::{self, ClipSource, ExportCodec, ExportParams}; +use std::path::PathBuf; + +const SOURCE_SEC: f64 = 4.0; + +/// Per-frame local colour tables, in order, from a GIF89a file. +/// +/// Enough of the format to walk block-to-block: extensions are skipped by +/// their sub-block chain, image descriptors yield their local table and then +/// their LZW data is skipped the same way. No LZW decode — the palette alone +/// says which colours a frame is made of, which is all the timing assertions +/// need. +fn gif_frame_palettes(bytes: &[u8]) -> Vec> { + fn table_len(packed: u8) -> usize { + if packed & 0x80 == 0 { + 0 + } else { + 3 * (1usize << ((packed & 0x07) + 1)) + } + } + /// Skips a `len,data…,0` sub-block chain, returning the position after it. + fn skip_sub_blocks(bytes: &[u8], mut p: usize) -> usize { + while p < bytes.len() && bytes[p] != 0 { + p += 1 + bytes[p] as usize; + } + p + 1 + } + + let mut palettes = Vec::new(); + let mut p = 6; // "GIF89a" + let packed = bytes[p + 4]; + p += 7 + table_len(packed); // logical screen descriptor + global table + + while p < bytes.len() { + match bytes[p] { + 0x21 => p = skip_sub_blocks(bytes, p + 2), // extension: 0x21, label, chain + 0x2C => { + let packed = bytes[p + 9]; + let start = p + 10; + let len = table_len(packed); + palettes.push( + bytes[start..start + len] + .chunks_exact(3) + .map(|c| [c[0], c[1], c[2]]) + .collect(), + ); + p = skip_sub_blocks(bytes, start + len + 1); // +1 = LZW min code size + } + _ => break, // 0x3B trailer, or done + } + } + palettes +} + +/// Mean `R - B` across a palette. The fixture's first second is pure red +/// (large positive) and its last is white (≈ 0), so this single number +/// separates "covered the timeline" from "stuck on frame 0". +fn redness(palette: &[[u8; 3]]) -> f64 { + if palette.is_empty() { + return 0.0; + } + palette + .iter() + .map(|c| c[0] as f64 - c[2] as f64) + .sum::() + / palette.len() as f64 +} + +fn media_dir() -> Option { + let dir = PathBuf::from(std::env::var("OPENSCREEN_TEST_MEDIA").ok()?); + dir.join("screen_colors.mp4").exists().then_some(dir) +} + +/// One clip covering the whole fixture. +fn whole_clip(dir: &PathBuf) -> ClipSource { + ClipSource { + screen: dir.join("screen_colors.mp4").to_string_lossy().into_owned(), + webcam: dir.join("webcam_gray.mp4").to_string_lossy().into_owned(), + source_start_sec: 0.0, + source_end_sec: SOURCE_SEC, + webcam_offset_sec: 0.0, + has_audio: false, + } +} + +/// MP4 at 30 fps over a 4 s source must emit 120 frames — i.e. the walk is +/// driven by OUTPUT time, not by "one source frame per output frame" (which +/// would still emit 120 frames but cover only 2 s of the recording; the GIF +/// test below is the one that catches the coverage half). +#[test] +fn mp4_export_frame_count_follows_output_fps() { + let Some(dir) = media_dir() else { + eprintln!("skipped: set OPENSCREEN_TEST_MEDIA"); + return; + }; + let gpu = Gpu::create(false).expect("gpu"); + let params = ExportParams { + width: 640, + height: 360, + fps: Some(30), + codec: ExportCodec::H264, + }; + let comp = Compositor::new_sized(&gpu, params.width, params.height).expect("compositor"); + let out = dir.join("out_timing.mp4"); + let stats = pipeline::run_composited_multi( + &[whole_clip(&dir)], + &out.to_string_lossy(), + &gpu, + &comp, + &Cfg::c8(), + ¶ms, + &mut |_| {}, + ) + .expect("mp4 export"); + + assert_eq!( + stats.frames, 120, + "4 s of source at 30 fps out must be 120 frames, got {}", + stats.frames + ); + let probed = pipeline::probe_frame_count(&out.to_string_lossy()).expect("probe"); + assert_eq!(probed, 120, "muxed file disagrees with the reported count"); +} + +/// The GIF must cover the WHOLE timeline, not just its first +/// `out_fps / source_fps` slice. +/// +/// This is the assertion frame count cannot make: an exporter that advances one +/// source frame per output frame still writes 48 frames for a 4 s / 12 fps +/// request — it just takes them all from the first 0.8 s, so every frame is red +/// and the GIF plays 5x slow. Comparing the first and last frame palettes +/// catches exactly that. +#[test] +fn gif_export_spans_the_whole_timeline() { + let Some(dir) = media_dir() else { + eprintln!("skipped: set OPENSCREEN_TEST_MEDIA"); + return; + }; + let out = dir.join("out_timing.gif"); + let params = GifExportParams { + width: Some(320), + height: Some(180), + fps: Some(12), + loop_count: None, + dither: false, + }; + let gpu = Gpu::create(false).expect("gpu"); + // Same contract as the MP4 path: the caller sizes the compositor to the output. + let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositor"); + let stats = gif_export::export_gif( + &[whole_clip(&dir)], + &out, + &gpu, + &comp, + &Cfg::c8(), + ¶ms, + &mut |_| {}, + ) + .expect("gif export"); + + assert_eq!( + stats.frames, 48, + "4 s at 12 fps must be 48 frames, got {}", + stats.frames + ); + + let bytes = std::fs::read(&out).expect("read gif"); + let palettes = gif_frame_palettes(&bytes); + assert_eq!(palettes.len(), 48, "GIF carries {} frames", palettes.len()); + + let first = redness(&palettes[0]); + let last = redness(&palettes[palettes.len() - 1]); + assert!( + first > 40.0, + "first frame should be red-dominated (redness {first:.1})" + ); + assert!( + last < first - 40.0, + "last frame still looks like the first — the export never advanced past \ + the opening red second (first {first:.1}, last {last:.1})" + ); +} diff --git a/crates/compositor/tests/output_geometry_golden.rs b/crates/compositor/tests/output_geometry_golden.rs new file mode 100644 index 0000000000..fca407d0df --- /dev/null +++ b/crates/compositor/tests/output_geometry_golden.rs @@ -0,0 +1,311 @@ +//! Golden pixel de la refonte « le RT est le cadre de sortie ». +//! +//! Le filet unitaire de `compositor::tests` verrouille la GÉOMÉTRIE (un carré +//! atterrit carré, un calque centré reste centré). Il ne peut rien dire des +//! PIXELS : or le contrat le plus fort de la refonte est « le 16:9 ne doit pas +//! bouger d'un pixel ». D'où ce golden, qui rend de vraies frames. +//! +//! Il est **piloté par l'environnement** et se saute proprement quand les +//! sources manquent — pas de fixture vidéo dans le dépôt, et la machine de CI +//! n'a pas forcément de GPU D3D11 : +//! +//! ```powershell +//! $env:OPENSCREEN_GOLDEN_SCREEN = "...\recording-.mp4" +//! $env:OPENSCREEN_GOLDEN_WEBCAM = "...\recording--webcam.webm" +//! cargo test --test output_geometry_golden -- --nocapture +//! ``` +//! +//! Mode d'emploi de la refonte : lancer AVANT la phase 1, garder la sortie, +//! relancer APRÈS, comparer. +//! - le hash du 16:9 doit être **identique** (c'est le contrat 4 : en 16:9 la +//! compensation est déjà l'identité, donc rien ne doit changer) ; +//! - le hash des autres formats CHANGE — c'est le but ; +//! - `grad_y` (énergie de gradient vertical) doit **monter** sur les formats +//! portrait : c'est la mesure du détail regagné, aujourd'hui perdu parce +//! que le canvas plafonne à 1080 lignes et que `blit_resized` agrandit. + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; +use openscreen_compositor::config; + +/// Instant fixe dans la source. Un seek explicite (`present_frame`) plutôt que +/// la lecture libre : le golden doit être reproductible à l'octet près. +const AT_SEC: f64 = 2.0; + +/// Mêmes formats que le filet unitaire, pour que les deux racontent la même +/// histoire. `native *` rappelle que « native » n'est borné par aucune liste. +const FORMATS: &[(&str, u32, u32)] = &[ + ("16-9", 1920, 1080), + ("9-16", 1080, 1920), + ("1-1", 1920, 1920), + ("4-5", 1536, 1920), + ("native-ultrawide", 3440, 1440), + ("4k-16-9", 3840, 2160), +]; + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// Énergie de gradient moyenne par axe (|Δluma| entre pixels adjacents). +/// Un agrandissement lisse les transitions et fait donc CHUTER cette valeur sur +/// l'axe agrandi — c'est exactement la perte que la phase 1 doit récupérer. +fn gradient_energy(rgba: &[u8], w: usize, h: usize) -> (f64, f64) { + let lum = |i: usize| { + 0.299 * rgba[i * 4] as f64 + 0.587 * rgba[i * 4 + 1] as f64 + 0.114 * rgba[i * 4 + 2] as f64 + }; + let (mut gx, mut gy) = (0.0, 0.0); + let (mut nx, mut ny) = (0usize, 0usize); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if x + 1 < w { + gx += (lum(i + 1) - lum(i)).abs(); + nx += 1; + } + if y + 1 < h { + gy += (lum(i + w) - lum(i)).abs(); + ny += 1; + } + } + } + (gx / nx.max(1) as f64, gy / ny.max(1) as f64) +} + +/// PPM P6 — pas de dépendance à encoder, et ça s'ouvre dans n'importe quel +/// visionneur. Permet l'inspection à l'œil en plus de la comparaison de hash. +fn write_ppm(path: &std::path::Path, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out) +} + +fn scene_json(screen: &str, webcam: &str, w: u32, h: u32) -> String { + // Chemins en slashes : le JSON n'échappe pas les backslashes Windows. + let (s, c) = (screen.replace('\\', "/"), webcam.replace('\\', "/")); + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"{c}","sourceStartSec":0,"sourceEndSec":30,"webcamOffsetSec":0,"hasAudio":true}}], + "layout": {{"preset":"picture-in-picture","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":0.1,"blur":true,"shadow":1.0,"roundnessPx":24,"motionBlur":0.0}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#eaebed","#bcc0c6"]}}, + "zoomRegions": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"## + ) +} + +#[test] +fn golden_frames_per_output_format() { + let (screen, webcam) = match ( + std::env::var("OPENSCREEN_GOLDEN_SCREEN"), + std::env::var("OPENSCREEN_GOLDEN_WEBCAM"), + ) { + (Ok(s), Ok(w)) => (s, w), + _ => { + println!( + "SKIP: definir OPENSCREEN_GOLDEN_SCREEN et OPENSCREEN_GOLDEN_WEBCAM \ + (chemins d'un enregistrement reel) pour produire le golden." + ); + return; + } + }; + + let out_dir = std::env::var("OPENSCREEN_GOLDEN_OUT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("target/golden")); + std::fs::create_dir_all(&out_dir).expect("creer le dossier de sortie"); + + // C8 = tous les effets (ombres, coins, fond flouté, motion blur) : le golden + // doit couvrir les calques que les 9 correctifs ont touchés. Zoom et anim de + // layout coupés — ce sont les plannings FIXTURE, pas le contrat de scène + // (même neutralisation que `live::render_thread`). + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + + let gpu = Gpu::create(false).expect("device d3d11"); + + println!("\n{:<18} {:>11} {:>18} {:>9} {:>9}", "format", "sortie", "hash", "grad_x", "grad_y"); + println!("{}", "-".repeat(72)); + + for &(name, w, h) in FORMATS { + // Un compositeur PAR FORMAT, rastérisant à la géométrie de sortie — c'est + // exactement ce que fait le chemin d'export. Avant la refonte il n'y avait + // qu'un seul compositeur 1920x1080 pour tous les formats. + let comp = Compositor::new_sized(&gpu, w, h).expect("compositor"); + let scene = Scene::from_json(&scene_json(&screen, &webcam, w, h)).expect("scene valide"); + comp.set_scene(Some(scene)); + comp.clear_cursor(); + + // Un Player par format : `present_frame` fait avancer les décodeurs, on + // repart donc d'un état propre pour que AT_SEC désigne bien la même + // image source d'un format à l'autre. + let rgba = unsafe { + let mut player = Player::open(&screen, &webcam, &gpu).expect("ouvrir les sources"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer la frame"); + comp.readback_resized(w, h).expect("readback") + }; + + assert_eq!( + rgba.len(), + (w as usize) * (h as usize) * 4, + "{name}: le readback ne fait pas w*h*4" + ); + + let (gx, gy) = gradient_energy(&rgba, w as usize, h as usize); + let hash = fnv1a(&rgba); + write_ppm(&out_dir.join(format!("{name}.ppm")), &rgba, w, h).expect("ecrire le ppm"); + + println!("{name:<18} {:>5}x{:<5} {hash:>18x} {gx:>9.3} {gy:>9.3}", w, h); + } + + println!("\nFrames ecrites dans {}", out_dir.display()); + println!( + "Apres la phase 1 : le hash du 16-9 doit etre INCHANGE ; grad_y doit MONTER \ + sur 9-16, 1-1, 4-5 et 4k-16-9." + ); +} + +/// Rend le preset side-by-side avec un rect webcam en COLONNE — exactement ce que +/// produit `computeCompositeLayout` (branche dual-frame : `webcamRect = webcamSlot`, +/// un slot de largeur fixe et de pleine hauteur, sans aucun ajustement d'aspect). +/// +/// C'est le cas qui étirait la caméra : le natif plaquait la frame entière sur ce +/// slot. Depuis `cover_crop_uv`, la coupe source suit le ratio de la boîte, donc la +/// caméra est rognée mais jamais déformée. Écrit un PPM pour inspection visuelle — +/// la propriété, elle, est verrouillée par les tests unitaires de `cover_crop_uv`. +#[test] +fn golden_side_by_side_webcam_is_not_stretched() { + let (screen, webcam) = match ( + std::env::var("OPENSCREEN_GOLDEN_SCREEN"), + std::env::var("OPENSCREEN_GOLDEN_WEBCAM"), + ) { + (Ok(s), Ok(w)) => (s, w), + _ => { + println!("SKIP: definir OPENSCREEN_GOLDEN_SCREEN / _WEBCAM"); + return; + } + }; + let out_dir = std::env::var("OPENSCREEN_GOLDEN_OUT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("target/golden")); + std::fs::create_dir_all(&out_dir).expect("dossier de sortie"); + + let (w, h) = (1920u32, 1080u32); + let (s, c) = (screen.replace('\\', "/"), webcam.replace('\\', "/")); + // slot webcam : colonne droite, ~31% de large, pleine hauteur → ratio ~0.55, + // très loin du 16:9 de la caméra. Sans cover, la tête est visiblement étirée. + let scene_json = format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"{c}","sourceStartSec":0,"sourceEndSec":30,"webcamOffsetSec":0,"hasAudio":true}}], + "layout": {{"preset":"dual-frame","webcamSize":1.0,"webcamShape":"rectangle","webcamMirror":false, + "webcamRect":{{"x":0.66,"y":0.06,"width":0.31,"height":0.88}},"webcamReactiveZoom":false}}, + "effects": {{"padding":0.0,"blur":true,"shadow":1.0,"roundnessPx":24,"motionBlur":0.0}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#b02a2a","#7a1414"]}}, + "zoomRegions": [], "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"## + ); + + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + + let gpu = Gpu::create(false).expect("device d3d11"); + let comp = Compositor::new_sized(&gpu, w, h).expect("compositor"); + comp.set_scene(Some(Scene::from_json(&scene_json).expect("scene valide"))); + comp.clear_cursor(); + + let rgba = unsafe { + let mut player = Player::open(&screen, &webcam, &gpu).expect("ouvrir les sources"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer"); + comp.readback_resized(w, h).expect("readback") + }; + let path = out_dir.join("side-by-side.ppm"); + write_ppm(&path, &rgba, w, h).expect("ecrire le ppm"); + println!("side-by-side ecrit: {}", path.display()); +} + +/// Rend un clip RECADRÉ avec le `screenRect` que l'app résout — le chemin qui a +/// régressé. `compositor.rs::fit_screen` consomme ce rect TEL QUEL (il saute son +/// propre fit au ratio du crop, le rect étant censé y être déjà) : si le rect ne +/// porte pas le ratio du crop, la vidéo est étirée pour remplir une boîte mal +/// formée, sans rien en aval pour rattraper. +/// +/// `SCREEN_RECT_AR` doit rester le ratio du crop (0.30*1920 / 0.89*1080 ≈ 0.599). +/// Le PPM permet de vérifier à l'œil que le texte n'est pas étiré. +#[test] +fn golden_cropped_clip_is_not_stretched() { + let (screen, webcam) = match ( + std::env::var("OPENSCREEN_GOLDEN_SCREEN"), + std::env::var("OPENSCREEN_GOLDEN_WEBCAM"), + ) { + (Ok(s), Ok(w)) => (s, w), + _ => { + println!("SKIP: definir OPENSCREEN_GOLDEN_SCREEN / _WEBCAM"); + return; + } + }; + let out_dir = std::env::var("OPENSCREEN_GOLDEN_OUT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("target/golden")); + std::fs::create_dir_all(&out_dir).expect("dossier de sortie"); + + let (w, h) = (1920u32, 1080u32); + let (s, c) = (screen.replace('\\', "/"), webcam.replace('\\', "/")); + // Crop du rapport utilisateur : bande verticale 30% x 89% d'une source 16:9. + let (crop_w, crop_h) = (0.30f32, 0.89f32); + let crop_ar = (1920.0 * crop_w) / (1080.0 * crop_h); // ≈ 0.599 + // Le rect que l'app DOIT produire : contain de ce ratio dans le cadre de sortie. + let rect_h = 0.94f32; + let rect_w = rect_h * crop_ar * (h as f32 / w as f32); + let scene_json = format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"{c}","sourceStartSec":0,"sourceEndSec":30,"webcamOffsetSec":0,"hasAudio":true}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rectangle","webcamMirror":false, + "screenRect":{{"x":{sx},"y":{sy},"width":{rect_w},"height":{rect_h}}},"webcamReactiveZoom":false}}, + "effects": {{"padding":0.0,"blur":true,"shadow":1.0,"roundnessPx":24,"motionBlur":0.0}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#1e3a5f","#0d1b2a"]}}, + "zoomRegions": [], "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [{{"x":0.44,"y":0.06,"width":{crop_w},"height":{crop_h}}}], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"##, + sx = 0.5 - rect_w * 0.5, + sy = 0.5 - rect_h * 0.5, + ); + + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + + let gpu = Gpu::create(false).expect("device d3d11"); + let comp = Compositor::new_sized(&gpu, w, h).expect("compositor"); + comp.set_scene(Some(Scene::from_json(&scene_json).expect("scene valide"))); + comp.clear_cursor(); + + let rgba = unsafe { + let mut player = Player::open(&screen, &webcam, &gpu).expect("ouvrir les sources"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer"); + comp.readback_resized(w, h).expect("readback") + }; + let path = out_dir.join("cropped-clip.ppm"); + write_ppm(&path, &rgba, w, h).expect("ecrire le ppm"); + println!("crop ar={crop_ar:.3} rect={rect_w:.3}x{rect_h:.3} -> {}", path.display()); +} diff --git a/crates/compositor/tests/remux_seek_index.rs b/crates/compositor/tests/remux_seek_index.rs new file mode 100644 index 0000000000..5e209bc823 --- /dev/null +++ b/crates/compositor/tests/remux_seek_index.rs @@ -0,0 +1,164 @@ +//! Le remux doit doter un Matroska « live » (sans index) de ses `Cues`. +//! +//! C'est le filet du correctif « les enregistrements Linux ne sont pas +//! seekables » : `MediaRecorder` écrit son WebM en flux live, donc sans `Cues` +//! ni `SeekHead`, et `av_seek_frame` échoue alors pour tout timestamp non nul. +//! +//! Le test est AUTONOME — il ne dépend ni d'un binaire ffmpeg ni d'une fixture +//! média (celles de `crates/fixture/` ne sont pas versionnées, cf. +//! `export_timing.rs` qui doit être opt-in pour cette raison). Il fabrique son +//! entrée avec le muxer matroska lui-même en mode `live=1`, qui est précisément +//! le mode « je ne peux pas revenir en arrière » que subit `MediaRecorder` : +//! aucun `Cues` n'est écrit. Une piste `rawvideo` porte quelques octets +//! arbitraires — le test est de niveau CONTENEUR, la charge utile n'est jamais +//! décodée. + +use openscreen_compositor::ffi::*; +use openscreen_compositor::remux::remux_to_seekable_matroska; +use std::ffi::CString; +use std::ptr; + +/// ID EBML de `Cues` (0x1C53BB6B) et de `SeekHead` (0x114D9B74), en big-endian +/// tels qu'ils apparaissent tels quels dans les octets du fichier. +const CUES_ID: &[u8] = &[0x1C, 0x53, 0xBB, 0x6B]; +const SEEKHEAD_ID: &[u8] = &[0x11, 0x4D, 0x9B, 0x74]; + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Écrit un Matroska minimal SANS `Cues`, en forçant l'option `live` du muxer. +/// +/// Retourne le nombre de paquets écrits, pour que le test puisse vérifier que le +/// remux les retrouve tous. +fn write_live_matroska(path: &str, packets: i64) -> i64 { + let cpath = CString::new(path).unwrap(); + let cfmt = CString::new("matroska").unwrap(); + unsafe { + let mut octx: *mut AVFormatContext = ptr::null_mut(); + assert!( + avformat_alloc_output_context2( + &mut octx, + ptr::null(), + cfmt.as_ptr(), + cpath.as_ptr() + ) >= 0, + "alloc_output_context2" + ); + + let st = avformat_new_stream(octx, ptr::null()); + assert!(!st.is_null(), "avformat_new_stream"); + // VP8 : matroska le range en `V_VP8` sans jamais regarder la charge + // utile (contrairement à H264, qui exige un `extradata` avcC valide, et + // à RAWVIDEO, que le muxer refuse). Le test reste donc de niveau + // conteneur, sans encodeur ni bitstream réel. + (*(*st).codecpar).codec_type = AVMediaType::AVMEDIA_TYPE_VIDEO; + (*(*st).codecpar).codec_id = AVCodecID::AV_CODEC_ID_VP8; + (*(*st).codecpar).width = 16; + (*(*st).codecpar).height = 16; + // 1 ms par tick : les timestamps du test sont alors directement des ms. + (*st).time_base = AVRational { num: 1, den: 1000 }; + + let mut pb: *mut AVIOContext = ptr::null_mut(); + assert!( + avio_open(&mut pb, cpath.as_ptr(), AVIO_FLAG_WRITE as i32) >= 0, + "avio_open" + ); + sn_fmt_set_pb(octx, pb); + + // `live=1` : le muxer se comporte comme s'il ne pouvait pas revenir en + // arrière — pas de Cues. C'est la contrainte que subit MediaRecorder. + let mut opts: *mut AVDictionary = ptr::null_mut(); + let k = CString::new("live").unwrap(); + let v = CString::new("1").unwrap(); + av_dict_set(&mut opts, k.as_ptr(), v.as_ptr(), 0); + assert!(avformat_write_header(octx, &mut opts) >= 0, "write_header"); + av_dict_free(&mut opts); + + // Contenu arbitraire : jamais décodé, seul le conteneur est testé. + let payload = vec![0x42u8; 256]; + let mut pkt = av_packet_alloc(); + for i in 0..packets { + assert!(av_new_packet(pkt, payload.len() as i32) >= 0, "av_new_packet"); + ptr::copy_nonoverlapping(payload.as_ptr(), (*pkt).data, payload.len()); + (*pkt).stream_index = 0; + (*pkt).pts = i * 40; // 25 fps en timebase 1/1000 + (*pkt).dts = i * 40; + (*pkt).duration = 40; + (*pkt).flags = AV_PKT_FLAG_KEY as i32; + assert!( + av_interleaved_write_frame(octx, pkt) >= 0, + "interleaved_write_frame" + ); + } + av_packet_free(&mut pkt); + assert!(av_write_trailer(octx) >= 0, "write_trailer"); + avio_closep(&mut pb); + avformat_free_context(octx); + } + packets +} + +#[test] +fn remux_adds_cues_to_a_live_matroska() { + // `avformat_find_stream_info` tente de décoder la charge utile bidon pour + // deviner les paramètres du flux et crache un « Invalid sync code » par + // paquet. C'est attendu ici (et sans effet : les `codecpar` sont déjà + // renseignés par le muxer d'entrée) ; on coupe le log pour que la sortie du + // test reste lisible. + unsafe { av_log_set_level(AV_LOG_QUIET) }; + + let dir = std::env::temp_dir().join(format!("openscreen-remux-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("live.webm"); + let output = dir.join("indexed.webm"); + let input_s = input.to_str().unwrap().to_string(); + let output_s = output.to_str().unwrap().to_string(); + + let written = write_live_matroska(&input_s, 25); + + // Prémisse du correctif : l'entrée n'a PAS d'index. Si cette assertion + // tombe un jour, c'est le mode `live` du muxer qui a changé, et le test ne + // prouve plus rien — mieux vaut qu'il échoue ici que silencieusement. + let before = std::fs::read(&input).unwrap(); + assert!( + !contains(&before, CUES_ID), + "l'entrée de test ne doit pas avoir de Cues (mode live)" + ); + + let stats = remux_to_seekable_matroska(&input_s, &output_s).expect("remux"); + + let after = std::fs::read(&output).unwrap(); + assert!(contains(&after, CUES_ID), "la sortie doit contenir des Cues"); + assert!( + contains(&after, SEEKHEAD_ID), + "la sortie doit contenir un SeekHead" + ); + assert_eq!(stats.packets, written as u64, "tous les paquets recopiés"); + assert_eq!(stats.streams, 1); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn remux_refuses_to_write_over_its_own_input() { + // Garde-fou : écrire sur l'entrée détruirait la seule copie des pixels dès + // la première erreur d'écriture. Le contrat « passe un chemin temporaire » + // est vérifié, pas seulement documenté. + let err = remux_to_seekable_matroska("/tmp/same.webm", "/tmp/same.webm") + .expect_err("doit refuser entrée == sortie"); + assert!(err.to_string().contains("identiques"), "message: {err}"); +} + +#[test] +fn remux_reports_an_error_for_a_missing_input() { + // Le caller TS traite toute erreur comme « garde l'original » ; encore + // faut-il qu'une entrée absente en produise une plutôt que de paniquer. + let out = std::env::temp_dir().join("openscreen-remux-never-written.webm"); + let err = remux_to_seekable_matroska( + "/nonexistent/openscreen/no-such-recording.webm", + out.to_str().unwrap(), + ) + .expect_err("doit échouer sur une entrée absente"); + assert!(err.to_string().contains("avformat_open_input"), "message: {err}"); +} 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_linux.h b/crates/compositor/wrapper_linux.h new file mode 100644 index 0000000000..e01a31a97b --- /dev/null +++ b/crates/compositor/wrapper_linux.h @@ -0,0 +1,14 @@ +/* Linux ffmpeg wrapper for bindgen (PR #183). + * + * Software/VAAPI decode+encode only: no D3D11VA (Windows) nor VideoToolbox + * (macOS) hwcontext headers, which pull platform-specific system headers + * (d3d11.h / CoreVideo) that don't exist on Linux. The generic hwcontext.h is + * kept for AVHWDeviceContext should the VAAPI path need it later. */ +#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/fixture/fixture.json b/crates/fixture/fixture.json new file mode 100644 index 0000000000..4e26d4c6b5 --- /dev/null +++ b/crates/fixture/fixture.json @@ -0,0 +1,75 @@ +{ + "_comment": "Fixture gelée pour spike-native. Coupée en -c copy : le bitstream est EXACTEMENT celui produit par la capture openscreen. Ne pas ré-encoder — cela changerait la complexité du flux et viderait C0 de son sens.", + "frozen_at": "2026-07-17", + "duration_s": 6.0, + "frames_per_source": 360, + "sources": { + "screen": { + "file": "screen.mp4", + "role": "source 1 — quad 16:9 scalé/positionné, coins arrondis (§6)", + "provenance": { + "origin": "C:\\Users\\camil\\AppData\\Roaming\\openscreen\\recordings\\recording-1783845220910.mp4", + "origin_duration_s": 111.6, + "cut_offset_s": 100, + "cut_method": "ffmpeg -ss 100 -t 6 -c copy (IDR-aligned, GOP=60)" + }, + "codec": "h264", + "profile": "Constrained Baseline", + "profile_idc": 66, + "constraint_set1_flag": 1, + "level_idc": 42, + "width": 1920, + "height": 1080, + "pix_fmt": "yuv420p", + "frame_rate": "60/1", + "cfr": true, + "frames": 360, + "color_space": "bt709 (MESURÉ, non taggé dans le flux)", + "color_range": "limited/tv (MESURÉ, non taggé dans le flux)", + "d3d11va_decode": "OK — pixfmt:d3d11", + "content": "navigateur plein écran, scroll de page GitHub. MAD=1.367, 16.7% des frames en mouvement (MAD>1), 81.1% de doublons." + }, + "webcam": { + "file": "webcam.mp4", + "role": "source 2 — center-crop carré + masque circulaire (§6/E5). Simule une webcam HQ : deuxième flux screen, pas la vraie webcam.", + "provenance": { + "origin": "C:\\Users\\camil\\AppData\\Roaming\\openscreen\\recordings\\recording-1783894784128.mp4", + "origin_duration_s": 866.5, + "cut_offset_s": 428, + "cut_method": "ffmpeg -ss 428 -t 6 -c copy (IDR-aligned, GOP=60)" + }, + "codec": "h264", + "profile": "Constrained Baseline", + "profile_idc": 66, + "constraint_set1_flag": 1, + "level_idc": 42, + "width": 1920, + "height": 1032, + "_height_note": "1032, pas 1080 — capture fenêtrée. Sans conséquence : cette source est center-croppée en carré (1032x1032) par le §6.", + "pix_fmt": "yuv420p", + "frame_rate": "60/1", + "cfr": true, + "frames": 360, + "color_space": "bt709 (MESURÉ, non taggé)", + "color_range": "limited/tv (MESURÉ, non taggé)", + "d3d11va_decode": "OK — pixfmt:d3d11", + "content": "fenêtre Claude Code, texte qui défile. MAD=1.310, 21.1% des frames en mouvement, 78.1% de doublons." + }, + "cursor": { + "file": "screen.cursor.json", + "role": "télémétrie curseur de la source screen — consommée par C7 (curseur custom + click bounce) et par le suivi auto du zoom.", + "provenance": { + "origin": "C:\\Users\\camil\\AppData\\Roaming\\openscreen\\recordings\\recording-1783845220910.mp4.cursor.json", + "cut_method": "AUCUNE coupe — copier le fichier BRUT tel quel. Le loader fenêtre lui-même : CursorTrack::load(path, offset_ms=100_000, dur_s=6.0) filtre samples[] sur timeMs et rebase à 0 (crates/compositor/src/cursor.rs). L'offset correspond au cut_offset_s=100 de la source screen." + }, + "samples_total": 2582, + "samples_in_window": 140, + "clicks_in_window": 1 + } + }, + "decoder_surface_note": "Le décodeur D3D11VA aligne en macroblocs : 1080 -> texture 1920x1088, 1032 -> 1920x1040. Seules les N premières lignes sont utiles. Les SRV/UV doivent en tenir compte (§5).", + "e1_matrix": { + "decision": "BT.709 limited/tv pour LES DEUX sources — une seule matrice.", + "basis": "Mesuré, pas lu : aucune des deux sources ne tague color_range. Histogramme du plan Y sur 3 enregistrements x 12 frames : pic massif à Y=235 (21-71% des pixels), ZÉRO pixel à 255, zéro au-dessus de 242 (la queue 236-242 est du ringing autour du plat à 235), zéro sous 16. Le §4 supposait full range pour une capture d'écran — la mesure dit l'inverse." + } +} diff --git a/crates/poc-d3d/Cargo.toml b/crates/poc-d3d/Cargo.toml new file mode 100644 index 0000000000..4c42f58200 --- /dev/null +++ b/crates/poc-d3d/Cargo.toml @@ -0,0 +1,21 @@ +# Le POC d'origine, conservé tel quel comme banc de mesure et pièce d'époque. +# Rien ici n'est packagé dans l'app : la GUI Win32 de preview/export (`app.rs`) et le +# harnais de bench fps (`bench.rs`) ne servent qu'à mesurer et à regarder. Tout ce qui +# est réellement expédié vit dans `openscreen-compositor` et `compositor-view-napi`. +[package] +name = "poc-d3d" +version.workspace = true +edition.workspace = true +description = "POC de mesure OpenScreen : GUI Win32 de preview/export + harnais de bench fps (§9/§10) au-dessus d'openscreen-compositor." + +[[bin]] +name = "poc-d3d" +path = "src/main.rs" + +[dependencies] +openscreen-compositor.workspace = true +anyhow.workspace = true + +# GUI Win32 : Windows-only (le POC ne se compile qu'en no-op ailleurs). +[target.'cfg(windows)'.dependencies] +windows.workspace = true diff --git a/crates/poc-d3d/src/app.rs b/crates/poc-d3d/src/app.rs new file mode 100644 index 0000000000..6c49b7d2b8 --- /dev/null +++ b/crates/poc-d3d/src/app.rs @@ -0,0 +1,556 @@ +//! GUI native (Win32) : preview/playback du compositing + export avec barre de +//! progression et bilan (temps + fps). Rapproche le POC d'une intégration app : +//! le compositeur/pipeline mesuré alimente une vraie boucle de rendu interactive. +//! +//! Architecture : +//! - une fenêtre hôte, un enfant "preview" portant une swapchain DXGI flip sur le +//! device D3D11 partagé (blit zéro-copie du RT composité → backbuffer) ; +//! - des contrôles Win32 natifs (combo preset, Play/Pause, Export, barre, label) — +//! aucun rendu de texte maison ; +//! - un modèle mono-thread coopératif : WM_TIMER cadence la playback à 60 fps ; +//! l'export tourne sur le thread UI et rafraîchit la barre entre frames. + +use openscreen_compositor::compositor::{Compositor, FIXTURE_FRAMES, OUT_H, OUT_W}; +use openscreen_compositor::config::{self, Cfg}; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::live::Player; +use openscreen_compositor::pipeline; +use anyhow::Result; +use std::ffi::c_void; +use std::time::Instant; +use windows::core::{Interface, PCWSTR}; +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::Graphics::Direct3D11::ID3D11RenderTargetView; +use windows::Win32::Graphics::Dxgi::Common::{ + DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_SAMPLE_DESC, +}; +use windows::Win32::Graphics::Dxgi::{ + IDXGIAdapter, IDXGIDevice, IDXGIFactory2, IDXGISwapChain1, DXGI_PRESENT, + DXGI_SCALING_STRETCH, DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_EFFECT_FLIP_DISCARD, + DXGI_USAGE_RENDER_TARGET_OUTPUT, +}; +use windows::Win32::Graphics::Gdi::{ + CreateFontW, GetSysColorBrush, UpdateWindow, COLOR_BTNFACE, HFONT, +}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use windows::Win32::UI::Controls::{ + InitCommonControlsEx, ICC_PROGRESS_CLASS, ICC_STANDARD_CLASSES, INITCOMMONCONTROLSEX, + PBM_SETPOS, PBM_SETRANGE32, +}; +use windows::Win32::UI::WindowsAndMessaging::*; + +const PREVIEW_W: i32 = 1280; +const PREVIEW_H: i32 = 720; +const STRIP_H: i32 = 64; +const CLIENT_W: i32 = PREVIEW_W; +const CLIENT_H: i32 = PREVIEW_H + STRIP_H; + +const ID_COMBO: isize = 101; +const ID_PLAY: isize = 102; +const ID_EXPORT: isize = 103; +const ID_PROGRESS: isize = 104; +const ID_STATUS: isize = 105; +const TIMER_TICK: usize = 1; + +/// Chaîne UTF-16 terminée par NUL (durée de vie tenue par l'appelant). +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Plus grand rectangle 16:9 centré dans `(cw, ch)` → viewport letterbox de la preview. +fn letterbox(cw: f32, ch: f32) -> (f32, f32, f32, f32) { + let ar = OUT_W as f32 / OUT_H as f32; + let (mut w, mut h) = (cw, ch); + if cw / ch > ar { + w = ch * ar; + } else { + h = cw / ar; + } + ((cw - w) * 0.5, (ch - h) * 0.5, w, h) +} + +/// État applicatif complet (possédé par la fenêtre via GWLP_USERDATA). +struct App { + gpu: Gpu, + comp: Compositor, + player: Player, + cfgs: Vec, + cur: usize, + playing: bool, + exporting: bool, + total_frames: u64, + screen: String, + webcam: String, + out: String, + // win32 + preview: HWND, + combo: HWND, + play_btn: HWND, + export_btn: HWND, + progress: HWND, + status: HWND, + // dxgi (preview) + swap: Option, + bb_rtv: Option, + // cadence playback + last: Instant, + acc: f64, +} + +impl App { + /// Compose + affiche la 1re frame, avant l'ouverture de la fenêtre. + unsafe fn init_first_frame(&mut self) { + let cfg = self.cfgs[self.cur].clone(); + let _ = self.player.step(&self.comp, &cfg); + let _ = self.render(); + self.update_ready_status(); + self.last = Instant::now(); + } + + /// Cadence 60 fps par horloge murale (accumulateur), avec garde anti-spirale. + unsafe fn on_tick(&mut self) -> Result<()> { + if self.exporting || !self.playing { + return Ok(()); + } + let now = Instant::now(); + let dt = (now - self.last).as_secs_f64().min(0.1); + self.last = now; + self.acc += dt; + let step = 1.0 / 60.0; + let cfg = self.cfgs[self.cur].clone(); + let mut stepped = false; + let mut n = 0; + while self.acc >= step && n < 3 { + if self.player.step(&self.comp, &cfg)? { + stepped = true; + } + self.acc -= step; + n += 1; + } + if self.acc > step { + self.acc = 0.0; // largue le retard accumulé (fenêtre masquée, etc.) + } + if stepped { + self.render()?; + } + Ok(()) + } + + /// Blit du RT composité vers le backbuffer, letterboxé, puis Present (vsync). + unsafe fn render(&mut self) -> Result<()> { + let (Some(swap), Some(rtv)) = (self.swap.as_ref(), self.bb_rtv.as_ref()) else { + return Ok(()); + }; + let mut rc = RECT::default(); + let _ = GetClientRect(self.preview, &mut rc); + let cw = (rc.right - rc.left).max(1) as f32; + let ch = (rc.bottom - rc.top).max(1) as f32; + let (x, y, w, h) = letterbox(cw, ch); + self.gpu.context.ClearRenderTargetView(rtv, &[0.02, 0.02, 0.03, 1.0]); + self.comp.blit_to(rtv, x, y, w, h); + let _ = swap.Present(1, DXGI_PRESENT(0)); + Ok(()) + } + + unsafe fn on_command(&mut self, wp: WPARAM) -> Result<()> { + let id = (wp.0 & 0xffff) as isize; + let code = ((wp.0 >> 16) & 0xffff) as u32; + match id { + ID_PLAY => self.toggle_play(), + ID_EXPORT => self.run_export()?, + ID_COMBO if code == CBN_SELCHANGE => { + let sel = SendMessageW(self.combo, CB_GETCURSEL, WPARAM(0), LPARAM(0)).0; + if sel >= 0 && (sel as usize) < self.cfgs.len() { + self.cur = sel as usize; + if !self.playing { + let cfg = self.cfgs[self.cur].clone(); + let _ = self.player.recompose(&self.comp, &cfg); + let _ = self.render(); + } + self.update_ready_status(); + } + } + _ => {} + } + Ok(()) + } + + unsafe fn toggle_play(&mut self) { + self.playing = !self.playing; + let label = wide(if self.playing { "Pause" } else { "Play" }); + let _ = SetWindowTextW(self.play_btn, PCWSTR(label.as_ptr())); + self.last = Instant::now(); + self.acc = 0.0; + } + + /// Export coopératif (thread UI) : la barre avance via SendMessage+UpdateWindow sur le + /// contrôle (pas de re-pompage du message-loop → aucune réentrance dans notre wndproc). + /// La mesure fps reste enveloppante (§10) dans `run_composited`. + unsafe fn run_export(&mut self) -> Result<()> { + if self.exporting { + return Ok(()); + } + self.exporting = true; + self.playing = false; + let pl = wide("Play"); + let _ = SetWindowTextW(self.play_btn, PCWSTR(pl.as_ptr())); + let _ = EnableWindow(self.export_btn, false); + let _ = EnableWindow(self.play_btn, false); + let _ = EnableWindow(self.combo, false); + + SendMessageW(self.progress, PBM_SETRANGE32, WPARAM(0), LPARAM(self.total_frames as isize)); + SendMessageW(self.progress, PBM_SETPOS, WPARAM(0), LPARAM(0)); + + let cfg = self.cfgs[self.cur].clone(); + let s = wide(&format!("Exporting {} — {} …", cfg.name, cfg.desc)); + let _ = SetWindowTextW(self.status, PCWSTR(s.as_ptr())); + let _ = UpdateWindow(self.status); + let _ = UpdateWindow(self.progress); + + // sonde de progression : SendMessage throttlé au pourcent (µs, cf. §10). + let prog = self.progress; + let total = self.total_frames.max(1); + let mut last_pct: i64 = -1; + let mut cb = move |done: u64| { + let pct = (done as i64 * 100) / total as i64; + if pct != last_pct { + last_pct = pct; + SendMessageW(prog, PBM_SETPOS, WPARAM(done as usize), LPARAM(0)); + let _ = UpdateWindow(prog); + } + }; + let r = pipeline::run_composited( + &self.screen, &self.webcam, &self.out, &self.gpu, &self.comp, &cfg, &mut cb, + ); + self.comp.clear_srv_cache(); + + let _ = EnableWindow(self.export_btn, true); + let _ = EnableWindow(self.play_btn, true); + let _ = EnableWindow(self.combo, true); + self.exporting = false; + + match r { + Ok(st) => { + SendMessageW(self.progress, PBM_SETPOS, WPARAM(st.frames as usize), LPARAM(0)); + let msg = format!( + "Done — {} · {} frames · {:.2}s · {:.1} fps -> {}", + cfg.name, st.frames, st.wall_s, st.fps, self.out + ); + let w = wide(&msg); + let _ = SetWindowTextW(self.status, PCWSTR(w.as_ptr())); + } + Err(e) => { + let w = wide(&format!("Export failed: {e}")); + let _ = SetWindowTextW(self.status, PCWSTR(w.as_ptr())); + } + } + + // reprise de la playback + self.playing = true; + let pb = wide("Pause"); + let _ = SetWindowTextW(self.play_btn, PCWSTR(pb.as_ptr())); + self.last = Instant::now(); + self.acc = 0.0; + Ok(()) + } + + unsafe fn update_ready_status(&self) { + let cfg = &self.cfgs[self.cur]; + let s = wide(&format!( + "Ready - {} · {} ({} frames · export -> {})", + cfg.name, cfg.desc, self.total_frames, self.out + )); + let _ = SetWindowTextW(self.status, PCWSTR(s.as_ptr())); + } + + unsafe fn report_err(&self, e: &anyhow::Error) { + let w = wide(&format!("error: {e}")); + let _ = SetWindowTextW(self.status, PCWSTR(w.as_ptr())); + eprintln!("[app] error: {e:#}"); + } +} + +/// Police Segoe UI 9pt ClearType — les contrôles créés par CreateWindowEx héritent sinon +/// d'une vieille police bitmap système. Fuit un HFONT (durée de vie = process). +unsafe fn ui_font() -> HFONT { + let face = wide("Segoe UI"); + // (height=-12 ≈ 9pt @96dpi, weight=400, charset=DEFAULT(1), quality=CLEARTYPE(5)) + CreateFontW(-12, 0, 0, 0, 400, 0, 0, 0, 1, 0, 0, 5, 0, PCWSTR(face.as_ptr())) +} + +/// Crée l'enfant preview + les contrôles natifs. Renvoie leurs HWND. +unsafe fn create_children( + parent: HWND, + hinst: windows::Win32::Foundation::HINSTANCE, + cfgs: &[Cfg], +) -> Result<(HWND, HWND, HWND, HWND, HWND, HWND)> { + let btn_cls = wide("BUTTON"); + let combo_cls = wide("COMBOBOX"); + let static_cls = wide("STATIC"); + let prog_cls = wide("msctls_progress32"); + let prev_cls = wide("PocD3DPreview"); + + let preview = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(prev_cls.as_ptr()), + PCWSTR::null(), + WS_CHILD | WS_VISIBLE, + 0, 0, PREVIEW_W, PREVIEW_H, + parent, + HMENU::default(), + hinst, + None, + )?; + + let y = PREVIEW_H + 18; + let combo_style = + WS_CHILD.0 | WS_VISIBLE.0 | WS_VSCROLL.0 | (CBS_DROPDOWNLIST as u32) | (CBS_HASSTRINGS as u32); + let combo = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(combo_cls.as_ptr()), + PCWSTR::null(), + WINDOW_STYLE(combo_style), + 14, y - 3, 250, 340, + parent, + HMENU(ID_COMBO as *mut c_void), + hinst, + None, + )?; + for c in cfgs { + let item = wide(&format!("{} — {}", c.name, c.desc)); + SendMessageW(combo, CB_ADDSTRING, WPARAM(0), LPARAM(item.as_ptr() as isize)); + } + SendMessageW(combo, CB_SETCURSEL, WPARAM(cfgs.len() - 1), LPARAM(0)); + + let pl = wide("Pause"); + let play_btn = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(btn_cls.as_ptr()), + PCWSTR(pl.as_ptr()), + WINDOW_STYLE(WS_CHILD.0 | WS_VISIBLE.0 | (BS_PUSHBUTTON as u32)), + 278, y, 96, 30, + parent, + HMENU(ID_PLAY as *mut c_void), + hinst, + None, + )?; + + let ex = wide("Export"); + let export_btn = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(btn_cls.as_ptr()), + PCWSTR(ex.as_ptr()), + WINDOW_STYLE(WS_CHILD.0 | WS_VISIBLE.0 | (BS_PUSHBUTTON as u32)), + 382, y, 96, 30, + parent, + HMENU(ID_EXPORT as *mut c_void), + hinst, + None, + )?; + + let progress = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(prog_cls.as_ptr()), + PCWSTR::null(), + WS_CHILD | WS_VISIBLE, + 494, y + 4, 300, 22, + parent, + HMENU(ID_PROGRESS as *mut c_void), + hinst, + None, + )?; + + let st = wide("Ready"); + let status = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(static_cls.as_ptr()), + PCWSTR(st.as_ptr()), + WS_CHILD | WS_VISIBLE, + 808, y + 6, 458, 40, + parent, + HMENU(ID_STATUS as *mut c_void), + hinst, + None, + )?; + + let font = ui_font(); + for c in [combo, play_btn, export_btn, status] { + SendMessageW(c, WM_SETFONT, WPARAM(font.0 as usize), LPARAM(1)); + } + Ok((combo, play_btn, export_btn, progress, status, preview)) +} + +/// Crée la swapchain flip + RTV du backbuffer sur le device D3D11 partagé. +unsafe fn create_swapchain( + device: &windows::Win32::Graphics::Direct3D11::ID3D11Device, + hwnd: HWND, +) -> Result<(IDXGISwapChain1, ID3D11RenderTargetView)> { + let dxdev: IDXGIDevice = device.cast()?; + let adapter: IDXGIAdapter = dxdev.GetAdapter()?; + let factory: IDXGIFactory2 = adapter.GetParent()?; + let desc = DXGI_SWAP_CHAIN_DESC1 { + Width: PREVIEW_W as u32, + Height: PREVIEW_H as u32, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, + BufferCount: 2, + SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD, + Scaling: DXGI_SCALING_STRETCH, + AlphaMode: DXGI_ALPHA_MODE_IGNORE, + ..Default::default() + }; + let swap = factory.CreateSwapChainForHwnd(device, hwnd, &desc, None, None)?; + let bb: windows::Win32::Graphics::Direct3D11::ID3D11Texture2D = swap.GetBuffer(0)?; + let mut rtv: Option = None; + device.CreateRenderTargetView(&bb, None, Some(&mut rtv))?; + Ok((swap, rtv.unwrap())) +} + +extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + unsafe { + let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut App; + if ptr.is_null() { + return DefWindowProcW(hwnd, msg, wp, lp); + } + let app = &mut *ptr; + match msg { + WM_TIMER => { + if let Err(e) = app.on_tick() { + app.report_err(&e); + } + LRESULT(0) + } + WM_COMMAND => { + if let Err(e) = app.on_command(wp) { + app.report_err(&e); + } + LRESULT(0) + } + WM_DESTROY => { + let _ = KillTimer(hwnd, TIMER_TICK); + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wp, lp), + } + } +} + +/// Point d'entrée GUI (appelé par `main` quand aucun argument bench n'est passé). +pub fn run_gui(screen: &str, webcam: &str, cursor_json: &str, out_dir: &str) -> Result<()> { + unsafe { run_gui_inner(screen, webcam, cursor_json, out_dir) } +} + +unsafe fn run_gui_inner(screen: &str, webcam: &str, cursor_json: &str, out_dir: &str) -> Result<()> { + std::fs::create_dir_all(out_dir).ok(); + let out = format!("{out_dir}/export.mp4"); + + let gpu = Gpu::create(false)?; + println!("d3d11 device ok (feature_level 0x{:X})", gpu.feature_level.0 as u32); + let mut comp = Compositor::new(&gpu)?; + if let Ok(track) = CursorTrack::load(cursor_json, 100_000.0, 6.0) { + comp.set_cursor(track); + } + let player = Player::open(screen, webcam, &gpu)?; + let total_frames = pipeline::probe_frame_count(screen).unwrap_or(FIXTURE_FRAMES as u64); + let cfgs = config::all(); + let cur = cfgs.len() - 1; // C8 (tous effets) par défaut + + let hinst = windows::Win32::Foundation::HINSTANCE(GetModuleHandleW(None)?.0); + + let icc = INITCOMMONCONTROLSEX { + dwSize: std::mem::size_of::() as u32, + dwICC: ICC_PROGRESS_CLASS | ICC_STANDARD_CLASSES, + }; + let _ = InitCommonControlsEx(&icc); + + let main_cls = wide("PocD3DMain"); + let prev_cls = wide("PocD3DPreview"); + let cursor = LoadCursorW(None, IDC_ARROW)?; + let wc_main = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: PCWSTR(main_cls.as_ptr()), + hCursor: cursor, + hbrBackground: GetSysColorBrush(COLOR_BTNFACE), + ..Default::default() + }; + RegisterClassW(&wc_main); + let wc_prev = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), // USERDATA null sur l'enfant → DefWindowProcW + hInstance: hinst, + lpszClassName: PCWSTR(prev_cls.as_ptr()), + hCursor: cursor, + hbrBackground: windows::Win32::Graphics::Gdi::HBRUSH(std::ptr::null_mut()), + ..Default::default() + }; + RegisterClassW(&wc_prev); + + let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; + let mut rc = RECT { left: 0, top: 0, right: CLIENT_W, bottom: CLIENT_H }; + let _ = AdjustWindowRectEx(&mut rc, style, false, WINDOW_EX_STYLE(0)); + let ww = rc.right - rc.left; + let wh = rc.bottom - rc.top; + + let title = wide("OpenScreen — POC D3D11 compositor · preview + export"); + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(main_cls.as_ptr()), + PCWSTR(title.as_ptr()), + style, + CW_USEDEFAULT, CW_USEDEFAULT, ww, wh, + HWND::default(), + HMENU::default(), + hinst, + None, + )?; + + let (combo, play_btn, export_btn, progress, status, preview) = + create_children(hwnd, hinst, &cfgs)?; + let (swap, bb_rtv) = create_swapchain(&gpu.device, preview)?; + + let app = Box::new(App { + gpu, + comp, + player, + cfgs, + cur, + playing: true, + exporting: false, + total_frames, + screen: screen.to_string(), + webcam: webcam.to_string(), + out, + preview, + combo, + play_btn, + export_btn, + progress, + status, + swap: Some(swap), + bb_rtv: Some(bb_rtv), + last: Instant::now(), + acc: 0.0, + }); + let app_ptr = Box::into_raw(app); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, app_ptr as isize); + + (*app_ptr).init_first_frame(); + let _ = ShowWindow(hwnd, SW_SHOW); + let _ = UpdateWindow(hwnd); + SetTimer(hwnd, TIMER_TICK, 15, None); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, HWND::default(), 0, 0).0 > 0 { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + drop(Box::from_raw(app_ptr)); + Ok(()) +} diff --git a/crates/poc-d3d/src/bench.rs b/crates/poc-d3d/src/bench.rs new file mode 100644 index 0000000000..cb45254bd1 --- /dev/null +++ b/crates/poc-d3d/src/bench.rs @@ -0,0 +1,393 @@ +//! Harnais de mesure du POC (§9/§10) et aiguillage des modes. +//! +//! Vivait dans `lib.rs` de l'ancienne crate `poc-d3d`, quand bibliothèque et POC étaient le même +//! paquet. Rien ici n'est packagé : c'est du banc de mesure au-dessus d'`openscreen-compositor`. + +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; + +fn arg(args: &[String], k: &str, d: &str) -> String { + args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned().unwrap_or_else(|| d.to_string()) +} + +// Trois modes : +// GUI (défaut) : poc-d3d.exe [--fixture ] [--out ] → preview + export +// Bench (§9/10) : poc-d3d.exe --cfg C0..C8 [--fixture ] [--repeat N] [--out ] +// Bench GIF : poc-d3d.exe --cfg GIF [--fixture ] [--repeat N] [--out ] +// (slice 1 du chemin natif GIF : `compositor::export_gif` end-to-end) +// Live (POC) : poc-d3d.exe --live [--fixture ] → vue D3D enfant embarquée (test embed) +pub fn run() -> Result<()> { + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--live") { + let fixture = arg(&args, "--fixture", "fixture"); + return live::run_standalone( + &format!("{fixture}/screen.mp4"), + &format!("{fixture}/webcam.mp4"), + &format!("{fixture}/screen.cursor.json"), + ); + } + let is_bench = args.iter().any(|a| a == "--cfg" || a == "--bench"); + if is_bench { + run_bench(&args) + } else { + let fixture = arg(&args, "--fixture", "fixture"); + let out = arg(&args, "--out", "out"); + crate::app::run_gui( + &format!("{fixture}/screen.mp4"), + &format!("{fixture}/webcam.mp4"), + &format!("{fixture}/screen.cursor.json"), + &out, + ) + } +} + +// poc-d3d.exe --cfg C0..C8 --fixture --repeat 3 --out out/ +// --cfg GIF → bench natif GIF (slice 1) +fn run_bench(args: &[String]) -> Result<()> { + let get = |k: &str, d: &str| -> String { arg(args, k, d) }; + let fixture = get("--fixture", "fixture"); + let out = get("--out", "out"); + let repeat: u32 = get("--repeat", "3").parse().unwrap_or(3); + let cfg_arg = get("--cfg", "C0..C8"); + + 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 mut cfgs: Vec = if cfg_arg.contains("..") { + all + } else { + cfg_arg + .split(',') + .filter_map(|n| config::Cfg::by_name(n.trim())) + .collect() + }; + + // `--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 + // avant/après perf sur une scène représentative. + let scene_arg = get("--scene", ""); + if !scene_arg.is_empty() { + let json = std::fs::read_to_string(&scene_arg) + .with_context(|| format!("lecture de la scène {scene_arg}"))?; + comp.set_scene(Some(scene::Scene::from_json(&json)?)); + 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"); + + for cfg in &cfgs { + let mut fps_runs = Vec::new(); + let mut frames = 0u64; + for r in 0..repeat { + let path = format!("{out}/{}.mp4", cfg.name); + 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); + // 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); + } + } + let best = fps_runs.iter().cloned().fold(f64::MIN, f64::max); + let worst = fps_runs.iter().cloned().fold(f64::MAX, f64::min); + let spread = if worst > 0.0 { 100.0 * (best - worst) / worst } else { 0.0 }; + let wall = frames as f64 / best; + let msf = 1000.0 / best; + println!( + "{:<4} {:>4}f {:>7.3}s {:>7.1} fps {:>6.2} ms/f spread {:.1}% {}", + cfg.name, frames, wall, best, msf, spread, cfg.desc + ); + rows.push((cfg.name.to_string(), frames, wall, best, msf, format!("{spread:.1}%"))); + let _ = write!( + json, + " {{ \"cfg\": \"{}\", \"frames\": {}, \"fps\": {:.2}, \"ms_per_frame\": {:.3}, \"spread_pct\": {:.1}, \"repeat\": {}, \"desc\": \"{}\" }}{}\n", + cfg.name, frames, best, msf, spread, repeat, cfg.desc, + if cfg.name == cfgs.last().unwrap().name { "" } else { "," } + ); + } + json.push_str(" ]\n}\n"); + std::fs::write(format!("{out}/report.json"), &json)?; + + // table markdown récap + println!("\ncfg frames wall_s fps ms/f spread"); + for (n, f, w, fps, msf, sp) in &rows { + println!("{n:<4} {f:<7} {w:<7.3} {fps:<8.1} {msf:<7.2} {sp}"); + } + println!("\nreport.json + out/C*.mp4 + out/C*_f{{60,180,300}}.png écrits dans {out}/"); + Ok(()) +} + +/// Native GIF export bench (slice 1). Drives `gif_export::export_gif` +/// end-to-end on the same fixture as the C0..C8 bench and reports: +/// - wall time (render) +/// - frame count +/// - resulting FPS (input vs output) +/// - output file size +/// - ms/frame +/// - spread across `--repeat` runs (same gate as the MP4 bench) +/// +/// This is the only honest signal for the "is the native GIF export a +/// win" question. The C0..C8 numbers above show the GPU compositor +/// itself is fast — what this bench prices is the readback + NeuQuant +/// + LZW encode on top, the layers a 5× regression would hide. See +/// `technical-documentation/engineering/rendering-performance.md` → +/// `Native GIF export — initial bench` for the recorded wall-time and +/// the comparison with the renderer-side `gif.js` path. +fn run_gif_bench( + args: &[String], + 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"); + let webcam = format!("{fixture}/webcam.mp4"); + let cursor = format!("{fixture}/screen.cursor.json"); + let out_path = Path::new(out).join("gif.gif"); + std::fs::create_dir_all(out).ok(); + + // The bench defaults to 854×480 / 12 fps / no dithering — exactly + // what `GifExportParams::default()` produces, which is the slice-1 + // target. The user can override via `--gif-width`, `--gif-height`, + // `--gif-fps` flags if they want to probe the readback cost at + // different sizes. + let width: u32 = get("--gif-width", "854").parse().unwrap_or(854); + let height: u32 = get("--gif-height", "480").parse().unwrap_or(480); + let fps: u32 = get("--gif-fps", "12").parse().unwrap_or(12); + let dither: bool = get("--gif-dither", "0") == "1"; + let params = GifExportParams { + width: Some(width), + height: Some(height), + fps: Some(fps), + loop_count: None, + dither, + }; + + println!("GIF bench: {screen} + {webcam} → {}", out_path.display()); + println!( + " output={}x{} @ {}fps dither={} runs={repeat}", + width, height, fps, dither + ); + + let mut frames = 0u64; + 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( + &clips, + &out_path, + &gpu, + &comp, + cfg, + ¶ms, + &mut |_| {}, + )?; + // Snapshot the fields we still need before `s` is moved into + // `last_stats` for the JSON dump at the end of the bench. + let run_frames = s.frames; + let run_wall = s.wall_s; + let run_fps = s.fps; + let run_bytes = s.file_bytes; + frames = run_frames; + file_bytes = run_bytes; + wall_runs.push(run_wall); + last_stats = Some(s); + println!( + " run {:>2}: {:>4}f {:>7.3}s {:>7.1} fps {:>6.2} ms/f {} KiB", + r + 1, + run_frames, + run_wall, + run_fps, + 1000.0 / run_fps.max(0.001), + run_bytes / 1024 + ); + } + + // Same spread gate as the MP4 bench: best/worst wall across runs. + // Smaller-is-better for wall, so we use the inverse of the MP4 + // "best of fps" idiom — best wall is the minimum, worst is the max. + let best_wall = wall_runs.iter().cloned().fold(f64::INFINITY, f64::min); + let worst_wall = wall_runs.iter().cloned().fold(0.0_f64, f64::max); + let spread = if best_wall > 0.0 { 100.0 * (worst_wall - best_wall) / best_wall } else { 0.0 }; + let avg_fps = last_stats + .as_ref() + .map(|s| s.fps) + .unwrap_or_else(|| if best_wall > 0.0 { frames as f64 / best_wall } else { 0.0 }); + + // JSON output (parity with the C0..C8 bench's `report.json`). + let json = format!( + "{{\n \"runs\": [\n {{ \"cfg\": \"GIF\", \"frames\": {frames}, \"fps\": {fps:.2}, \"ms_per_frame\": {msf:.3}, \"wall_s_best\": {wall_best:.3}, \"wall_s_worst\": {wall_worst:.3}, \"spread_pct\": {spread:.1}, \"file_bytes\": {bytes}, \"output\": \"{w}x{h}@{out_fps}fps\", \"repeat\": {repeat}, \"dither\": {dither} }}\n ]\n}}\n", + frames = frames, + fps = avg_fps, + msf = 1000.0 / avg_fps.max(0.001), + wall_best = best_wall, + wall_worst = worst_wall, + spread = spread, + bytes = file_bytes, + w = width, + h = height, + out_fps = fps, + repeat = repeat, + dither = dither, + ); + std::fs::write(format!("{out}/report-gif.json"), &json)?; + + println!( + "\nGIF {frames}f {wall:.3}s {fps:.1} fps {msf:.2} ms/f spread {spread:.1}% {kb} KiB → {out_path}", + frames = frames, + wall = best_wall, + fps = avg_fps, + msf = 1000.0 / avg_fps.max(0.001), + spread = spread, + kb = file_bytes / 1024, + out_path = out_path.display(), + ); + println!("\nreport-gif.json + out/gif.gif écrits dans {out}/"); + 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] { + let _ = std::process::Command::new("ffmpeg") + .args([ + "-v", "error", "-y", "-i", mp4, + "-vf", &format!("select=eq(n\\,{f})"), + "-frames:v", "1", + &format!("{out}/{cfg}_f{f}.png"), + ]) + .status(); + } +} diff --git a/crates/poc-d3d/src/main.rs b/crates/poc-d3d/src/main.rs new file mode 100644 index 0000000000..9d5365faf9 --- /dev/null +++ b/crates/poc-d3d/src/main.rs @@ -0,0 +1,19 @@ +//! Binaire du POC de mesure : GUI Win32 de preview/export, harnais de bench fps, mode live. +//! Tout le rendu vient d'`openscreen-compositor` — ce crate ne fait que le piloter et le mesurer. + +// GUI Win32 + bench encodeurs Windows : Windows-only. Sur les autres plateformes +// le POC n'a rien a piloter (le rendu/bench passe par les tests du crate compositor). +#[cfg(windows)] +mod app; +#[cfg(windows)] +mod bench; + +#[cfg(windows)] +fn main() -> anyhow::Result<()> { + bench::run() +} + +#[cfg(not(windows))] +fn main() { + eprintln!("poc-d3d : bench GUI Win32 (Windows-only), rien a faire sur cette plateforme."); +} diff --git a/crates/x.bat b/crates/x.bat new file mode 100644 index 0000000000..f4d7df1317 --- /dev/null +++ b/crates/x.bat @@ -0,0 +1,8 @@ +@echo off +REM Enveloppe de build, portable dans le repo (chemins via %~dp0) : +REM vcvars (INCLUDE/LIB pour libclang + linker MSVC), ffmpeg/bin sur PATH runtime, cargo. +REM Ajuste le chemin vcvars ci-dessous si ta version de Visual Studio diffère. +call "C:\Program Files\Microsoft Visual Studio\18\Insiders\VC\Auxiliary\Build\vcvars64.bat" >nul 2>&1 +set "PATH=%PATH%;%~dp0thirdparty\ffmpeg-n8.1.2-win64-lgpl-shared\bin" +cd /d "%~dp0" +"%USERPROFILE%\.cargo\bin\cargo.exe" %* diff --git a/design/DESIGN.md b/design/DESIGN.md new file mode 100644 index 0000000000..8450dcb04e --- /dev/null +++ b/design/DESIGN.md @@ -0,0 +1,156 @@ +# OpenScreen Design System + +> Category: Video & Productivity Tools +> Surface: Desktop web (Electron) +> Source: tokens extracted from `openscreen-editor-2.html` (canonical) + +A high-fidelity, professional dark-and-light desktop application design system +optimized for video recording, post-processing editing, and timeline +manipulation. Mint is the single brand accent; red is reserved strictly for +REC / cut / skip / trim / transcript-highlight states; amber covers warnings. + +--- + +## Color Palette + +### Light theme (`:root`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#fafbfc` | Off-white, never pure white; soft radial gradient | +| Panel | `--surface` | `#ffffff` | Raised panels on canvas | +| Card | `--surface-1` | `#f7f8fa` | Tier 1 raised | +| Tier 2 | `--surface-2` | `#f3f5f8` | Raised on panel | +| Hover | `--surface-3` | `#eef0f3` | Active/hover state | +| Popover | `--surface-hi` | `#ffffff` | Highest elevation | +| Border | `--border` | `#e7e9ee` | Soft hairline | +| Border soft | `--border-soft` | `#f1f2f5` | Subtle dividers | +| Border hi | `--border-hi` | `#d1d5db` | Emphasis lines | +| Foreground | `--fg` | `#1f2937` | Slate, not pure black | +| Foreground emphasis | `--fg-emphasis` | `#111827` | Headlines | +| Muted | `--muted` | `#6b7280` | Body / labels | +| Meta | `--meta` | `#9ca3af` | Timestamps, captions | +| Brand (mint) | `--accent` | `#10b981` | Primary action, focus ring, toggle-on | +| Brand glow | `--brand-glow` | `rgba(16,185,129,0.35)` | Focus / hover | +| Danger | `--danger` | `#ef4444` | REC, skip, trim, transcript highlight | +| Warning | `--warn` | `#f59e0b` | Soft amber | +| Success | `--success` | `#10b981` | Alias of brand | + +### Dark theme (`:root[data-theme="dark"]`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#0a0d12` | Near-black with faint blue cast | +| Panel | `--surface` | `#14181f` | Tier 1 panel | +| Card | `--surface-1` | `#14181f` | Same as panel base | +| Tier 2 | `--surface-2` | `#1c2029` | Raised | +| Hover | `--surface-3` | `#252a35` | Hover/active | +| Popover | `--surface-hi` | `#2e3440` | Highest | +| Border | `--border` | `#252a35` | Reads on canvas | +| Foreground | `--fg` | `#e6e9ef` | Off-white, never pure `#ffffff` | +| Muted | `--muted` | `#8b95a3` | Tuned for AA on dark | +| Brand (mint) | `--accent` | `#10b981` | Same hue, brighter tone on `--brand-lo: #34d399` | +| Danger | `--danger` | `#f87171` | Softer red for dark bg | + +### Traffic lights (macOS chrome) + +| Token | Hex | +|---|---| +| `--light-red` | `#ff5f57` | +| `--light-yellow` | `#febc2e` | +| `--light-green` | `#28c840` | + +--- + +## Typography + +- **Display:** system-ui stack — `system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif` +- **Body:** system-ui stack (same as display) +- **Mono:** `ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace` + +### Type scale (desktop editor, base 13px) + +| Token | Size | Use | +|---|---|---| +| `--fs-app` | 13px | Base UI | +| `--fs-app-sm` | 12px | Secondary | +| `--fs-app-lg` | 14px | Primary UI | +| `--fs-title` | 16px | Panel titles | +| `--fs-section` | 11px | Section headers (uppercase, tracked) | +| `--fs-display` | 24px | Hero numerals | + +Nothing below 11px. + +--- + +## Layout + +- **Radius:** 8px standard; `--r-xs: 4px`, `--r-sm: 6px`, `--r-md: 8px`, `--r-lg: 12px`, `--r-pill: 9999px` +- **Border weight:** 1px +- **Spacing:** 4px baseline grid (`--sp-1` through `--sp-6`) +- **Editor wireframe:** + - Titlebar: 34px + - Left utility rail: 48px + - Left panel: 320px + - Right properties: 320px + - Resize handle: 6px + - Bottom timeline/toolbar: 224px + +### Posture rules + +- **One accent, used at most twice per screen.** Default budget is eyebrow + primary CTA. +- **Red is reserved** for REC, cut, skip, trim, and transcript highlight only. It is not a brand color. +- **Mint is the single brand color** for active state, focus ring, toggle-on, and brand mark. +- **Off-white canvas, tiered surfaces.** Never use pure `#000` or pure `#fff` for editorial chrome. +- **Soft elevation, slate-based shadows.** Two `--elev-card` and `--elev-pop` are enough. +- **System-ui type, mono numerics.** No web fonts loaded; ships the declared fallback stack. + +--- + +## Motion + +- `--motion-fast: 120ms` — hover, focus ring +- `--motion-base: 180ms` — state transitions +- `--ease: cubic-bezier(0.2, 0, 0, 1)` — standard easing + +--- + +## Voice & Tone + +- **Adjectives:** high-fidelity, professional, calm, restrained. +- **Tone:** a confident dark-themed tool. Marketing prose is sparse; product UI carries the work. +- **Messaging pillar:** video recording, post-processing editing, and timeline manipulation — same three jobs the editor does. + +### Vocabulary + +- **Use:** Record, Trim, Cut, Skip, Timeline, Clip, Track, Transcript. +- **Avoid:** playful emoji feature labels (✨ 🚀 🎯), "AI-powered" in product chrome, generic SaaS copy ("supercharge your workflow"). + +--- + +## Imagery + +- **Style:** schematic, palette-derived. Scene illustration in preview is grayscale made from the same neutrals — not literal artwork. +- **Treatment:** replace placeholder scenes with real project footage when shipping. +- **Avoid:** stock photography, decorative illustration in tool chrome, hand-drawn mascots. + +--- + +## Files in this directory + +| File | Role | +|---|---| +| `openscreen-editor-2.html` | Latest editor (light + dark themes in one file). Canonical source of tokens. | +| `openscreen-editor.html` | First editor pass. Light-only. | +| `editor.html` | Earlier experimental editor with red REC accent. | +| `openscreen-landing.html` | Marketing landing page. | +| `index.html` | Launcher / overview. | +| `DESIGN.md` | This document. | + +--- + +## Open questions / follow-ups + +- `editor.html` uses a different red-dominant accent and predates the mint brand color. Keep as historical reference; do not import its tokens into new work. +- Landing page (`openscreen-landing.html`) is light-only; consider a dark variant once product photography is sourced. +- No logo asset is committed yet. Brand mark should be a simple wordmark or geometric mark in mint on the dark canvas. diff --git a/design/SKILL.md b/design/SKILL.md new file mode 100644 index 0000000000..cf8e245985 --- /dev/null +++ b/design/SKILL.md @@ -0,0 +1,16 @@ +--- +name: openscreen-design +description: Use this skill to generate well-branded interfaces and assets for OpenScreen (an AI-native screen-recording studio & video editor), either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping. +user-invocable: true +--- + +Read the README.md file within this skill, and explore the other available files. +If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. +If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. + +Quick orientation: +- `styles.css` is the single CSS entry point — link it and use the CSS custom properties (never hard-code hex). Dark is the default; add `data-theme="light"` on a wrapper for the light palette. +- Type: Geist (UI) + Geist Mono (numbers/technical). Base 13px. +- One brand hue: emerald `--accent`. Ration it. Timeline lanes add amber/orange/red semantic accents. +- Components live in `components/` (forms, display, editor); full-screen recreations in `ui_kits/editor/`. Icons are Lucide. +- No emoji, no gradients-as-decoration, no photographic backgrounds. Dense pro-tool density, glassy floating panels, hairline borders. diff --git a/design/_ds_manifest.json b/design/_ds_manifest.json new file mode 100644 index 0000000000..082a6cb6ba --- /dev/null +++ b/design/_ds_manifest.json @@ -0,0 +1 @@ +{"namespace":"DesignSystem_d5355e","components":[{"name":"Badge","sourcePath":"components/display/Badge.jsx"},{"name":"Card","sourcePath":"components/display/Card.jsx"},{"name":"Chip","sourcePath":"components/display/Chip.jsx"},{"name":"ProgressBar","sourcePath":"components/display/ProgressBar.jsx"},{"name":"ChatBubble","sourcePath":"components/editor/ChatBubble.jsx"},{"name":"FacetRailButton","sourcePath":"components/editor/FacetRailButton.jsx"},{"name":"MediaCard","sourcePath":"components/editor/MediaCard.jsx"},{"name":"ProposalCard","sourcePath":"components/editor/ProposalCard.jsx"},{"name":"TimelinePill","sourcePath":"components/editor/TimelinePill.jsx"},{"name":"Button","sourcePath":"components/forms/Button.jsx"},{"name":"IconButton","sourcePath":"components/forms/IconButton.jsx"},{"name":"SegmentedControl","sourcePath":"components/forms/SegmentedControl.jsx"},{"name":"Select","sourcePath":"components/forms/Select.jsx"},{"name":"Slider","sourcePath":"components/forms/Slider.jsx"},{"name":"Switch","sourcePath":"components/forms/Switch.jsx"},{"name":"TextField","sourcePath":"components/forms/TextField.jsx"}],"startingPoints":[{"name":"editor","path":"ui_kits/editor/index.html","previewPath":"ui_kits/editor/index.html","kind":"screen","section":"Editor","subtitle":"OpenScreen editor shell (chat + stage + timeline)","viewport":"1360x840"}],"cards":[{"path":"guidelines/brand-elevation.card.html","group":"Brand","viewport":"700x170","subtitle":"Resting card vs floating popover","name":"Elevation"},{"path":"guidelines/brand-logo.card.html","group":"Brand","viewport":"700x140","subtitle":"Icon mark + Geist wordmark lockup","name":"Logo"},{"path":"guidelines/brand-motion.card.html","group":"Brand","viewport":"700x150","subtitle":"One easing, 0.15s; record dot pulses","name":"Motion"},{"path":"guidelines/brand-radii.card.html","group":"Brand","viewport":"700x170","subtitle":"Nested-radius rhythm, 2 → fully round","name":"Radii"},{"path":"guidelines/colors-brand.card.html","group":"Colors","viewport":"700x150","subtitle":"Emerald — the single brand hue","name":"Brand / accent"},{"path":"guidelines/colors-semantic.card.html","group":"Colors","viewport":"700x150","subtitle":"Timeline lanes & status — rationed accents","name":"Semantic"},{"path":"guidelines/colors-surfaces.card.html","group":"Colors","viewport":"700x160","subtitle":"Near-black blue-grey, low → high","name":"Surfaces & borders"},{"path":"guidelines/colors-text.card.html","group":"Colors","viewport":"700x140","subtitle":"Primary → meta text ramp","name":"Text"},{"path":"components/display/display.card.html","group":"Components","viewport":"700x300","subtitle":"Badge · Chip · Card · ProgressBar","name":"Display"},{"path":"components/editor/editor.card.html","group":"Components","viewport":"700x520","subtitle":"ChatBubble · ProposalCard · MediaCard · TimelinePill · FacetRailButton","name":"Editor"},{"path":"components/forms/forms.card.html","group":"Components","viewport":"700x430","subtitle":"Button · IconButton · SegmentedControl · Switch · Slider · Select · TextField","name":"Forms"},{"path":"ui_kits/editor/index.html","group":"Editor","viewport":"1360x840","subtitle":"Full OpenScreen editor — Edit mode"},{"path":"guidelines/spacing-scale.card.html","group":"Spacing","viewport":"700x150","subtitle":"Common rungs — dense, off-grid","name":"Scale"},{"path":"guidelines/type-families.card.html","group":"Type","viewport":"700x170","subtitle":"Geist (UI) + Geist Mono (numeric)","name":"Families"},{"path":"guidelines/type-reading.card.html","group":"Type","viewport":"700x150","subtitle":"Transcript reads at 1.85 line-height","name":"Reading"},{"path":"guidelines/type-scale.card.html","group":"Type","viewport":"700x230","subtitle":"9.5px labels → 16.5px headings","name":"Scale"}],"templates":[],"hasThumbnailHtml":false,"globalCssPaths":["tokens/base.css","tokens/fonts.css","tokens/colors.css","tokens/typography.css","tokens/spacing.css","tokens/radii.css","tokens/elevation.css","tokens/effects.css","styles.css"],"tokens":[{"name":"--ease","value":"cubic-bezier(0.4, 0, 0.2, 1)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur","value":"0.15s","kind":"other","definedIn":"tokens/base.css"},{"name":"--brand-green","value":"#34b27b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-hover","value":"#2d9e6c","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-active","value":"#27885c","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-ring","value":"rgba(52, 178, 123, 0.5)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-glow","value":"rgba(74, 222, 128, 0.4)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-tint","value":"rgba(52, 178, 123, 0.13)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--rec-red","value":"#ef4444","kind":"color","definedIn":"tokens/colors.css"},{"name":"--rec-red-text","value":"#f87171","kind":"font","definedIn":"tokens/colors.css"},{"name":"--rec-amber","value":"#f59e0b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--rec-amber-text","value":"#fbbf24","kind":"font","definedIn":"tokens/colors.css"},{"name":"--background","value":"0 0% 100%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--foreground","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--card","value":"0 0% 100%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--card-foreground","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--popover","value":"0 0% 100%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--popover-foreground","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--primary","value":"24 9.8% 10%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--primary-foreground","value":"60 9.1% 97.8%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--secondary","value":"60 4.8% 95.9%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--secondary-foreground","value":"24 9.8% 10%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--muted","value":"60 4.8% 95.9%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--muted-foreground","value":"25 5.3% 44.7%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--accent","value":"60 4.8% 95.9%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--accent-foreground","value":"24 9.8% 10%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--destructive","value":"0 84.2% 60.2%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--destructive-foreground","value":"60 9.1% 97.8%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--border","value":"20 5.9% 90%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--input","value":"20 5.9% 90%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--ring","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--radius","value":"0.5rem","kind":"radius","definedIn":"tokens/spacing.css"},{"name":"--shell-0","value":"#050606","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-1","value":"#07080a","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-2","value":"#08090b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-3","value":"#090a0c","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-4","value":"#09090b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-5","value":"#0b0c10","kind":"color","definedIn":"tokens/colors.css"},{"name":"--line-subtle","value":"rgba(255, 255, 255, 0.055)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--line","value":"rgba(255, 255, 255, 0.075)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--line-strong","value":"rgba(255, 255, 255, 0.10)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--fill-subtle","value":"rgba(255, 255, 255, 0.032)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--fill","value":"rgba(255, 255, 255, 0.045)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--fill-hover","value":"rgba(255, 255, 255, 0.075)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--text-hi","value":"#ffffff","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-1","value":"#e4e4e7","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-2","value":"#d9e2ee","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-3","value":"#a1a1aa","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-4","value":"rgba(255, 255, 255, 0.6)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-5","value":"rgba(255, 255, 255, 0.4)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-slate-500","value":"#64748b","kind":"font","definedIn":"tokens/colors.css"},{"name":"--color-bg","value":"hsl(var(--background))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--color-fg","value":"hsl(var(--foreground))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-card","value":"hsl(var(--card))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-popover","value":"hsl(var(--popover))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--text-body","value":"hsl(var(--foreground))","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-muted","value":"hsl(var(--muted-foreground))","kind":"font","definedIn":"tokens/colors.css"},{"name":"--border-default","value":"hsl(var(--border))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--focus-ring","value":"0 0 0 3px rgba(16,185,129,0.32)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--accent-primary","value":"var(--brand-green)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--font-sans","value":"\"Inter\", ui-sans-serif, system-ui, -apple-system,\n\t\t\"Segoe UI\", Roboto, Helvetica, Arial, sans-serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-mono","value":"\"IBM Plex Mono\", \"Fira Code\", ui-monospace,\n\t\tSFMono-Regular, Menlo, Consolas, monospace","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-marker","value":"\"Permanent Marker\", cursive","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-hand","value":"\"Caveat\", cursive","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-impact","value":"\"Bebas Neue\", \"Oswald\", sans-serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-serif","value":"\"Playfair Display\", Georgia, serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-grotesk","value":"\"Space Grotesk\", sans-serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--fw-regular","value":"400","kind":"other","definedIn":"tokens/typography.css"},{"name":"--fw-medium","value":"500","kind":"other","definedIn":"tokens/typography.css"},{"name":"--fw-semibold","value":"600","kind":"other","definedIn":"tokens/typography.css"},{"name":"--fw-bold","value":"700","kind":"other","definedIn":"tokens/typography.css"},{"name":"--text-9","value":"9px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-10","value":"10px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-11","value":"11px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-12","value":"12px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-13","value":"13px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-sm","value":"14px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-base","value":"16px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-lg","value":"18px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-xl","value":"20px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-2xl","value":"24px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--lh-none","value":"1","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-tight","value":"1.15","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-snug","value":"1.35","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-normal","value":"1.5","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-relaxed","value":"1.6","kind":"other","definedIn":"tokens/typography.css"},{"name":"--tracking-tight","value":"-0.01em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-normal","value":"0","kind":"font","definedIn":"tokens/typography.css"},{"name":"--space-0","value":"0","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-0-5","value":"2px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-1","value":"4px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-1-5","value":"6px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-2","value":"8px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-2-5","value":"10px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-3","value":"12px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-3-5","value":"14px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-4","value":"16px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-5","value":"20px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-6","value":"24px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-8","value":"32px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-10","value":"40px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-12","value":"48px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--radius-sm","value":"6px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-md","value":"9px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-lg","value":"11px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-xl","value":"12px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-2xl","value":"14px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-3xl","value":"16px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-window","value":"24px","kind":"radius","definedIn":"tokens/spacing.css"},{"name":"--radius-full","value":"9999px","kind":"radius","definedIn":"tokens/spacing.css"},{"name":"--control-h-xs","value":"28px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-h-sm","value":"32px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-h-md","value":"36px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-h-lg","value":"40px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-xs","value":"11px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-sm","value":"13px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-md","value":"16px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-lg","value":"20px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--rail-width","value":"clamp(286px, 20vw, 352px)","kind":"other","definedIn":"tokens/spacing.css"},{"name":"--workspace-pad","value":"14px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--radius-xs","value":"2px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-pill","value":"9999px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--elev-card","value":"inset 0 1px 0 rgba(255,255,255,0.04), 0 2px 10px -4px rgba(0,0,0,0.55)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--elev-pop","value":"0 20px 50px -16px rgba(0,0,0,0.75), inset 0 1px 0 rgba(255,255,255,0.05)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--glow-accent","value":"0 2px 10px -3px var(--accent-soft)","kind":"color","definedIn":"tokens/elevation.css"},{"name":"--elev-card","value":"0 1px 2px rgba(15,23,42,0.05), 0 2px 10px -3px rgba(15,23,42,0.08)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-theme=\"light\"]"},{"name":"--elev-pop","value":"0 20px 48px -16px rgba(15,23,42,0.22), 0 4px 12px rgba(15,23,42,0.08)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-theme=\"light\"]"},{"name":"--focus-ring","value":"0 0 0 3px rgba(16,185,129,0.2)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-theme=\"light\"]"},{"name":"--shadow-hud","value":"0 20px 60px rgba(0, 0, 0, 0.42),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.06)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-panel","value":"0 24px 70px rgba(0, 0, 0, 0.42),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.045)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-popover","value":"0 18px 42px rgba(0, 0, 0, 0.48),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.045)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-card","value":"inset 0 1px 0 rgba(255, 255, 255, 0.035)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-card-hover","value":"0 10px 24px rgba(0, 0, 0, 0.22),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.04)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-badge","value":"0 6px 14px rgba(0, 0, 0, 0.35)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-inset-hi","value":"inset 0 1px 0 rgba(255, 255, 255, 0.025)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-capture","value":"0 40px 90px rgba(0, 0, 0, 0.45),\n\t\t0 12px 32px rgba(0, 0, 0, 0.35)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--glass-blur","value":"blur(24px) saturate(150%)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--glass-blur-strong","value":"blur(24px) saturate(140%)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--glass-shell","value":"linear-gradient(\n\t\t145deg,\n\t\trgba(13, 14, 17, 0.94) 0%,\n\t\trgba(8, 9, 12, 0.9) 100%\n\t)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--glass-hud","value":"rgba(7, 8, 10, 0.9)","kind":"color","definedIn":"tokens/effects.css"},{"name":"--glass-popover","value":"rgba(8, 9, 12, 0.96)","kind":"color","definedIn":"tokens/effects.css"},{"name":"--gradient-workspace","value":"radial-gradient(\n\t\t\tcircle at 18% 0%,\n\t\t\trgba(52, 178, 123, 0.08),\n\t\t\ttransparent 30%\n\t\t),\n\t\tlinear-gradient(180deg, #08090b 0%, #050606 100%)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--gradient-panel","value":"linear-gradient(\n\t\t\t180deg,\n\t\t\trgba(255, 255, 255, 0.035),\n\t\t\trgba(255, 255, 255, 0.012)\n\t\t),\n\t\t#090a0c","kind":"other","definedIn":"tokens/effects.css"},{"name":"--gradient-selected","value":"linear-gradient(\n\t\t145deg,\n\t\trgba(52, 178, 123, 0.13),\n\t\trgba(255, 255, 255, 0.045)\n\t)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-fast","value":"120ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-base","value":"150ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-mid","value":"200ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-slow","value":"300ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--press-scale","value":"0.95","kind":"other","definedIn":"tokens/effects.css"},{"name":"--ring-focus","value":"0 0 0 2px var(--brand-green-ring)","kind":"color","definedIn":"tokens/effects.css"},{"name":"--ring-slider","value":"0 0 0 4px rgba(52, 178, 123, 0.12)","kind":"shadow","definedIn":"tokens/effects.css"}],"themes":[{"selector":".dark","label":"Dark"},{"selector":"[data-theme=\"light\"]","label":"Light"}],"fonts":[],"brandFonts":[{"family":"Inter","status":"ok","tokens":["--font-sans"],"path":"tokens/typography.css"},{"family":"IBM Plex Mono","status":"ok","tokens":["--font-mono"],"path":"tokens/typography.css"},{"family":"Permanent Marker","status":"ok","tokens":["--font-caption-marker"],"path":"tokens/typography.css"},{"family":"Caveat","status":"ok","tokens":["--font-caption-hand"],"path":"tokens/typography.css"},{"family":"Bebas Neue","status":"ok","tokens":["--font-caption-impact"],"path":"tokens/typography.css"},{"family":"Playfair Display","status":"ok","tokens":["--font-caption-serif"],"path":"tokens/typography.css"},{"family":"Space Grotesk","status":"ok","tokens":["--font-caption-grotesk"],"path":"tokens/typography.css"}],"source":"spa"} diff --git a/design/assets/logo-icon.png b/design/assets/logo-icon.png new file mode 100644 index 0000000000..7163a46553 Binary files /dev/null and b/design/assets/logo-icon.png differ diff --git a/design/components/display/Badge.jsx b/design/components/display/Badge.jsx new file mode 100644 index 0000000000..ae809b1497 --- /dev/null +++ b/design/components/display/Badge.jsx @@ -0,0 +1,45 @@ +import React from 'react'; + +/** + * OpenScreen Badge — the small status pill. Optional leading dot. + * tones: accent (default) · neutral · danger · warn. `soft` uses the + * tinted fill (default); pass soft={false} for a bordered outline. + */ +export function Badge({ + children, + tone = 'accent', + dot = false, + soft = true, + mono = true, + style = {}, +}) { + const map = { + accent: { fg: 'var(--accent)', bg: 'var(--accent-soft)', bd: 'var(--accent-border)', dot: 'var(--accent)' }, + neutral: { fg: 'var(--muted)', bg: 'var(--surface-2)', bd: 'var(--border)', dot: 'var(--muted)' }, + danger: { fg: 'var(--danger)', bg: 'var(--danger-soft)', bd: 'var(--danger)', dot: 'var(--danger)' }, + warn: { fg: 'var(--annotation)', bg: 'var(--annotation-wash)', bd: 'var(--annotation)', dot: 'var(--annotation)' }, + }; + const c = map[tone] || map.accent; + return ( + + {dot && } + {children} + + ); +} diff --git a/design/components/display/Card.jsx b/design/components/display/Card.jsx new file mode 100644 index 0000000000..44a5065504 --- /dev/null +++ b/design/components/display/Card.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +/** + * OpenScreen Card — the flat bordered surface container. `elevation` + * "card" (resting) or "pop" (floating). `level` picks the fill surface. + * Optional `title` renders a bordered header. + */ +export function Card({ + children, + title = null, + headerRight = null, + elevation = 'card', + level = 1, + radius = 14, + padding = 14, + style = {}, + bodyStyle = {}, +}) { + const fills = { 0: 'var(--surface)', 1: 'var(--surface-1)', 2: 'var(--surface-2)' }; + return ( +
+ {title && ( +
+ {title} + {headerRight && {headerRight}} +
+ )} +
{children}
+
+ ); +} diff --git a/design/components/display/Chip.jsx b/design/components/display/Chip.jsx new file mode 100644 index 0000000000..670e6d3cfd --- /dev/null +++ b/design/components/display/Chip.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +/** + * OpenScreen Chip — rounded action/filter pill with optional leading + * icon. Used for the quick-action row above the composer. Hover gives + * an accent wash + border. + */ +export function Chip({ children, icon = null, onClick, style = {} }) { + const [hover, setHover] = React.useState(false); + return ( + + ); +} diff --git a/design/components/display/ProgressBar.jsx b/design/components/display/ProgressBar.jsx new file mode 100644 index 0000000000..26cc7fcecb --- /dev/null +++ b/design/components/display/ProgressBar.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +/** + * OpenScreen ProgressBar — thin track with an emerald (or gradient) + * fill. Used for the recipe step progress and generic determinate + * progress. Height defaults to the 3px recipe bar. + */ +export function ProgressBar({ value = 0, height = 3, gradient = true, style = {} }) { + const pct = Math.max(0, Math.min(100, value)); + return ( +
+
+
+ ); +} diff --git a/design/components/display/display.card.html b/design/components/display/display.card.html new file mode 100644 index 0000000000..df22c869d7 --- /dev/null +++ b/design/components/display/display.card.html @@ -0,0 +1,58 @@ + + + + + + + + + + +
+ + diff --git a/design/components/editor/ChatBubble.jsx b/design/components/editor/ChatBubble.jsx new file mode 100644 index 0000000000..7d0959a035 --- /dev/null +++ b/design/components/editor/ChatBubble.jsx @@ -0,0 +1,54 @@ +import React from 'react'; + +/** + * OpenScreen ChatBubble — a single agent-conversation row. Handles the + * three roles: assistant (avatar + left bubble, meta header), user + * (right, emerald-tinted), system (centered pill). + */ +export function ChatBubble({ role = 'assistant', author, time, children, avatar = null }) { + if (role === 'system') { + return ( +
+
+ {children} +
+
+ ); + } + if (role === 'user') { + return ( +
+
+ {(author || time) && ( +
+ {author} + {time && {time}} +
+ )} +
+ {children} +
+
+
+ ); + } + // assistant + return ( +
+ +
+ {(author || time) && ( +
+ {author} + {time && {time}} +
+ )} +
+ {children} +
+
+
+ ); +} diff --git a/design/components/editor/FacetRailButton.jsx b/design/components/editor/FacetRailButton.jsx new file mode 100644 index 0000000000..9490d85f0c --- /dev/null +++ b/design/components/editor/FacetRailButton.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +/** + * OpenScreen FacetRailButton — an icon button in the vertical facet + * rail beside the inspector. Active facet gets the emerald-soft fill. + */ +export function FacetRailButton({ active = false, title, onClick, children, style = {} }) { + const [hover, setHover] = React.useState(false); + let color = active ? 'var(--accent)' : 'var(--muted)'; + let background = active ? 'var(--accent-soft)' : 'transparent'; + if (!active && hover) { color = 'var(--fg)'; background = 'var(--surface-3)'; } + return ( + + ); +} diff --git a/design/components/editor/MediaCard.jsx b/design/components/editor/MediaCard.jsx new file mode 100644 index 0000000000..68a0508adb --- /dev/null +++ b/design/components/editor/MediaCard.jsx @@ -0,0 +1,52 @@ +import React from 'react'; + +/** + * OpenScreen MediaCard — a clip tile in the media library. Gradient + * thumbnail with a film-strip glyph, a drag-handle affordance, and a + * name/duration/size footer. Selected → 1.5px emerald border. + */ +export function MediaCard({ + name, + duration, + size, + from = '#10b981', + to = '#0d986a', + selected = false, + draggable = true, + onClick, + onDragStart, + onDragEnd, + style = {}, +}) { + return ( + + ); +} diff --git a/design/components/editor/ProposalCard.jsx b/design/components/editor/ProposalCard.jsx new file mode 100644 index 0000000000..f8320a9dda --- /dev/null +++ b/design/components/editor/ProposalCard.jsx @@ -0,0 +1,49 @@ +import React from 'react'; + +/** + * OpenScreen ProposalCard — the agent's "Proposed cuts" card: a header + * with total + confidence badge, a list of time-range rows with striped + * clip chips, an apply/review action row, and a rationale footnote. + */ +export function ProposalCard({ + title = 'Proposed cuts', + total, + confidence = 'High confidence', + items = [], + rationale, + applyLabel = 'Apply', + onApply, + onReview, +}) { + return ( +
+
+ + {title} + {total != null && −{total}} + + {confidence} + +
+
+ {items.map((it, i) => ( +
+
+ ))} +
+
+ + +
+ {rationale && ( +
+ + {rationale} +
+ )} +
+ ); +} diff --git a/design/components/editor/TimelinePill.jsx b/design/components/editor/TimelinePill.jsx new file mode 100644 index 0000000000..91d4572f02 --- /dev/null +++ b/design/components/editor/TimelinePill.jsx @@ -0,0 +1,51 @@ +import React from 'react'; + +/** + * OpenScreen TimelinePill — a labelled marker on a timeline lane. + * tone maps to the four lane accents. `fixedWidth` (tag mode) sizes to + * content; otherwise it spans a range via left/width percentages. + */ +export function TimelinePill({ + tone = 'accent', + icon = null, + children, + leftPct = 0, + widthPct = null, + style = {}, +}) { + const map = { + accent: { c: 'var(--accent)', w: 'var(--accent-soft)' }, + annotation: { c: 'var(--annotation)', w: 'var(--annotation-wash)' }, + speed: { c: 'var(--speed)', w: 'var(--speed-wash)' }, + danger: { c: 'var(--danger)', w: 'var(--danger-soft)' }, + }; + const t = map[tone] || map.accent; + return ( + + {icon} + {children} + + ); +} diff --git a/design/components/editor/editor.card.html b/design/components/editor/editor.card.html new file mode 100644 index 0000000000..017c424c88 --- /dev/null +++ b/design/components/editor/editor.card.html @@ -0,0 +1,73 @@ + + + + + + + + + + +
+ + diff --git a/design/components/forms/Button.jsx b/design/components/forms/Button.jsx new file mode 100644 index 0000000000..da7b521d46 --- /dev/null +++ b/design/components/forms/Button.jsx @@ -0,0 +1,88 @@ +import React from 'react'; + +/** + * OpenScreen Button — the primary text action. + * variants: primary (emerald fill) · secondary (surface + border) · ghost (transparent). + * Icons are passed as children alongside a label, or use IconButton for icon-only. + */ +export function Button({ + variant = 'primary', + size = 'md', + icon = null, + iconRight = null, + disabled = false, + fullWidth = false, + onClick, + children, + style = {}, + ...rest +}) { + const sizes = { + sm: { h: 28, px: 10, fs: 12, gap: 6, radius: 8 }, + md: { h: 32, px: 14, fs: 13, gap: 7, radius: 9 }, + lg: { h: 40, px: 18, fs: 14, gap: 9, radius: 12 }, + }; + const s = sizes[size] || sizes.md; + + const variants = { + primary: { + background: 'var(--accent)', + color: '#fff', + border: '1px solid var(--accent)', + boxShadow: '0 2px 10px -3px var(--accent-soft)', + }, + secondary: { + background: 'var(--surface-1)', + color: 'var(--fg-2)', + border: '1px solid var(--border)', + }, + ghost: { + background: 'transparent', + color: 'var(--fg-2)', + border: '1px solid transparent', + }, + }; + const v = variants[variant] || variants.primary; + + const base = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: s.gap, + height: s.h, + padding: `0 ${s.px}px`, + borderRadius: s.radius, + fontFamily: 'var(--font-display)', + fontSize: s.fs, + fontWeight: 600, + lineHeight: 1, + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.45 : 1, + whiteSpace: 'nowrap', + ...v, + ...style, + }; + + const [hover, setHover] = React.useState(false); + const hoverStyle = !disabled && hover ? ( + variant === 'primary' ? { background: 'var(--brand-lo)' } + : variant === 'secondary' ? { borderColor: 'var(--border-hi)', color: 'var(--fg)' } + : { background: 'var(--surface-1)', borderColor: 'var(--border)' } + ) : {}; + + return ( + + ); +} diff --git a/design/components/forms/IconButton.jsx b/design/components/forms/IconButton.jsx new file mode 100644 index 0000000000..46421a7e8d --- /dev/null +++ b/design/components/forms/IconButton.jsx @@ -0,0 +1,59 @@ +import React from 'react'; + +/** + * OpenScreen IconButton — square, icon-only. The workhorse of toolbars, + * the topbar, and floating chrome. Ghost by default; `active` gives the + * emerald-soft selected look; `tone="danger"` for destructive. + */ +export function IconButton({ + size = 32, + active = false, + tone = 'default', + disabled = false, + title, + onClick, + children, + style = {}, + ...rest +}) { + const [hover, setHover] = React.useState(false); + + const rest_ = { default: 'var(--muted)', danger: 'var(--muted)' }[tone]; + let color = active ? 'var(--accent)' : rest_; + let background = active ? 'var(--accent-soft)' : 'transparent'; + + if (!disabled && hover && !active) { + if (tone === 'danger') { color = 'var(--danger)'; background = 'var(--danger-soft)'; } + else { color = 'var(--fg)'; background = 'var(--surface-2)'; } + } + + return ( + + ); +} diff --git a/design/components/forms/SegmentedControl.jsx b/design/components/forms/SegmentedControl.jsx new file mode 100644 index 0000000000..852654c116 --- /dev/null +++ b/design/components/forms/SegmentedControl.jsx @@ -0,0 +1,64 @@ +import React from 'react'; + +/** + * OpenScreen SegmentedControl — the pill-in-a-trough tab switcher. + * Used for Media/Edit/Rec stage modes, Image/Color/Gradient background + * tabs, Screen/Window source, etc. The active segment gets a raised + * surface chip; inactive segments are muted text. + */ +export function SegmentedControl({ + options = [], + value, + onChange, + size = 'md', + style = {}, +}) { + const s = size === 'sm' + ? { pad: '6px 8px', fs: 11.5, trough: 2, radius: 8, inner: 6 } + : { pad: '7px 14px', fs: 12.5, trough: 3, radius: 11, inner: 8 }; + + return ( +
+ {options.map((opt) => { + const val = typeof opt === 'string' ? opt : opt.value; + const label = typeof opt === 'string' ? opt : opt.label; + const active = val === value; + return ( + + ); + })} +
+ ); +} diff --git a/design/components/forms/Select.jsx b/design/components/forms/Select.jsx new file mode 100644 index 0000000000..9399542727 --- /dev/null +++ b/design/components/forms/Select.jsx @@ -0,0 +1,43 @@ +import React from 'react'; + +/** + * OpenScreen Select — native dropdown styled to match. Used for + * caption style, layout preset, transcript language, etc. + */ +export function Select({ + options = [], + value, + onChange, + fullWidth = true, + style = {}, + ...rest +}) { + return ( + + ); +} diff --git a/design/components/forms/Slider.jsx b/design/components/forms/Slider.jsx new file mode 100644 index 0000000000..fcab589fd7 --- /dev/null +++ b/design/components/forms/Slider.jsx @@ -0,0 +1,59 @@ +import React from 'react'; + +/** + * OpenScreen Slider — the labelled range control inside inspector cards. + * Renders the whole card: label (left) + mono value in emerald (right) + * above a filled range track. Pass `card={false}` for a bare track. + */ +export function Slider({ + label, + value = 0, + min = 0, + max = 100, + step = 1, + format, + onChange, + card = true, + style = {}, +}) { + const pct = ((value - min) / (max - min)) * 100; + const trackStyle = { + width: '100%', + display: 'block', + backgroundImage: `linear-gradient(var(--accent),var(--accent)), linear-gradient(var(--surface-3),var(--surface-3))`, + backgroundSize: `${pct}% 5px, 100% 5px`, + }; + const display = format ? format(value) : value; + + const input = ( + onChange && onChange(Number(e.target.value))} + style={trackStyle} + /> + ); + + if (!card) return input; + + return ( +
+
+ {label} + {display} +
+ {input} +
+ ); +} diff --git a/design/components/forms/Switch.jsx b/design/components/forms/Switch.jsx new file mode 100644 index 0000000000..ee13eb719f --- /dev/null +++ b/design/components/forms/Switch.jsx @@ -0,0 +1,47 @@ +import React from 'react'; + +/** + * OpenScreen Switch — the pill toggle used in inspector setting rows + * (Blur background, Mirror webcam, Shrink on zoom, Show cursor…). + * Track fills emerald when on; knob slides right. + */ +export function Switch({ checked = false, onChange, disabled = false, style = {} }) { + const W = 38, H = 22, KNOB = 16, PAD = 3; + return ( + + ); +} diff --git a/design/components/forms/TextField.jsx b/design/components/forms/TextField.jsx new file mode 100644 index 0000000000..1cc6298286 --- /dev/null +++ b/design/components/forms/TextField.jsx @@ -0,0 +1,69 @@ +import React from 'react'; + +/** + * OpenScreen TextField — one primitive for both single-line inputs and + * the multiline composer (same border/fill/radius vocabulary). + * `multiline` swaps for an auto-height +
+ + +
+
+ + + + + + +
+ + +
+
+
+ + 00:00:12.4 + 1920 × 1080 · 60 fps +
Aperçu · 16:9
+
+ +
+
+
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + +
+
+ + + +
+ + Scroll + Pan + + + Ctrl+ Scroll + Zoom + +
+ +
+ +
+ + + + +
+
+
0:00.0
+
0:30.0
+
1:00.0
+
1:30.0
+
2:00.0
+
2:30.0
+
3:00.0
+
3:30.0
+
4:00.0
+
4:30.0
+
5:00.0
+
5:30.0
+
6:00.0
+
6:30.0
+
7:00.0
+
+
+ + +
+ +
+ + + + +
+ + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

demo-n8n-as-code-1.mp4

+

0:00.0 — 2:34.7 source 0:00.0–2:34.7

+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

2026-03-10 09-10-39.mp4

+

2:34.7 — 7:03.6 source 0:00.0–4:28.9

+
+
+
+ +
+ +
+
+ + + +
+ +
+ + +
+
+ + + +
+
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + diff --git a/design/openscreen-widget.html b/design/openscreen-widget.html new file mode 100644 index 0000000000..314cbc2de8 --- /dev/null +++ b/design/openscreen-widget.html @@ -0,0 +1,181 @@ + + + + + Bundled Page + + + + +
+ + + + + + +
+
Unpacking...
+ + + + + + + + + + \ No newline at end of file diff --git a/design/readme.md b/design/readme.md new file mode 100644 index 0000000000..1213b8b69f --- /dev/null +++ b/design/readme.md @@ -0,0 +1,148 @@ +# OpenScreen Design System + +A design system extracted from the **OpenScreen Editor** — an AI-native screen-recording +studio and video editor. Think "Screen Studio meets a chat agent": you record your screen, +and an AI agent cleans up dead air, filler words, and pacing, proposes cuts you approve, and +lets you restyle backgrounds, cursors, captions, zoom and layout from a floating inspector. + +The UI is a **dark-first, dense, pro-tool** aesthetic built around a single emerald brand hue, +Geist / Geist Mono type, glassy floating panels, and a rich multi-lane timeline. + +## Source + +Everything here was extracted from a single ground-truth file in this project: + +- **`OpenScreen Editor v4.dc.html`** — the most complete build of the editor (chat + stage + timeline). + +No external codebase or Figma was provided; the `.dc.html` source IS the ground truth. Related +earlier files (`OpenScreen Editor v2/v3`, `OpenScreen Recording Widget`) were left out of scope +per the request (v4 only). + +> **Sharing:** to let others in your org use this, open the **Share** menu and set the file +> type to **Design System**. + +--- + +## CONTENT FUNDAMENTALS + +How OpenScreen writes. + +- **Voice:** calm, competent, first-person-as-agent. The agent says *"On it — scanning track 1 + for silences now"* and *"I'll flag anything over 600ms."* It narrates what it's doing in plain + language, commits to specifics (numbers, thresholds), and never gushes. +- **Person:** the product speaks as **"I"** (the agent) to **"you"** (the creator). UI chrome is + impersonal and imperative: *"Describe the edit you want…"*, *"Drag a clip onto the timeline + below to add it"*, *"Add chapter at playhead"*. +- **Casing:** Sentence case for body, buttons, and helper text. **UPPERCASE mono micro-labels** + for section eyebrows only — `RECIPE`, `PRESET`, `STYLE`, `ASPECT RATIO`, `CAMERA SHAPE`. +- **Numbers are first-class.** Timecodes (`0:00.0`), durations (`−0:37.3`), resolutions + (`1920 × 1080 · 60 fps`), counts (`3/5`, `−3 cuts`), percentages (`0% context`) all render in + **Geist Mono**. Precision reads as trustworthy. +- **Verbs for actions:** "Apply 3 cuts", "Review each", "Regenerate", "Import media", "Export". + Short, concrete, no "Click here". +- **Tone of empty states:** factual, lightly guiding — *"Not generated yet — pick a language and + click regenerate."*, *"No annotations yet"*. +- **No emoji.** None anywhere in the product. Don't introduce them. +- **Filenames stay real:** `demo-n8n-as-code-1.mp4`, `recording-1783066227227.mp4` — never + "My Video.mp4". + +--- + +## VISUAL FOUNDATIONS + +- **Theme:** dark-first. A light theme exists and is a full token swap (`data-theme="light"`), + but dark is the default and the "hero" look. The accent emerald is identical in both themes. +- **Color:** near-black blue-grey surfaces (`#080a0d` → `#2b313b`), a single **emerald** brand + hue (`#10b981`) used sparingly for primary actions, active states, and focus. Timeline lanes + add three semantic accents — **amber** annotations, **orange** speed ramps, **red/danger** + skips & cuts. Color is rationed: most of the UI is greyscale, emerald marks the one thing that + matters on screen. +- **Type:** Geist for everything UI, Geist Mono for anything numeric/technical. Base 13px. Tight + negative tracking on headings; wide positive tracking on tiny uppercase labels. +- **Backgrounds:** subtle radial gradient on the app shell (lighter toward top-center). The + preview stage sits on its own radial vignette. No photographic backgrounds, no patterns, no + noise/grain. Surfaces are flat fills separated by hairline borders. +- **Borders:** 1px hairlines everywhere (`--border`), with a softer variant for internal dividers + (`--border-soft`) and a brighter one for hover/handles (`--border-hi`). Selected/active + controls get a 1.5px emerald border. +- **Elevation:** two levels. Resting cards use a barely-there inset top highlight + soft shadow. + Floating things (inspector, transport, popover menus) use `--elev-pop` — a deep soft drop + shadow plus a 1px inner top highlight, reading as glass. +- **Blur / transparency:** floating panels and on-video chrome use `backdrop-filter: blur(12–20px)` + over semi-transparent dark fills. This is reserved for elements that float **over the video + stage** (transport, inspector, recording bar, clip labels) — flat panels in the shell do not + blur. +- **Corner radii:** nested-radius rhythm — small controls 6–9px, cards 11–12px, panels/popovers + 14–16px, status chips fully round. Containers always round more than what's nested in them. +- **Cards:** flat `--surface`/`--surface-1` fill, 1px border, radius 11–14px, `--elev-card` + shadow. Setting cards in the inspector pair a label (left) with a mono value in emerald (right) + above a slider. +- **Motion:** one shared easing `cubic-bezier(0.2,0,0,1)` at **0.15s** on color/border/shadow/ + transform for every interactive element. Sliders scale their thumb 1.14× on hover with an + emerald halo. The record dot pulses (`os-pulse`, 1.4s). Content fades up 6px (`os-fade`). No + bounces in chrome, no long durations. +- **Hover:** surfaces step up one level (`transparent → --surface-1/2/3`); ghost icon buttons go + `--muted → --fg`; primary buttons darken to `--brand-lo`; chips gain an `--accent-wash` fill and + `--accent-border`. +- **Press/active/selected:** active tabs & tools get an `--accent-soft` fill with `--accent` text; + selected clips/media get a 1.5px emerald border; toggles slide a knob and fill emerald. +- **Focus:** 3px emerald ring (`--focus-ring`) via `:focus-visible`, no outline. +- **Density:** high. This is a desktop pro tool, not a marketing site. Compact controls + (26–36px tall), off-grid odd paddings (7/9/11/13px), tight gaps. + +--- + +## ICONOGRAPHY + +- **Lucide-style line icons.** Every icon in the product is a stroked SVG at `viewBox="0 0 24 24"`, + `stroke-width` 1.8–2.4, round caps/joins, `fill:none` (a few small glyphs use solid fill — + play/pause triangles, cursor arrow, waveform bars). This matches the **Lucide** icon set almost + exactly, so this system standardises on **Lucide** (`https://unpkg.com/lucide@latest`) as the + icon source. Component cards link Lucide from CDN. +- Icon sizes: 9–17px inside controls, scaled to the control. Stroke colour is always + `currentColor` so icons inherit the control's text colour (muted → fg on hover, accent when + active). +- **No emoji, no icon font, no PNG icons** anywhere. One raster asset only: the logo mark. +- A few icons in the source are hand-tuned (the OpenScreen agent avatar built from ``s, the + waveform bars, the cursor arrow). Treat those as bespoke; use Lucide for everything standard. + +### Brand mark + +- **`assets/logo-icon.png`** — the OpenScreen app icon, transparent (24×24 in the topbar; the + mark is a disc, so it needs a little more box than a filled tile to carry the same optical + weight). This is the only provided brand asset. There is **no wordmark file**; the product sets + "OpenScreen" in Geist 600, 15px, `-0.015em` tracking next to the icon. Do not redraw or + recolour the mark. + +--- + +## Index / manifest + +Root: +- `styles.css` — the single entry point consumers link. `@import`s everything below. +- `readme.md` — this file. +- `SKILL.md` — Agent-Skill front-matter wrapper. +- `assets/logo-icon.png` — brand mark. + +`tokens/` — CSS custom properties (all reachable from `styles.css`): +- `fonts.css` · `colors.css` · `typography.css` · `spacing.css` · `radii.css` · `elevation.css` · `effects.css` · `base.css` +- `v4.css` — **the vocabulary the cards and components actually use** (`--bg`, `--fg`, + `--font-display`, `--surface-N`, `--accent-*`, …). A verbatim re-export of the product's + `src/styles/design-tokens.css`, imported last so it wins the dozen names the older files + also declare. Re-export it after any token change upstream; `diff` the two files and only + the header comment should differ. Author new work against these names — the older token + files are legacy and largely unreferenced. + +`guidelines/` — foundation specimen cards (Design System tab): colors, type, spacing, radii, elevation, motion. + +`components/` — reusable React primitives (see each `*.prompt.md`): +- `forms/` — Button, IconButton, SegmentedControl, Switch, Slider, Select, TextField +- `display/` — Badge, Chip, Card, ProgressBar +- `editor/` — ChatBubble, ProposalCard, MediaCard, TimelinePill, FacetRailButton + +`ui_kits/editor/` — high-fidelity recreation of the OpenScreen Editor (chat + stage + timeline). + +## Intentional additions + +None. The component inventory is exactly what the v4 editor defines. `TextField` merges the +`` and `