diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 2a3000ef8..b7817f12d 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1,8 @@
* @EtienneLescot
+
+# Linux / Wayland capture — @Beetix
+/electron/native/pipewire-capture/ @Beetix @EtienneLescot
+/electron/native-bridge/capture/linuxNativeCaptureSession* @Beetix @EtienneLescot
+/electron/native-bridge/cursor/recording/pipeWire* @Beetix @EtienneLescot
+/crates/compositor/src/linux_*.rs @Beetix @EtienneLescot
+/crates/compositor/src/*_linux.rs @Beetix @EtienneLescot
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..993fd236d
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,4 @@
+# These are supported funding model platforms
+
+github: [EtienneLescot]
+ko_fi: etiennelescot
diff --git a/.github/workflows/build-onnxruntime-macos.yml b/.github/workflows/build-onnxruntime-macos.yml
new file mode 100644
index 000000000..22aef9432
--- /dev/null
+++ b/.github/workflows/build-onnxruntime-macos.yml
@@ -0,0 +1,315 @@
+name: Build ONNX Runtime (macOS 13 floor)
+
+# Builds ONNX Runtime for macOS arm64 with the deployment target pinned to the
+# floor this app declares, and publishes it as an artifact for a maintainer to
+# attach to a release.
+#
+# WHY THIS EXISTS. Microsoft's own `onnxruntime-osx-arm64-*.tgz` is built for
+# macOS 14. `electron-builder.json5` declares `minimumSystemVersion: "13.0"`,
+# and `scripts/before-pack.cjs` refuses to package any binary that demands more
+# than the floor — correctly, because the deployment target decides which
+# symbols the linker resolves against the OS rather than emitting locally, so a
+# too-high floor strands users on the older OS with `Symbol not found` at dyld
+# time (#515). The result is that `npm run build:mac` cannot package at all.
+#
+# Every published release from 1.24 onward is `minos 14.0`, and every release
+# before it is at least 13.3, so no prebuilt artifact has ever satisfied a 13.0
+# floor. Building it is the only way to keep both macOS 13 support and webcam
+# segmentation.
+#
+# WHAT IS NOT BUILT. The CoreML execution provider. `segmentation.rs` builds its
+# session with no explicit provider, i.e. the CPU EP, which
+# technical-documentation/engineering/webcam-segmentation.md records as a
+# measured decision ("Inference p50, CPU EP: 3.575 ms — the CPU is faster").
+# Dropping CoreML is what takes the library from 36.7 MB to ~22 MB.
+#
+# HOW IT IS PUBLISHED. This workflow does not create releases. It builds,
+# verifies, and prints the exact `PINNED` entry for
+# `scripts/fetch-onnxruntime.mjs` in the job summary; attaching the archive to a
+# release and pasting that entry stays a deliberate human step, so the
+# supply-chain posture the script documents — immutable URL, SHA-256 verified
+# before the archive is opened — is preserved rather than replaced by "whatever
+# CI last uploaded".
+
+on:
+ workflow_dispatch:
+ push:
+ # BRANCHES ONLY. Without this, pushing a tag matches too — and publishing the
+ # artifact under `v0.0.0-onnxruntime-1.27.1` started a fresh 22-minute build of
+ # the very thing that had just been attached to the release.
+ branches:
+ - main
+ paths:
+ - ".github/workflows/build-onnxruntime-macos.yml"
+ - "scripts/fetch-onnxruntime.mjs"
+
+# One build per branch. Without this, every push to a branch that touches the paths
+# above starts another 1-2 h build and none of the earlier ones stop: three ran
+# concurrently on this workflow's own PR, the oldest for nearly four hours.
+concurrency:
+ group: onnxruntime-macos-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ # Provenance attestation. `id-token` mints the OIDC token GitHub signs with, and
+ # `attestations` lets the run record the result. Both are needed by
+ # `actions/attest-build-provenance`; neither grants write access to the repository.
+ id-token: write
+ attestations: write
+
+jobs:
+ build:
+ name: macOS arm64, deployment target 13.0
+ # arm64 runner: the only macOS target upstream ships, and the only one the
+ # app packages. There is no Intel build to match.
+ runs-on: macos-latest
+ # The default is 6 h. This build takes ~10 min on an 8-core M1 with `--parallel 4`
+ # and well over an hour on the runner; a cap turns "pathologically slow" into a
+ # failure somebody sees rather than six hours of quietly burnt minutes.
+ timeout-minutes: 150
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # This job compiles third-party source in the same workspace. Leaving the
+ # token in .git/config would put it within reach of ONNX Runtime's own build
+ # scripts — and this workflow holds `attestations: write`. Same setting
+ # build.yml, docs.yml and nix-build.yml already use.
+ persist-credentials: false
+
+ - name: Read the pinned version from fetch-onnxruntime.mjs
+ id: pin
+ # Single source of truth. `fetch-onnxruntime.test.mjs` already cross-checks
+ # that VERSION satisfies the `api-NN` feature `crates/Cargo.toml` gives
+ # `ort`; reading it here rather than repeating it means a bump cannot leave
+ # this workflow building a version nothing consumes.
+ run: |
+ set -euo pipefail
+ VERSION="$(sed -n 's/^const VERSION = "\(.*\)";$/\1/p' scripts/fetch-onnxruntime.mjs)"
+ [ -n "$VERSION" ] || { echo "::error::VERSION not found in scripts/fetch-onnxruntime.mjs"; exit 1; }
+ COMMIT="$(sed -n 's/^const SOURCE_COMMIT = "\(.*\)";$/\1/p' scripts/fetch-onnxruntime.mjs)"
+ [ -n "$COMMIT" ] || { echo "::error::SOURCE_COMMIT not found in scripts/fetch-onnxruntime.mjs"; exit 1; }
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
+ echo "Building ONNX Runtime v$VERSION at $COMMIT"
+
+ - name: Read the deployment floor from electron-builder.json5
+ id: floor
+ # Also single-sourced: if somebody raises `mac.minimumSystemVersion`, this
+ # build follows rather than silently producing a library for the old floor.
+ run: |
+ set -euo pipefail
+ FLOOR="$(grep -o '"minimumSystemVersion": *"[0-9.]*"' electron-builder.json5 | grep -o '[0-9][0-9.]*')"
+ [ -n "$FLOOR" ] || { echo "::error::minimumSystemVersion not found"; exit 1; }
+ echo "floor=$FLOOR" >> "$GITHUB_OUTPUT"
+ echo "Deployment target: $FLOOR"
+
+ - name: Checkout ONNX Runtime
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: microsoft/onnxruntime
+ # The COMMIT, not the tag. `v1.27.1` upstream is a lightweight tag — it points
+ # straight at a commit and can be moved. Building from a tag would mean
+ # attesting an artifact to "whatever that tag meant this morning", which is
+ # precisely the property this workflow exists to provide.
+ ref: ${{ steps.pin.outputs.commit }}
+ path: onnxruntime-src
+ submodules: recursive
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Check the pinned commit is still what the tag names
+ # Not fatal to the build — the commit above is what gets built either way — but a
+ # tag that has moved means the pin and the version string no longer describe the
+ # same thing, and somebody should look before adopting the artifact.
+ run: |
+ set -euo pipefail
+ TAGGED="$(git -C onnxruntime-src ls-remote https://github.com/microsoft/onnxruntime "refs/tags/v${{ steps.pin.outputs.version }}" | cut -f1)"
+ if [ "$TAGGED" != "${{ steps.pin.outputs.commit }}" ]; then
+ echo "::error::v${{ steps.pin.outputs.version }} now resolves to ${TAGGED:-nothing}, not the pinned ${{ steps.pin.outputs.commit }}"
+ exit 1
+ fi
+ echo "v${{ steps.pin.outputs.version }} still resolves to ${{ steps.pin.outputs.commit }}"
+
+ - name: Read the runner image
+ id: image
+ # `${{ env.ImageOS }}` DOES NOT WORK, and it fails silently. The `env`
+ # expression context only carries what a workflow, job or step `env:` block
+ # defined; `ImageOS`/`ImageVersion` are set by the runner in its own
+ # environment, so the expression evaluates to the empty string and the cache
+ # key simply loses that component. The evidence is in this repo's own cache
+ # list: `build-whisper-stt.yml` builds its key the same way and the stored
+ # keys read `whisper-stt-build-darwin-arm64---` — three hyphens, both
+ # values empty. Reading them in a `run:` step, where they are ordinary shell
+ # variables, is what actually works.
+ run: |
+ set -euo pipefail
+ echo "tag=${ImageOS:-unknown}-${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT"
+ echo "Runner image: ${ImageOS:-unknown} ${ImageVersion:-unknown}"
+
+ - name: Cache the CMake build tree
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: onnxruntime-build
+ # Keyed on the version, the floor and the runner image. The image matters:
+ # CMake bakes absolute SDK paths into the tree, so when GitHub rolls Xcode a
+ # restored tree fails on paths that no longer exist. Scoping the key to the
+ # image busts it automatically on every roll.
+ key: ort-${{ steps.pin.outputs.version }}-${{ steps.floor.outputs.floor }}-${{ steps.image.outputs.tag }}
+
+ - name: Configure
+ # Driven straight at CMake rather than through upstream's `build.sh`.
+ # `build.sh` -> `build_args.py` uses `match`, so it needs Python 3.10+,
+ # and `cmake/CMakeLists.txt` asks for `find_package(Python 3.10)` — but
+ # that requirement is only real for the Python bindings, which are not
+ # built here. The one thing Python IS needed for is generating the symbol
+ # export list (`onnxruntime.lds`); pointing `Python_EXECUTABLE` at
+ # whatever the runner has is enough, and `gen_def.py` parses on 3.9.
+ run: |
+ set -euo pipefail
+ cmake -S onnxruntime-src/cmake -B onnxruntime-build \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_OSX_DEPLOYMENT_TARGET=${{ steps.floor.outputs.floor }} \
+ -DCMAKE_OSX_ARCHITECTURES=arm64 \
+ -Donnxruntime_BUILD_SHARED_LIB=ON \
+ -Donnxruntime_BUILD_UNIT_TESTS=OFF \
+ -DPython_EXECUTABLE="$(command -v python3)"
+
+ - name: Build
+ # BOUNDED, deliberately. Bare `--parallel` means "as many jobs as cores", and
+ # ONNX Runtime's C++ translation units are memory-hungry: on a runner with far
+ # less RAM per core than the reference M1, that is how a 10-minute build becomes
+ # an hour of swapping. `nproc`-1 leaves the machine a core to breathe.
+ run: |
+ set -euo pipefail
+ JOBS="$(( $(sysctl -n hw.ncpu) > 2 ? $(sysctl -n hw.ncpu) - 1 : 1 ))"
+ echo "Building with $JOBS jobs on $(sysctl -n hw.ncpu) cores, $(( $(sysctl -n hw.memsize) / 1073741824 )) GiB"
+ cmake --build onnxruntime-build --config Release --parallel "$JOBS"
+
+ - name: Verify the deployment target
+ # The entire reason this workflow exists. A library that comes out at 14.0
+ # anyway is worse than no library, because it would sail through packaging
+ # and strand macOS 13 users at dyld time.
+ run: |
+ set -euo pipefail
+ V="${{ steps.pin.outputs.version }}"
+ DYLIB="onnxruntime-build/libonnxruntime.${V}.dylib"
+ [ -f "$DYLIB" ] || { echo "::error::$DYLIB was not produced"; exit 1; }
+ MINOS="$(otool -l "$DYLIB" | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}')"
+ echo "minos=$MINOS floor=${{ steps.floor.outputs.floor }}"
+ [ "$MINOS" = "${{ steps.floor.outputs.floor }}" ] || {
+ echo "::error::built for macOS $MINOS, expected ${{ steps.floor.outputs.floor }}"; exit 1; }
+
+ - name: Verify the ABI surface
+ # `ort` is wired `load-dynamic`, so it dlopens this file and calls
+ # `OrtGetApiBase`. The CPU provider is the one `segmentation.rs` uses.
+ # CoreML is deliberately absent and is NOT checked for.
+ run: |
+ set -euo pipefail
+ V="${{ steps.pin.outputs.version }}"
+ DYLIB="onnxruntime-build/libonnxruntime.${V}.dylib"
+ for sym in _OrtGetApiBase _OrtSessionOptionsAppendExecutionProvider_CPU; do
+ nm -gU "$DYLIB" | grep -q " ${sym}$" || { echo "::error::missing export ${sym}"; exit 1; }
+ done
+ lipo -info "$DYLIB"
+ echo "ABI surface OK"
+
+ - name: Package in the upstream layout
+ # Byte-for-byte the same shape as `onnxruntime-osx-arm64-.tgz`, so
+ # `fetch-onnxruntime.mjs` needs no extraction change — only a URL and a
+ # digest. `member` there is the VERSIONED file; the two symlinks beside it
+ # are kept so the archive stays a drop-in for anything that expects them.
+ run: |
+ set -euo pipefail
+ V="${{ steps.pin.outputs.version }}"
+ DIR="onnxruntime-osx-arm64-${V}"
+ mkdir -p "stage/${DIR}/lib" "stage/${DIR}/include"
+ cp "onnxruntime-build/libonnxruntime.${V}.dylib" "stage/${DIR}/lib/"
+ ln -s "libonnxruntime.${V}.dylib" "stage/${DIR}/lib/libonnxruntime.dylib"
+ ln -s "libonnxruntime.${V}.dylib" "stage/${DIR}/lib/libonnxruntime.1.dylib"
+ cp onnxruntime-src/include/onnxruntime/core/session/*.h "stage/${DIR}/include/" || true
+ # NOT optional. `fetch-onnxruntime.mjs` refuses an archive with no LICENSE
+ # (`LICENSE not found inside …`) and then reads it to confirm the library
+ # really is MIT — "asset names are not evidence". Without this the archive
+ # would pass its SHA-256 and fail at vendoring, which is the worst place to
+ # find out. `ThirdPartyNotices.txt` rides along because upstream ships it and
+ # THIRD-PARTY-NOTICES.md is what carries the attribution.
+ cp onnxruntime-src/LICENSE "stage/${DIR}/LICENSE"
+ cp onnxruntime-src/ThirdPartyNotices.txt "stage/${DIR}/ThirdPartyNotices.txt"
+ tar -czf "${DIR}.tgz" -C stage "${DIR}"
+ shasum -a 256 "${DIR}.tgz"
+
+ - name: Attest build provenance
+ id: attest
+ # A SHA-256 in `fetch-onnxruntime.mjs` says "these are the bytes somebody
+ # pinned". It cannot say WHERE they came from — and once the publisher is us
+ # rather than Microsoft, that is the question that matters. This binds the
+ # archive's digest to the commit, workflow and run that produced it, signed by
+ # GitHub, so provenance becomes verifiable rather than asserted:
+ #
+ # gh attestation verify onnxruntime-osx-arm64-.tgz --repo getopenscreen/openscreen
+ #
+ # It does not replace the digest pin, it answers a different question. Keep both.
+ uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
+ with:
+ subject-path: onnxruntime-osx-arm64-*.tgz
+
+ - name: Upload
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: onnxruntime-osx-arm64
+ path: onnxruntime-osx-arm64-*.tgz
+ if-no-files-found: error
+ retention-days: 90
+
+ - name: Workflow summary
+ if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ V="${{ steps.pin.outputs.version }}"
+ ARCHIVE="onnxruntime-osx-arm64-${V}.tgz"
+ {
+ echo "## ONNX Runtime ${V}, macOS arm64, deployment target ${{ steps.floor.outputs.floor }}"
+ echo ""
+ echo "- Source: microsoft/onnxruntime@\`${{ steps.pin.outputs.commit }}\`"
+ echo ""
+ echo "- Result: ${{ job.status }}"
+ } >> "$GITHUB_STEP_SUMMARY"
+ if [ "${{ steps.attest.outcome }}" != "success" ]; then
+ {
+ echo ""
+ echo "> **Provenance was NOT attested** (\`${{ steps.attest.outcome }}\`)."
+ echo "> Do not adopt this archive: the digest below says what the bytes are,"
+ echo "> nothing says where they came from. Re-run the workflow."
+ } >> "$GITHUB_STEP_SUMMARY"
+ fi
+ if [ -f "$ARCHIVE" ] && [ "${{ steps.attest.outcome }}" = "success" ]; then
+ SHA="$(shasum -a 256 "$ARCHIVE" | cut -d' ' -f1)"
+ {
+ echo "- Archive: \`${ARCHIVE}\` ($(du -h "$ARCHIVE" | cut -f1))"
+ echo "- SHA-256: \`${SHA}\`"
+ echo ""
+ echo "Provenance is attested; before adopting, verify it with:"
+ echo ""
+ echo '```bash'
+ echo "gh attestation verify ${ARCHIVE} --repo ${{ github.repository }}"
+ echo '```'
+ echo ""
+ echo "To adopt it: attach the archive to a release, then point the"
+ echo "\`darwin-arm64\` entry of \`PINNED\` in \`scripts/fetch-onnxruntime.mjs\`"
+ echo "at that release with this digest:"
+ echo ""
+ echo '```js'
+ echo '"darwin-arm64": {'
+ echo ' slug: "osx-arm64",'
+ echo ' ext: "tgz",'
+ echo " sha256: \"${SHA}\","
+ echo " member: \`libonnxruntime.\${VERSION}.dylib\`,"
+ echo ' out: "libonnxruntime.dylib",'
+ echo ' baseUrl: "",'
+ echo '},'
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ fi
diff --git a/.github/workflows/build-whisper-stt.yml b/.github/workflows/build-whisper-stt.yml
index 6f5b3de5b..9fe27584f 100644
--- a/.github/workflows/build-whisper-stt.yml
+++ b/.github/workflows/build-whisper-stt.yml
@@ -16,6 +16,13 @@ name: Build whisper-stt binaries
on:
workflow_dispatch:
push:
+ # ALL branches, NO tags. `paths:` alone also matches a tag push, and it has:
+ # publishing `v0.0.0-onnxruntime-1.27.1` started a four-platform whisper build
+ # for a tag that touched none of these files. Listing `branches` is what
+ # excludes tags; `'**'` keeps every branch working, which is the point of this
+ # workflow — contributors push a branch to get binaries built.
+ branches:
+ - "**"
paths:
- "scripts/build-whisper-stt.sh"
- "electron/native/whisper-stt/**"
@@ -139,6 +146,23 @@ jobs:
"${VCPKG_ROOT_DIR}/vcpkg" install spirv-headers:x64-windows
echo "CMAKE_PREFIX_PATH=${VCPKG_ROOT_DIR}/installed/x64-windows" >> "$GITHUB_ENV"
+ # `${{ env.ImageOS }}` DOES NOT WORK, and it fails silently — the `env`
+ # expression context holds only what a workflow/job/step `env:` block put
+ # there, and the runner sets ImageOS/ImageVersion into its own process
+ # environment instead. Written that way, both halves expanded to the empty
+ # string and the cache below was keyed on the tag and the CMakeLists hash
+ # alone, for every platform, for as long as the line existed: the repo's
+ # own cache list read `whisper-stt-build-darwin-arm64---`. Reading
+ # them here in a shell is what actually gets their values.
+ #
+ # `shell: bash` is required, not decorative: this matrix includes
+ # windows-latest, where the default shell is PowerShell and `${VAR:-default}`
+ # is not syntax. GitHub ships bash on the Windows image.
+ - name: Read the runner image
+ id: image
+ shell: bash
+ run: echo "tag=${ImageOS:-unknown}-${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT"
+
- name: Cache whisper.cpp build tree
uses: actions/cache@v6
with:
@@ -151,15 +175,15 @@ jobs:
# 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
+ # helps. The runner image version (read by the step above) 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') }}
+ key: whisper-stt-build-${{ matrix.tag }}-${{ steps.image.outputs.tag }}-${{ hashFiles('electron/native/whisper-stt/CMakeLists.txt') }}
restore-keys: |
- whisper-stt-build-${{ matrix.tag }}-${{ env.ImageOS }}-${{ env.ImageVersion }}-
+ whisper-stt-build-${{ matrix.tag }}-${{ steps.image.outputs.tag }}-
- name: Run whisper-stt build script
run: bash scripts/build-whisper-stt.sh
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4d6ecbfb6..9b3beb623 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,6 +4,19 @@ on:
push:
tags:
- "v*"
+ # `v0.0.0-*` is this repository's marker for a tag that is NOT a product
+ # version — a place to hang a binary that needs a permanent public URL, the
+ # way `v0.0.0-stt-models` hosts the 360 MB Whisper model. Without this
+ # exclusion, creating one runs the whole matrix — Windows, macOS x2, Linux,
+ # installers, notarisation — to publish an archive nobody asked it to build.
+ # That has already happened once.
+ #
+ # The other five release-triggered workflows do not need the same guard:
+ # AUR, Nix, winget and Homebrew all gate on
+ # `!github.event.release.prerelease`, and an internal release is marked as a
+ # prerelease. Only the Discord announcement still fires, which is noise
+ # rather than a wrong publication.
+ - "!v0.0.0-*"
workflow_dispatch:
inputs:
arch:
@@ -353,6 +366,16 @@ jobs:
- name: Build Metal compositor addon
run: npm run build:native:compositor:mac
+ # Third step this job has to spell out, same reason as the two above: it is in
+ # `npm run build:mac`, which this job does not run — it needs `--dir` plus a
+ # hand-rolled DMG. Windows and Linux get it free from `build:win` / `build:linux`.
+ # Unlike the compositor addon, a missing ONNX Runtime does not fail the pack: the
+ # camera-background control just does nothing, on every shipped Mac, with nothing
+ # in CI raising a word. No-ops on x64 — upstream publishes no osx-x64 asset, so the
+ # script says so and exits 0 rather than failing that build.
+ - name: Stage ONNX Runtime
+ run: npm run fetch:onnxruntime
+
- name: Package .app bundle
run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never
env:
@@ -988,6 +1011,12 @@ jobs:
# with a tag ref and failed its deploy every time. See docs.yml.
if: ${{ steps.release.outputs.is_prerelease == 'false' }}
timeout-minutes: 20
+ # Bookkeeping, not publishing: the release itself is already out by the time
+ # this runs. Letting it fail the job silently skipped publish-msstore on
+ # v1.10.0 (an unrelated Store submission, gated on this job's `needs` success)
+ # even though "Publish release assets" above had already succeeded. Allowed
+ # to fail so a flaky docs deploy never blocks a downstream publish step.
+ continue-on-error: true
env:
GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }}
run: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7fcb0c20b..5ee421be9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -159,9 +159,19 @@ jobs:
# [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.
+ #
+ # Without a library on ORT_DYLIB_PATH, `runtime_available()` is false and every
+ # segmentation test returns early — the suite goes green having exercised no
+ # inference at all, which is exactly how the `ort`-panics-when-absent bug got
+ # in. Staging it here is what makes `the_whole_loop_produces_a_mask_from_
+ # compose_frame_alone` a real test on this runner instead of a skipped one.
+ # ~30 MB, next to nothing beside `brew install ffmpeg` above.
+ - name: Stage ONNX Runtime
+ run: node scripts/fetch-onnxruntime.mjs
- name: cargo test (compositor, aarch64-apple-darwin)
env:
MAC_FFMPEG_DIR: /opt/homebrew/opt/ffmpeg
+ ORT_DYLIB_PATH: ${{ github.workspace }}/electron/native/bin/darwin-arm64/libonnxruntime.dylib
run: |
cd crates
cargo test -p openscreen-compositor --lib --tests
@@ -266,11 +276,21 @@ jobs:
# et rendre une erreur qui ne designe pas la cause non plus.
test -n "$libclang" || { echo "libclang introuvable apres l'installation"; exit 1; }
echo "LIBCLANG_PATH=$(dirname "$libclang")" >> "$GITHUB_ENV"
+ # Meme raison que sur le job macOS : sans bibliotheque sur ORT_DYLIB_PATH,
+ # `runtime_available()` est faux et chaque test de segmentation rend la main
+ # tout de suite — la suite passe au vert sans avoir exerce la moindre
+ # inference, ce qui est exactement par ou le bug « ort panique quand elle
+ # manque » est entre. C'est ce qui fait de
+ # `the_whole_loop_produces_a_mask_from_compose_frame_alone` un vrai test ici
+ # plutot qu'un test saute. Builtins node uniquement, comme fetch:ffmpeg:sdk.
+ - name: Stage ONNX Runtime
+ run: node scripts/fetch-onnxruntime.mjs
- name: cargo test (compositor)
env:
# Les .so ffmpeg vendorises ne sont dans aucun chemin systeme : sans ca
# le binaire de test se lance puis meurt sur `libavformat.so.62`.
LD_LIBRARY_PATH: ${{ github.workspace }}/crates/thirdparty/ffmpeg-linux64-lgpl-shared/lib
+ ORT_DYLIB_PATH: ${{ github.workspace }}/electron/native/bin/linux-x64/libonnxruntime.so
# Fait ECHOUER `cpu_backend_linux.rs` s'il n'obtient pas le backend CPU,
# au lieu de le sauter en silence comme sur un poste sans lavapipe.
OPENSCREEN_REQUIRE_CPU_BACKEND: "1"
diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml
index a8531c9ac..e2ae200cb 100644
--- a/.github/workflows/nix-build.yml
+++ b/.github/workflows/nix-build.yml
@@ -24,6 +24,25 @@ name: Nix build
# front of every merge. Promote it once the schedule has reported a few times.
on:
workflow_dispatch:
+ # The job that actually builds the derivation now runs on the pull requests that
+ # can break it. It did not, and the gap was not academic: `nix-check.yml` only
+ # compares npmDepsHash, so a PR rewriting the addon's source filter, its RPATH
+ # handling or its symbols.map went green on ~18 checks without one of them
+ # building it, and the first real signal arrived on main half an hour after the
+ # merge. #371 shipped a change to `nix/compositor-view.nix` that way.
+ #
+ # Path-filtered rather than universal: this takes about half an hour, and a PR
+ # that touches none of these files cannot change what it produces.
+ pull_request:
+ paths:
+ - flake.nix
+ - flake.lock
+ - nix/**
+ - crates/**
+ - package-lock.json
+ # Including itself, or a PR that only edits this file gets no validation of
+ # the change it is making -- the same rule nix-check.yml already follows.
+ - .github/workflows/nix-build.yml
push:
branches: [main]
schedule:
@@ -56,7 +75,11 @@ concurrency:
# That is the affordable half. Verifying each merge would need a queue this
# workflow does not have, and is not worth it for a half-hour job whose purpose
# is catching drift rather than gating a commit.
- cancel-in-progress: false
+ #
+ # On a pull request the opposite is right: a new push makes the previous run
+ # answer a question nobody is asking any more, and at half an hour each they
+ # would pile up. `github.ref` is the PR's merge ref, so the group is per-PR.
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
@@ -421,84 +444,100 @@ jobs:
fi
# The real acceptance test. Everything above proves the package starts
- # and can list a screen; none of it touches the compositor addon, which
- # is what actually renders output. Record a couple of seconds, export it,
- # and look at what came out.
+ # and can answer an enumeration call; none of it touches the compositor
+ # addon, which is what actually renders output.
#
- # This block was briefly moved ahead of the sources loop and moved back,
- # so that it is not tried a third time. The theory was that position
- # explained why record seemed to fail far more often than sources --
- # run_cli spawns a fresh `xvfb-run -a` each time, so record was always
- # invocations 6-8, after five Xvfb servers had come and gone. The
- # experiment could not answer it: by the time it ran, record had started
- # succeeding from its old position anyway, so there was no contrast left
- # to measure. From position 4 it succeeded, which proves nothing it was
- # not already doing from position 9.
+ # It used to record two seconds and export the result. That never once
+ # worked here. `record` needs a display index and this host has no
+ # display to give: Chromium's X11 capturer logs
+ # "screen_capturer_x11.cc: Failed to initialize pixel buffer", `sources`
+ # answers with `displays: []`, and record dies on "Display index 0 not
+ # found (0 screen(s) available)". So export never ran, and the step that
+ # exists to vouch for the addon vouched for nothing -- runs 32707512544
+ # (24/08) and 32827253816 (25/08) failed exactly here, in the 34-36
+ # minutes they took to get past the build.
#
- # What the runs did establish is that the premise was wrong. Enumeration
- # here is bimodal -- 12-31ms when it answers, no return at all when it
- # does not, with nothing in between across every measurement so far --
- # and the failures cluster by run and by window within a run rather than
- # by command. The apparent record-versus-sources gap was that clustering
- # seen through a denominator, not a property of either path. Reopen this
- # with the run_cli labels, on a run that actually fails, before assuming
- # otherwise.
+ # Measured before rewriting it, against the built artefact under Xvfb:
+ # with `xvfb-run -a` as this workflow invokes it, 4 runs in 10 saw a
+ # display; starting Xvfb by hand and polling xdpyinfo until the server
+ # answered before launching the app, 3 in 10. So it is not the startup
+ # race it looks like -- waiting for the server changes nothing -- and
+ # whatever it is lives inside Chromium's X11 capturer. On this runner it
+ # comes up zero every time rather than a third of the time.
#
- # Up to three goes, because screen capture on this host is unreliable in
- # its own right. One success is enough for the question being asked here.
- echo "--- record then export (first run_cli here is #$((RUN_CLI_N + 1))) ---"
- EXPORTED=""
- # Tracked apart from EXPORTED so the verdict can name the stage that
- # actually failed. For three runs every attempt died in record without
- # export ever executing, while the annotation said "the export path does
- # not work" -- an accusation aimed at the one component the run never
- # reached, and the compositor addon is precisely what this step exists
- # to vouch for.
- RECORDED=0
- for i in 1 2 3; do
- echo "=== export attempt $i/3 (run_cli #$((RUN_CLI_N + 1))) ==="
- rm -f /tmp/demo.openscreen /tmp/demo.mp4
- RC=0
- CLI_TIMEOUT=120 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS record --duration 2 --project /tmp/demo.openscreen >"/tmp/rec.$i.out" 2>&1 || RC=$?
- # Outside the failure branch for the same reason as above: a record that
- # works is exactly the measurement missing from the comparison, since
- # this path has never yet produced one.
- grep -a "get-sources\]" "/tmp/rec.$i.out" || true
- if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.openscreen ]; then
- echo "record failed (rc=$RC); last lines:"
- tail -5 "/tmp/rec.$i.out" || true
- continue
- fi
- RECORDED=1
- echo "recorded. project:"
- head -c 200 /tmp/demo.openscreen; echo
+ # So the input stops being a recording. ffmpeg synthesises two seconds of
+ # video, a three-line project points at it, and export renders that.
+ # Identical in what it proves -- the packaged compositor addon loads,
+ # decodes, composes through Vulkan and muxes an MP4 -- and it asks for no
+ # capability a headless runner is ever going to have. Measured at 6/6
+ # locally where record measured 4/10.
+ #
+ # Capture is still worth watching, so one probe still runs. It is
+ # informational: it cannot pass here, and nothing gates on it.
+ echo "--- capture probe (informational; run_cli #$((RUN_CLI_N + 1))) ---"
+ RC=0
+ CLI_TIMEOUT=120 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS record --duration 2 --project /tmp/probe.openscreen >/tmp/probe.out 2>&1 || RC=$?
+ if [ "$RC" -eq 0 ] && [ -f /tmp/probe.openscreen ]; then
+ echo "::warning::record worked on this runner. Capture is no longer broken here -- see whether the export check below should go back to using a real recording."
+ else
+ echo "capture still unavailable (rc=$RC); last lines:"
+ tail -3 /tmp/probe.out || true
+ fi
- RC=0
- CLI_TIMEOUT=180 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS export /tmp/demo.openscreen -o /tmp/demo.mp4 >"/tmp/exp.$i.out" 2>&1 || RC=$?
- if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.mp4 ]; then
- echo "export failed (rc=$RC); last lines:"
- tail -15 "/tmp/exp.$i.out" || true
- continue
- fi
- EXPORTED=/tmp/demo.mp4
- break
- done
+ echo "--- synthesise a clip and export it (first run_cli here is #$((RUN_CLI_N + 1))) ---"
+ # From the flake's own nixpkgs, for the same reason the Vulkan ICD is:
+ # the ambient registry drifts, and a decoder that is never the same twice
+ # is drift injected into a check that exists to catch it.
+ FFMPEG=$(nix build --no-link --print-out-paths --inputs-from . nixpkgs#ffmpeg-headless)
+ FFMPEG=${FFMPEG%%$'\n'*}
+ echo "ffmpeg: $FFMPEG"
+
+ # H.264 in MP4 with an AAC track: the shape a real recording arrives in,
+ # so the export walks its ordinary decode path rather than a special one.
+ "$FFMPEG/bin/ffmpeg" -loglevel error -y \
+ -f lavfi -i "testsrc2=size=1280x720:rate=30" \
+ -f lavfi -i "sine=frequency=440:sample_rate=48000" \
+ -t 2 -pix_fmt yuv420p -c:v libx264 -c:a aac -shortest /tmp/demo-src.mp4
+ ls -l /tmp/demo-src.mp4
+ # The whole project format the exporter needs: a media path and an empty
+ # editor, which normalises to a single full-length clip. This is what
+ # `record --project` writes, minus the parts a recording fills in.
+ cat > /tmp/demo.openscreen <<'JSON'
+ {
+ "version": 2,
+ "media": { "screenVideoPath": "/tmp/demo-src.mp4" },
+ "editor": {}
+ }
+ JSON
+ # Parse it back before handing it over, so a future edit that breaks the
+ # JSON fails here with a parse error rather than 300 s later as an
+ # export that could not read its project.
+ python3 -c "import json;json.load(open('/tmp/demo.openscreen'))"
+ cat /tmp/demo.openscreen
+
+ echo "--- openscreen info ---"
+ CLI_TIMEOUT=120 run_cli $SANDBOX $CHROME_FLAGS info /tmp/demo.openscreen || true
+
+ rm -f /tmp/demo.mp4
+ RC=0
+ CLI_TIMEOUT=300 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS export /tmp/demo.openscreen -o /tmp/demo.mp4 >/tmp/exp.out 2>&1 || RC=$?
EXPORT_OK=0
- if [ -z "$EXPORTED" ] && [ "$RECORDED" -eq 0 ]; then
- echo "::error::No attempt got past record, so export never ran and the compositor addon is unproven. This is a capture failure on this host, not an export failure."
- elif [ -z "$EXPORTED" ]; then
- echo "::error::record produced a project but no attempt produced an MP4. The compositor addon is packaged and the export path does not work."
+ if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.mp4 ]; then
+ echo "::error::export failed (rc=$RC). The compositor addon is packaged and the export path does not work."
+ tail -25 /tmp/exp.out || true
else
- SIZE=$(wc -c < "$EXPORTED")
+ SIZE=$(wc -c < /tmp/demo.mp4)
# An MP4 opens with a 4-byte length then 'ftyp'. A zero-length or
# truncated file would otherwise pass a mere existence check.
- MAGIC=$(dd if="$EXPORTED" bs=1 skip=4 count=4 2>/dev/null || true)
+ MAGIC=$(dd if=/tmp/demo.mp4 bs=1 skip=4 count=4 2>/dev/null || true)
echo "exported $SIZE bytes, magic at offset 4: $MAGIC"
if [ "$MAGIC" != "ftyp" ]; then
echo "::error::output is not an MP4 (no ftyp box)"
+ tail -25 /tmp/exp.out || true
elif [ "$SIZE" -lt 10000 ]; then
echo "::error::MP4 is only $SIZE bytes, too small to hold two seconds of video"
+ tail -25 /tmp/exp.out || true
else
echo "Export works: $SIZE bytes of MP4."
EXPORT_OK=1
@@ -509,7 +548,7 @@ jobs:
# flaky must not hide whether export works, which is the whole point of
# having packaged the compositor addon.
#
- # The gate is "did enumeration ever work" and "does export work", not
+ # The gate is "did enumeration ever answer" and "does export work", not
# "did all five attempts pass". Requiring FAILED -eq 0 made the job red
# by construction: the standing numbers on this runner are 1/5, 3/5 and
# 4/5 ok, so a run where export is perfect and four enumerations succeed
@@ -518,7 +557,11 @@ jobs:
# per-attempt warnings above keep that flakiness visible without letting
# it decide the build; tighten this to $ATTEMPTS once the capture failure
# is understood and fixed.
- echo "=== verdict: enumeration $OK/$ATTEMPTS ok, record $RECORDED, export $EXPORT_OK, $RUN_CLI_N run_cli invocations ==="
+ #
+ # Export, on the other hand, is a hard gate on every trigger again. It no
+ # longer depends on a capability this host does not have, so there is
+ # nothing left to excuse: if it fails now, the package is broken.
+ echo "=== verdict: enumeration $OK/$ATTEMPTS ok, export $EXPORT_OK, $RUN_CLI_N run_cli invocations ==="
if [ "$EXPORT_OK" -ne 1 ] || [ "$OK" -eq 0 ]; then
exit 1
fi
diff --git a/.gitignore b/.gitignore
index a1822ee05..24ad425eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,3 +132,4 @@ workbench/fixtures/
/aur_ci
/aur_ci.pub
/aur_known_hosts
+tmp_handoff.md
diff --git a/README.md b/README.md
index 88c05f3de..7f197284a 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@
-
+
+
@@ -62,6 +63,15 @@ See [docs/cli.md](./docs/cli.md).
Every platform has a recommended route below. On Windows that is the Microsoft Store; everywhere else it is the installer from the [GitHub Releases](https://github.com/getopenscreen/openscreen/releases) page.
+### System requirements
+
+- **Windows**: version 1903+ (build 18362) with Intel 8th Gen / AMD Ryzen 2000 series or newer minimum; Windows 11 with Intel 12th Gen / Ryzen 4000 series or newer recommended
+- **macOS**: 13 (Ventura) or later — required by ScreenCaptureKit for capture
+- **Linux**: `xdg-desktop-portal` and PipeWire for native capture and system audio; recording still works without them through the browser-capture fallback, with fewer capabilities (see [Platform differences](#platform-differences))
+- **RAM**: 8 GB minimum, 16 GB recommended
+
+Full table and notes on older integrated graphics: [system requirements](https://getopenscreen.com/docs/installation#system-requirements).
+
### macOS
Download the `.dmg` installer directly from the [Releases page](https://github.com/getopenscreen/openscreen/releases) and drag OpenScreen into your Applications folder. Builds from 1.9.0 onward are signed with a Developer ID certificate and notarized by Apple, so Gatekeeper does not block them and no terminal step is needed.
@@ -162,10 +172,10 @@ You may need to grant screen recording permissions depending on your desktop env
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), 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**.
+- **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. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs your user in the `input` group. Without that, recording is unaffected and every cursor sample is simply a move.
- **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).
+ - **macOS**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission.
- **Windows**: works out of the box.
- **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work).
@@ -185,9 +195,10 @@ For safety, download OpenScreen only from the official GitHub Releases linked fr
OpenScreen is community-driven. If you need help, want to report a bug, or just want to chat with other users and contributors:
-- 💬 **Discord** — [Join the OpenScreen Discord](https://discord.gg/VvT6Vtnyh) for real-time help, showcase, and discussion
+- 💬 **Discord** — [Join the OpenScreen Discord](https://getopenscreen.com/discord) for real-time help, showcase, and discussion
- 🐞 **[GitHub Issues](https://github.com/getopenscreen/openscreen/issues)** — bug reports and feature requests
- 🗺️ **[Roadmap](./ROADMAP.md)** — see what we're building next
+- ❤️ **Support the project** — [GitHub Sponsors](https://github.com/sponsors/EtienneLescot) or [Ko-fi](https://ko-fi.com/etiennelescot)
---
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 330bf7492..6bfe88fb4 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -49,19 +49,62 @@ distributed by their own registries, not redistributed inside our binaries.
- 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`.
-## Microsoft OpenMP runtime — `vcomp140.dll` (Windows only)
+## ONNX Runtime (Windows and Apple Silicon macOS)
-- **Component**: `resources/electron/native/bin/win32-x64/vcomp140.dll`.
+- **Component**: `onnxruntime.dll` / `libonnxruntime.dylib`, under
+ `resources/electron/native/bin/-/`.
+- **License**: MIT — .
+- Not built here: the pinned upstream release archive is downloaded, SHA-256
+ verified and unpacked by `scripts/fetch-onnxruntime.mjs`, which also checks the
+ archive's own LICENSE really is MIT before vendoring anything.
+- **Why it ships**: the native compositor segments the webcam subject with it, on
+ the CPU execution provider, to drive the camera background cutout/blur/custom
+ modes. The `gpu_cuda*` builds are deliberately not used — they are an order of
+ magnitude larger and carry NVIDIA redistribution terms.
+- **Not on Intel macOS**: upstream publishes no `osx-x86_64` asset from 1.27 on,
+ so the x64 DMG ships without it and the camera background effects are simply
+ absent there. Not shipped on Linux either, where the compositor has no capture
+ path for the mask yet.
+- The segmentation model it runs is a separate component, immediately below.
+
+## MediaPipe Selfie Segmentation — model weights
+
+- **Components**: `selfie_segmentation.tflite`,
+ `selfie_segmentation_landscape.tflite` and the `selfie_segmentation_landscape.onnx`
+ derived from them, shipped inside `app.asar` under `dist/mediapipe/`.
+- **License**: Apache-2.0 — .
+ Copyright The MediaPipe Authors.
+- The `.onnx` is a **derived work**, generated from the vendored `.tflite` by
+ `scripts/convert-selfie-segmentation-to-onnx.py`. No third-party weights are
+ downloaded at build time.
+- **Why it is listed here**: these weights are redistributed inside the installer,
+ and Apache-2.0 §4 asks that the attribution travel with them. The provenance note
+ in `public/mediapipe/selfie_segmentation/README.md` does not — electron-builder's
+ `"!*.md"` filter strips it from the package — so this file is the only copy a user
+ ever receives.
+- The MediaPipe **JavaScript** solution and its two ~5.6 MB WASM builds are no longer
+ bundled: inference moved into the native compositor, and nothing loaded them.
+
+## Microsoft Visual C++ runtime — `vcomp140.dll`, `msvcp140*.dll`, `vcruntime140*.dll` (Windows only)
+
+- **Components**: under `resources/electron/native/bin/win32-x64/` —
+ `vcomp140.dll`, `msvcp140.dll`, `msvcp140_1.dll`, `vcruntime140.dll`,
+ `vcruntime140_1.dll`.
- **License**: redistributable under the Microsoft Visual C++ Redistributable
- terms accompanying Visual Studio; the copy shipped is taken from the
- `VC\Redist\MSVC\\x64\Microsoft.VC.OpenMP\` directory of the
- Visual Studio installation that builds the release, never from `System32`.
-- **Why it ships**: the ggml backends above are compiled with OpenMP and import
- it. It is **not** part of Windows, so without it `whisper-stt-server` dies in
- the loader before `main()` on any machine that has no Visual C++
- Redistributable, and transcription and captions fail with no usable error.
- Staged by `scripts/stage-vcomp-runtime.mjs`; `scripts/before-pack.cjs` refuses
- to package if it is missing while anything still imports it.
+ terms accompanying Visual Studio; the copies shipped are taken from the
+ `VC\Redist\MSVC\\x64\Microsoft.VC.OpenMP\` and
+ `…\Microsoft.VC.CRT\` directories of the Visual Studio installation that
+ builds the release, never from `System32`.
+- **Why they ship**: two prebuilt binaries in the payload import them, and
+ neither is ours to recompile against the static CRT. The ggml backends above
+ are compiled with OpenMP and import `vcomp140.dll`; the vendored ONNX Runtime
+ imports the CRT proper. None of these are **part of Windows**, so without them
+ `whisper-stt-server` dies in the loader before `main()` on any machine that has
+ no Visual C++ Redistributable — transcription and captions fail with no usable
+ error — and `onnxruntime.dll` fails to load, leaving the camera background
+ silently inert. Staged by `scripts/stage-vcomp-runtime.mjs`;
+ `scripts/before-pack.cjs` refuses to package if any is missing while something
+ still imports it.
## PipeWire — headers (Linux only)
diff --git a/biome.json b/biome.json
index 4fa1d2fdc..8954e61bf 100644
--- a/biome.json
+++ b/biome.json
@@ -3,7 +3,7 @@
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"ignoreUnknown": false,
- "includes": ["**", "!**/*.css", "!**/design/**", "!**/.worktrees/**"]
+ "includes": ["**", "!**/*.css", "!**/design/**", "!**/.worktrees/**", "!**/public/mediapipe/**"]
},
"formatter": {
"enabled": true,
diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml
index 29197773e..cb1fd42fb 100644
--- a/crates/.cargo/config.toml
+++ b/crates/.cargo/config.toml
@@ -1,5 +1,10 @@
# 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 deux valeurs sont celles de WINDOWS et cargo n'a pas de `[target..env]` :
+# le `[env]` ci-dessous est global, donc elles sont posées sur les trois OS. C'est
+# `crates/compositor/build.rs` qui les neutralise ailleurs — voir
+# `point_libclang_at_the_xcode_toolchain()` (macOS) et `drop_unusable_libclang_path()`
+# (Linux). Ne pas supposer qu'un `LIBCLANG_PATH` non vide désigne un vrai libclang.
# 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-34-g9b6c8969e0, tag BtbN
diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index dbe2f253a..da41d7070 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -44,7 +44,7 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
@@ -180,7 +180,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
- "libloading",
+ "libloading 0.8.9",
]
[[package]]
@@ -652,7 +652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
- "libloading",
+ "libloading 0.8.9",
"pkg-config",
]
@@ -678,6 +678,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "libloading"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
[[package]]
name = "libm"
version = "0.2.16"
@@ -720,6 +730,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -867,7 +887,37 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
dependencies = [
- "libloading",
+ "libloading 0.8.9",
+]
+
+[[package]]
+name = "ndarray"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
]
[[package]]
@@ -889,6 +939,24 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -918,6 +986,7 @@ name = "openscreen-compositor"
version = "0.0.0"
dependencies = [
"anyhow",
+ "ash",
"bindgen",
"block",
"cc",
@@ -925,11 +994,14 @@ dependencies = [
"cosmic-text",
"image",
"metal 0.29.0",
+ "ndarray 0.16.1",
"objc",
+ "ort",
"pollster",
"serde",
"serde_json",
"wgpu",
+ "wgpu-hal",
"windows",
]
@@ -942,6 +1014,25 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "ort"
+version = "2.0.0-rc.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b"
+dependencies = [
+ "libloading 0.9.0",
+ "ndarray 0.17.2",
+ "ort-sys",
+ "smallvec",
+ "tracing",
+]
+
+[[package]]
+name = "ort-sys"
+version = "2.0.0-rc.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1011,6 +1102,21 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "presser"
version = "0.3.1"
@@ -1075,6 +1181,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
[[package]]
name = "read-fonts"
version = "0.37.0"
@@ -1434,6 +1546,25 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -1630,7 +1761,7 @@ dependencies = [
"js-sys",
"khronos-egl",
"libc",
- "libloading",
+ "libloading 0.8.9",
"log",
"metal 0.31.0",
"naga",
diff --git a/crates/Cargo.toml b/crates/Cargo.toml
index 208f376cc..abd74df99 100644
--- a/crates/Cargo.toml
+++ b/crates/Cargo.toml
@@ -34,6 +34,26 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
wgpu = { version = "24", features = ["wgsl"] }
pollster = "0.4"
cosmic-text = "0.19"
+# Inference for the webcam segmentation mask. CPU execution provider only: measured on the
+# target integrated GPU it costs +0.47 ms/frame against DirectML's +1.03, its cost does not
+# scale with input resolution, and choosing it deletes the whole D3D11<->D3D12 interop
+# (technical-documentation/engineering/webcam-segmentation.md).
+#
+# Behind the `segmentation` feature and OFF by default: `download-binaries` fetches the
+# ONNX Runtime libs at build time, which is a packaging decision (nix, AUR, MS Store, CI)
+# that has not been taken yet. The default build is unchanged.
+# `load-dynamic` et NON `download-binaries` : ce dernier tire une build STATIQUE d'ONNX
+# Runtime avec DirectML dedans (DirectML.lib, DXCORE.lib et les DmlOperator* apparaissent
+# dans la ligne de lien) — exactement la dépendance que le choix de l'EP CPU sert à
+# supprimer. En chargement dynamique, la lib est résolue à l'exécution, ce qui laisse le
+# packaging la stager par plateforme comme il le fait déjà pour whisper-stt.
+ort = { version = "2.0.0-rc.13", default-features = false, features = [
+ "std",
+ "ndarray",
+ "load-dynamic",
+ "api-27",
+] }
+ndarray = "0.16"
[workspace.dependencies.windows]
version = "0.58"
diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs
index 62c164e0c..69476ffc2 100644
--- a/crates/compositor-view-napi/src/lib.rs
+++ b/crates/compositor-view-napi/src/lib.rs
@@ -70,6 +70,22 @@ pub fn probe_backend() -> String {
.to_string()
}
+/// Si cette machine peut produire un masque de segmentation, c'est-à-dire si la bibliothèque
+/// ONNX Runtime est là où l'app l'a posée.
+///
+/// Sert à NE PAS MENTIR : le contrôle « fond de caméra » est le seul de l'éditeur dont l'effet
+/// dépend d'un binaire optionnel. Sans lui, `Segmenter::load` refuse, le compositeur dessine la
+/// webcam telle quelle, et l'utilisateur clique sur un réglage qui ne fait rien — exactement ce
+/// qu'un contrôle ne doit jamais faire.
+///
+/// Une question posée au système plutôt que devinée depuis la plateforme : `darwin` ne suffit
+/// pas à répondre, puisque l'amont ne publie aucun binaire ONNX pour les Macs Intel, et une
+/// build de dev ou un `--dir` n'en ont pas davantage. Seul l'état réel de la machine le sait.
+#[napi]
+pub fn segmentation_runtime_available() -> bool {
+ openscreen_compositor::segmentation::runtime_available()
+}
+
#[napi]
pub fn create_view(
rect: CompositorViewRect,
diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml
index f968b5497..face980ee 100644
--- a/crates/compositor/Cargo.toml
+++ b/crates/compositor/Cargo.toml
@@ -12,12 +12,40 @@ path = "src/lib.rs"
bindgen = "0.70"
cc = "1"
+[features]
+default = ["segmentation"]
+# Segmentation IA de la webcam via ONNX Runtime (EP CPU).
+#
+# Activée par défaut, ce qui ne coûte rien au build : `ort` est lié en `load-dynamic`, donc
+# aucune bibliothèque n'est nécessaire pour COMPILER. Elle l'est pour tourner — absente,
+# `Segmenter::load` échoue, le compositeur écrit une ligne et dessine la webcam telle quelle.
+# La désactiver reste possible pour une build qui ne veut pas du tout du code d'inférence.
+segmentation = ["dep:ort", "dep:ndarray"]
+
[dependencies]
anyhow.workspace = true
+wgpu.workspace = true
+pollster.workspace = true
+cosmic-text.workspace = true
+ort = { workspace = true, optional = true }
+ndarray = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
image.workspace = true
+# Acces Vulkan brut, UNIQUEMENT pour ouvrir le device avec les extensions de
+# memoire externe (cf. `d3d_linux::open_device_with_dmabuf_export`). Les versions
+# sont celles que wgpu 24 tire deja : en prendre d'autres ferait cohabiter deux
+# bindings pour un meme `VkDevice`.
+#
+# LINUX SEULEMENT, et rien d'autre ne doit venir ici : ces deux lignes ont ete
+# ajoutees sous cet en-tete par une edition qui l'a place trop haut, emportant
+# `wgpu`, `serde`, `ort` et le reste avec elles. Windows et macOS ont alors cesse
+# de compiler.
+[target.'cfg(target_os = "linux")'.dependencies]
+ash = "0.38"
+wgpu-hal = { version = "24", features = ["vulkan"] }
+
# Windows : D3D11 + D3D11VA + Direct2D/DirectWrite + HLSL à l'exécution.
[target.'cfg(windows)'.dependencies]
windows.workspace = true
@@ -47,7 +75,3 @@ 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
index f8c9e5f5b..99b11e2f6 100644
--- a/crates/compositor/build.rs
+++ b/crates/compositor/build.rs
@@ -10,6 +10,8 @@ fn main() {
if target_is_macos {
point_libclang_at_the_xcode_toolchain();
+ } else if target_os == "linux" {
+ drop_unusable_libclang_path();
}
// Le pin ffmpeg est porté par `.cargo/config.toml` ; sur Windows c'est le
@@ -29,28 +31,25 @@ fn main() {
// 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())
- })
+ .filter(|v| usable_ffmpeg_tree(Path::new(v)))
+ .or_else(|| vendored_ffmpeg_tree("ffmpeg-n8.1.2-macos64-lgpl-shared"))
.or_else(|| {
env::var("FFMPEG_DIR")
.ok()
- .filter(|v| Path::new(v).join("include").exists())
+ .filter(|v| usable_ffmpeg_tree(Path::new(v)))
})
+ } else if target_os == "linux" {
+ // Même piège que LIBCLANG_PATH, même remède : le `FFMPEG_DIR` du `[env]` global
+ // désigne l'arbre win64, qui n'existe pas ici, donc on ne l'accepte que s'il
+ // pointe sur un arbre RÉEL — c'est-à-dire quand un dev ou
+ // scripts/build-linux-compositor-addon.mjs l'a posé à la main. Sinon on retombe
+ // sur l'emplacement vendorisé conventionnel, dans le même ordre que ce script
+ // (`resolveFfmpegDir`), pour qu'un `cargo check` nu et un build via npm voient
+ // le même arbre.
+ env::var("FFMPEG_DIR")
+ .ok()
+ .filter(|v| usable_ffmpeg_tree(Path::new(v)))
+ .or_else(|| vendored_ffmpeg_tree("ffmpeg-linux64-lgpl-shared"))
} else {
env::var("FFMPEG_DIR").ok()
};
@@ -58,9 +57,12 @@ fn main() {
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={}). \
+ "crates/compositor build.rs: aucun arbre ffmpeg utilisable (target={}). \
Sur Windows, voir crates/.cargo/config.toml. Sur macOS, poser \
- MAC_FFMPEG_DIR ou vendoriser thirdparty/ffmpeg-n8.1.2-macos64-lgpl-shared.",
+ MAC_FFMPEG_DIR ou vendoriser thirdparty/ffmpeg-n8.1.2-macos64-lgpl-shared. \
+ Sur Linux, poser FFMPEG_DIR ou vendoriser \
+ thirdparty/ffmpeg-linux64-lgpl-shared (arbre *shared*, avec include/ et \
+ lib/ — celui de scripts/fetch-ffmpeg.mjs est statique et ne convient pas).",
target_os
),
};
@@ -69,7 +71,7 @@ fn main() {
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"] {
+ for lib in ["avformat", "avcodec", "avutil", "swscale", "swresample", "avfilter"] {
println!("cargo:rustc-link-lib=dylib={}", lib);
}
}
@@ -316,3 +318,109 @@ fn point_libclang_at_the_xcode_toolchain() {
None => env::remove_var("LIBCLANG_PATH"),
}
}
+
+/// Un arbre ffmpeg exploitable : `include/` pour bindgen ET `lib/` pour le linkage.
+///
+/// Les deux, pas seulement le premier : la section « linkage » plus bas pose un
+/// `rustc-link-search` sur `/lib` et réclame avformat/avcodec/avutil/swscale/
+/// swresample. Un arbre n'ayant que les en-têtes passait le filtre, écartait le repli
+/// vers un arbre vendorisé complet, et échouait bien plus tard sur un `cannot find
+/// -lavformat` qui ne désigne pas sa cause. `resolveFfmpegDir()` dans
+/// scripts/build-linux-compositor-addon.mjs vérifie déjà les deux — c'est la même règle
+/// des deux côtés.
+fn usable_ffmpeg_tree(dir: &Path) -> bool {
+ dir.join("include").is_dir() && dir.join("lib").is_dir()
+}
+
+/// L'arbre ffmpeg vendorisé sous `crates/thirdparty/`, s'il existe vraiment.
+///
+/// `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/`.
+fn vendored_ffmpeg_tree(name: &str) -> Option {
+ let candidate = Path::new(&env::var("CARGO_MANIFEST_DIR").ok()?)
+ .parent()?
+ .join("thirdparty")
+ .join(name);
+ usable_ffmpeg_tree(&candidate).then(|| candidate.to_string_lossy().to_string())
+}
+
+/// Même remède que le versant macOS, pour la même cause.
+///
+/// `crates/.cargo/config.toml` pose `LIBCLANG_PATH` dans un `[env]` GLOBAL, faute de
+/// `[target..env]` en cargo. La valeur est celle de Windows
+/// (`C:\Program Files\LLVM\bin`) et elle est donc renseignée sous Linux aussi, où
+/// clang-sys la prend au mot : il ne regarde nulle part ailleurs et abandonne sur
+/// « Unable to find libclang », alors qu'un `libclang.so` de distribution est presque
+/// toujours installé. Un `cargo check -p openscreen-compositor` nu échouait donc sur
+/// une Ubuntu de série — exactement ce que `freestanding_header_args()` juste au-dessus
+/// s'emploie à éviter par ailleurs.
+///
+/// On ne devine pas le bon chemin : clang-sys sait chercher tout seul (LD_LIBRARY_PATH,
+/// PATH, /usr/lib/llvm-*/lib …). Il suffit de ne pas lui mentir. Une valeur posée par le
+/// dev et réellement utilisable est conservée telle quelle — `force = false` fait déjà
+/// gagner l'environnement réel sur la config, et on ne casse pas un choix explicite.
+fn drop_unusable_libclang_path() {
+ println!("cargo:rerun-if-env-changed=LIBCLANG_PATH");
+ let Ok(value) = env::var("LIBCLANG_PATH") else {
+ return;
+ };
+ // clang-sys accepte DEUX formes : un fichier bibliothèque, ou un répertoire qui en
+ // contient un (`search_libclang_directories` : « Check if the path is a matching
+ // file », puis « … a directory containing a matching file »). Ne traiter que le
+ // répertoire retirerait un `LIBCLANG_PATH` parfaitement valide pointant sur
+ // `/usr/lib/llvm-N/lib/libclang.so.1`.
+ let path = Path::new(&value);
+ let usable = if path.is_file() {
+ path.file_name()
+ .is_some_and(|n| is_libclang_filename(&n.to_string_lossy()))
+ } else {
+ std::fs::read_dir(path).is_ok_and(|entries| {
+ entries
+ .flatten()
+ // `is_file()` autant que le nom : `read_dir` rend aussi les
+ // sous-répertoires et les fichiers spéciaux, et un répertoire qui
+ // s'appellerait `libclang.so` passerait le seul test de nom — clang-sys
+ // le retiendrait puis échouerait à le charger, sans repli possible.
+ .any(|e| {
+ e.path().is_file() && is_libclang_filename(&e.file_name().to_string_lossy())
+ })
+ })
+ };
+ if !usable {
+ env::remove_var("LIBCLANG_PATH");
+ }
+}
+
+/// Les motifs EXACTS que clang-sys cherche sous Linux : `libclang.so`,
+/// `libclang-.so`, `libclang.so.`, `libclang-.so.`.
+///
+/// Coller aux motifs, et pas seulement au préfixe, parce que `search_libclang_directories`
+/// s'arrête net sur `LIBCLANG_PATH` quand la variable est posée — « Search only the path
+/// indicated by the relevant environment variable » — sans jamais retomber sur
+/// `llvm-config`, le PATH ou les répertoires connus. Conserver un chemin qui ne contient
+/// qu'un `libclang_extra.so` ou un `libclang.software` reviendrait donc à condamner le
+/// build, exactement comme le faisait la valeur Windows.
+///
+/// `libclang-cpp.*` est écarté d'entrée : clang-sys l'écarte lui-même
+/// (`filename.contains("-cpp.")`), `libclang_shared` ayant été renommé `libclang-cpp` à
+/// partir de Clang 10.
+fn is_libclang_filename(name: &str) -> bool {
+ if name.contains("-cpp.") {
+ return false;
+ }
+ let Some(rest) = name.strip_prefix("libclang") else {
+ return false;
+ };
+ // Soit `libclang.so…`, soit `libclang-.so…` avec un `` non vide.
+ let rest = match rest.strip_prefix('-') {
+ Some(versioned) => match versioned.find(".so") {
+ None | Some(0) => return false,
+ Some(i) => &versioned[i..],
+ },
+ None => rest,
+ };
+ rest == ".so" || rest.starts_with(".so.")
+}
diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs
index db9b46e13..ee90c1374 100644
--- a/crates/compositor/src/audio.rs
+++ b/crates/compositor/src/audio.rs
@@ -3,8 +3,9 @@
//! unique encodeur AAC alimente le même muxer que la vidéo.
use crate::ffi::*;
+
use crate::regions::SpeedSegment;
-use crate::scene::SceneAudio;
+use crate::scene::{SceneAudio, SceneAudioTrack};
use anyhow::{bail, Result};
use std::f32::consts::PI;
use std::ffi::CString;
@@ -477,6 +478,12 @@ pub struct WsolaTimeStretcher {
buf: PlanarPcm,
mono: Vec,
buf_start: i64,
+ /// Décalage de lecture dans `buf`/`mono`. `discard_below` ne recopiait pas moins que le
+ /// reste du buffer à chaque grain : la région entière est poussée d'un coup, donc pour
+ /// 65 M d'échantillons cela faisait ~N²/(2·ha) ≈ 1,1e12 f32 recopiés par canal — le vrai
+ /// coût du chemin WSOLA, devant la recherche par grain. On avance un curseur et on ne
+ /// compacte que lorsque la tête dépasse la moitié du buffer, ce qui rend le total O(N).
+ buf_head: usize,
out: PlanarPcm,
win_sum: Vec,
out_start: usize,
@@ -522,6 +529,7 @@ impl WsolaTimeStretcher {
buf: vec![Vec::new(); channels],
mono: Vec::new(),
buf_start: 0,
+ buf_head: 0,
out: vec![Vec::new(); channels],
win_sum: Vec::new(),
out_start: 0,
@@ -587,8 +595,12 @@ impl WsolaTimeStretcher {
}
}
+ fn buf_len(&self) -> usize {
+ self.buf[0].len() - self.buf_head
+ }
+
fn buf_end(&self) -> i64 {
- self.buf_start + self.buf[0].len() as i64
+ self.buf_start + self.buf_len() as i64
}
fn sample_at(&self, channel: usize, absolute_index: i64) -> f32 {
@@ -596,7 +608,10 @@ impl WsolaTimeStretcher {
if index < 0 {
0.0
} else {
- self.buf[channel].get(index as usize).copied().unwrap_or(0.0)
+ self.buf[channel]
+ .get(self.buf_head + index as usize)
+ .copied()
+ .unwrap_or(0.0)
}
}
@@ -605,12 +620,19 @@ impl WsolaTimeStretcher {
if index < 0 {
0.0
} else {
- self.mono.get(index as usize).copied().unwrap_or(0.0)
+ self.mono
+ .get(self.buf_head + index as usize)
+ .copied()
+ .unwrap_or(0.0)
}
}
fn process(&mut self, final_chunk: bool) -> PlanarPcm {
let mut emitted = self.empty_chunk();
+ // Pas de garde anti-stagnation ici : `search_target` croît de `ha > 0` à chaque tour
+ // et `grain_pos` ne s'en écarte que de `search_radius` au plus, donc le break sur
+ // `buf_end` finit toujours par tomber. Une garde de plus tronquerait `emitted` — la
+ // région sortirait muette pour tout signal — sans jamais se déclencher.
loop {
let search_target = (self.ideal_pos + self.ha).round() as i64;
let required_end = (self.grain_pos + self.n as i64)
@@ -738,12 +760,18 @@ impl WsolaTimeStretcher {
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_head += (drop_count as usize).min(self.buf_len());
self.buf_start = absolute_index;
+ // Compactage amorti : ne recopier que lorsque la tête consommée dépasse ce qui
+ // reste laisse un coût total en O(N) au lieu du O(N²) d'une recopie par grain.
+ if self.buf_head > self.buf[0].len() - self.buf_head {
+ let head = self.buf_head;
+ for channel in 0..self.channels {
+ self.buf[channel].drain(..head);
+ }
+ self.mono.drain(..head);
+ self.buf_head = 0;
+ }
}
}
@@ -767,6 +795,20 @@ fn stretch_pcm_to_length(pcm: &[Vec], target_samples: usize) -> PlanarPcm {
}
let speed = source_samples as f64 / target_samples as f64;
+
+ // atempo d'abord : le WSOLA ci-dessous fait le même time-stretch préservant la hauteur,
+ // mais coûte un ordre de grandeur de plus. Mesuré en release sur une région de 5 min
+ // (14,4 M échantillons) : 0,6 s contre 20 s à 1,25×, 4,9 s contre 55 s à 0,25× — et
+ // c'est le WSOLA APRÈS la correction de `discard_below`, qui recopiait tout le buffer
+ // restant à chaque grain et faisait tenir un export mesuré (65,4 M échantillons) plus de
+ // dix minutes sans finir, l'export paraissant figé à ~80 %. `avfilter_atempo_stretch`
+ // rend `None` si la chaîne ne monte pas, si le sink négocie un format inattendu ou si la
+ // sortie reste plus courte que la cible ; le WSOLA reste alors le chemin de repli exact
+ // d'avant, en journalisant la raison.
+ if let Some(stretched) = unsafe { avfilter_atempo_stretch(pcm, target_samples, speed) } {
+ return stretched;
+ }
+
let mut stretcher = WsolaTimeStretcher::new(
AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_OUTPUT_CHANNELS,
@@ -792,6 +834,431 @@ fn stretch_pcm_to_length(pcm: &[Vec], target_samples: usize) -> PlanarPcm {
exact
}
+/// Plafond du nombre d'étages atempo chaînés.
+///
+/// `speed` vient de `source_samples / target_samples`, pas de l'éditeur : une scène corrompue
+/// où une poignée d'échantillons vise une cible d'une heure donne un ratio arbitrairement
+/// petit, et le chaînage par 0.5 empile alors une trentaine d'étages — plus d'un millier pour
+/// un subnormal — chacun avec sa fenêtre d'analyse et sa perte d'amorçage. Huit couvre
+/// jusqu'à 0.5⁸ ≈ 0,0039, soit vingt-cinq fois sous `MIN_PLAYBACK_SPEED` (0,1) ; au-delà on
+/// rend `None` et le WSOLA, qui n'a pas de bornes, prend le relais.
+const ATEMPO_MAX_STAGES: usize = 8;
+
+/// Découpe un facteur de vitesse en facteurs que `atempo` accepte individuellement : le
+/// filtre n'admet que [0.5, 100.0], on chaîne donc les dépassements (0.2 → [0.5, 0.5, 0.8],
+/// 250 → [100.0, 2.5]) — le produit des facteurs reconstitue la vitesse demandée.
+///
+/// Rend `None` au-delà de `ATEMPO_MAX_STAGES` maillons. La borne haute est chaînée elle
+/// aussi : `MAX_PLAYBACK_SPEED` vaut 100 donc un seul étage suffit à tout ce que l'éditeur
+/// produit, mais `speed` est un rapport de longueurs quantifiées, pas la vitesse cliquée, et
+/// rien ne garantit qu'il reste sous la borne du filtre.
+fn atempo_factors(speed: f64) -> Option> {
+ let mut factors = Vec::new();
+ let mut remaining = speed;
+ while remaining > 100.0 || remaining < 0.5 {
+ if factors.len() >= ATEMPO_MAX_STAGES {
+ return None;
+ }
+ if remaining > 100.0 {
+ factors.push(100.0);
+ remaining /= 100.0;
+ } else {
+ factors.push(0.5);
+ remaining /= 0.5;
+ }
+ }
+ factors.push(remaining);
+ Some(factors)
+}
+
+/// RAII : libère le graphe même en sortie précoce sur erreur.
+struct FilterGraphGuard(*mut AVFilterGraph);
+
+impl Drop for FilterGraphGuard {
+ fn drop(&mut self) {
+ if !self.0.is_null() {
+ unsafe { avfilter_graph_free(&mut self.0) };
+ }
+ }
+}
+
+/// RAII : libère la trame de drain même en sortie précoce sur erreur.
+struct FrameGuard(*mut AVFrame);
+
+impl Drop for FrameGuard {
+ fn drop(&mut self) {
+ if !self.0.is_null() {
+ unsafe { av_frame_free(&mut self.0) };
+ }
+ }
+}
+
+/// Taille des trames poussées vers le graphe. Le drain est entrelacé avec l'alimentation
+/// (cf. `atempo_drain`) : sans cela `av_buffersrc_add_frame` empile toute la région dans la
+/// file du buffersrc — ~523 Mo pour une speed region stéréo de 20 min, en plus du slice
+/// d'entrée et de l'accumulateur de sortie.
+const ATEMPO_FEED_CHUNK: usize = 4096;
+
+/// Rallonge de silence poussée derrière la région avant l'EOF, par étage atempo.
+///
+/// atempo laisse tomber la dernière fenêtre de chaque étage. En prolongeant l'entrée d'un
+/// silence, la fenêtre perdue devient du silence et le contenu réel sort en entier : mesuré
+/// sur le pin ffmpeg n8.1.2 (48 kHz stéréo), le manque tombe de 981 à 217 échantillons pour
+/// un étage 0.5×, de 2 735 à 553 pour deux, de 8 234 à 2 676 pour quatre. Au-delà la courbe
+/// est plate — un tail 16× plus grand ne change plus rien — ce qui reste est traité par la
+/// correction de tempo de `avfilter_atempo_stretch`.
+const ATEMPO_PRIME_TAIL: usize = 4096;
+
+/// Marge de sécurité, en échantillons, sur la longueur demandée à la passe corrigée.
+///
+/// Le manque de la seconde passe n'est pas exactement celui mesuré à la première (le tempo
+/// a bougé de moins de 1 %, la chaîne est la même). Viser 64 échantillons de plus fait
+/// tomber le résidu du côté du surplus, tronqué : 1,3 ms de contenu en moins plutôt qu'un
+/// trou de silence.
+const ATEMPO_LENGTH_GUARD: usize = 64;
+
+/// Longueur du silence à pousser derrière la région pour une chaîne donnée.
+fn atempo_prime_tail(factors: &[f64], speed: f64) -> usize {
+ ATEMPO_PRIME_TAIL
+ .saturating_mul(factors.len() + 1)
+ .saturating_mul(speed.max(1.0).ceil() as usize)
+}
+
+/// Vide le buffersink dans `stretched`, sans jamais y garder plus de `keep` échantillons par
+/// plan, et compte dans `produced` TOUT ce qui est sorti — y compris ce qui est jeté.
+///
+/// Les deux chiffres servent à des choses différentes : `stretched` est le résultat, alors
+/// que `produced` mesure ce que la chaîne a réellement rendu pour une entrée de longueur
+/// connue, donc son manque (cf. `avfilter_atempo_stretch`).
+///
+/// Rend `Some(true)` sur EOF, `Some(false)` quand le graphe n'a plus rien de prêt (EAGAIN),
+/// `None` sur une vraie panne — l'appelant retombe alors sur WSOLA.
+unsafe fn atempo_drain(
+ sink_ctx: *mut AVFilterContext,
+ frame: *mut AVFrame,
+ stretched: &mut PlanarPcm,
+ keep: usize,
+ produced: &mut usize,
+) -> Option {
+ loop {
+ let ret = av_buffersink_get_frame(sink_ctx, frame);
+ if ret == AVERROR_EAGAIN {
+ return Some(false);
+ }
+ if ret == AVERROR_EOF {
+ return Some(true);
+ }
+ if ret < 0 {
+ eprintln!(
+ "[openscreen-compositor] atempo: av_buffersink_get_frame a échoué (ret={ret}), repli WSOLA"
+ );
+ return None;
+ }
+ let count = (*frame).nb_samples.max(0) as usize;
+ let channels = (*frame).ch_layout.nb_channels.max(0) as usize;
+ // La chaîne est épinglée en flt entrelacé de bout en bout (cf. `avfilter_atempo_stretch`) ;
+ // tout autre format signifie que la négociation a fait autre chose que ce qu'on a
+ // demandé, et le désentrelacement ci-dessous lirait n'importe quoi.
+ if (*frame).format != AVSampleFormat::AV_SAMPLE_FMT_FLT as i32
+ || channels != AUDIO_OUTPUT_CHANNELS
+ {
+ eprintln!(
+ "[openscreen-compositor] atempo: trame de sortie inattendue (format={} canaux={channels}), repli WSOLA",
+ (*frame).format
+ );
+ av_frame_unref(frame);
+ return None;
+ }
+ let wanted = count.min(keep.saturating_sub(stretched[0].len()));
+ if wanted > 0 {
+ let interleaved = *(*frame).extended_data.add(0) as *const f32;
+ let samples =
+ std::slice::from_raw_parts(interleaved, count * AUDIO_OUTPUT_CHANNELS);
+ for channel in 0..AUDIO_OUTPUT_CHANNELS {
+ let plane = &mut stretched[channel];
+ plane.reserve(wanted);
+ for index in 0..wanted {
+ plane.push(samples[index * AUDIO_OUTPUT_CHANNELS + channel]);
+ }
+ }
+ }
+ *produced += count;
+ av_frame_unref(frame);
+ }
+}
+
+/// Monte `abuffer → atempo… → abuffersink`, y pousse `pcm` suivi de `prime_tail` échantillons
+/// de silence, et rend le nombre total d'échantillons sortis — `stretched` en reçoit les
+/// `keep` premiers.
+///
+/// La chaîne est épinglée en **flt entrelacé** 48 kHz stéréo, pas en fltp : `af_atempo`
+/// n'annonce que des formats packed (U8/S16/S32/FLT/DBL, cf. son `query_formats`), donc un
+/// abuffer en fltp fait insérer un aresample de conversion et rend une branche planaire du
+/// drain inatteignable — mesuré, le sink négociait déjà `AV_SAMPLE_FMT_FLT`. En demandant flt
+/// des deux côtés il n'y a aucun filtre de conversion dans le graphe, et l'entrelacement est
+/// absorbé par la recopie qu'on fait de toute façon.
+unsafe fn atempo_pass(
+ pcm: &[Vec],
+ factors: &[f64],
+ prime_tail: usize,
+ keep: usize,
+ stretched: &mut PlanarPcm,
+) -> Option {
+ let graph_guard = FilterGraphGuard(avfilter_graph_alloc());
+ let graph = graph_guard.0;
+ if graph.is_null() {
+ return None;
+ }
+
+ let abuffer_name = CString::new("abuffer").ok()?;
+ let abuffersink_name = CString::new("abuffersink").ok()?;
+ let atempo_name = CString::new("atempo").ok()?;
+ let abuffer = avfilter_get_by_name(abuffer_name.as_ptr());
+ let abuffersink = avfilter_get_by_name(abuffersink_name.as_ptr());
+ let atempo = avfilter_get_by_name(atempo_name.as_ptr());
+ if abuffer.is_null() || abuffersink.is_null() || atempo.is_null() {
+ return None;
+ }
+
+ let create_filter = |graph: *mut AVFilterGraph,
+ filter: *const AVFilter,
+ name: &str,
+ args: Option<&str>,
+ options: &[(&str, &str)]|
+ -> Option<*mut AVFilterContext> {
+ let cname = CString::new(name).ok()?;
+ let cargs = match args {
+ Some(args) => Some(CString::new(args).ok()?),
+ None => None,
+ };
+ let ctx = avfilter_graph_alloc_filter(graph, filter, cname.as_ptr());
+ if ctx.is_null() {
+ eprintln!("[openscreen-compositor] atempo: alloc_filter({name}) a rendu null");
+ return None;
+ }
+ // Les options typées se posent entre l'alloc et l'init — `avfilter_init_str` fige la
+ // négociation. Un échec n'est pas fatal : l'abuffer porte déjà le format, ceci ne fait
+ // que l'imposer aussi côté sink pour qu'aucun build ffmpeg ne puisse y glisser un
+ // aresample. Le drain vérifie le format reçu de toute façon.
+ for (key, value) in options {
+ let ckey = CString::new(*key).ok()?;
+ let cvalue = CString::new(*value).ok()?;
+ let ret = av_opt_set(
+ ctx as *mut std::ffi::c_void,
+ ckey.as_ptr(),
+ cvalue.as_ptr(),
+ AV_OPT_SEARCH_CHILDREN as i32,
+ );
+ if ret < 0 {
+ eprintln!(
+ "[openscreen-compositor] atempo: av_opt_set({name}.{key}={value}) a échoué (ret={ret}), négociation laissée libre"
+ );
+ }
+ }
+ // `map_or` consommerait `cargs` et le pointeur rendu par la closure serait dangling
+ // avant même l'appel — on emprunte donc pour la durée de l'appel.
+ let args_ptr = match &cargs {
+ Some(args) => args.as_ptr(),
+ None => ptr::null(),
+ };
+ let ret = avfilter_init_str(ctx, args_ptr);
+ if ret < 0 {
+ eprintln!(
+ "[openscreen-compositor] atempo: init_str({name}, {:?}) a échoué (ret={ret})",
+ args.unwrap_or("")
+ );
+ return None;
+ }
+ Some(ctx)
+ };
+
+ let rate = AUDIO_OUTPUT_SAMPLE_RATE;
+ let src_ctx = create_filter(
+ graph,
+ abuffer,
+ "in",
+ Some(&format!(
+ "time_base=1/{rate}:sample_rate={rate}:sample_fmt=flt:channel_layout=stereo"
+ )),
+ &[],
+ )?;
+ let sink_ctx = create_filter(graph, abuffersink, "out", None, &[("sample_fmts", "flt")])?;
+
+ let mut previous = src_ctx;
+ for (index, factor) in factors.iter().enumerate() {
+ let stage = create_filter(
+ graph,
+ atempo,
+ &format!("atempo{index}"),
+ Some(&format!("{factor}")),
+ &[],
+ )?;
+ if avfilter_link(previous, 0, stage, 0) < 0 {
+ eprintln!("[openscreen-compositor] atempo: avfilter_link a échoué au maillon {index}");
+ return None;
+ }
+ previous = stage;
+ }
+ if avfilter_link(previous, 0, sink_ctx, 0) < 0 {
+ eprintln!("[openscreen-compositor] atempo: avfilter_link vers le sink a échoué");
+ return None;
+ }
+ if avfilter_graph_config(graph, ptr::null_mut()) < 0 {
+ eprintln!("[openscreen-compositor] atempo: avfilter_graph_config a échoué");
+ return None;
+ }
+
+ let sink_frame = FrameGuard(av_frame_alloc());
+ if sink_frame.0.is_null() {
+ return None;
+ }
+
+ // Alimentation : le PCM passe par trames flt de 4096 échantillons, prolongé par la
+ // rallonge de silence. `av_buffersrc_add_frame` déplace les références du frame dans le
+ // graphe ; on alloue donc une trame neuve par tranche et on la libère après envoi (le
+ // shell est vide à ce point). Le drain est entrelacé ici : sans lui la file du buffersrc
+ // porterait toute la région d'un coup. La condition d'arrêt est l'entrée épuisée, PAS
+ // « on a de quoi remplir la cible » — s'arrêter là couperait la rallonge, et les derniers
+ // grains du contenu réel resteraient dans le graphe.
+ let source_samples = pcm.first().map(|plane| plane.len()).unwrap_or(0);
+ let total_input = source_samples.saturating_add(prime_tail);
+ let mut offset = 0usize;
+ let mut produced = 0usize;
+ let mut drained_to_eof = false;
+ while offset < total_input {
+ let count = ATEMPO_FEED_CHUNK.min(total_input - offset);
+ let mut frame = av_frame_alloc();
+ if frame.is_null() {
+ eprintln!("[openscreen-compositor] atempo: av_frame_alloc (feed) a échoué");
+ return None;
+ }
+ (*frame).format = AVSampleFormat::AV_SAMPLE_FMT_FLT as i32;
+ (*frame).sample_rate = rate;
+ (*frame).nb_samples = count as i32;
+ av_channel_layout_default(&mut (*frame).ch_layout, AUDIO_OUTPUT_CHANNELS as i32);
+ if av_frame_get_buffer(frame, 0) < 0 {
+ eprintln!("[openscreen-compositor] atempo: av_frame_get_buffer (feed) a échoué");
+ av_frame_free(&mut frame);
+ return None;
+ }
+ // Un seul plan en flt : on écrit entrelacé. Le `write_bytes` couvre à la fois les
+ // canaux absents d'une source mono et la rallonge de silence finale.
+ let destination = *(*frame).extended_data.add(0) as *mut f32;
+ ptr::write_bytes(destination, 0, count * AUDIO_OUTPUT_CHANNELS);
+ for channel in 0..AUDIO_OUTPUT_CHANNELS {
+ if let Some(plane) = pcm.get(channel) {
+ let available = plane.len().saturating_sub(offset).min(count);
+ for index in 0..available {
+ *destination.add(index * AUDIO_OUTPUT_CHANNELS + channel) =
+ plane[offset + index];
+ }
+ }
+ }
+ (*frame).pts = offset as i64;
+ let ret = av_buffersrc_add_frame(src_ctx, frame);
+ av_frame_free(&mut frame);
+ if ret < 0 {
+ eprintln!("[openscreen-compositor] atempo: av_buffersrc_add_frame (offset={offset}) a échoué (ret={ret})");
+ return None;
+ }
+ offset += count;
+ if atempo_drain(sink_ctx, sink_frame.0, stretched, keep, &mut produced)? {
+ drained_to_eof = true;
+ break;
+ }
+ }
+
+ // EOF : le graphe vide alors ses derniers grains.
+ if !drained_to_eof {
+ if av_buffersrc_add_frame(src_ctx, ptr::null_mut()) < 0 {
+ eprintln!("[openscreen-compositor] atempo: flush du buffersrc a échoué, repli WSOLA");
+ return None;
+ }
+ atempo_drain(sink_ctx, sink_frame.0, stretched, keep, &mut produced)?;
+ }
+ Some(produced)
+}
+
+/// Étire le PCM d'un facteur `speed` via une chaîne `abuffer → atempo… → abuffersink` montée
+/// en processus, dans l'avfilter LGPL déjà vendored avec l'app (avfilter-11.dll /
+/// libavfilter.so.11 / libavfilter.11.dylib voyagent dans le même lot que avcodec — cf.
+/// scripts/fetch-ffmpeg.mjs qui copie TOUTES les av*.dll du build BtbN).
+///
+/// **Deux passes.** atempo ne rend pas exactement `n/tempo` échantillons : il en manque un
+/// nombre fixe par chaîne, indépendant de la longueur de l'entrée (mesuré sur n8.1.2 :
+/// ~217 pour un étage, ~550 pour deux, ~2 700 pour quatre, soit jusqu'à 56 ms à 0,1×). Le
+/// manque ne se rattrape pas en poussant plus d'entrée — c'est une différence de durée
+/// rendue, pas une queue retenue — et le combler par des zéros collait un trou de silence
+/// devant le segment suivant, puisque le crossfade equal-power ne couvre que les frontières
+/// de clip, jamais la concaténation par segment. La première passe mesure donc le manque sur
+/// le contenu réel, sans rien garder, et la seconde demande `cible + manque` pour que le
+/// contenu remplisse la cible ; le surplus est tronqué. Aux vitesses > 1 le manque est nul et
+/// la seconde passe est sautée.
+///
+/// Retourne `None` sur toute défaillance (montage, négociation, exécution, sortie plus courte
+/// que la cible) : l'appelant retombe alors sur le WSOLA d'origine.
+unsafe fn avfilter_atempo_stretch(
+ pcm: &[Vec],
+ target_samples: usize,
+ speed: f64,
+) -> Option {
+ if !speed.is_finite() || speed <= 0.0 || target_samples == 0 {
+ return None;
+ }
+ let source_samples = pcm.first().map(|plane| plane.len()).unwrap_or(0);
+ if source_samples == 0 {
+ return None;
+ }
+
+ let planes = |capacity: usize| -> PlanarPcm {
+ (0..AUDIO_OUTPUT_CHANNELS)
+ .map(|_| Vec::with_capacity(capacity))
+ .collect()
+ };
+
+ let factors = atempo_factors(speed)?;
+ let prime_tail = atempo_prime_tail(&factors, speed);
+ let mut stretched = planes(target_samples);
+ let produced = atempo_pass(pcm, &factors, prime_tail, target_samples, &mut stretched)?;
+ let expected = ((source_samples + prime_tail) as f64 / speed).round() as usize;
+ let shortfall = expected.saturating_sub(produced);
+
+ if shortfall > 0 {
+ // Le contenu réel s'arrête `shortfall` échantillons avant la cible, et ce qui suit
+ // dans `stretched` n'est que la rallonge de silence étirée. On rejoue en demandant
+ // une cible plus longue du même montant : la chaîne étant la même, elle en perd
+ // autant, et le contenu tombe cette fois pile sur `target_samples`.
+ let corrected_target = target_samples + shortfall + ATEMPO_LENGTH_GUARD;
+ let corrected_speed = source_samples as f64 / corrected_target as f64;
+ let corrected_factors = atempo_factors(corrected_speed)?;
+ let corrected_tail = atempo_prime_tail(&corrected_factors, corrected_speed);
+ let mut corrected = planes(target_samples);
+ atempo_pass(
+ pcm,
+ &corrected_factors,
+ corrected_tail,
+ target_samples,
+ &mut corrected,
+ )?;
+ if corrected[0].len() >= target_samples {
+ stretched = corrected;
+ }
+ }
+
+ // Plus court que la cible : la chaîne n'a pas fait son travail. On rend `None` — compléter
+ // par des zéros exporterait un trou en se faisant passer pour un succès, et le contrat de
+ // `stretch_pcm_to_length` est un repli WSOLA sur échec.
+ if stretched[0].len() < target_samples {
+ eprintln!(
+ "[openscreen-compositor] atempo: sortie de {} échantillons pour une cible de {target_samples} (vitesse {speed}, {} étages), repli WSOLA",
+ stretched[0].len(),
+ factors.len()
+ );
+ return None;
+ }
+ Some(stretched)
+}
+
/// 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],
@@ -914,6 +1381,153 @@ pub fn assemble_concatenated_pcm(
output
}
+/// Mix imported audio tracks (issue #350) over the assembled programme.
+///
+/// Each track is decoded across its trim window — already resampled to 48 kHz
+/// stereo by `decode_clip_audio`, the same path a clip's own audio takes — scaled
+/// by its per-track gain (the same `10^(dB/20)` law as `finish_audio`), and summed
+/// into the programme at `start_sec`. The programme length is NOT extended: a
+/// track that runs past the video is truncated to it, so the audio and video
+/// streams stay the same length for the muxer.
+///
+/// The decode window is capped up front at the room left in the programme after
+/// `start_sec`, and a track starting at/after the end is skipped without decoding.
+/// `decode_clip_audio` preallocates from the window, so this keeps a long track
+/// pinned near a short programme's end from buffering (and clamping away) hours of
+/// PCM. `trim_end_sec` must therefore be concrete — the renderer sends
+/// `trimEnd ?? durationSec`.
+///
+/// A track whose file has no decodable audio is skipped — the same degradation a
+/// stream-less clip gets.
+pub fn mix_external_tracks(mut programme: PlanarPcm, tracks: &[SceneAudioTrack]) -> PlanarPcm {
+ let programme_len = programme.first().map(Vec::len).unwrap_or(0);
+ if programme_len == 0 {
+ return programme;
+ }
+ for track in tracks {
+ let offset = (track.start_sec.max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize;
+ // A track that starts at or past the programme end contributes nothing —
+ // skip it before decoding anything.
+ if offset >= programme_len {
+ continue;
+ }
+ let trim_start = track.trim_start_sec.max(0.0);
+ let Some(trim_end_full) = track.trim_end_sec else {
+ // Without a concrete end there is no safe window to decode (see the doc
+ // comment); the renderer always resolves one, so this only guards a
+ // hand-written scene.
+ continue;
+ };
+ // Cap the decode window at the room left in the programme. Everything past
+ // `offset` that overflows is discarded by `overlay_track_pcm` anyway, so
+ // decoding it only wastes time and memory — a three-hour track placed at
+ // second 9 of a ten-second export must not buffer three hours of PCM.
+ let remaining_sec = (programme_len - offset) as f64 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ let trim_end = trim_end_full.min(trim_start + remaining_sec);
+ // The track's own length, before that cap. The fades belong to the track, not to
+ // whatever the programme had room for — capping first and measuring after is what
+ // made a fade-out ramp down at the truncation point instead of at the real end.
+ let full_len =
+ ((trim_end_full - trim_start).max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64) as usize;
+ if trim_end <= trim_start {
+ continue;
+ }
+ let decoded = match decode_clip_audio(&track.path, trim_start, trim_end) {
+ Ok(Some(pcm)) => pcm,
+ _ => continue,
+ };
+ // The app's own range is -60..+12 dB (the inspector slider); clamping at
+ // -12 here floored every quiet bed at a tenth of the attenuation asked for.
+ let gain = 10.0f32.powf(track.gain_db.clamp(-60.0, 12.0) / 20.0);
+ overlay_track_pcm(
+ &mut programme,
+ &decoded,
+ offset,
+ gain,
+ track.fade_in_sec.max(0.0),
+ track.fade_out_sec.max(0.0),
+ full_len,
+ );
+ }
+ programme
+}
+
+/// Sum one decoded track into the programme at `offset` samples, scaled by `gain`,
+/// truncated at the programme's end. Split out of `mix_external_tracks` so the
+/// placement/gain/clamp math is testable without ffmpeg, exactly like
+/// `mix_aligned_tracks` is split from the decode above.
+fn overlay_track_pcm(
+ programme: &mut PlanarPcm,
+ decoded: &PlanarPcm,
+ offset: usize,
+ gain: f32,
+ fade_in_sec: f64,
+ fade_out_sec: f64,
+ // The track's length before the programme cap, in samples, or 0 when nothing capped it.
+ // `decoded` may be shorter because the decode window was capped at the room left in the
+ // programme; the ramps belong to the track, not to the room.
+ full_len: usize,
+) {
+ let programme_len = programme.first().map(Vec::len).unwrap_or(0);
+ if offset >= programme_len {
+ return;
+ }
+ let room = programme_len - offset;
+ // The ramps are measured against the DECODED length, not the room left in the
+ // programme: a track running past the end is cut off there, and a fade-out
+ // timed to the cut would ramp down over audio the export never reaches.
+ let decoded_len = decoded.iter().map(Vec::len).max().unwrap_or(0);
+ let envelope_len = full_len.max(decoded_len);
+ let (fade_in, fade_out) = resolve_fade_samples(envelope_len, fade_in_sec, fade_out_sec);
+ for channel in 0..AUDIO_OUTPUT_CHANNELS {
+ let Some(source) = decoded.get(channel) else {
+ continue;
+ };
+ let count = source.len().min(room);
+ let dst = &mut programme[channel];
+ for k in 0..count {
+ dst[offset + k] += source[k] * gain * fade_envelope(k, envelope_len, fade_in, fade_out);
+ }
+ }
+}
+
+/// Fade lengths in samples, reduced to fit inside `len`.
+///
+/// Fades that do not fit share the window in proportion rather than being clamped
+/// independently: clamping each to the length first would turn an asymmetric pair
+/// into a symmetric one, losing the shape asked for. Kept identical to the app's
+/// `resolveFadeSecs` so the preview and the render agree.
+fn resolve_fade_samples(len: usize, fade_in_sec: f64, fade_out_sec: f64) -> (usize, usize) {
+ if len == 0 {
+ return (0, 0);
+ }
+ let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ let mut fade_in = fade_in_sec.max(0.0) * rate;
+ let mut fade_out = fade_out_sec.max(0.0) * rate;
+ let total = fade_in + fade_out;
+ if total > len as f64 && total > 0.0 {
+ let scale = len as f64 / total;
+ fade_in *= scale;
+ fade_out *= scale;
+ }
+ (fade_in.round() as usize, fade_out.round() as usize)
+}
+
+/// Linear ramp factor at sample `k` of a `len`-sample track.
+fn fade_envelope(k: usize, len: usize, fade_in: usize, fade_out: usize) -> f32 {
+ let mut v = 1.0f32;
+ if fade_in > 0 && k < fade_in {
+ v = v.min(k as f32 / fade_in as f32);
+ }
+ if fade_out > 0 && len > k {
+ let remaining = len - k;
+ if remaining <= fade_out {
+ v = v.min(remaining as f32 / fade_out as f32);
+ }
+ }
+ v
+}
+
/// 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 {
@@ -1026,6 +1640,40 @@ impl Drop for AacEncoder {
}
}
+#[cfg(test)]
+mod hold_tests {
+ use super::*;
+
+ /// Les images tenues allongent le CRÉNEAU audio du clip sans allonger son PCM, et
+ /// `assemble_concatenated_pcm` laisse des zéros dans ce qui dépasse. Le silence d'une
+ /// pause est donc gratuit : aucun fichier muet à décoder, aucune entrée de mix en plus.
+ #[test]
+ fn a_longer_slot_than_pcm_leaves_silence_at_its_tail() {
+ // 2s de créneau à 1 fps, mais seulement 1s de PCM décodé.
+ let plan = build_audio_concat_plan(&[2], &[true], 1.0);
+ let one_sec = AUDIO_OUTPUT_SAMPLE_RATE as usize;
+ let pcm = vec![Some(vec![vec![0.5f32; one_sec]; AUDIO_OUTPUT_CHANNELS])];
+ let out = assemble_concatenated_pcm(&pcm, &plan);
+ assert_eq!(out[0].len(), 2 * one_sec);
+ assert!((out[0][0] - 0.5).abs() < 1e-6, "le vrai son est bien là");
+ assert_eq!(out[0][2 * one_sec - 1], 0.0, "la queue du créneau est du silence");
+ }
+
+ /// Et le son réel n'est PAS étiré pour remplir le créneau : la voix garde son rythme.
+ #[test]
+ fn the_clips_own_audio_is_not_stretched_to_fill_the_hold() {
+ let plan = build_audio_concat_plan(&[4], &[true], 1.0);
+ let one_sec = AUDIO_OUTPUT_SAMPLE_RATE as usize;
+ let mut source = vec![0.0f32; one_sec];
+ source[0] = 1.0;
+ let pcm = vec![Some(vec![source.clone(), source])];
+ let out = assemble_concatenated_pcm(&pcm, &plan);
+ // L'impulsion reste au premier échantillon, pas répartie sur quatre secondes.
+ assert!((out[0][0] - 1.0).abs() < 1e-6);
+ assert_eq!(out[0][1], 0.0);
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1036,6 +1684,187 @@ mod tests {
vec![samples.to_vec(), samples.to_vec()]
}
+ /// Un sinus 440 Hz de `secs` secondes sur les deux canaux.
+ fn sine(secs: f64) -> PlanarPcm {
+ let total = (secs * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize;
+ let mut pcm: PlanarPcm = vec![Vec::with_capacity(total); AUDIO_OUTPUT_CHANNELS];
+ for i in 0..total {
+ let t = i as f32 / AUDIO_OUTPUT_SAMPLE_RATE as f32;
+ let sample = (2.0 * PI * 440.0 * t).sin() * 0.5;
+ for channel in 0..AUDIO_OUTPUT_CHANNELS {
+ pcm[channel].push(sample);
+ }
+ }
+ pcm
+ }
+
+ /// Hauteur mesurée par passages à zéro montants sur une fenêtre d'une seconde.
+ fn pitch_hz(plane: &[f32], start: usize) -> usize {
+ let window = (AUDIO_OUTPUT_SAMPLE_RATE as usize).min(plane.len().saturating_sub(start + 1));
+ (start..start + window)
+ .filter(|&i| plane[i] <= 0.0 && plane[i + 1] > 0.0)
+ .count()
+ }
+
+ /// Énergie RMS des `count` derniers échantillons.
+ fn tail_rms(plane: &[f32], count: usize) -> f32 {
+ let start = plane.len().saturating_sub(count);
+ let slice = &plane[start..];
+ if slice.is_empty() {
+ return 0.0;
+ }
+ (slice.iter().map(|v| v * v).sum::() / slice.len() as f32).sqrt()
+ }
+
+ /// Les presets réellement cliquables dans l'éditeur (`SPEED_OPTIONS`), plus les bornes
+ /// `MIN_PLAYBACK_SPEED` / `MAX_PLAYBACK_SPEED` de `src/components/video-editor/types.ts`.
+ const EDITOR_SPEEDS: [f64; 13] = [
+ 0.1, 0.25, 0.5, 0.75, 1.25, 1.5, 1.75, 2.0, 3.0, 4.0, 5.0, 10.0, 100.0,
+ ];
+
+ #[test]
+ fn atempo_covers_every_editor_speed_without_a_silent_tail() {
+ // Le bug que ce test verrouille : atempo n'émet jamais sa dernière fenêtre, et
+ // compléter le manque par des zéros collait jusqu'à 181 ms de blanc (0,1×, quatre
+ // étages) devant le segment suivant — un dropout audible dans un export « réussi ».
+ // La rallonge de silence en entrée fait sortir les derniers grains pour de bon, donc
+ // la fin de région doit porter autant de signal que son milieu, à toute vitesse et
+ // sur des spans courts comme longs.
+ for &speed in &EDITOR_SPEEDS {
+ for &secs in &[0.05f64, 0.5, 3.0] {
+ let pcm = sine(secs);
+ let source = pcm[0].len();
+ let target = (source as f64 / speed).round() as usize;
+ if target == 0 {
+ continue;
+ }
+ let stretched = unsafe { avfilter_atempo_stretch(&pcm, target, speed) }
+ .unwrap_or_else(|| {
+ panic!("atempo doit couvrir {speed}× sur {secs}s (cible {target})")
+ });
+ for plane in &stretched {
+ assert_eq!(plane.len(), target, "vitesse {speed}× durée {secs}s");
+ }
+ // 10 ms de queue : le zero-padding d'avant en laissait au moins 5 ms à 0,1×.
+ // Le trou : un silence numérique en fin de région. Zéro tolérance — la
+ // correction de tempo est faite pour que le contenu tombe pile sur la cible.
+ let trailing_silence =
+ stretched[0].iter().rev().take_while(|v| **v == 0.0).count();
+ assert_eq!(
+ trailing_silence, 0,
+ "{trailing_silence} échantillons de silence en fin de région à {speed}× sur {secs}s"
+ );
+ let tail = (AUDIO_OUTPUT_SAMPLE_RATE as usize / 100).min(target);
+ assert!(
+ tail_rms(&stretched[0], tail) > 0.05,
+ "queue sans énergie à {speed}× sur {secs}s : rms={}",
+ tail_rms(&stretched[0], tail)
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn atempo_preserves_pitch_through_a_chain_of_stages() {
+ // 0,25× et 0,1× sortent des bornes [0.5, 100] d'un seul atempo et passent donc par
+ // la chaîne multi-étages — le cas que le test d'origine (1,25×, un seul maillon)
+ // ne touchait pas, alors que 0,25× est un preset de la liste déroulante.
+ for &speed in &[0.1f64, 0.25, 0.5] {
+ let pcm = sine(2.0);
+ let target = (pcm[0].len() as f64 / speed).round() as usize;
+ let stretched = unsafe { avfilter_atempo_stretch(&pcm, target, speed) }
+ .unwrap_or_else(|| panic!("la chaîne atempo doit monter à {speed}×"));
+ let measured = pitch_hz(&stretched[0], target / 2);
+ assert!(
+ (measured as f64 - 440.0).abs() <= 2.0,
+ "hauteur à {speed}× : {measured} Hz (un rééchantillonnage la déplacerait)"
+ );
+ }
+ }
+
+ #[test]
+ fn stretch_pcm_to_length_is_exact_at_every_editor_speed() {
+ // Contrat de bout en bout, repli WSOLA compris : quelle que soit la branche prise,
+ // la longueur rendue est exactement celle que le plan de concaténation attend.
+ for &speed in &EDITOR_SPEEDS {
+ let pcm = sine(0.5);
+ let target = (pcm[0].len() as f64 / speed).round() as usize;
+ let stretched = stretch_pcm_to_length(&pcm, target);
+ assert_eq!(stretched.len(), AUDIO_OUTPUT_CHANNELS);
+ for plane in &stretched {
+ assert_eq!(plane.len(), target, "vitesse {speed}×");
+ }
+ }
+ }
+
+ #[test]
+ fn wsola_fallback_still_stretches_and_keeps_pitch() {
+ // Le chemin de repli reste atteignable (avfilter absent d'un build, graphe qui ne
+ // monte pas) et sa recopie de buffer a été remplacée par un curseur de lecture :
+ // ce test verrouille qu'il rend toujours la bonne durée à la bonne hauteur.
+ let pcm = sine(2.0);
+ let speed = 0.5;
+ let target = (pcm[0].len() as f64 / speed).round() as usize;
+ let mut stretcher = WsolaTimeStretcher::new(
+ AUDIO_OUTPUT_SAMPLE_RATE,
+ AUDIO_OUTPUT_CHANNELS,
+ speed,
+ target,
+ );
+ let mut emitted: PlanarPcm = vec![Vec::new(); AUDIO_OUTPUT_CHANNELS];
+ for chunk in [stretcher.push(&pcm), stretcher.flush()] {
+ for channel in 0..AUDIO_OUTPUT_CHANNELS {
+ emitted[channel].extend_from_slice(&chunk[channel]);
+ }
+ }
+ // Le WSOLA vise la durée sans la garantir à l'échantillon près : c'est
+ // `stretch_pcm_to_length` qui recadre. On tolère 1 % ici.
+ let produced = emitted[0].len() as f64;
+ assert!(
+ (produced - target as f64).abs() / (target as f64) < 0.02,
+ "WSOLA a rendu {produced} pour une cible de {target}"
+ );
+ let measured = pitch_hz(&emitted[0], target / 2);
+ assert!(
+ (measured as f64 - 440.0).abs() <= 3.0,
+ "hauteur WSOLA : {measured} Hz"
+ );
+ }
+
+ #[test]
+ fn atempo_factors_split_out_of_range_speeds() {
+ // Dans les bornes : un seul maillon.
+ assert_eq!(atempo_factors(1.25), Some(vec![1.25]));
+ assert_eq!(atempo_factors(0.5), Some(vec![0.5]));
+ // Hors bornes : chaîne dont le produit reconstitue la vitesse.
+ assert_eq!(atempo_factors(0.2), Some(vec![0.5, 0.5, 0.8]));
+ assert_eq!(atempo_factors(250.0), Some(vec![100.0, 2.5]));
+ for speed in [0.07f64, 0.3, 1.0, 3.7, 4_000.0] {
+ let product: f64 = atempo_factors(speed).expect("dans le plafond").iter().product();
+ assert!((product - speed).abs() < 1e-9, "produit={product} attendu={speed}");
+ }
+ // MIN_PLAYBACK_SPEED tient largement dans le plafond.
+ assert_eq!(atempo_factors(0.1).map(|f| f.len()), Some(4));
+ }
+
+ #[test]
+ fn atempo_declines_a_chain_it_would_have_to_stack() {
+ // `speed` est `source_samples / target_samples`, pas la vitesse cliquée : une scène
+ // corrompue où une poignée d'échantillons vise une cible d'une heure produit un
+ // ratio arbitrairement petit. Sans plafond le chaînage empilait une trentaine
+ // d'étages — plus d'un millier pour un subnormal — chacun avec sa perte d'amorçage.
+ assert_eq!(atempo_factors(1.0 / 48_000.0 / 3_600.0), None);
+ assert_eq!(atempo_factors(f64::MIN_POSITIVE), None);
+ assert_eq!(atempo_factors(1e30), None);
+ // Et le repli tient le contrat de longueur : c'est le WSOLA qui prend la main.
+ let pcm = sine(0.05);
+ let target = pcm[0].len() * 5_000;
+ let stretched = stretch_pcm_to_length(&pcm, target);
+ for plane in &stretched {
+ assert_eq!(plane.len(), target);
+ }
+ }
+
#[test]
fn single_track_passes_through_unchanged() {
let track = planar(&[0.25, -0.5, 0.75]);
@@ -1044,6 +1873,68 @@ mod tests {
assert_eq!(mixed[1], vec![0.25, -0.5, 0.75]);
}
+ // Imported audio track overlay (issue #350).
+ #[test]
+ fn overlay_sums_at_offset_with_gain() {
+ let mut programme = planar(&[0.1, 0.1, 0.1, 0.1]);
+ // ×2 gain, placed at sample offset 1.
+ overlay_track_pcm(&mut programme, &planar(&[0.2, 0.2]), 1, 2.0, 0.0, 0.0, 0);
+ assert_eq!(programme[0], vec![0.1, 0.5, 0.5, 0.1]);
+ assert_eq!(programme[1], vec![0.1, 0.5, 0.5, 0.1]);
+ }
+
+ #[test]
+ fn overlay_truncates_a_track_that_runs_past_the_programme() {
+ let mut programme = planar(&[0.0, 0.0, 0.0]);
+ // A 4-sample track placed at offset 2 has room for only 1 sample.
+ overlay_track_pcm(&mut programme, &planar(&[1.0, 1.0, 1.0, 1.0]), 2, 1.0, 0.0, 0.0, 0);
+ assert_eq!(programme[0], vec![0.0, 0.0, 1.0]);
+ }
+
+ #[test]
+ fn overlay_past_the_end_is_a_no_op() {
+ let mut programme = planar(&[0.3, 0.3]);
+ overlay_track_pcm(&mut programme, &planar(&[1.0]), 5, 1.0, 0.0, 0.0, 0);
+ assert_eq!(programme[0], vec![0.3, 0.3]);
+ }
+
+ #[test]
+ fn mix_external_tracks_skips_empty_windows() {
+ let programme = planar(&[0.4, 0.4]);
+ let tracks = vec![SceneAudioTrack {
+ path: "/nope.mp3".into(),
+ start_sec: 0.0,
+ gain_db: 0.0,
+ trim_start_sec: 2.0,
+ trim_end_sec: Some(1.0), // end <= start: empty window, never decoded
+ fade_in_sec: 0.0,
+ fade_out_sec: 0.0,
+ }];
+ // The empty window is skipped before any decode, so the programme is
+ // untouched even though the path does not exist.
+ let out = mix_external_tracks(programme, &tracks);
+ assert_eq!(out[0], vec![0.4, 0.4]);
+ }
+
+ #[test]
+ fn mix_external_tracks_skips_a_track_that_starts_past_the_programme() {
+ // 2 samples = ~0.00004 s of programme at 48 kHz; the track starts at 1 s, so
+ // its offset is past the end. It must be skipped before any decode is
+ // attempted (the path does not exist), never buffering its window.
+ let programme = planar(&[0.4, 0.4]);
+ let tracks = vec![SceneAudioTrack {
+ path: "/nope.mp3".into(),
+ start_sec: 1.0,
+ gain_db: 0.0,
+ trim_start_sec: 0.0,
+ trim_end_sec: Some(3600.0),
+ fade_in_sec: 0.0,
+ fade_out_sec: 0.0,
+ }];
+ let out = mix_external_tracks(programme, &tracks);
+ assert_eq!(out[0], vec![0.4, 0.4]);
+ }
+
#[test]
fn single_track_is_not_clamped() {
// Promesse de non-régression : une source mono-piste ressort telle quelle, y compris
@@ -1136,6 +2027,91 @@ mod tests {
assert!((loud[0][0] - ceiling).abs() < 1e-6);
}
+ #[test]
+ fn fades_that_fit_are_left_alone() {
+ let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ let (fin, fout) = resolve_fade_samples(rate as usize, 0.1, 0.2);
+ assert_eq!(fin, (0.1 * rate).round() as usize);
+ assert_eq!(fout, (0.2 * rate).round() as usize);
+ }
+
+ #[test]
+ fn fades_too_long_for_the_track_share_it_in_proportion() {
+ // 6 s + 4 s of fade on a 2 s track → 1.2 s / 0.8 s, not a clamped 1 s / 1 s.
+ // Mirrors `resolveFadeSecs` on the app side; the two must agree or the
+ // preview and the render shape the same track differently.
+ let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ let len = (2.0 * rate) as usize;
+ let (fin, fout) = resolve_fade_samples(len, 6.0, 4.0);
+ assert_eq!(fin, (1.2 * rate).round() as usize);
+ assert_eq!(fout, (0.8 * rate).round() as usize);
+ assert!(fin + fout <= len + 1);
+ }
+
+ #[test]
+ fn a_fade_in_longer_than_the_track_still_reaches_full_volume() {
+ // Left unreduced this holds the gain near zero for the whole track — the
+ // layer exports silent.
+ let (fin, fout) = resolve_fade_samples(100, 10.0, 0.0);
+ assert_eq!((fin, fout), (100, 0));
+ assert!((fade_envelope(99, 100, fin, fout) - 0.99).abs() < 1e-3);
+ }
+
+ #[test]
+ fn the_envelope_ramps_at_both_edges_and_holds_between() {
+ assert_eq!(fade_envelope(0, 100, 10, 10), 0.0);
+ assert!((fade_envelope(5, 100, 10, 10) - 0.5).abs() < 1e-6);
+ assert_eq!(fade_envelope(50, 100, 10, 10), 1.0);
+ assert!((fade_envelope(95, 100, 10, 10) - 0.5).abs() < 1e-6);
+ }
+
+ #[test]
+ fn overlay_applies_the_fade_over_the_decoded_length() {
+ // The ramps are measured against the DECODED length, not the room left in
+ // the programme: a fade-out timed to the programme's end would ramp down
+ // over audio the export never reaches.
+ let mut programme = planar(&[0.0, 0.0, 0.0, 0.0]);
+ let decoded = planar(&[1.0, 1.0, 1.0, 1.0]);
+ // A 4-sample fade-in at 48 kHz is far below one sample of real time, so
+ // ask for the whole decoded length in seconds.
+ let four = 4.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ overlay_track_pcm(&mut programme, &decoded, 0, 1.0, four, 0.0, 0);
+ assert_eq!(programme[0][0], 0.0);
+ assert!(programme[0][1] > 0.0 && programme[0][1] < 1.0);
+ assert!(programme[0][3] > programme[0][1]);
+ }
+
+ #[test]
+ fn a_capped_track_keeps_its_fade_out_at_its_real_end() {
+ // The decode window is capped at the room left in the programme, so `decoded` is
+ // SHORTER than the track. Measuring the ramp against what came back would put the
+ // fade-out at the truncation point — the export would hear a track fading out that
+ // is in fact being cut off mid-sentence.
+ let mut programme = planar(&[0.0, 0.0, 0.0, 0.0]);
+ let decoded = planar(&[1.0, 1.0, 1.0, 1.0]);
+ let four = 4.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
+ // The track really runs eight samples; the programme had room for four.
+ overlay_track_pcm(&mut programme, &decoded, 0, 1.0, 0.0, four, 8);
+ // Nothing audible has started to ramp: the fade belongs to samples 4..8, which the
+ // programme never reaches.
+ for k in 0..4 {
+ assert_eq!(programme[0][k], 1.0, "sample {k} should be untouched");
+ }
+ }
+
+ #[test]
+ fn a_track_gain_below_the_output_bound_is_honoured() {
+ // The per-track gain range is the inspector's -60..+12, NOT the project
+ // output trim's ±12: clamping here at -12 floored every quiet bed at a
+ // tenth of the attenuation asked for.
+ let mut programme = planar(&[0.0]);
+ let decoded = planar(&[1.0]);
+ let gain = 10.0f32.powf(-40.0 / 20.0);
+ overlay_track_pcm(&mut programme, &decoded, 0, gain, 0.0, 0.0, 0);
+ assert!((programme[0][0] - gain).abs() < 1e-9);
+ assert!(programme[0][0] < 10.0f32.powf(-12.0 / 20.0));
+ }
+
#[test]
fn output_is_clipped_to_full_scale_and_keeps_its_length() {
// The trim can push a hot signal past full scale; the timeline must come back the
diff --git a/crates/compositor/src/audio_jobs.rs b/crates/compositor/src/audio_jobs.rs
new file mode 100644
index 000000000..8d97be40c
--- /dev/null
+++ b/crates/compositor/src/audio_jobs.rs
@@ -0,0 +1,240 @@
+//! Décodage et étirement de l'audio d'un clip, en parallèle du parcours vidéo.
+//!
+//! Les trois pipelines faisaient ce travail **dans** le callback `on_clip_end` de
+//! `walk_composited_timeline`, donc sur le thread de rendu et entre deux clips. Rien
+//! n'appelle `progress()` pendant ce temps : la barre d'export s'arrêtait sur le
+//! pourcentage de la dernière frame du clip et y restait pour toute la durée du décodage
+//! et de l'étirement. C'est la moitié « reporting » du « figé à ~80 % » — la moitié
+//! « coût » a été traitée par le passage à atempo, mais un clip long, un repli WSOLA ou
+//! n'importe quelle étape audio future reproduisent le symptôme à l'identique.
+//!
+//! Y répondre en publiant une progression pendant cette phase aurait demandé de changer le
+//! protocole natif → JS (il ne transporte qu'un compteur de frames absolu) et de répartir
+//! un total que les deux côtés calculent séparément. Déplacer le travail est plus simple et
+//! strictement meilleur : l'audio d'un clip ne dépend que de ce clip, il n'y a donc aucune
+//! raison qu'il occupe le thread qui compose les frames du clip suivant. Le parcours vidéo
+//! continue de rapporter sa progression sans interruption, et le temps audio disparaît du
+//! mur d'export au lieu d'y être seulement mieux affiché — ce que
+//! `export-pipeline.md` prétendait déjà.
+//!
+//! Chaque job ouvre son propre `AVFormatContext` sur le fichier du clip : libavformat
+//! n'a pas d'état partagé entre contextes, et le décodeur vidéo du parcours en a un autre
+//! sur le même chemin, en lecture seule lui aussi.
+
+use crate::audio::{decode_clip_audio, stretch_clip_pcm_by_speed, PlanarPcm};
+use crate::regions::SpeedSegment;
+use std::collections::VecDeque;
+use std::thread::JoinHandle;
+
+/// Nombre de jobs audio en vol.
+///
+/// Un thread par clip serait sans plafond : une timeline de deux cents clips décoderait
+/// deux cents pistes à la fois, chacune avec son contexte ffmpeg et son PCM complet en
+/// mémoire. Quatre suffisent à couvrir le décodage d'un clip par le rendu du suivant, qui
+/// est tout ce qu'on cherche ici.
+const MAX_INFLIGHT_AUDIO_JOBS: usize = 4;
+
+/// Le corps d'un job : décode la fenêtre gardée du clip et l'étire sur ses spans de vitesse.
+///
+/// Rend `None` quand le clip se déclare audio mais n'a pas de flux décodable, ou quand le
+/// décodage échoue — dans les deux cas l'export continue et le clip sort muet, comme avant
+/// que ce travail passe sur un thread. Les deux messages sont les mêmes qu'alors ; ils
+/// sortent seulement d'un autre thread.
+pub fn decode_and_stretch_clip_audio(
+ clip_index: usize,
+ screen_path: &str,
+ source_start_sec: f64,
+ source_end_sec: f64,
+ speed_segments: &[SpeedSegment],
+ out_fps: f64,
+) -> Option {
+ match decode_clip_audio(screen_path, source_start_sec, source_end_sec) {
+ Ok(Some(pcm)) => Some(stretch_clip_pcm_by_speed(&pcm, speed_segments, out_fps)),
+ Ok(None) => {
+ eprintln!(
+ "[pipeline] warning: clip #{clip_index} déclaré audio mais sans flux décodable; silence conservé"
+ );
+ None
+ }
+ Err(error) => {
+ eprintln!(
+ "[pipeline] warning: décodage audio du clip #{clip_index} échoué ({error:#}); silence conservé"
+ );
+ None
+ }
+ }
+}
+
+/// Collecte les résultats de jobs indexés lancés au fil du parcours, en bornant le nombre
+/// de threads simultanés.
+///
+/// L'ordre de restitution est celui des index, pas celui d'achèvement : `into_results` rend
+/// un `Vec` de la taille annoncée où chaque case porte le résultat de son clip.
+pub struct ClipAudioJobs {
+ inflight: VecDeque<(usize, JoinHandle)>,
+ results: Vec>,
+}
+
+impl ClipAudioJobs {
+ pub fn new(clip_count: usize) -> Self {
+ Self {
+ inflight: VecDeque::new(),
+ results: (0..clip_count).map(|_| None).collect(),
+ }
+ }
+
+ /// Lance `job` pour `clip_index`. Si le plafond est atteint, attend d'abord le plus
+ /// ancien job en vol — celui qui a eu le plus de temps pour finir.
+ pub fn spawn(&mut self, clip_index: usize, job: impl FnOnce() -> T + Send + 'static) {
+ while self.inflight.len() >= MAX_INFLIGHT_AUDIO_JOBS {
+ self.collect_oldest();
+ }
+ self.inflight
+ .push_back((clip_index, std::thread::spawn(job)));
+ }
+
+ /// Attend tous les jobs restants et rend les résultats rangés par index de clip.
+ pub fn into_results(mut self) -> Vec> {
+ while !self.inflight.is_empty() {
+ self.collect_oldest();
+ }
+ // `mem::take` et pas un move : le `Drop` ci-dessous interdit de sortir un champ de
+ // `self`. Il ne trouvera plus rien à joindre, la file étant vide.
+ std::mem::take(&mut self.results)
+ }
+
+ fn collect_oldest(&mut self) {
+ let Some((clip_index, handle)) = self.inflight.pop_front() else {
+ return;
+ };
+ match handle.join() {
+ Ok(value) => {
+ if let Some(slot) = self.results.get_mut(clip_index) {
+ *slot = Some(value);
+ }
+ }
+ // Un panic dans un job audio ne doit pas emporter l'export : le clip sort
+ // muet, comme il le faisait déjà quand `decode_clip_audio` échouait.
+ Err(_) => eprintln!(
+ "[pipeline] warning: le job audio du clip #{clip_index} a paniqué; silence conservé"
+ ),
+ }
+ }
+}
+
+/// Un `JoinHandle` droppé **détache** son thread. Entre le premier `spawn` et
+/// `into_results` il y a des `?` — le parcours lui-même, le flush de l'encodeur — et sur
+/// l'un d'eux la collection partait en fumée en laissant jusqu'à quatre décodages en vol
+/// dans un addon natif que l'hôte peut décharger. On joint donc à la destruction : rien ne
+/// survit à la portée, chemin d'erreur compris.
+///
+/// Ce n'est pas une annulation : `decode_clip_audio` est un appel opaque et long, et
+/// l'interrompre demanderait de lui passer un `AVIOInterruptCB` — un autre changement, dans
+/// un autre fichier. L'attente est bornée par le plus lent des quatre, soit quelques
+/// secondes depuis que le stretch passe par atempo, et elle ne coûte que sur un export qui
+/// a déjà échoué.
+impl Drop for ClipAudioJobs {
+ fn drop(&mut self) {
+ for (clip_index, handle) in std::mem::take(&mut self.inflight) {
+ if handle.join().is_err() {
+ eprintln!(
+ "[pipeline] warning: le job audio du clip #{clip_index} a paniqué pendant l'abandon de l'export"
+ );
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::Arc;
+
+ #[test]
+ fn results_are_indexed_by_clip_not_by_completion_order() {
+ // Le premier job est le plus lent : si on rangeait par ordre d'achèvement, le PCM
+ // du clip 0 atterrirait sur le clip 2 et l'export monterait l'audio dans le
+ // désordre sans rien signaler.
+ let mut jobs = ClipAudioJobs::new(3);
+ jobs.spawn(0, || {
+ std::thread::sleep(std::time::Duration::from_millis(60));
+ "zero"
+ });
+ jobs.spawn(1, || "one");
+ jobs.spawn(2, || "two");
+ assert_eq!(
+ jobs.into_results(),
+ vec![Some("zero"), Some("one"), Some("two")]
+ );
+ }
+
+ #[test]
+ fn a_clip_without_a_job_keeps_its_empty_slot() {
+ // Les clips sans audio ne lancent rien ; leur case doit rester `None` pour que
+ // `assemble_concatenated_pcm` y mette du silence.
+ let mut jobs = ClipAudioJobs::new(3);
+ jobs.spawn(1, || 7u32);
+ assert_eq!(jobs.into_results(), vec![None, Some(7), None]);
+ }
+
+ #[test]
+ fn never_more_than_the_cap_run_at_once() {
+ // Sans plafond, une timeline longue ouvrirait un contexte ffmpeg et un PCM complet
+ // par clip, tous en même temps.
+ let live = Arc::new(AtomicUsize::new(0));
+ let peak = Arc::new(AtomicUsize::new(0));
+ let mut jobs = ClipAudioJobs::new(32);
+ for index in 0..32 {
+ let live = Arc::clone(&live);
+ let peak = Arc::clone(&peak);
+ jobs.spawn(index, move || {
+ let now = live.fetch_add(1, Ordering::SeqCst) + 1;
+ peak.fetch_max(now, Ordering::SeqCst);
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ live.fetch_sub(1, Ordering::SeqCst);
+ index
+ });
+ }
+ let results = jobs.into_results();
+ assert_eq!(results.len(), 32);
+ assert!(results.iter().enumerate().all(|(i, r)| *r == Some(i)));
+ assert!(
+ peak.load(Ordering::SeqCst) <= MAX_INFLIGHT_AUDIO_JOBS,
+ "jusqu'à {} jobs simultanés pour un plafond de {MAX_INFLIGHT_AUDIO_JOBS}",
+ peak.load(Ordering::SeqCst)
+ );
+ }
+
+ #[test]
+ fn dropping_the_collection_joins_its_jobs_instead_of_detaching_them() {
+ // Le chemin d'erreur : entre le premier `spawn` et `into_results` il y a des `?`.
+ // Sans le `Drop`, jusqu'à quatre décodages continuaient dans le vide après l'abandon
+ // de l'export, dans un addon que l'hôte peut décharger.
+ let finished = Arc::new(AtomicUsize::new(0));
+ {
+ let mut jobs = ClipAudioJobs::new(4);
+ for index in 0..4 {
+ let finished = Arc::clone(&finished);
+ jobs.spawn(index, move || {
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ finished.fetch_add(1, Ordering::SeqCst);
+ });
+ }
+ // Pas d'`into_results` : on abandonne, comme le ferait un `?`.
+ }
+ assert_eq!(
+ finished.load(Ordering::SeqCst),
+ 4,
+ "des jobs tournaient encore après la destruction de la collection"
+ );
+ }
+
+ #[test]
+ fn a_panicking_job_leaves_its_clip_silent_without_taking_the_export_down() {
+ let mut jobs = ClipAudioJobs::new(2);
+ jobs.spawn(0, || panic!("décodage impossible"));
+ jobs.spawn(1, || 42u32);
+ assert_eq!(jobs.into_results(), vec![None, Some(42)]);
+ }
+}
diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs
index 03a0aa6b9..d27cbdf1a 100644
--- a/crates/compositor/src/compositor_linux.rs
+++ b/crates/compositor/src/compositor_linux.rs
@@ -21,6 +21,13 @@
//! 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`).
+//!
+//! **Segmentation du sujet webcam.** Les quatre etages tournent ici comme sur les
+//! deux autres back-ends : `capture_webcam_rgb` rend la camera dans une cible
+//! 256x144 et la relit, `segmentation.rs` (partage, EP CPU d'ONNX Runtime) produit
+//! le masque sur son propre thread, `set_webcam_mask` le televerse en R8, et
+//! `layer.wgsl` branche dessus sur `fx.z`. Cf.
+//! `technical-documentation/engineering/webcam-segmentation.md`.
use std::cell::RefCell;
@@ -44,12 +51,37 @@ 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");
+/// Budget du cache de textures image (`img_cache`), en octets.
+///
+/// Doit tenir le JEU ACTIF d'une frame -- au pire un wallpaper d'ecran ET un
+/// fond de camera, que rien n'empeche d'etre deux 7680x7680 a 225 Mo piece.
+/// Sous ce seuil l'eviction ne peut plus rendre de memoire sans toucher au jeu
+/// actif, ce qu'elle refuse de faire. 512 Mo borne la fuite (1 774 Mo mesures
+/// en parcourant les 18 wallpapers livres) en laissant le jeu actif resident.
+const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
+
/// `&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) }
}
+/// Un calque de fond deja lie, en attente de son `draw`. `_buf`/`_tex`/`_view`
+/// ne sont jamais relus : ils gardent en vie ce que le bind group reference
+/// jusqu'au submit. Ce backend encode toute la frame avant de la soumettre, la
+/// ou D3D11 dessine au fil de l'eau ; d'ou cette boite, la que Windows n'a pas
+/// besoin d'equivalent.
+///
+/// Vit au niveau module (et non dans `compose_frame`) parce que le fond d'ecran
+/// ET le fond de la bulle webcam sont desormais construits par les memes
+/// methodes.
+struct BgDraw {
+ _buf: wgpu::Buffer,
+ _tex: Option,
+ _view: Option,
+ bind: wgpu::BindGroup,
+}
+
/// 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
@@ -101,6 +133,112 @@ struct ReadbackRing {
pending: std::collections::VecDeque,
}
+/// Cibles et pipelines de la conversion RGBA -> YUV420P sur le GPU.
+///
+/// Trois cibles R8Unorm plutot qu'une seule : Y est en pleine resolution et U/V
+/// en demie (4:2:0), et wgpu ne sait pas ecrire des attachements de tailles
+/// differentes dans une meme passe.
+/// Disposition de la chrominance. PAS un gout : une consequence de l'encodeur
+/// qui va consommer la frame. `libopenh264` n'accepte que du YUV420P planaire
+/// (ses `pix_fmts` sont yuv420p/yuvj420p), VAAPI encode depuis du NV12. Le
+/// compositeur doit donc savoir produire les deux.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum YuvFormat {
+ /// U et V dans deux plans `R8Unorm` separes.
+ I420,
+ /// U et V entrelaces dans un seul plan `Rg8Unorm`.
+ Nv12,
+}
+
+/// Les cibles de chrominance, dont la forme depend du format.
+enum Chroma {
+ Planar {
+ _u: wgpu::Texture,
+ _v: wgpu::Texture,
+ u_view: wgpu::TextureView,
+ v_view: wgpu::TextureView,
+ pipe_u: wgpu::RenderPipeline,
+ pipe_v: wgpu::RenderPipeline,
+ },
+ Interleaved {
+ _uv: wgpu::Texture,
+ uv_view: wgpu::TextureView,
+ pipe_uv: wgpu::RenderPipeline,
+ },
+}
+
+struct YuvTargets {
+ /// Gardee en vie pour sa vue ; seule la vue sert au rendu.
+ _y: wgpu::Texture,
+ y_view: wgpu::TextureView,
+ chroma: Chroma,
+ fmt: YuvFormat,
+ bind: wgpu::BindGroup,
+ pipe_y: wgpu::RenderPipeline,
+ /// Dimensions pour lesquelles tout ceci a ete construit : un resize doit
+ /// tout refaire, et comparer ici est moins fragile que de s'en souvenir.
+ w: u32,
+ h: u32,
+ /// `bytes_per_row` alignes a 256. En 1080p, Y passe de 1920 a 2048 et U/V de
+ /// 960 a 1024 : contrairement au RGBA (7680 = 30*256, deja aligne), les plans
+ /// PORTENT du padding, et le lecteur doit le retirer ligne a ligne.
+ bpr_y: u32,
+ bpr_uv: u32,
+ /// Offsets des plans de chrominance dans le buffer de staging unique.
+ /// Alignes a 256 (exigence de `copy_texture_to_buffer`), ce que la taille du
+ /// plan Y garantit deja puisque `bpr_y` l'est. En NV12 il n'y a qu'un plan de
+ /// chrominance : `off_v` vaut alors `off_u` et ne doit pas etre lu.
+ off_u: u64,
+ off_v: u64,
+ total: u64,
+}
+
+// ---------------------------------------------------------------------------
+// Segmentation du sujet webcam
+// ---------------------------------------------------------------------------
+
+/// Cadence de l'inference. Meme valeur et meme raison que
+/// `compositor_windows::SEGMENTATION_HZ` : une silhouette ne bouge pas de facon
+/// perceptible en 16 ms, et c'est le seul levier mesure qui divise le cout par
+/// deux sans toucher au modele.
+const SEGMENTATION_HZ: u32 = 30;
+
+/// Cible RGBA + buffer de staging pour extraire la frame webcam a la resolution
+/// du modele. Pendant wgpu de `compositor_windows::SegCapture`.
+///
+/// La divergence tient au `bpr`. D3D11 rend un row pitch decide par le driver et
+/// Metal accepte la largeur nue ; `copy_texture_to_buffer` exige, lui, un
+/// `bytes_per_row` multiple de 256. Il est donc padde ICI, a la creation, et
+/// depadde a la lecture — exactement ce que `ReadbackRing` fait deja pour le RT.
+/// A 256 px de large le padding est nul (1024 est deja aligne), mais rien dans
+/// cette structure ne le suppose : c'est `width` qui decide, pas le modele.
+struct SegCapture {
+ /// Cible de la passe de capture, et source de la copie vers `staging`.
+ rt: wgpu::Texture,
+ view: wgpu::TextureView,
+ /// Buffer de staging REUTILISE d'une capture a l'autre : a 30 Hz, en
+ /// reallouer un par tour serait un cout gratuit.
+ staging: wgpu::Buffer,
+ width: u32,
+ height: u32,
+ bpr: u32,
+}
+
+/// Texture du masque de segmentation, recreee seulement quand la resolution du
+/// modele change — c'est-a-dire jamais, en regime etabli.
+///
+/// La vue vit A COTE de la texture plutot que d'etre recreee par draw :
+/// `make_bind` lie le binding 4 sur CHAQUE draw de calque (le layout l'exige, cf.
+/// `tex_entry(4)`), donc une vue par draw ferait une dizaine d'allocations par
+/// frame pour rien. La texture, elle, reste indispensable : `write_texture`
+/// prend une `Texture`, pas une `TextureView`.
+struct WebcamMask {
+ tex: wgpu::Texture,
+ view: wgpu::TextureView,
+ width: u32,
+ height: u32,
+}
+
pub struct Compositor {
gpu: Gpu,
render_w: u32,
@@ -142,6 +280,18 @@ pub struct Compositor {
/// publiques du compositeur sont `&self`, comme tout le reste de l'etat.
readback: RefCell,
+ /// Conversion RGBA -> Y/U/V sur le GPU, construite a la premiere demande.
+ ///
+ /// Paresseuse et non dans `new` pour une raison de contrat : la preview
+ /// n'en veut pas (elle rend du RGBA a un ``) et la payer a chaque
+ /// construction de compositeur couterait trois textures et trois pipelines
+ /// a tout le monde pour le seul benefice de l'export.
+ yuv: RefCell>,
+ /// Ring de staging DEDIEE aux plans YUV : ses buffers font 3,1 Mo la ou
+ /// ceux de `readback` en font 8,3, et melanger les deux tailles dans une
+ /// seule ring rendrait la reutilisation dependante de l'ordre des appels.
+ readback_yuv: RefCell,
+
// Etat pilote par live.rs (interior mutability : les methodes sont `&self`).
live_params: RefCell,
scene: RefCell>,
@@ -155,8 +305,20 @@ pub struct Compositor {
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>,
+ /// role que `img_cache` cote macOS. Charge une fois, PAS pour la session : l'entree
+ /// est evincable des qu'elle sort du jeu actif d'une frame, et un retour dessus la
+ /// rechargera -- cf. `cached_image`.
+ /// Le quatrieme champ du tuple est le tick d'usage, qui donne l'ordre LRU
+ /// -- cf. `cached_image`.
+ img_cache: RefCell>,
+ /// Compteur d'acces de `img_cache`, pour l'ordre LRU. Un compteur plutot
+ /// que l'index de frame : une frame touche plusieurs entrees, et il faut
+ /// pouvoir les ordonner entre elles.
+ img_tick: std::cell::Cell,
+ /// Valeur de `img_tick` au debut de la frame en cours. Tout ce qui a ete
+ /// touche depuis appartient au jeu actif et ne peut pas etre evince -- voir
+ /// `cached_image`.
+ img_frame_start: std::cell::Cell,
/// 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
@@ -172,6 +334,35 @@ pub struct Compositor {
/// frame. La longueur de la source sert de temoin de changement, comme cote
/// macOS.
ann_img_cache: RefCell>,
+
+ // --- Segmentation du sujet webcam (cf. `pump_segmentation`) ---
+ /// Masque du sujet, R8 a la resolution du modele. Ecrit par
+ /// `set_webcam_mask`, lu par `make_bind` au moment de construire chaque bind
+ /// group. `None` tant qu'aucune frame n'a ete segmentee — l'effet reste
+ /// alors eteint plutot que de rendre une webcam invisible en detourage.
+ webcam_mask: RefCell>,
+ /// Cible + staging de la capture, crees a la premiere capture et jamais
+ /// redimensionnes : le modele a une entree fixe.
+ seg_capture: RefCell >,
+ /// Worker d'inference, absent tant que `enable_segmentation` n'a pas ete
+ /// appele.
+ seg_worker: RefCell >,
+ /// Segmenteur tenu SUR LE THREAD DE RENDU, utilise a la place du worker en
+ /// mode deterministe. Voir `set_segmentation_deterministic`.
+ seg_sync: RefCell >,
+ /// Export : cadence par frame et inference synchrone, au lieu de l'horloge
+ /// et du worker.
+ seg_deterministic: std::cell::Cell,
+ /// Boite aux lettres du worker. Le masque est depose depuis le thread
+ /// d'inference et televerse depuis le thread de rendu : aucun appel wgpu ne
+ /// traverse de thread, ce qui compte ici puisque `Compositor` n'est ni `Send`
+ /// ni `Sync` (tout son etat vit dans des `RefCell`).
+ seg_inbox: std::sync::Arc>>>,
+ seg_rate: RefCell,
+ /// Frame RGB reutilisee d'une capture a l'autre.
+ seg_scratch: RefCell>,
+ /// Le chargement du modele a echoue : ne pas reessayer a chaque frame.
+ seg_failed: RefCell,
}
impl Compositor {
@@ -238,6 +429,17 @@ impl Compositor {
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
+ // Masque de segmentation du sujet webcam. TOUJOURS declare, meme sans
+ // masque : wgpu valide le bind group contre le layout, donc une entree
+ // absente ferait echouer chaque draw et pas seulement ceux qui l'utilisent.
+ // `dummy_view()` est lie a la place, et la branche du shader n'est de
+ // toute facon prise que si fx.z > 0.5.
+ tex_entry(4),
+ // Plan V. En 5 et pas en 3 : les bindings 0-4 etaient deja
+ // pris quand le chroma est passe d'un plan entrelace a deux
+ // plans, et renumeroter aurait touche tous les bind groups
+ // pour un gain nul.
+ tex_entry(5),
],
});
let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -432,6 +634,13 @@ impl Compositor {
free: vec![Self::make_staging(&gpu, readback_bpr, h)],
pending: std::collections::VecDeque::new(),
});
+ // Vide : les buffers YUV sont dimensionnes par `YuvTargets` (qui connait
+ // les trois `bytes_per_row` alignes) et alloues a la premiere relecture.
+ let readback_yuv = RefCell::new(ReadbackRing {
+ depth: 1,
+ free: Vec::new(),
+ pending: std::collections::VecDeque::new(),
+ });
Ok(Compositor {
gpu,
@@ -454,6 +663,8 @@ impl Compositor {
accum_view,
readback_bpr,
readback,
+ yuv: RefCell::new(None),
+ readback_yuv,
live_params: RefCell::new(LiveParams::default()),
scene: RefCell::new(None),
cursor: RefCell::new(None),
@@ -461,10 +672,21 @@ impl Compositor {
timeline_time: RefCell::new(None),
text_raster: crate::text::TextRasterizer::new().ok(),
img_cache: RefCell::new(std::collections::HashMap::new()),
+ img_tick: std::cell::Cell::new(0),
+ img_frame_start: std::cell::Cell::new(0),
ann_copy,
ann_copy_view,
ann_copy_mips,
ann_img_cache: RefCell::new(std::collections::HashMap::new()),
+ webcam_mask: RefCell::new(None),
+ seg_capture: RefCell::new(None),
+ seg_worker: RefCell::new(None),
+ seg_sync: RefCell::new(None),
+ seg_deterministic: std::cell::Cell::new(false),
+ seg_inbox: std::sync::Arc::new(std::sync::Mutex::new(None)),
+ seg_rate: RefCell::new(crate::segmentation::RateLimiter::new(SEGMENTATION_HZ)),
+ seg_scratch: RefCell::new(Vec::new()),
+ seg_failed: RefCell::new(false),
})
}
@@ -773,7 +995,7 @@ impl Compositor {
unsafe fn nv12_srvs(
&self,
frame: *const AVFrame,
- ) -> Result<(wgpu::TextureView, wgpu::TextureView)> {
+ ) -> Result<(wgpu::TextureView, wgpu::TextureView, wgpu::TextureView)> {
crate::linux_frames::nv12_planes(frame)
}
@@ -916,7 +1138,7 @@ impl Compositor {
fn make_bind(
&self,
cb: &LayerCB,
- planes: Option<(&wgpu::TextureView, &wgpu::TextureView)>,
+ planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>,
dummy: &wgpu::TextureView,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -924,7 +1146,16 @@ impl Compositor {
contents: layer_bytes(cb),
usage: wgpu::BufferUsages::UNIFORM,
});
- let (y, uv) = planes.unwrap_or((dummy, dummy));
+ let (y, u, v) = planes.unwrap_or((dummy, dummy, dummy));
+ // Le masque est lie sur TOUS les draws, pas seulement celui de la camera.
+ // wgpu valide le bind group contre le layout : le binding 4 est declare
+ // (`tex_entry(4)`), donc une entree absente ferait echouer CHAQUE draw et
+ // pas seulement ceux qui l'echantillonnent. Le lier partout ne coute rien
+ // — la branche du shader n'est prise que si `fx.z > 0.5`, et seul le
+ // calque webcam leve `fx.z`. `dummy` reste le repli tant qu'aucune frame
+ // n'a ete segmentee.
+ let mask = self.webcam_mask.borrow();
+ let mask_view = mask.as_ref().map_or(dummy, |m| &m.view);
let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("layer"),
layout: &self.bind_group_layout,
@@ -939,12 +1170,20 @@ impl Compositor {
},
wgpu::BindGroupEntry {
binding: 2,
- resource: wgpu::BindingResource::TextureView(uv),
+ resource: wgpu::BindingResource::TextureView(u),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
+ wgpu::BindGroupEntry {
+ binding: 4,
+ resource: wgpu::BindingResource::TextureView(mask_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 5,
+ resource: wgpu::BindingResource::TextureView(v),
+ },
],
});
(uniform, bind)
@@ -993,6 +1232,599 @@ impl Compositor {
Ok((tex, w, h))
}
+ /// Ouvre une frame du point de vue d'`img_cache` : tout ce qui sera touche
+ /// apres cet appel est le jeu actif, et devient inevincable jusqu'a la
+ /// frame suivante.
+ fn begin_image_frame(&self) {
+ // `+ 1` : la premiere entrée de cette frame recevra `img_tick + 1`, et la protection
+ // porte sur `tick >= img_frame_start`. Sans le decalage on protégerait aussi la
+ // DERNIERE entrée de la frame precedente, qui n'appartient plus au jeu actif — le
+ // résident pourrait alors dépasser le budget d'une texture entière.
+ self.img_frame_start.set(self.img_tick.get() + 1);
+ }
+
+ /// Texture d'un fichier image, decodee une seule fois puis reutilisee.
+ ///
+ /// Le cache etait NON BORNE, et c'est un vrai cout : les wallpapers livres
+ /// pesent 23,7 Mo sur disque mais 1 774 Mo une fois decodes en RGBA8 --
+ /// `wallpaper8.jpg` fait 7680x7680, soit 225 Mo a lui seul. Parcourir le
+ /// selecteur les chargeait tous et n'en liberait aucun.
+ ///
+ /// L'eviction est LRU sous un budget en octets, et ne touche jamais une
+ /// texture que la frame EN COURS a deja servie : sans ca, un fond d'ecran
+ /// et un fond de camera un peu gros se chasseraient l'un l'autre a chaque
+ /// frame, et un decodage coute 129 ms contre les ~3,5 ms d'une frame. Si le
+ /// jeu actif depasse a lui seul le budget, on depasse le budget.
+ fn cached_image(&self, path: &str) -> Result<(wgpu::Texture, u32, u32)> {
+ let tick = self.img_tick.get() + 1;
+ self.img_tick.set(tick);
+ // Recherche isolee dans un `let` pour que l'emprunt immuable soit
+ // relache AVANT le `borrow_mut` (piege du double emprunt 1re frame).
+ let hit = self.img_cache.borrow().get(path).cloned();
+ if let Some((tex, w, h, _)) = hit {
+ self.img_cache.borrow_mut().insert(path.to_string(), (tex.clone(), w, h, tick));
+ return Ok((tex, w, h));
+ }
+ let (tex, w, h) = self.load_image_texture(path)?;
+ let mut cache = self.img_cache.borrow_mut();
+ cache.insert(path.to_string(), (tex.clone(), w, h, tick));
+ // La politique vit dans `frame_geometry` : les trois backends la
+ // partagent, comme la geometrie, plutot que d'entretenir trois copies
+ // qui finiraient par diverger.
+ let entries: Vec<(String, u64, u64)> =
+ cache.iter().map(|(k, e)| (k.clone(), e.1 as u64 * e.2 as u64 * 4, e.3)).collect();
+ let protect_from = self.img_frame_start.get();
+ for key in
+ crate::frame_geometry::lru_evictions(&entries, IMG_CACHE_BUDGET_BYTES, protect_from)
+ {
+ cache.remove(&key);
+ }
+ Ok((tex, w, h))
+ }
+
+ /// Calque image (mode 6) couvrant `dst`, en cover-fit contre `aspect` -- le
+ /// ratio du RECT vise, et non celui de la sortie : le rognage se calcule
+ /// contre la zone qu'on remplit, ce qui permet a la bulle webcam d'emprunter
+ /// le chemin du fond d'ecran au lieu d'en refaire un.
+ ///
+ /// Err plutot qu'un repli maison : chaque appelant a son propre message et
+ /// son propre repli, et un echec silencieux redonnerait le noir qu'on corrige.
+ fn image_bg_draw(
+ &self,
+ path: &str,
+ dst: [f32; 4],
+ quad_px: [f32; 2],
+ radius_px: f32,
+ aspect: f32,
+ dummy: &wgpu::TextureView,
+ ) -> Result {
+ let (tex, iw, ih) = self.cached_image(path)?;
+ // Cover-fit : l'image remplit tout le rect, on rogne l'axe long.
+ let ai = iw as f32 / ih.max(1) as f32;
+ let src = if ai > aspect {
+ let vis = aspect / ai;
+ [(1.0 - vis) * 0.5, 0.0, 1.0 - (1.0 - vis) * 0.5, 1.0]
+ } else {
+ let vis = ai / aspect;
+ [0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5]
+ };
+ let cb = LayerCB {
+ dst,
+ src,
+ quad_px,
+ radius_px,
+ mode: 6.0,
+ ..Default::default()
+ };
+ let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
+ let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), dummy);
+ Ok(BgDraw { _buf: buf, _tex: Some(tex), _view: Some(view), bind })
+ }
+
+ /// Prepare le fond du mode « personnalise », peint DANS la bulle webcam juste
+ /// avant que la camera n'y soit decoupee par-dessus.
+ ///
+ /// Le shader ne sait peindre qu'une couleur plate sous le masque, donc un
+ /// degrade ou une image y tombaient sur du noir -- et le defaut EST une image
+ /// (`DEFAULT_WALLPAPER`), si bien que le mode ne rendait jamais ce que le
+ /// selecteur montrait. Peindre le fond puis composer la camera en detourage
+ /// donne exactement le meme resultat (`lerp(fond, camera, personne)`, ici par
+ /// le melange alpha) pour les trois sortes de fond, en reutilisant les chemins
+ /// deja eprouves du fond d'ecran, et sans rien ajouter aux trois shaders.
+ ///
+ /// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit epouser ses
+ /// coins arrondis, sinon un rectangle deborde derriere la camera.
+ fn webcam_bg_draw(
+ &self,
+ bg: Option<&SceneBackground>,
+ dst: [f32; 4],
+ quad_px: [f32; 2],
+ radius_px: f32,
+ dummy: &wgpu::TextureView,
+ ) -> BgDraw {
+ const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
+ let flat = |cb: LayerCB| {
+ let (buf, bind) = self.make_bind(&cb, None, dummy);
+ BgDraw { _buf: buf, _tex: None, _view: None, bind }
+ };
+ let solid = |color: [f32; 4]| LayerCB {
+ dst,
+ quad_px,
+ radius_px,
+ mode: 1.0,
+ color,
+ ..Default::default()
+ };
+ match bg {
+ Some(SceneBackground::Color { color }) => {
+ flat(solid(parse_hex(color).unwrap_or(BLACK)))
+ }
+ Some(SceneBackground::Gradient { angle_deg, stops }) => {
+ let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK);
+ let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0);
+ // angle CSS -> direction unitaire, meme convention que le fond
+ // d'ecran (dont la direction se lit en espace SORTIE : le degrade
+ // traverse le cadre, la bulle n'en montre que sa tranche).
+ let a = angle_deg.to_radians();
+ flat(LayerCB {
+ dst,
+ src: [c1[0], c1[1], c1[2], c1[3]],
+ quad_px,
+ radius_px,
+ mode: 5.0,
+ color: c0,
+ fx: [a.sin(), -a.cos(), 0.0, 0.0],
+ ..Default::default()
+ })
+ }
+ Some(SceneBackground::Image { path }) => {
+ // Le cover-fit se mesure sur la BULLE, pas sur la sortie : c'est
+ // elle que l'image doit remplir sans etirement.
+ let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 };
+ match self.image_bg_draw(path, dst, quad_px, radius_px, aspect, dummy) {
+ Ok(d) => d,
+ Err(e) => {
+ // Meme contrat que le fond d'ecran : un chemin casse est
+ // logge puis remplace par du noir. Un repli silencieux
+ // redonnerait le bug qu'on corrige.
+ eprintln!("[fond webcam] \"{path}\" : {e:#}");
+ flat(solid(BLACK))
+ }
+ }
+ }
+ // Personnalise sans fond : noir, comme avant -- mais c'est desormais
+ // le seul chemin qui y mene, au lieu de l'etre pour toute image et
+ // tout degrade.
+ None => flat(solid(BLACK)),
+ }
+ }
+
+ // -- segmentation du sujet webcam --
+
+ /// Extrait la frame webcam en RGB8 a la resolution du modele, dans `out`.
+ ///
+ /// Pendant wgpu de `compositor_windows::capture_webcam_rgb`, avec les memes
+ /// contraintes d'appel. Comme cote Metal, rien n'est « requisitionne » : la
+ /// passe s'ouvre sur `SegCapture::view` et se referme. La contrainte d'ordre
+ /// tient malgre tout, et pour une autre raison — cette methode ATTEND sa
+ /// propre soumission, donc l'appeler une fois la passe de composition
+ /// encodee serialiserait CPU et GPU sur exactement le chemin que cette
+ /// conception garde recouvert. Elle tourne donc dans le prologue de
+ /// `compose_frame`, avant le moindre encodeur.
+ ///
+ /// `src` est le rect source en UV. L'appelant y passe la frame ENTIERE et non
+ /// le sous-rect dessine — cf. `pump_segmentation`.
+ ///
+ /// # Le readback
+ ///
+ /// Meme forme que `ReadbackRing`, en plus simple parce qu'il n'y a rien a
+ /// recouvrir : une seule copie, attendue tout de suite. Ce qui EST repris de
+ /// la ring, parce que c'est la lecon qu'elle porte, c'est
+ /// `WaitForSubmissionIndex` — jamais `Maintain::Wait`, qui absorberait toute
+ /// la file GPU (3,8 a 6,2 ms mesurees en 1080p, cf. l'en-tete de
+ /// `ReadbackRing`) au lieu de la seule copie de 147 Ko demandee ici.
+ ///
+ /// C'est le second readback synchrone du chemin de preview, qui en paie deja
+ /// un a profondeur 1 (`live.rs`). C'est le seul cout que ce portage ajoute au
+ /// rendu, et il ne se paie que quand un effet est demande.
+ ///
+ /// A l'EXPORT, ou la ring tourne a profondeur 2, il faut etre honnete sur ce
+ /// que cette attente coute : une file GPU se termine dans l'ordre, donc
+ /// attendre CETTE soumission, c'est attendre aussi la copie de la frame
+ /// precedente que la ring gardait justement en vol. Le travail CPU de la
+ /// frame courante ne la recouvre donc plus. Ce n'est pas gratuit, c'est
+ /// seulement borne : 30 Hz et non 60, et zero quand aucun effet n'est demande.
+ /// Aucune des deux mesures §C.2 n'a ete faite — cf. « Still open » dans
+ /// `webcam-segmentation.md`.
+ pub unsafe fn capture_webcam_rgb(
+ &self,
+ wy: &wgpu::TextureView,
+ wu: &wgpu::TextureView,
+ wv: &wgpu::TextureView,
+ src: [f32; 4],
+ width: u32,
+ height: u32,
+ out: &mut Vec,
+ ) -> Result<()> {
+ if width == 0 || height == 0 {
+ anyhow::bail!("capture webcam de dimensions nulles ({width}x{height})");
+ }
+ {
+ let mut slot = self.seg_capture.borrow_mut();
+ if !matches!(slot.as_ref(), Some(c) if c.width == width && c.height == height) {
+ let rt = self.gpu.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("seg-capture"),
+ size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ // Meme format que le RT : c'est celui que `mk_layer` a cable
+ // dans la cible couleur du pipeline de calque, et une passe
+ // dont la piece jointe ne l'a pas est refusee.
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
+ | wgpu::TextureUsages::COPY_SRC,
+ view_formats: &[],
+ });
+ let view = rt.create_view(&wgpu::TextureViewDescriptor::default());
+ let bpr = (width * 4).div_ceil(256) * 256;
+ let staging = self.gpu.device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("seg-capture-staging"),
+ size: u64::from(bpr) * u64::from(height),
+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
+ mapped_at_creation: false,
+ });
+ *slot = Some(SegCapture { rt, view, staging, width, height, bpr });
+ }
+ }
+ let slot = self.seg_capture.borrow();
+ let cap = slot.as_ref().expect("cree juste au-dessus");
+
+ // Plein cadre de la cible, sans coins ni motion blur : le modele veut
+ // l'image, pas la mise en forme. `fx` reste a zero — la branche de masque
+ // du shader ne doit surtout pas se prendre sur la capture qui l'alimente.
+ // `color.a = 1` n'est pas decoratif : `fs_main` calcule son alpha en
+ // `layer.color.a * alpha_mask`, donc le defaut (0) rendrait un quad
+ // entierement transparent.
+ let (_uniform, bind) = self.make_bind(
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ src,
+ quad_px: [width as f32, height as f32],
+ mode: 0.0,
+ color: [0.0, 0.0, 0.0, 1.0],
+ mb: [1.0, 1.0, 1.0, 0.0],
+ ..Default::default()
+ },
+ Some((wy, wu, wv)),
+ &self.dummy_view(),
+ );
+
+ let mut encoder = self.gpu.device.create_command_encoder(
+ &wgpu::CommandEncoderDescriptor { label: Some("seg-capture") },
+ );
+ {
+ let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("seg-capture-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &cap.view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ rpass.set_pipeline(&self.pipeline);
+ rpass.set_bind_group(0, &bind, &[]);
+ rpass.draw(0..4, 0..1);
+ }
+ encoder.copy_texture_to_buffer(
+ wgpu::TexelCopyTextureInfo {
+ texture: &cap.rt,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ wgpu::TexelCopyBufferInfo {
+ buffer: &cap.staging,
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(cap.bpr),
+ rows_per_image: Some(height),
+ },
+ },
+ wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
+ );
+ let idx = self.gpu.context.submit(std::iter::once(encoder.finish()));
+ let (tx, rx) = std::sync::mpsc::channel();
+ cap.staging.slice(..).map_async(wgpu::MapMode::Read, move |r| {
+ let _ = tx.send(r);
+ });
+ // `WaitForSubmissionIndex` et JAMAIS `Maintain::Wait` : cf. l'en-tete de
+ // `ReadbackRing`, qui est le proces-verbal de cette regression-la.
+ self.gpu.device.poll(wgpu::Maintain::WaitForSubmissionIndex(idx));
+ rx.recv()
+ .map_err(|_| anyhow::anyhow!("map_async channel (capture webcam)"))?
+ .map_err(|e| anyhow::anyhow!("map_async (capture webcam): {e:?}"))?;
+ let slice = cap.staging.slice(..);
+ let mapped = slice.get_mapped_range();
+
+ let (w, h, bpr) = (width as usize, height as usize, cap.bpr as usize);
+ // `clear` + `reserve` plutot qu'un `Vec` neuf : la capacite survit d'une
+ // capture a l'autre, donc apres le premier tour plus une seule
+ // reallocation. A 30 Hz ce n'est pas une coquetterie.
+ out.clear();
+ out.reserve(w * h * 3);
+ for row in 0..h {
+ // La ligne fait `w * 4` octets utiles dans un pas de `bpr` : le
+ // padding d'alignement se saute ici, il n'a jamais de sens pour le
+ // modele.
+ for px in mapped[row * bpr..row * bpr + w * 4].chunks_exact(4) {
+ // RGBA -> RGB : le modele n'a pas de canal alpha en entree.
+ out.push(px[0]);
+ out.push(px[1]);
+ out.push(px[2]);
+ }
+ }
+ drop(mapped);
+ // Sans `unmap`, la capture suivante echouerait a re-armer `map_async` sur
+ // un buffer deja mappe.
+ cap.staging.unmap();
+ Ok(())
+ }
+
+ /// Publie le masque de segmentation du sujet webcam (R8, `width`x`height`,
+ /// 0 = fond).
+ ///
+ /// `Queue::write_texture` et non une copie par buffer : il n'impose aucun
+ /// alignement de ligne (c'est `copy_texture_to_buffer` qui exige 256, cf.
+ /// `SegCapture`), et c'est deja par lui que `linux_frames` televerse les plans
+ /// NV12 avec les strides SIMD de swscale. La texture n'est recreee que si la
+ /// resolution du modele change, ce qui n'arrive pas en regime etabli.
+ ///
+ /// Pas de double buffer, et pour la meme raison que cote Metal : quand
+ /// `pump_segmentation` appelle ceci, la frame precedente est deja drainee —
+ /// `capture_webcam_rgb` attend sa soumission, et la preview comme l'export
+ /// passent par `readback_take`, qui attend la sienne. Si cet invariant
+ /// changeait, c'est ce code-ci qui casserait.
+ pub fn set_webcam_mask(&self, data: &[u8], width: u32, height: u32) -> Result<()> {
+ if width == 0 || height == 0 {
+ anyhow::bail!("masque webcam de dimensions nulles ({width}x{height})");
+ }
+ let expected = (width as usize) * (height as usize);
+ if data.len() < expected {
+ anyhow::bail!(
+ "masque webcam trop court : {} octets pour {width}x{height}",
+ data.len()
+ );
+ }
+
+ let mut slot = self.webcam_mask.borrow_mut();
+ if !matches!(slot.as_ref(), Some(m) if m.width == width && m.height == height) {
+ let tex = self.gpu.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("webcam-mask"),
+ size: wgpu::Extent3d { width, height, 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 view = tex.create_view(&wgpu::TextureViewDescriptor::default());
+ *slot = Some(WebcamMask { tex, view, width, height });
+ }
+ let mask = slot.as_ref().expect("alloue juste au-dessus");
+ self.gpu.context.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &mask.tex,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ // `data` peut etre plus long que le masque (le garde ci-dessus est un
+ // minimum) : on ne televerse que ce que la texture porte.
+ &data[..expected],
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(width),
+ rows_per_image: Some(height),
+ },
+ wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
+ );
+ Ok(())
+ }
+
+ /// Un tour de segmentation : televerse le masque pret, puis soumet une
+ /// nouvelle frame si la cadence l'autorise. Port de
+ /// `compositor_windows::pump_segmentation` — worker, boite aux lettres,
+ /// limiteur de cadence et demarrage paresseux sont independants de la
+ /// plateforme, seuls les deux appels GPU changent.
+ ///
+ /// Les deux moities sont volontairement desynchronisees. Le masque televerse
+ /// ici vient de la frame precedente — une frame de retard sur une silhouette
+ /// est invisible, alors qu'attendre l'inference bloquerait le rendu, ce qui
+ /// est exactement le cout que toute cette conception cherche a ne pas payer.
+ unsafe fn pump_segmentation(
+ &self,
+ wy: &wgpu::TextureView,
+ wu: &wgpu::TextureView,
+ wv: &wgpu::TextureView,
+ valid: [f32; 2],
+ ) -> Result<()> {
+ if *self.seg_failed.borrow() {
+ return Ok(());
+ }
+ // Rien a faire si aucun effet n'est demande : ni capture, ni inference,
+ // ni masque. Le cout de la fonctionnalite est alors exactement nul.
+ let (wants_effect, model_path) = {
+ let scene = self.scene.borrow();
+ match scene.as_ref().and_then(|s| s.webcam_effect.as_ref()) {
+ Some(e) if e.shader_code() > 0.0 => (true, e.model_path.clone()),
+ _ => (false, None),
+ }
+ };
+ if !wants_effect {
+ return Ok(());
+ }
+
+ // Demarrage paresseux, pilote par la scene : personne n'a a appeler
+ // `enable_segmentation` a la main, et un modele introuvable eteint l'effet
+ // au lieu de faire tomber le rendu.
+ if self.seg_worker.borrow().is_none() && self.seg_sync.borrow().is_none() {
+ let Some(path) = model_path else { return Ok(()) };
+ if let Err(e) = self.enable_segmentation(std::path::Path::new(&path)) {
+ eprintln!("[segmentation] desactivee : {e}");
+ // Une scene qui reste identique retenterait a chaque frame ; on
+ // leve le verrou plutot que de journaliser 60 fois par seconde.
+ *self.seg_failed.borrow_mut() = true;
+ return Ok(());
+ }
+ // En preview on rend cette frame sans masque : le worker vient de
+ // demarrer et l'effet apparaitra dans quelques millisecondes, ce que
+ // personne ne voit. A l'export cette frame part dans le fichier — on
+ // enchaine donc sur la capture et l'inference plutot que de la laisser
+ // sortir non detouree.
+ if !self.seg_deterministic.get() {
+ return Ok(());
+ }
+ }
+
+ if let Some(mask) = self.seg_inbox.lock().unwrap().take() {
+ self.set_webcam_mask(
+ &mask,
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ )?;
+ }
+
+ // La cadence horloge est le bon reglage en preview et le mauvais a
+ // l'export, ou les frames defilent aussi vite que la machine decode : le
+ // nombre de frames couvertes par un masque dependrait alors de la charge.
+ // En deterministe, une inference par frame.
+ if !self.seg_deterministic.get()
+ && !self.seg_rate.borrow_mut().should_run(std::time::Instant::now())
+ {
+ return Ok(());
+ }
+ let mut scratch = self.seg_scratch.borrow_mut();
+ // La frame ENTIERE, pas le sous-rect dessine : un crop utilisateur serre
+ // amputerait le sujet en entree du modele, et le masque serait faux la ou
+ // il compte le plus. Le shader ramene ses coordonnees dans cet espace via
+ // `fx.xy`.
+ self.capture_webcam_rgb(
+ wy,
+ wu,
+ wv,
+ [0.0, 0.0, valid[0], valid[1]],
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ &mut scratch,
+ )?;
+ if self.seg_deterministic.get() {
+ // Synchrone : le masque doit exister avant que cette frame ne soit
+ // composee, sinon on retombe sur le defaut qu'on corrige. Une
+ // inference ratee laisse le masque precedent, comme le fait le worker.
+ let mut sync = self.seg_sync.borrow_mut();
+ if let Some(seg) = sync.as_mut() {
+ match seg.run(&scratch) {
+ Ok(mask) => {
+ // `run` rend une tranche empruntee au segmenteur : copier
+ // puis relacher, sinon `set_webcam_mask` reemprunterait
+ // `seg_sync` encore emprunte ici.
+ let mask = mask.to_vec();
+ drop(sync);
+ self.set_webcam_mask(
+ &mask,
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ )?;
+ }
+ Err(e) => eprintln!("[segmentation] frame ignoree : {e}"),
+ }
+ }
+ } else if let Some(w) = self.seg_worker.borrow().as_ref() {
+ w.submit(&scratch);
+ }
+ Ok(())
+ }
+
+ /// Demarre la segmentation du sujet webcam pour ce compositeur.
+ ///
+ /// Idempotent. Tant qu'elle n'est pas appelee, `compose_frame` ne fait rien de
+ /// plus et la webcam se dessine comme avant — c'est ce qui rend l'effet inerte
+ /// plutot que casse sur une build sans modele.
+ pub fn enable_segmentation(&self, model_path: &std::path::Path) -> Result<()> {
+ if self.seg_worker.borrow().is_some() || self.seg_sync.borrow().is_some() {
+ return Ok(());
+ }
+ let segmenter = crate::segmentation::Segmenter::load(model_path)?;
+ // En deterministe, le segmenteur reste ici : l'inference tourne sur le
+ // thread de rendu, donc le masque de la frame N est pret AVANT qu'elle ne
+ // soit composee. Le worker est un choix de preview — ne jamais bloquer
+ // l'affichage — et c'est exactement ce qui rend l'export irreproductible,
+ // le masque arrivant quelques frames plus tard selon la charge.
+ if self.seg_deterministic.get() {
+ *self.seg_sync.borrow_mut() = Some(segmenter);
+ return Ok(());
+ }
+ let inbox = std::sync::Arc::clone(&self.seg_inbox);
+ let worker =
+ crate::segmentation::SegmentationWorker::spawn(segmenter, move |mask, _, _| {
+ // Ecrase le masque precedent s'il n'a pas encore ete televerse :
+ // c'est le plus recent qui vaut, jamais une file.
+ *inbox.lock().unwrap() = Some(mask.to_vec());
+ });
+ *self.seg_worker.borrow_mut() = Some(worker);
+ Ok(())
+ }
+
+ /// Bascule la segmentation en mode reproductible, pour l'export.
+ ///
+ /// En preview, la cadence suit l'horloge (30 Hz reels) et l'inference tourne
+ /// sur un worker : c'est le bon choix, l'affichage ne doit jamais attendre. A
+ /// l'export les frames sont rendues aussi vite que la machine decode, sans
+ /// rapport avec le temps reel — et ces deux choix deviennent alors des bugs.
+ /// La cadence horloge fait dependre le nombre de frames couvertes par un
+ /// masque de la vitesse de la machine, et le worker asynchrone rend les
+ /// premieres frames AVANT que le premier masque n'existe : elles partent dans
+ /// le fichier avec le vrai arriere-plan de la webcam. Deux exports du meme
+ /// projet ne donnent donc pas les memes pixels, ce qui casse l'invariant
+ /// « l'export est identique a la preview ».
+ ///
+ /// En deterministe : une inference PAR FRAME, synchrone. Plus couteux
+ /// (~3 ms/frame), mais l'export est hors ligne et chaque frame porte le masque
+ /// calcule depuis SA propre image.
+ ///
+ /// A appeler avant la premiere frame — c'est ce qui decide comment
+ /// `enable_segmentation` s'installe.
+ pub fn set_segmentation_deterministic(&self, on: bool) {
+ if self.seg_deterministic.get() == on {
+ return;
+ }
+ self.seg_deterministic.set(on);
+ // Changer de mode change le MOTEUR, et `enable_segmentation` est idempotent sur la
+ // PRESENCE d'un moteur : sans demonter celui qui ne correspond plus, le drapeau
+ // mentirait. Un compositeur qui a deja servi en preview garderait son worker,
+ // `seg_sync` resterait vide, et l'export entier ne ferait AUCUNE inference. Le
+ // demarrage paresseux de `pump_segmentation` reinstalle le bon moteur a la frame
+ // suivante.
+ *self.seg_worker.borrow_mut() = None;
+ *self.seg_sync.borrow_mut() = None;
+ // Et le masque que le worker demonte avait peut-etre deja depose : il vient de l'autre
+ // mode, il n'a rien a faire sur la premiere frame de celui-ci.
+ *self.seg_inbox.lock().unwrap() = None;
+ }
+
+ /// Eteint l'effet : la webcam se redessine telle quelle a la frame suivante.
+ pub fn clear_webcam_mask(&self) {
+ *self.webcam_mask.borrow_mut() = None;
+ }
+
/// 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.
@@ -1003,10 +1835,11 @@ impl Compositor {
frame: f32,
cfg: &Cfg,
) -> Result<()> {
+ self.begin_image_frame();
if Self::pixel_buffer_of(screen).is_none() {
return self.clear_rt();
}
- let (sy, suv) = self.nv12_srvs(screen)?;
+ let (sy, su, sv) = 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);
@@ -1018,6 +1851,36 @@ impl Compositor {
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);
+ // Etendue valide de la texture webcam : les decodeurs allouent des
+ // textures alignees (`linux_frames` arrondit deja aux dimensions paires),
+ // donc la frame n'occupe pas forcement toute la texture. `.max(1)` au
+ // denominateur — `tex_dims` rend (1, 1) sur une webcam absente, la ou le
+ // chemin Windows divise sans garde parce qu'il a toujours les deux frames.
+ let w_valid = [wcw / (wtw.max(1)) as f32, wch / (wth.max(1)) as f32];
+
+ // Segmentation, AVANT le moindre encodeur de composition :
+ // `capture_webcam_rgb` attend sa propre soumission, et attendre au milieu
+ // de la frame serialiserait CPU et GPU. Dernier point ou `wtw/wth/wcw/wch`
+ // sont en portee sans emprunt de `self.scene` — `pump_segmentation`
+ // emprunte la scene lui-meme.
+ //
+ // L'effet est teste ICI en plus de l'etre dans `pump_segmentation` : sur
+ // ce backend `nv12_srvs` ALLOUE deux `TextureView` a chaque appel (il n'y
+ // a pas de cache, cf. `clear_srv_cache`), et la fonctionnalite doit couter
+ // exactement zero quand elle est eteinte — ce qui est le cas general.
+ let wants_seg = self
+ .scene
+ .borrow()
+ .as_ref()
+ .and_then(|s| s.webcam_effect.as_ref())
+ .is_some_and(|e| e.shader_code() > 0.0);
+ if wants_seg && !webcam.is_null() {
+ // `nv12_srvs` dereference `data[0]` sans verifier la frame elle-meme,
+ // d'ou le garde de nullite au-dessus (meme condition que le draw PiP).
+ if let Ok((wy, wu, wv)) = self.nv12_srvs(webcam) {
+ self.pump_segmentation(&wy, &wu, &wv, w_valid)?;
+ }
+ }
let scene_ref = self.scene.borrow();
let cursor_ref = self.cursor.borrow();
@@ -1113,7 +1976,7 @@ impl Compositor {
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],
+ mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
},
Some(quad) => self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius),
@@ -1122,7 +1985,7 @@ impl Compositor {
// `_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);
+ self.make_bind(&screen_layer, Some((&sy, &su, &sv)), &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
@@ -1154,54 +2017,23 @@ impl Compositor {
});
// 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 })
}
+ // Le wallpaper couvre tout le cadre, donc dst plein et pas de coins :
+ // `image_bg_draw` sert aussi la bulle webcam, qui elle en a.
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 })
+ match self
+ .image_bg_draw(&path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, rw / rh, &dummy)
+ {
+ Ok(d) => Some(d),
+ Err(e) => {
+ eprintln!("[fond image] \"{path}\" : {e:#}");
+ None
+ }
+ }
}
});
@@ -1219,7 +2051,56 @@ impl Compositor {
} else {
None
};
- let webcam_draw = webcam_planes.as_ref().map(|(wy, wuv)| {
+ // Effet d'arriere-plan : le mode vient de la scene, le masque par pixel de
+ // l'inference. Les DEUX sont requis — un mode sans masque rendrait la
+ // webcam invisible en detourage, donc tant que rien n'a ete segmente on
+ // dessine la piste telle quelle. C'est aussi ce qui rend le premier
+ // lancement gracieux, le temps que l'inference rende son premier masque.
+ //
+ // Calcule ICI, avant le draw comme avant l'ombre : les deux en dependent.
+ let (effect_code, blur_intensity, webcam_bg) = {
+ let has_mask = self.webcam_mask.borrow().is_some();
+ let effect = scene_ref
+ .as_ref()
+ .and_then(|s| s.webcam_effect.as_ref())
+ .filter(|_| has_mask)
+ .map(|e| (e.shader_code(), e))
+ .filter(|(code, _)| *code > 0.0);
+ match effect {
+ // Fond personnalise : on PEINT le fond dans la bulle, puis on y
+ // decoupe la camera par-dessus — le melange alpha donne
+ // `lerp(fond, camera, personne)`, soit exactement ce que la branche
+ // « mode 3 » du shader calculait, mais pour les TROIS sortes de
+ // fond. Le shader ne sait peindre qu'une couleur plate sous le
+ // masque ; degrades et images y tombaient sur du noir, et le defaut
+ // EST une image.
+ Some((code, e)) if code > 2.5 => {
+ // Sans piste webcam le fond peindrait un rectangle seul dans le
+ // cadre : il ne se prepare que si la camera se dessine.
+ let bg = webcam_planes.is_some().then(|| {
+ self.webcam_bg_draw(
+ e.background.as_ref(),
+ g.w_dst,
+ g.w_px,
+ g.w_radius,
+ &dummy,
+ )
+ });
+ (1.0, 0.0, bg)
+ }
+ Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0), None),
+ None => (0.0, 0.0, None),
+ }
+ };
+ // L'ombre se juge sur le mode DE LA SCENE, pas sur `effect_code` : le fond
+ // personnalise se compose desormais en detourage (code 1) tout en gardant
+ // sa bulle, et tester le code compose la lui retirerait. Meme lecture que
+ // `is_cutout` cote Windows.
+ let is_cutout = matches!(
+ scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()),
+ Some(e) if e.shader_code() == 1.0
+ ) && self.webcam_mask.borrow().is_some();
+ let webcam_draw = webcam_planes.as_ref().map(|(wy, wu, wv)| {
// 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
@@ -1255,22 +2136,36 @@ impl Compositor {
quad_px: g.w_px,
radius_px: g.w_radius,
mode: 0.0,
+ // `color.a` porte l'alpha du decoupage (`color.a * personne`) ; le
+ // RGB n'est plus lu, le fond ayant deja ete peint sous la camera.
color: [0.0, 0.0, 0.0, 1.0],
+ // `fx.xy` = etendue valide de la texture webcam, par quoi le
+ // shader divise `uv` pour retomber dans l'espace du masque ;
+ // `fx.z` = mode, `fx.w` = intensite du flou. Contrat commun aux
+ // trois back-ends, cf. `layer.wgsl` et `webcam-segmentation.md`.
+ fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
src_prev: [u0, cv0, u1, cv1],
dst_prev: g.w_dst_prev,
- mb: [g.mb_taps, 1.0, 1.0, 0.0],
+ mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
};
- self.make_bind(&cb, Some((wy, wuv)), &dummy)
+ // Le masque est lie par `make_bind` sur tous les draws, pas seulement
+ // celui-ci : le layout l'exige (cf. `tex_entry(4)`).
+ self.make_bind(&cb, Some((wy, wu, wv)), &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.
+ //
+ // Pas en detourage non plus : l'ombre appartient a la bulle PiP, et en
+ // detourage il n'y a plus de bulle — une ombre portee par un rectangle
+ // devenu invisible se lit comme un artefact.
let webcam_shadow = (cfg.shadow
&& g.shape_fade > 0.0
&& webcam_draw.is_some()
+ && !is_cutout
&& !matches!(
g.scene_preset.as_deref(),
Some("dual-frame") | Some("vertical-stack")
@@ -1410,7 +2305,7 @@ impl Compositor {
// la lit.
let (buf, bind) = self.make_bind(
&cb,
- Some((&self.ann_copy_view, &self.ann_copy_view)),
+ Some((&self.ann_copy_view, &self.ann_copy_view, &self.ann_copy_view)),
&dummy,
);
ann_draws.push(AnnDraw::plain(buf, bind));
@@ -1470,7 +2365,7 @@ impl Compositor {
fx: [0.0, 0.0, 1.0, 1.0],
..Default::default()
};
- let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy);
+ let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy);
ann_draws.push(AnnDraw {
_buf: buf,
_glyphs: None,
@@ -1619,7 +2514,7 @@ impl Compositor {
};
// Atlas R8 au binding 1 (texY) que le mode 11 echantillonne.
let (buf, bind) =
- self.make_bind(&cb, Some((&glyphs.view, &glyphs.view)), &dummy);
+ self.make_bind(&cb, Some((&glyphs.view, &glyphs.view, &glyphs.view)), &dummy);
ann_draws.push(AnnDraw {
_buf: buf,
_glyphs: Some(glyphs),
@@ -1675,21 +2570,13 @@ impl Compositor {
.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;
- }
- },
+ // Charge (ou recupere du cache) le sprite.
+ let (tex, iw, ih) = match self.cached_image(&sprite.path) {
+ Ok(v) => 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;
@@ -1774,7 +2661,7 @@ impl Compositor {
}
};
// Sprite RGBA au binding 1 (texY) que le mode 7 echantillonne.
- let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy);
+ let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy);
bufs.push(buf);
binds.push(bind);
}
@@ -1881,6 +2768,14 @@ impl Compositor {
rpass.set_bind_group(0, bind, &[]);
rpass.draw(0..4, 0..1);
}
+ // Fond personnalise : ENTRE l'ombre et la camera. C'est ce sandwich qui
+ // remplace la branche « mode 3 » du shader — la camera, decoupee, se
+ // fond dessus par alpha ; l'ombre reste dessous, elle appartient a la
+ // bulle et non a son contenu.
+ if let Some(bg) = &webcam_bg {
+ rpass.set_bind_group(0, &bg.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);
@@ -1961,9 +2856,9 @@ impl Compositor {
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 {
+ for (k, bind) in c.binds.iter().enumerate() {
+ let w = crate::frame_geometry::cursor_tap_weight(k as u32, c.binds.len() as u32) as f64;
+ rpass.set_blend_constant(wgpu::Color { r: w, g: w, b: w, a: w });
rpass.set_bind_group(0, bind, &[]);
rpass.draw(0..4, 0..1);
}
@@ -2075,7 +2970,387 @@ impl Compositor {
Ok(())
}
- /// Soumet la copie RT -> staging de la frame COURANTE sans l'attendre, puis
+ /// Construit (ou reconstruit apres resize) les cibles et pipelines YUV.
+ fn ensure_yuv(&self) -> Result<()> {
+ // I420 par defaut : c'est le seul format que l'encodeur software sait
+ // lire, donc le seul que l'export utilise aujourd'hui.
+ self.ensure_yuv_fmt(YuvFormat::I420)
+ }
+
+ /// La disposition du buffer de staging pour un format donne, sans rien
+ /// construire. Existe pour que le test puisse verifier l'arithmetique sans
+ /// GPU — c'est elle qui doit correspondre a ce que VAAPI attend, et une
+ /// erreur d'un octet y donnerait une image decalee plutot qu'une panne.
+ pub fn yuv_layout_for(w: u32, h: u32, fmt: YuvFormat) -> (u32, u32, u64, u64) {
+ let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
+ let bpr_y = w.div_ceil(256) * 256;
+ let chroma_row_bytes = match fmt {
+ YuvFormat::I420 => cw,
+ YuvFormat::Nv12 => cw * 2,
+ };
+ let bpr_uv = chroma_row_bytes.div_ceil(256) * 256;
+ let size_y = u64::from(bpr_y) * u64::from(h);
+ let size_uv = u64::from(bpr_uv) * u64::from(ch);
+ let total = match fmt {
+ YuvFormat::I420 => size_y + 2 * size_uv,
+ YuvFormat::Nv12 => size_y + size_uv,
+ };
+ (bpr_y, bpr_uv, size_y, total)
+ }
+
+ /// Comme `ensure_yuv`, pour un format donne. Reconstruit tout si le format
+ /// change : les cibles, les pipelines et la disposition du buffer en
+ /// dependent toutes.
+ fn ensure_yuv_fmt(&self, fmt: YuvFormat) -> Result<()> {
+ let (w, h) = (self.render_w, self.render_h);
+ if let Some(t) = self.yuv.borrow().as_ref() {
+ if t.w == w && t.h == h && t.fmt == fmt {
+ return Ok(());
+ }
+ }
+ // 4:2:0 : les plans de chrominance font la moitie, arrondie au superieur
+ // pour ne jamais perdre la derniere colonne/ligne d'une dimension impaire.
+ let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
+ let gpu = &self.gpu;
+
+ let mk = |label: &str, tw: u32, th: u32, f: wgpu::TextureFormat| {
+ gpu.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ size: wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: f,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
+ view_formats: &[],
+ })
+ };
+ let r8 = wgpu::TextureFormat::R8Unorm;
+ let y = mk("yuv-y", w, h, r8);
+ let y_view = y.create_view(&wgpu::TextureViewDescriptor::default());
+
+ let module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("yuv"),
+ source: wgpu::ShaderSource::Wgsl(include_str!("vk_shaders/yuv.wgsl").into()),
+ });
+ let bgl = gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("yuv-bgl"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ 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: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ ],
+ });
+ // Sampler LINEAIRE : c'est lui qui fait le sous-echantillonnage 2x2 des
+ // plans de chrominance. Avec un `Nearest` on prendrait un pixel sur
+ // quatre au lieu de leur moyenne, ce qui aliase visiblement les bords.
+ let samp = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("yuv-samp"),
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+ let bind = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("yuv-bg"),
+ layout: &bgl,
+ entries: &[
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self.rt_view) },
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&samp) },
+ ],
+ });
+ let layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("yuv-pl"),
+ bind_group_layouts: &[&bgl],
+ push_constant_ranges: &[],
+ });
+ let mk_pipe = |entry: &str, label: &str, target: wgpu::TextureFormat| {
+ gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some(label),
+ layout: Some(&layout),
+ vertex: wgpu::VertexState {
+ module: &module,
+ entry_point: Some("vs_fullscreen"),
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &module,
+ entry_point: Some(entry),
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format: target,
+ 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 bpr_y = w.div_ceil(256) * 256;
+ // La LARGEUR EN OCTETS d'une ligne de chrominance, pas en texels : en NV12
+ // le plan est `Rg8Unorm`, donc 2 octets par texel. En 1080p, I420 donne
+ // 960 -> 1024 et NV12 1920 -> 2048.
+ let chroma_row_bytes = match fmt {
+ YuvFormat::I420 => cw,
+ YuvFormat::Nv12 => cw * 2,
+ };
+ let bpr_uv = chroma_row_bytes.div_ceil(256) * 256;
+ let size_y = u64::from(bpr_y) * u64::from(h);
+ let size_uv = u64::from(bpr_uv) * u64::from(ch);
+ let (chroma, off_v, total) = match fmt {
+ YuvFormat::I420 => {
+ let u = mk("yuv-u", cw, ch, r8);
+ let v = mk("yuv-v", cw, ch, r8);
+ let d = wgpu::TextureViewDescriptor::default();
+ let (u_view, v_view) = (u.create_view(&d), v.create_view(&d));
+ (
+ Chroma::Planar {
+ _u: u,
+ _v: v,
+ u_view,
+ v_view,
+ pipe_u: mk_pipe("fs_u", "yuv-u", r8),
+ pipe_v: mk_pipe("fs_v", "yuv-v", r8),
+ },
+ size_y + size_uv,
+ size_y + 2 * size_uv,
+ )
+ }
+ YuvFormat::Nv12 => {
+ let rg8 = wgpu::TextureFormat::Rg8Unorm;
+ let uv = mk("yuv-uv", cw, ch, rg8);
+ let uv_view = uv.create_view(&wgpu::TextureViewDescriptor::default());
+ (
+ Chroma::Interleaved {
+ _uv: uv,
+ uv_view,
+ pipe_uv: mk_pipe("fs_uv", "yuv-uv", rg8),
+ },
+ // Un seul plan de chrominance : `off_v` duplique `off_u` et
+ // n'est jamais lu (cf. le commentaire du champ).
+ size_y,
+ size_y + size_uv,
+ )
+ }
+ };
+ let targets = YuvTargets {
+ _y: y,
+ y_view,
+ chroma,
+ fmt,
+ bind,
+ pipe_y: mk_pipe("fs_y", "yuv-y", r8),
+ w,
+ h,
+ bpr_y,
+ bpr_uv,
+ off_u: size_y,
+ off_v,
+ total,
+ };
+ // Les buffers de l'ancienne taille ne conviennent plus.
+ self.readback_yuv.borrow_mut().free.clear();
+ *self.yuv.borrow_mut() = Some(targets);
+ Ok(())
+ }
+
+ /// Pendant YUV de `readback_submit` : convertit le RT en Y/U/V sur le GPU,
+ /// copie les trois plans dans UN buffer de staging, et recolte la frame
+ /// precedente. Meme contrat de ring et de profondeur que la version RGBA.
+ ///
+ /// Rend les plans avec leur padding : `(w, h, buf)` ou `buf` contient Y a
+ /// l'offset 0 (stride `align256(w)`), puis U et V (stride `align256(w/2)`).
+ /// L'appelant recalcule ces strides depuis `w`/`h` — les depadder ici
+ /// couterait une recopie de plus pour rien, l'encodeur sachant lire un
+ /// `linesize`.
+ pub unsafe fn readback_submit_yuv(&self, f: F) -> Result
+ where
+ F: FnMut(u32, u32, &[u8]) -> Result<()>,
+ {
+ self.ensure_yuv()?;
+ let (w, h, cw, ch, bpr_y, bpr_uv, off_u, off_v, total) = {
+ let g = self.yuv.borrow();
+ let t = g.as_ref().expect("ensure_yuv");
+ (t.w, t.h, t.w.div_ceil(2), t.h.div_ceil(2), t.bpr_y, t.bpr_uv, t.off_u, t.off_v, t.total)
+ };
+
+ let buf = {
+ let mut ring = self.readback_yuv.borrow_mut();
+ match ring.free.pop() {
+ Some(b) => b,
+ None => self.gpu.device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("readback-yuv"),
+ size: total,
+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
+ mapped_at_creation: false,
+ }),
+ }
+ };
+
+ let mut encoder = self
+ .gpu
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("yuv-convert") });
+ {
+ let g = self.yuv.borrow();
+ let t = g.as_ref().expect("ensure_yuv");
+ // Une passe par plan : Y toujours, puis U et V separement (I420) ou
+ // un seul plan entrelace (NV12).
+ let mut passes: Vec<(&wgpu::TextureView, &wgpu::RenderPipeline)> =
+ vec![(&t.y_view, &t.pipe_y)];
+ match &t.chroma {
+ Chroma::Planar { u_view, v_view, pipe_u, pipe_v, .. } => {
+ passes.push((u_view, pipe_u));
+ passes.push((v_view, pipe_v));
+ }
+ Chroma::Interleaved { uv_view, pipe_uv, .. } => passes.push((uv_view, pipe_uv)),
+ }
+ for (view, pipe) in passes {
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("yuv-plane"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ // Chaque passe reecrit chaque texel : `Load` ferait lire
+ // une cible dont on va ecraser le contenu.
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ pass.set_pipeline(pipe);
+ pass.set_bind_group(0, &t.bind, &[]);
+ pass.draw(0..3, 0..1);
+ }
+ // `pw` est en TEXELS (`copy_texture_to_buffer` veut une extent), et
+ // `bpr` en octets : en NV12 le plan de chrominance fait `cw` texels de
+ // 2 octets, d'ou le meme `cw` avec un `bpr_uv` deux fois plus grand.
+ let mut copies: Vec<(&wgpu::Texture, u64, u32, u32, u32)> =
+ vec![(&t._y, 0u64, bpr_y, w, h)];
+ match &t.chroma {
+ Chroma::Planar { _u, _v, .. } => {
+ copies.push((_u, off_u, bpr_uv, cw, ch));
+ copies.push((_v, off_v, bpr_uv, cw, ch));
+ }
+ Chroma::Interleaved { _uv, .. } => copies.push((_uv, off_u, bpr_uv, cw, ch)),
+ }
+ for (tex, off, bpr, pw, ph) in copies {
+ encoder.copy_texture_to_buffer(
+ wgpu::TexelCopyTextureInfo {
+ texture: tex,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ wgpu::TexelCopyBufferInfo {
+ buffer: &buf,
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: off,
+ bytes_per_row: Some(bpr),
+ rows_per_image: Some(ph),
+ },
+ },
+ wgpu::Extent3d { width: pw, height: ph, depth_or_array_layers: 1 },
+ );
+ }
+ }
+
+ let idx = self.gpu.context.submit(std::iter::once(encoder.finish()));
+ 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_yuv.borrow_mut();
+ ring.pending.push_back(PendingCopy { buf, idx, rx, w, h, bpr: bpr_y });
+ if ring.pending.len() < ring.depth {
+ return Ok(false); // amorcage, comme la ring RGBA
+ }
+ }
+ self.readback_take_yuv_with(f)
+ }
+
+ /// Recolte la plus ancienne conversion en vol et la PRESENTE au lecteur sans
+ /// la copier : `f` recoit la vue mappee telle quelle, lignes paddees a 256
+ /// comprises. Rend `false` si la ring est vide. Pendant de `readback_take`.
+ ///
+ /// POURQUOI UNE CLOSURE, ET PAS UN `Vec` RENDU. La version precedente faisait
+ /// `mapped.to_vec()` — 3,3 Mo alloues, copies puis liberes par frame, soit
+ /// 11,9 Go de va-et-vient sur un export de 3600 frames — dans le seul but que
+ /// la donnee survive a l'`unmap`. Or l'appelant la recopie immediatement dans
+ /// l'AVFrame de l'encodeur : la copie intermediaire ne servait que la
+ /// signature. Avec une closure, le lecteur travaille dans la fenetre ou le
+ /// buffer est mappe et il n'y a plus qu'une seule copie sur le chemin.
+ ///
+ /// LE SLOT EST RENDU MEME SI `f` ECHOUE. Autrement une erreur d'encodage
+ /// laisserait le buffer mappe et hors de la ring : la frame suivante en
+ /// allouerait un neuf, et ainsi de suite jusqu'a epuisement de la memoire
+ /// mappable — un mode de panne bien pire que l'erreur d'origine.
+ pub unsafe fn readback_take_yuv_with(&self, mut f: F) -> Result
+ where
+ F: FnMut(u32, u32, &[u8]) -> Result<()>,
+ {
+ let Some(p) = self.readback_yuv.borrow_mut().pending.pop_front() else {
+ return Ok(false);
+ };
+ self.gpu.device.poll(wgpu::Maintain::WaitForSubmissionIndex(p.idx));
+ p.rx
+ .recv()
+ .map_err(|_| anyhow::anyhow!("map_async channel (yuv)"))?
+ .map_err(|e| anyhow::anyhow!("map_async yuv: {e:?}"))?;
+ // `mapped` et `slice` meurent a la fin du bloc : `unmap` ne peut donc pas
+ // etre appele pendant qu'une vue est encore accessible (wgpu l'assert).
+ let r = {
+ let slice = p.buf.slice(..);
+ let mapped = slice.get_mapped_range();
+ f(p.w, p.h, &mapped)
+ };
+ p.buf.unmap();
+ self.readback_yuv.borrow_mut().free.push(p.buf);
+ r.map(|()| true)
+ }
+
+ /// Profondeur de la ring YUV. Meme role et memes raisons que
+ /// `set_readback_depth` pour la ring RGBA.
+ pub fn set_readback_yuv_depth(&self, depth: usize) -> Result<()> {
+ let depth = depth.max(1);
+ // SAFETY : meme contrat que `set_readback_depth` — le drain ne touche que
+ // des buffers dont la soumission est terminee.
+ while unsafe { self.readback_take_yuv_with(|_, _, _| Ok(()))? } {}
+ let mut ring = self.readback_yuv.borrow_mut();
+ ring.depth = depth;
+ while ring.free.len() > depth {
+ ring.free.pop();
+ }
+ 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
@@ -2205,3 +3480,1151 @@ impl Compositor {
last.ok_or_else(|| anyhow::anyhow!("readback_direct: aucune frame recoltee"))
}
}
+
+// ---------------------------------------------------------------------------
+// Tests
+//
+// Tous rendent de VRAIS pixels sur le device de la machine, et tous sauf un se
+// lisent SANS ONNX Runtime : le masque y est pose a la main par
+// `set_webcam_mask` et l'inference n'est pas ce qu'ils testent. C'est delibere —
+// ce que ce portage ajoute cote GPU doit etre verifiable la ou la bibliotheque
+// n'est pas installee, ce qui est le cas de la CI. Meme parti que
+// `compositor_macos::tests`, dont ceci est le pendant.
+//
+// `poc-d3d` etant `cfg(windows)`, le banc `--cfg C8 --scene` qui a prouve le
+// chemin Windows n'existe pas ici : ces tests en tiennent lieu, plus le harnais
+// visuel opt-in en fin de fichier pour ce qu'une assertion ne peut pas dire.
+// ---------------------------------------------------------------------------
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::d3d::Gpu;
+ use crate::ffi::AVFrame;
+
+ /// NV12 « limited range » (BT.709), les memes valeurs que `yuv709_limited`
+ /// inverse : 16 rend du noir franc, 235 du blanc franc, 128 une chroma nulle.
+ const Y_WHITE: u8 = 235;
+ const Y_BLACK: u8 = 16;
+ const UV_NEUTRAL: u8 = 128;
+
+ /// `create_auto` et NON `create` : la CI (`rust-linux-compositor-check`,
+ /// ubuntu-latest) n'a pas de GPU et rend sur lavapipe. Avec la creation
+ /// hardware-stricte, tous ces tests s'y sauteraient en silence — c'est-a-dire
+ /// que le seul endroit ou ils tournent automatiquement ne les executerait pas.
+ fn gpu() -> Option {
+ match Gpu::create_auto(false) {
+ Ok(g) => Some(g),
+ Err(e) => {
+ eprintln!("pas d'adaptateur Vulkan ({e:#}) — test saute");
+ None
+ }
+ }
+ }
+
+ /// Deux `TextureView` NV12-split, comme `linux_frames::nv12_planes` en rend.
+ ///
+ /// Les textures ne sont pas retournees : en wgpu une `TextureView` garde la
+ /// sienne en vie (c'est deja ce dont depend la pyramide de blur du
+ /// compositeur, qui n'existe que sous forme de vues).
+ fn nv12_views(
+ gpu: &Gpu,
+ w: u32,
+ h: u32,
+ luma: impl Fn(u32, u32) -> u8,
+ ) -> (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView) {
+ let mut y = vec![0u8; (w * h) as usize];
+ for row in 0..h {
+ for col in 0..w {
+ y[(row * w + col) as usize] = luma(col, row);
+ }
+ }
+ let (ytex, utex, vtex) =
+ nv12_textures(gpu, w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize]);
+ let d = wgpu::TextureViewDescriptor::default();
+ (ytex.create_view(&d), utex.create_view(&d), vtex.create_view(&d))
+ }
+
+ /// Le couple de textures NV12-split (Y `R8Unorm`, UV entrelacee `Rg8Unorm`)
+ /// exactement comme `linux_frames::CpuFrames::ensure_textures` les alloue.
+ fn nv12_textures(
+ gpu: &Gpu,
+ w: u32,
+ h: u32,
+ y: &[u8],
+ uv: &[u8],
+ ) -> (wgpu::Texture, wgpu::Texture, wgpu::Texture) {
+ let mk = |label: &str, format, tw: u32, th: u32| {
+ gpu.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ size: wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ })
+ };
+ let ytex = mk("test-nv12-y", wgpu::TextureFormat::R8Unorm, w, h);
+ // Les helpers de test parlent encore NV12 entrelace parce que c'est la
+ // forme lisible pour ecrire un cas ; le carrier, lui, veut deux plans.
+ // On desentrelace ici plutot que de reecrire chaque test.
+ let utex = mk("test-yuv-u", wgpu::TextureFormat::R8Unorm, w / 2, h / 2);
+ let vtex = mk("test-yuv-v", wgpu::TextureFormat::R8Unorm, w / 2, h / 2);
+ let u_plane: Vec = uv.iter().step_by(2).copied().collect();
+ let v_plane: Vec = uv.iter().skip(1).step_by(2).copied().collect();
+ let write = |tex: &wgpu::Texture, data: &[u8], bpr: u32, tw: u32, th: u32| {
+ gpu.context.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: tex,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ data,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(bpr),
+ rows_per_image: Some(th),
+ },
+ wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 },
+ );
+ };
+ write(&ytex, y, w, w, h);
+ write(&utex, &u_plane, w / 2, w / 2, h / 2);
+ write(&vtex, &v_plane, w / 2, w / 2, h / 2);
+ (ytex, utex, vtex)
+ }
+
+ /// Masque 0 sur la moitie gauche, 255 sur la droite. La frontiere tombe pile
+ /// au milieu, donc un echantillon pris au quart et un aux trois quarts sont
+ /// loin du degrade que le filtrage lineaire pose sur la couture.
+ fn half_mask(w: u32, h: u32) -> Vec {
+ (0..w * h).map(|i| if i % w < w / 2 { 0u8 } else { 255u8 }).collect()
+ }
+
+ /// Le buffer de staging exportable doit etre une VRAIE zone partagee : ce que
+ /// wgpu y ecrit, notre propre mapping doit le relire a l'identique.
+ ///
+ /// C'est le seul point reellement incertain de l'export dmabuf, et il se
+ /// verifie sans encodeur. Si ce test passe, la memoire qu'on remettra a VAAPI
+ /// est bien celle que le compositeur remplit ; s'il echoue, tout ce qui est
+ /// bati dessus produirait une image fausse plutot qu'une panne.
+ #[test]
+ fn exportable_staging_round_trips_through_wgpu() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ const N: u64 = 4096;
+ let Some(st) = comp.create_exportable_staging(N) else {
+ eprintln!("pas d'extensions de memoire externe — test saute");
+ return;
+ };
+ assert!(st.fd >= 0, "descripteur dmabuf invalide");
+ assert_eq!(st.size, N);
+
+ // Un motif non trivial : un remplissage constant passerait meme si les
+ // deux cotes regardaient deux zones differentes mais nulles.
+ let pattern: Vec = (0..N as usize).map(|i| (i * 31 + 7) as u8).collect();
+ gpu.context.write_buffer(st.buffer(), 0, &pattern);
+ gpu.context.submit(std::iter::empty());
+ gpu.device.poll(wgpu::Maintain::Wait);
+
+ let got = st.read_back().expect("read_back");
+ assert_eq!(got.len(), N as usize);
+ assert_eq!(got, pattern, "la memoire exportee ne porte pas ce que wgpu y a ecrit");
+ }
+
+ /// La disposition NV12 doit etre EXACTEMENT celle que le pilote produit pour
+ /// une image NV12 lineaire, parce que c'est elle qu'on decrira a VAAPI dans
+ /// un `AVDRMFrameDescriptor`. Les valeurs ci-dessous ne sont pas devinees :
+ /// elles ont ete relevees sur ce materiel via `vkGetImageSubresourceLayout`
+ /// d'une `VkImage` NV12 en `DRM_FORMAT_MOD_LINEAR` (Y pitch 2048, UV a
+ /// l'offset 2211840, pitch 2048, total 3317760). Un ecart d'un octet ici
+ /// donnerait une image decalee et non une panne, d'ou le test.
+ #[test]
+ fn nv12_layout_matches_what_the_driver_produces() {
+ let (bpr_y, bpr_uv, off_uv, total) =
+ Compositor::yuv_layout_for(1920, 1080, YuvFormat::Nv12);
+ assert_eq!(bpr_y, 2048, "pitch du plan Y");
+ assert_eq!(bpr_uv, 2048, "pitch du plan UV entrelace (960 texels x 2 octets)");
+ assert_eq!(off_uv, 2_211_840, "offset du plan UV");
+ assert_eq!(total, 3_317_760, "taille totale");
+ }
+
+ /// I420 reste ce qu'il etait : c'est le format que l'encodeur software lit,
+ /// et ce test est ce qui garantit qu'ajouter NV12 ne l'a pas deplace.
+ #[test]
+ fn i420_layout_is_unchanged() {
+ let (bpr_y, bpr_uv, off_u, total) =
+ Compositor::yuv_layout_for(1920, 1080, YuvFormat::I420);
+ assert_eq!((bpr_y, bpr_uv), (2048, 1024));
+ assert_eq!(off_u, 2_211_840);
+ assert_eq!(total, 2_211_840 + 2 * 1024 * 540);
+ }
+
+ /// Dessine UN calque plein cadre sur le RT, par-dessus `clear`, et rend le
+ /// RGBA relu.
+ ///
+ /// Court-circuite `compose_frame` a dessein : ces tests-ci isolent le shader
+ /// et la liaison du masque, pas la geometrie que `plan_frame` decide.
+ fn draw_one_layer(
+ comp: &Compositor,
+ clear: wgpu::Color,
+ cb: &LayerCB,
+ planes: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
+ ) -> (u32, u32, Vec) {
+ let dummy = comp.dummy_view();
+ let (_buf, bind) = comp.make_bind(cb, Some(planes), &dummy);
+ let mut encoder = comp.gpu.device.create_command_encoder(
+ &wgpu::CommandEncoderDescriptor { label: Some("test-layer") },
+ );
+ {
+ let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("test-layer-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &comp.rt_view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(clear),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ rpass.set_pipeline(&comp.pipeline);
+ rpass.set_bind_group(0, &bind, &[]);
+ rpass.draw(0..4, 0..1);
+ }
+ comp.gpu.context.submit(std::iter::once(encoder.finish()));
+ unsafe { comp.readback_direct().expect("readback_direct") }
+ }
+
+ // -----------------------------------------------------------------------
+ // La capture
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn the_webcam_capture_comes_back_as_interleaved_rgb_at_model_resolution() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ // Moitie gauche noire, moitie droite blanche : la capture doit rendre les
+ // deux dans le bon sens. Une inversion d'axe passerait un test de taille
+ // sans se voir.
+ let (y, u, v) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE });
+
+ let mut out = Vec::new();
+ unsafe {
+ comp.capture_webcam_rgb(
+ &y,
+ &u,
+ &v,
+ [0.0, 0.0, 1.0, 1.0],
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ &mut out,
+ )
+ .expect("capture_webcam_rgb");
+ }
+
+ let (w, h) = (
+ crate::segmentation::MODEL_WIDTH as usize,
+ crate::segmentation::MODEL_HEIGHT as usize,
+ );
+ assert_eq!(out.len(), w * h * 3, "le modele veut du RGB8 entrelace, sans alpha");
+
+ let px = |buf: &[u8], col: usize, row: usize| -> [u8; 3] {
+ let i = (row * w + col) * 3;
+ [buf[i], buf[i + 1], buf[i + 2]]
+ };
+ let left = px(&out, w / 4, h / 2);
+ let right = px(&out, 3 * w / 4, h / 2);
+ assert!(left.iter().all(|&c| c < 24), "moitie gauche pas noire : {left:?}");
+ assert!(right.iter().all(|&c| c > 231), "moitie droite pas blanche : {right:?}");
+
+ // Deuxieme capture sur le meme buffer : c'est le regime etabli (30 fois
+ // par seconde), et il ne doit ni reallouer ni trainer les octets du tour
+ // precedent.
+ let capacity = out.capacity();
+ unsafe {
+ comp.capture_webcam_rgb(
+ &y,
+ &u,
+ &v,
+ [0.0, 0.0, 1.0, 1.0],
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ &mut out,
+ )
+ .expect("deuxieme capture");
+ }
+ assert_eq!(out.len(), w * h * 3);
+ assert_eq!(out.capacity(), capacity, "le scratch se realloue d'une frame a l'autre");
+ assert_eq!(px(&out, w / 4, h / 2), left);
+ assert_eq!(px(&out, 3 * w / 4, h / 2), right);
+ }
+
+ /// Le piege PROPRE a ce backend : `copy_texture_to_buffer` exige un
+ /// `bytes_per_row` multiple de 256, et le depadder est a la charge de
+ /// l'appelant. A la resolution livree (256 px, 1024 octets) le padding est nul
+ /// — donc la resolution livree n'exerce JAMAIS ce chemin. Il faut une largeur
+ /// qui le fasse : 100 px = 400 octets utiles dans un pas de 512.
+ ///
+ /// Un depad rate ne rend pas du bruit, il rend un CISAILLEMENT : chaque ligne
+ /// glisse de 28 px sur la precedente. D'ou l'echantillonnage sur plusieurs
+ /// lignes plutot que sur une seule.
+ #[test]
+ fn a_capture_whose_rows_need_padding_is_depadded_correctly() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let (y, u, v) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE });
+
+ let (w, h) = (100usize, 56usize);
+ assert_ne!((w * 4) % 256, 0, "cette largeur doit justement ETRE mal alignee");
+ let mut out = Vec::new();
+ unsafe {
+ comp.capture_webcam_rgb(&y, &u, &v, [0.0, 0.0, 1.0, 1.0], w as u32, h as u32, &mut out)
+ .expect("capture_webcam_rgb");
+ }
+ assert_eq!(out.len(), w * h * 3, "le padding d'alignement a fuit dans la sortie");
+
+ let px = |col: usize, row: usize| -> [u8; 3] {
+ let i = (row * w + col) * 3;
+ [out[i], out[i + 1], out[i + 2]]
+ };
+ for row in [0usize, h / 3, h / 2, h - 1] {
+ let left = px(w / 4, row);
+ let right = px(3 * w / 4, row);
+ assert!(left.iter().all(|&c| c < 24), "ligne {row}, gauche pas noire : {left:?}");
+ assert!(right.iter().all(|&c| c > 231), "ligne {row}, droite pas blanche : {right:?}");
+ }
+ }
+
+ #[test]
+ fn a_capture_of_zero_size_is_refused_rather_than_rendered() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let (y, u, v) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE);
+ let mut out = Vec::new();
+ let r = unsafe { comp.capture_webcam_rgb(&y, &u, &v, [0.0, 0.0, 1.0, 1.0], 0, 144, &mut out) };
+ assert!(r.is_err(), "une cible de largeur nulle doit etre refusee");
+ }
+
+ // -----------------------------------------------------------------------
+ // Le masque
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn the_mask_texture_is_allocated_once_and_a_short_buffer_is_refused() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let (w, h) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT);
+ let mask = vec![255u8; (w * h) as usize];
+
+ comp.set_webcam_mask(&mask, w, h).expect("premier televersement");
+ let first = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.clone());
+ comp.set_webcam_mask(&mask, w, h).expect("deuxieme televersement");
+ let second = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.clone());
+ assert_eq!(
+ first, second,
+ "la texture est recreee a chaque frame alors que la resolution du modele est fixe"
+ );
+
+ // Un masque trop court doit etre refuse, pas lu hors bornes.
+ assert!(comp.set_webcam_mask(&mask[..(w * h) as usize - 1], w, h).is_err());
+ assert!(comp.set_webcam_mask(&mask, 0, h).is_err());
+ comp.clear_webcam_mask();
+ assert!(comp.webcam_mask.borrow().is_none());
+ }
+
+ /// Le test qui compte : le masque DECOUPE vraiment la camera.
+ ///
+ /// Il rend le calque webcam plein cadre avec `fx.z = 1` (detourage) et un
+ /// masque mi-fond mi-sujet, puis relit les pixels. Il couvre d'un coup les
+ /// trois choses que le portage ajoute et qu'aucune compilation ne verifie :
+ /// le televersement R8, la liaison de la texture au binding 4, et la branche
+ /// `fx.z` de `fs_main` sur un vrai device.
+ #[test]
+ fn the_mask_actually_cuts_the_camera_out() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized");
+ comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask");
+ let (y, u, v) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE);
+
+ // Fond bleu franc : une couleur que la camera (blanche, chroma neutre) ne
+ // peut pas produire, donc « il reste du bleu » signifie « la camera a ete
+ // decoupee ici ».
+ let (rw, _, rgba) = draw_one_layer(
+ &comp,
+ wgpu::Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 },
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ src: [0.0, 0.0, 1.0, 1.0],
+ quad_px: [64.0, 64.0],
+ mode: 0.0,
+ color: [0.0, 0.0, 0.0, 1.0],
+ // fx.xy = etendue valide (toute la texture ici), fx.z = 1 -> detourage.
+ fx: [1.0, 1.0, 1.0, 0.0],
+ src_prev: [0.0, 0.0, 1.0, 1.0],
+ dst_prev: [0.0, 0.0, 1.0, 1.0],
+ mb: [1.0, 1.0, 1.0, 0.0],
+ ..Default::default()
+ },
+ (&y, &u, &v),
+ );
+
+ let px = |col: usize, row: usize| -> [u8; 4] {
+ let i = (row * rw as usize + col) * 4;
+ [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
+ };
+ assert_eq!(px(16, 32), [0, 0, 255, 255], "masque a 0 : le fond doit rester visible");
+ assert_eq!(px(48, 32), [255, 255, 255, 255], "masque a 255 : la camera doit rester opaque");
+ }
+
+ /// Meme montage, mode fond personnalise (`fx.z = 3`) : la ou le masque dit
+ /// « fond », le shader doit peindre `color` — c'est le seul mode ou
+ /// `LayerCB::color` cesse d'etre du noir opaque decoratif et porte une valeur
+ /// que le portage doit transmettre.
+ #[test]
+ fn the_custom_background_colour_replaces_the_masked_out_pixels() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized");
+ comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask");
+ let (y, u, v) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE);
+
+ let (rw, _, rgba) = draw_one_layer(
+ &comp,
+ wgpu::Color::BLACK,
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ src: [0.0, 0.0, 1.0, 1.0],
+ quad_px: [64.0, 64.0],
+ mode: 0.0,
+ color: [1.0, 0.0, 0.0, 1.0],
+ fx: [1.0, 1.0, 3.0, 0.0],
+ src_prev: [0.0, 0.0, 1.0, 1.0],
+ dst_prev: [0.0, 0.0, 1.0, 1.0],
+ mb: [1.0, 1.0, 1.0, 0.0],
+ ..Default::default()
+ },
+ (&y, &u, &v),
+ );
+ let px = |col: usize, row: usize| -> [u8; 4] {
+ let i = (row * rw as usize + col) * 4;
+ [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
+ };
+ assert_eq!(px(16, 32), [255, 0, 0, 255], "fond masque : la couleur custom doit peindre");
+ assert_eq!(px(48, 32), [255, 255, 255, 255], "sujet : la camera doit rester intacte");
+ }
+
+ // -----------------------------------------------------------------------
+ // `compose_frame` de bout en bout
+ //
+ // Les tests ci-dessus prouvent les pieces ; ceux-ci prouvent le CABLAGE — que
+ // `compose_frame` porte bien `fx`/`color` sur le calque webcam, qu'il lie le
+ // masque, et qu'il ne leve `fx.z` qu'une fois un masque reellement televerse.
+ // Ils passent par de vraies `AVFrame` porteuses d'un carrier `VkFrameTex`,
+ // donc par le MEME `nv12_srvs` que le decodeur : aucun raccourci n'est pris
+ // sur le seam de frame.
+ // -----------------------------------------------------------------------
+
+ /// Une `AVFrame` du backend Linux. `compose_frame` n'en lit que `format`,
+ /// `data[0]`, `width` et `height` : le reste peut rester a zero.
+ struct FakeFrame {
+ frame: Box,
+ }
+
+ impl FakeFrame {
+ fn new(gpu: &Gpu, w: u32, h: u32, luma: impl Fn(u32, u32) -> u8) -> FakeFrame {
+ let mut y = vec![0u8; (w * h) as usize];
+ for row in 0..h {
+ for col in 0..w {
+ y[(row * w + col) as usize] = luma(col, row);
+ }
+ }
+ FakeFrame::from_planes(gpu, w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize])
+ }
+
+ fn from_planes(gpu: &Gpu, w: u32, h: u32, y: &[u8], uv: &[u8]) -> FakeFrame {
+ let (ytex, utex, vtex) = nv12_textures(gpu, w, h, y, uv);
+ // Le carrier que `linux_frames::nv12_planes` et `carrier_dims`
+ // deballent. `Box::into_raw` ici, `Box::from_raw` dans `Drop` — c'est
+ // exactement la mecanique de `CpuFrames::attach_carrier`.
+ let carrier = Box::into_raw(Box::new(crate::linux_frames::VkFrameTex {
+ y: ytex,
+ u: utex,
+ v: vtex,
+ width: w,
+ height: h,
+ })) as *mut u8;
+ let mut frame: Box = Box::new(unsafe { std::mem::zeroed() });
+ // Le sentinel « buffer GPU natif dans data[0] », le meme que pose
+ // `CpuFrames::present`.
+ frame.format = crate::ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32;
+ frame.data[0] = carrier;
+ frame.width = w as i32;
+ frame.height = h as i32;
+ FakeFrame { frame }
+ }
+
+ fn as_ptr(&self) -> *const AVFrame {
+ &*self.frame as *const AVFrame
+ }
+ }
+
+ impl Drop for FakeFrame {
+ fn drop(&mut self) {
+ if !self.frame.data[0].is_null() {
+ unsafe {
+ drop(Box::from_raw(
+ self.frame.data[0] as *mut crate::linux_frames::VkFrameTex,
+ ));
+ }
+ self.frame.data[0] = std::ptr::null_mut();
+ }
+ }
+ }
+
+ /// Scene PiP minimale. `effect` est le JSON de `webcamEffect` (`"null"` pour
+ /// aucun).
+ ///
+ /// `effects.shadow` vaut 0 A DESSEIN : ce curseur ne pilote plus que l'ombre
+ /// de l'ecran, alors que celle du PiP est fixe (`WEBCAM_SHADOW_OPACITY`) et ne
+ /// depend que de `cfg.shadow`. Le mettre a zero est donc ce qui isole les
+ /// deux — sinon un test sur `cfg.shadow` mesure les deux ombres a la fois et
+ /// ne dit plus rien de la camera.
+ fn pip_scene_json(effect: &str) -> String {
+ format!(
+ r##"{{"clips":[],
+ "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle",
+ "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},
+ "effects":{{"padding":0.18,"blur":false,"shadow":0,"roundnessFrac":0.05,"motionBlur":0}},
+ "background":{{"kind":"color","color":"#0080ff"}},
+ "zoomRegions":[],"annotations":[],
+ "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,
+ "clipToBounds":false,"theme":"default"}},
+ "cropByClip":[],
+ "webcamEffect":{effect},
+ "output":{{"width":1920,"height":1080,"fps":30}}}}"##
+ )
+ }
+
+ /// Compose une frame et rend le RGBA du RT. `screen` est gris moyen, `webcam`
+ /// blanche : le blanc franc devient alors la SIGNATURE de la camera, une
+ /// couleur qu'aucun autre calque de cette scene ne produit, donc comptable
+ /// sans connaitre la geometrie du PiP.
+ ///
+ /// Le fond est un bleu franc et NON du noir : le PiP par defaut tombe dans la
+ /// marge, hors de l'ecran, et une ombre noire sur un fond noir ne se voit
+ /// pas — le controle du test d'ombre passerait alors pour une suppression
+ /// reussie.
+ ///
+ /// `set_live_params(live_params_from_scene(..))` n'est PAS decoratif : padding,
+ /// effets et forme de la webcam transitent par `LiveParams` et non par la
+ /// scene brute. L'omettre laisse la scene parser correctement puis etre
+ /// ignoree, et le rendu tombe sur les defauts.
+ fn compose_pip(
+ comp: &Compositor,
+ gpu: &Gpu,
+ effect: &str,
+ shadow: bool,
+ ) -> Vec {
+ let scene = Scene::from_json(&pip_scene_json(effect)).expect("scene json");
+ comp.set_live_params(live_params_from_scene(&scene));
+ comp.set_has_webcam(true);
+ comp.set_scene(Some(scene));
+
+ let screen = FakeFrame::new(gpu, 128, 128, |_, _| 126);
+ let webcam = FakeFrame::new(gpu, 64, 64, |_, _| Y_WHITE);
+ let mut cfg = Cfg::c8();
+ cfg.bg_blur = false;
+ cfg.zoom = false;
+ cfg.layout_anim = false;
+ cfg.cursor = false;
+ cfg.mblur_n = 1;
+ cfg.shadow = shadow;
+ unsafe {
+ comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg)
+ .expect("compose_frame");
+ let (_, _, rgba) = comp.readback_direct().expect("readback_direct");
+ rgba
+ }
+ }
+
+ /// Pixels quasi blancs = pixels de camera encore visibles.
+ fn camera_pixels(rgba: &[u8]) -> usize {
+ rgba.chunks_exact(4)
+ .filter(|px| px[0] > 240 && px[1] > 240 && px[2] > 240)
+ .count()
+ }
+
+ const NO_EFFECT: &str = "null";
+ const CUTOUT: &str =
+ r#"{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":null}"#;
+
+ /// Le piege que le brief nomme : un mode SANS masque ne doit rien changer.
+ ///
+ /// `effect_code` doit rester a 0 tant que rien n'a ete segmente, sinon le
+ /// detourage rend une webcam invisible sur les premieres frames — le temps que
+ /// l'inference rende son premier masque, c'est-a-dire a chaque ouverture de
+ /// l'editeur. L'assertion est octet pour octet : « inchange » ne souffre pas
+ /// d'a-peu-pres.
+ #[test]
+ fn a_mode_without_a_mask_composites_exactly_like_no_effect_at_all() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let plain = compose_pip(&comp, &gpu, NO_EFFECT, true);
+ let requested = compose_pip(&comp, &gpu, CUTOUT, true);
+ assert!(
+ comp.webcam_mask.borrow().is_none(),
+ "aucun masque n'a ete televerse : `modelPath` est absent, donc rien ne segmente"
+ );
+ assert!(
+ camera_pixels(&plain) > 200,
+ "la camera n'est pas a l'ecran, le test ne prouve rien"
+ );
+ assert_eq!(plain, requested, "un mode sans masque a change des pixels");
+ }
+
+ /// Et une fois le masque la, le detourage doit VRAIMENT decouper — dans la
+ /// bonne proportion. Le masque couvre la moitie de la camera, donc la moitie
+ /// de ses pixels doit disparaitre. Compter plutot que d'echantillonner un
+ /// point evite de coder en dur la geometrie du PiP, qui appartient a
+ /// `plan_frame` et non a ce portage.
+ #[test]
+ fn compose_frame_cuts_the_camera_out_once_a_mask_exists() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let whole = camera_pixels(&compose_pip(&comp, &gpu, NO_EFFECT, true));
+ assert!(whole > 200, "la camera n'est pas a l'ecran, le test ne prouve rien");
+
+ let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT);
+ comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask");
+ let cut = camera_pixels(&compose_pip(&comp, &gpu, CUTOUT, true));
+
+ let expected = whole as f32 / 2.0;
+ assert!(
+ (cut as f32 - expected).abs() < expected * 0.15,
+ "detourage : {cut} pixels de camera restants pour ~{expected:.0} attendus \
+ (entier : {whole})"
+ );
+ }
+
+ /// L'ombre portee du PiP doit disparaitre en detourage : une ombre projetee
+ /// par un rectangle devenu invisible se lit comme un artefact. Le test le
+ /// prouve sans jamais localiser l'ombre — en detourage, `cfg.shadow` ne doit
+ /// plus rien changer du tout.
+ ///
+ /// Le controle est ce qui empeche l'assertion d'etre vide : sans effet,
+ /// `cfg.shadow` DOIT changer des pixels, sinon la premiere moitie passerait
+ /// aussi pour une scene ou aucune ombre n'a jamais ete dessinee.
+ #[test]
+ fn the_pip_shadow_is_suppressed_in_cutout_mode() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ assert_ne!(
+ compose_pip(&comp, &gpu, NO_EFFECT, true),
+ compose_pip(&comp, &gpu, NO_EFFECT, false),
+ "controle : sans effet, l'ombre du PiP doit bel et bien se voir"
+ );
+
+ let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT);
+ comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask");
+ assert_eq!(
+ compose_pip(&comp, &gpu, CUTOUT, true),
+ compose_pip(&comp, &gpu, CUTOUT, false),
+ "en detourage, l'ombre est encore dessinee"
+ );
+ }
+
+ /// Le tour complet, celui qui a besoin d'ONNX Runtime : capture -> inference
+ /// -> masque -> composite, entraine par `compose_frame` seul. Se saute
+ /// proprement sans la bibliotheque, ce que fait la CI — cf.
+ /// `segmentation::runtime_available`.
+ #[test]
+ fn the_whole_loop_produces_a_mask_from_compose_frame_alone() {
+ if !crate::segmentation::runtime_available() {
+ eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — test saute");
+ return;
+ }
+ let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx");
+ if !model.is_file() {
+ eprintln!("modele absent ({}) — test saute", model.display());
+ return;
+ }
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let effect = format!(
+ r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{}}}"#,
+ serde_json::to_string(&model.to_string_lossy()).expect("chemin serialisable")
+ );
+
+ // Le limiteur est a 30 Hz : une frame par tour ne suffirait pas, et
+ // l'inference est asynchrone. On laisse au worker le temps de rendre un
+ // masque, sans jamais l'attendre dans le rendu — ce qui est precisement le
+ // contrat.
+ let mut uploaded = false;
+ for _ in 0..40 {
+ let _ = compose_pip(&comp, &gpu, &effect, true);
+ if comp.webcam_mask.borrow().is_some() {
+ uploaded = true;
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(40));
+ }
+ assert!(
+ uploaded,
+ "aucun masque n'est remonte : la boucle capture -> inference -> upload est rompue"
+ );
+ assert!(!*comp.seg_failed.borrow(), "la segmentation s'est eteinte d'elle-meme");
+ }
+
+ // -----------------------------------------------------------------------
+ // Harnais visuel (opt-in)
+ //
+ // Les tests ci-dessus prouvent le mecanisme sur des images synthetiques, ou le
+ // masque est pose a la main et donc trivialement juste. Ils ne peuvent rien
+ // dire de la QUALITE du masque que le modele produit sur une vraie camera — et
+ // « un masque qui composite » n'est pas la meme affirmation que « un masque qui
+ // est correct ».
+ //
+ // Meme forme d'opt-in que `tests/compose_linux.rs` (variable d'environnement +
+ // skip propre), et pour la meme raison : ca rend sur GPU et ca lit un fichier
+ // que le depot ne porte pas.
+ //
+ // ```
+ // ORT_DYLIB_PATH=/chemin/libonnxruntime.so \
+ // OPENSCREEN_SEG_CAM=camera.png \
+ // OPENSCREEN_SEG_VISUAL=target/seg \
+ // cargo test -p openscreen-compositor --lib seg_visual -- --nocapture
+ // ```
+ // -----------------------------------------------------------------------
+
+ /// RGB8 -> NV12 BT.709 limited. Inverse EXACT de `yuv709_limited` dans
+ /// `layer.wgsl` : une autre matrice ferait deriver les couleurs du rendu et on
+ /// croirait a un bug du compositeur la ou il n'y aurait qu'une conversion
+ /// d'entree fausse.
+ fn rgb_to_nv12(rgb: &[u8], w: u32, h: u32) -> (Vec, Vec) {
+ let luma = |i: usize| -> (f32, f32, f32, f32) {
+ let (r, g, b) = (
+ rgb[i * 3] as f32 / 255.0,
+ rgb[i * 3 + 1] as f32 / 255.0,
+ rgb[i * 3 + 2] as f32 / 255.0,
+ );
+ (r, g, b, 0.2126 * r + 0.7152 * g + 0.0722 * b)
+ };
+ let mut y = vec![0u8; (w * h) as usize];
+ for i in 0..(w * h) as usize {
+ let (_, _, _, yl) = luma(i);
+ y[i] = (16.0 + 219.0 * yl).round().clamp(0.0, 255.0) as u8;
+ }
+ // Chroma au plus proche voisin : l'echantillon en haut a gauche de chaque
+ // bloc 2x2. Un vrai filtre ne changerait rien a ce que ce harnais donne a
+ // voir.
+ let mut uv = vec![0u8; (w * (h / 2)) as usize];
+ for row in 0..h / 2 {
+ for col in 0..w / 2 {
+ let (r, _, b, yl) = luma(((row * 2) * w + col * 2) as usize);
+ let cb = 128.0 + 224.0 * ((b - yl) / 1.8556);
+ let cr = 128.0 + 224.0 * ((r - yl) / 1.5748);
+ let o = (row * w + col * 2) as usize;
+ uv[o] = cb.round().clamp(0.0, 255.0) as u8;
+ uv[o + 1] = cr.round().clamp(0.0, 255.0) as u8;
+ }
+ }
+ (y, uv)
+ }
+
+ fn frame_from_png(gpu: &Gpu, path: &std::path::Path) -> FakeFrame {
+ let img = image::open(path)
+ .unwrap_or_else(|e| panic!("{} : {e}", path.display()))
+ .to_rgb8();
+ // NV12 veut des dimensions paires ; on rogne d'un pixel plutot que de
+ // reechantillonner.
+ let (w, h) = (img.width() & !1, img.height() & !1);
+ let src = img.as_raw();
+ let mut rgb = vec![0u8; (w * h * 3) as usize];
+ for row in 0..h {
+ let (d, s) = ((row * w * 3) as usize, (row * img.width() * 3) as usize);
+ rgb[d..d + (w * 3) as usize].copy_from_slice(&src[s..s + (w * 3) as usize]);
+ }
+ let (y, uv) = rgb_to_nv12(&rgb, w, h);
+ FakeFrame::from_planes(gpu, w, h, &y, &uv)
+ }
+
+ #[test]
+ fn seg_visual_renders_the_four_modes_from_a_real_photo() {
+ let (Ok(out_dir), Ok(cam)) = (
+ std::env::var("OPENSCREEN_SEG_VISUAL"),
+ std::env::var("OPENSCREEN_SEG_CAM"),
+ ) else {
+ eprintln!(
+ "harnais visuel : OPENSCREEN_SEG_VISUAL + OPENSCREEN_SEG_CAM absents — saute"
+ );
+ return;
+ };
+ if !crate::segmentation::runtime_available() {
+ eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — saute");
+ return;
+ }
+ let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx");
+ let Some(gpu) = gpu() else { return };
+ std::fs::create_dir_all(&out_dir).expect("dossier de sortie");
+
+ let (rw, rh) = (1280u32, 720u32);
+ let comp = Compositor::new_sized(&gpu, rw, rh).expect("Compositor::new_sized");
+ let webcam = frame_from_png(&gpu, std::path::Path::new(&cam));
+ let screen = match std::env::var("OPENSCREEN_SEG_SCREEN") {
+ Ok(p) => frame_from_png(&gpu, std::path::Path::new(&p)),
+ // Sans capture d'ecran sous la main, un damier : il rend le detourage
+ // lisible, la ou un aplat laisserait croire a un fond simplement peint.
+ Err(_) => FakeFrame::new(&gpu, 640, 360, |col, row| {
+ if (col / 40 + row / 40) % 2 == 0 { 180 } else { 60 }
+ }),
+ };
+ let model_json = serde_json::to_string(&model.to_string_lossy()).expect("chemin");
+
+ let mut wrote = Vec::new();
+ for (name, effect) in [
+ ("00-none", "null".to_string()),
+ ("01-cutout", format!(r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{model_json}}}"#)),
+ ("02-blur", format!(r#"{{"mode":"blur","blurIntensity":0.8,"background":null,"modelPath":{model_json}}}"#)),
+ ("03-custom", format!(r##"{{"mode":"custom","blurIntensity":0,"background":{{"kind":"color","color":"#ff2d95"}},"modelPath":{model_json}}}"##)),
+ ] {
+ // Le masque arrive de facon asynchrone : on tourne jusqu'a ce qu'il
+ // soit la, ce qui est aussi une verification en soi — la boucle du
+ // rendu ne l'attend jamais.
+ let mut rgba = Vec::new();
+ for _ in 0..60 {
+ rgba = compose_visual(&comp, &screen, &webcam, &effect);
+ if effect == "null" || comp.webcam_mask.borrow().is_some() {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(30));
+ }
+ let path = format!("{out_dir}/seg-{name}.png");
+ image::RgbaImage::from_raw(rw, rh, rgba)
+ .expect("dimensions du readback")
+ .save(&path)
+ .unwrap_or_else(|e| panic!("ecriture {path} : {e}"));
+ wrote.push(path);
+ }
+ for p in &wrote {
+ println!("wrote {p}");
+ }
+ assert!(
+ comp.webcam_mask.borrow().is_some(),
+ "aucun masque n'a ete produit : les trois modes d'effet sont sans objet"
+ );
+ }
+
+ /// Camera grand format (rect force via `webcamRect`), pour que le masque
+ /// occupe une bonne part de l'image et se juge a taille reelle.
+ fn compose_visual(
+ comp: &Compositor,
+ screen: &FakeFrame,
+ webcam: &FakeFrame,
+ effect: &str,
+ ) -> Vec {
+ let json = format!(
+ r##"{{"clips":[],
+ "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle",
+ "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false,
+ "webcamRect":{{"x":0.06,"y":0.10,"width":0.55,"height":0.72}}}},
+ "effects":{{"padding":0.10,"blur":false,"shadow":1,"roundnessFrac":0.02,"motionBlur":0}},
+ "background":{{"kind":"gradient","angleDeg":45,"stops":["#1b2a4a","#0b0f1a"]}},
+ "zoomRegions":[],"annotations":[],
+ "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,
+ "clipToBounds":false,"theme":"default"}},
+ "cropByClip":[],
+ "webcamEffect":{effect},
+ "output":{{"width":1280,"height":720,"fps":30}}}}"##
+ );
+ let scene = Scene::from_json(&json).expect("scene json");
+ comp.set_live_params(live_params_from_scene(&scene));
+ comp.set_has_webcam(true);
+ comp.set_scene(Some(scene));
+ let mut cfg = Cfg::c8();
+ cfg.zoom = false;
+ cfg.layout_anim = false;
+ cfg.cursor = false;
+ cfg.mblur_n = 1;
+ unsafe {
+ comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg)
+ .expect("compose_frame");
+ let (_, _, rgba) = comp.readback_direct().expect("readback_direct");
+ rgba
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Staging exportable en dmabuf
+// ---------------------------------------------------------------------------
+
+/// Un buffer de staging dont la MEMOIRE est exportable en dmabuf, pour qu'un
+/// encodeur materiel puisse la lire sans repasser par le CPU.
+///
+/// POURQUOI IL EN FAUT UN DEUXIEME, ET PAS UN DRAPEAU SUR L'EXISTANT. wgpu
+/// n'expose aucun moyen de demander une allocation exportable : il faut la
+/// fabriquer soi-meme et la lui confier. Or `buffer_from_raw` construit un
+/// `Buffer { block: None }` -- wgpu accepte d'y ECRIRE (c'est une cible de
+/// `copy_texture_to_buffer` comme une autre) mais ne peut pas le faire lire par
+/// le CPU, sa mecanique de mapping passant par ce bloc qu'il ne possede pas.
+/// Le chemin logiciel, lui, DOIT le lire. Les deux ne peuvent donc pas partager
+/// un buffer, et l'export choisit lequel il alloue selon l'encodeur retenu.
+///
+/// La memoire est demandee HOST_VISIBLE et HOST_COHERENT pour que la
+/// verification puisse la relire directement et sans invalidation ; un chemin
+/// purement GPU pourrait se passer des deux.
+pub struct ExportableStaging {
+ /// Vue wgpu, utilisable comme destination de copie. En `Option` UNIQUEMENT
+ /// pour pouvoir la relacher explicitement avant la memoire dans `Drop`, cf.
+ /// l'ordre impose la-bas.
+ buffer: Option,
+ /// Le descripteur a passer au consommateur. Possede : ferme dans `Drop`.
+ pub fd: i32,
+ pub size: u64,
+ device: ash::Device,
+ memory: ash::vk::DeviceMemory,
+}
+
+impl ExportableStaging {
+ /// La cible de copie a passer a wgpu.
+ pub fn buffer(&self) -> &wgpu::Buffer {
+ self.buffer.as_ref().expect("buffer relache")
+ }
+}
+
+impl ExportableStaging {
+ /// Relit la memoire exportee telle que le GPU l'a laissee.
+ ///
+ /// Passe par `vkMapMemory` et NON par wgpu, pour la raison ci-dessus. C'est
+ /// ce qui permet de verifier le contenu sans encodeur : si ces octets sont
+ /// ceux du chemin de relecture normal, la memoire exportee porte bien
+ /// l'image composee.
+ pub fn read_back(&self) -> Result> {
+ unsafe {
+ let p = self
+ .device
+ .map_memory(self.memory, 0, self.size, ash::vk::MemoryMapFlags::empty())
+ .map_err(|e| anyhow::anyhow!("vkMapMemory: {e}"))?;
+ let out = std::slice::from_raw_parts(p as *const u8, self.size as usize).to_vec();
+ self.device.unmap_memory(self.memory);
+ Ok(out)
+ }
+ }
+}
+
+impl Drop for ExportableStaging {
+ fn drop(&mut self) {
+ // L'ORDRE EST LE FOND DU SUJET, et le premier jet le faisait a l'envers :
+ // il detruisait le `VkBuffer` puis liberait la memoire, alors que wgpu
+ // detruit DEJA le buffer quand son wrapper tombe -- double liberation,
+ // et par-dessus, memoire liberee alors qu'un buffer y etait encore lie.
+ //
+ // Le partage est donc : wgpu possede le HANDLE (il l'a recu par
+ // `buffer_from_raw` et le detruira), nous possedons la MEMOIRE (son
+ // `block` est `None`, personne d'autre ne la liberera). D'ou : relacher
+ // le wrapper d'abord, liberer la memoire ensuite.
+ drop(self.buffer.take());
+ unsafe {
+ self.device.free_memory(self.memory, None);
+ }
+ // Le fd est un handle a part : l'exporter duplique la propriete, donc le
+ // fermer ne libere pas la memoire -- mais l'oublier fuirait un
+ // descripteur par frame.
+ if self.fd >= 0 {
+ let _ = nix_close(self.fd);
+ }
+ }
+}
+
+fn nix_close(fd: i32) -> std::io::Result<()> {
+ // `libc::close` sans dependance supplementaire : la libc est deja liee.
+ extern "C" {
+ fn close(fd: i32) -> i32;
+ }
+ if unsafe { close(fd) } == 0 {
+ Ok(())
+ } else {
+ Err(std::io::Error::last_os_error())
+ }
+}
+
+impl Compositor {
+ /// Alloue un buffer de staging exportable de `size` octets, ou `None` si le
+ /// device n'a pas ete ouvert avec les extensions de memoire externe (cf.
+ /// `d3d_linux::open_device_with_dmabuf_export`).
+ pub fn create_exportable_staging(&self, size: u64) -> Option {
+ use ash::vk;
+ unsafe {
+ self.gpu.device.as_hal::(|hal| {
+ let hal = hal?;
+ let dev = hal.raw_device().clone();
+ let phys = hal.raw_physical_device();
+ let instance = hal.shared_instance().raw_instance();
+
+ let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
+ .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
+ let bci = vk::BufferCreateInfo::default()
+ .push_next(&mut ext_info)
+ .size(size)
+ .usage(vk::BufferUsageFlags::TRANSFER_DST)
+ .sharing_mode(vk::SharingMode::EXCLUSIVE);
+ let raw = dev.create_buffer(&bci, None).ok()?;
+
+ let req = dev.get_buffer_memory_requirements(raw);
+ let props = instance.get_physical_device_memory_properties(phys);
+ // HOST_VISIBLE pour que `read_back` puisse verifier le contenu,
+ // et COHERENT parce qu'il lit SANS invalider : sur une memoire
+ // seulement visible, le mapping peut rendre des octets perimes et
+ // le test passerait ou echouerait selon le cache, pas selon le
+ // code. Exiger les deux est plus simple qu'un
+ // `vkInvalidateMappedMemoryRanges` correct a chaque lecture.
+ let want = vk::MemoryPropertyFlags::HOST_VISIBLE
+ | vk::MemoryPropertyFlags::HOST_COHERENT;
+ let mt = (0..props.memory_type_count).find(|i| {
+ req.memory_type_bits & (1 << i) != 0
+ && props.memory_types[*i as usize].property_flags.contains(want)
+ })?;
+
+ let mut export = vk::ExportMemoryAllocateInfo::default()
+ .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
+ let mai = vk::MemoryAllocateInfo::default()
+ .push_next(&mut export)
+ .allocation_size(req.size)
+ .memory_type_index(mt);
+ let memory = dev.allocate_memory(&mai, None).ok()?;
+ dev.bind_buffer_memory(raw, memory, 0).ok()?;
+
+ let getter = ash::khr::external_memory_fd::Device::new(instance, &dev);
+ let fd = getter
+ .get_memory_fd(
+ &vk::MemoryGetFdInfoKHR::default()
+ .memory(memory)
+ .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT),
+ )
+ .ok()?;
+
+ let hal_buf = wgpu_hal::vulkan::Device::buffer_from_raw(raw);
+ let buffer = self.gpu.device.create_buffer_from_hal::(
+ hal_buf,
+ &wgpu::BufferDescriptor {
+ label: Some("staging-exportable"),
+ size,
+ usage: wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ },
+ );
+ Some(ExportableStaging { buffer: Some(buffer), fd, size, device: dev, memory })
+ })
+ }
+ }
+}
+
+impl Compositor {
+ /// Compose la frame courante en NV12 et la depose dans `staging`, dont la
+ /// memoire est exportable en dmabuf. Rend la main quand le GPU a fini.
+ ///
+ /// PAS DE RING, PAS DE `map_async`, CONTRAIREMENT A `readback_submit_yuv`.
+ /// Cette variante-ci n'a rien a faire relire par le CPU : le consommateur est
+ /// l'encodeur materiel, qui lit la meme memoire par son fd. Toute la
+ /// mecanique de staging mappe et de recolte differee n'aurait donc personne a
+ /// servir.
+ ///
+ /// NE BLOQUE PAS. Rend l'index de soumission ; l'appelant attend dessus juste
+ /// avant de donner le fd a l'encodeur, ce qui lui laisse la fenetre pour
+ /// composer la frame suivante pendant que celle-ci finit. C'est le meme
+ /// pipelining que la ring de relecture software, avec des tampons
+ /// exportables a la place des buffers mappes.
+ pub unsafe fn compose_into_dmabuf(
+ &self,
+ staging: &ExportableStaging,
+ ) -> Result {
+ self.ensure_yuv_fmt(YuvFormat::Nv12)?;
+ let (bpr_y, bpr_uv, off_uv, total) = {
+ let g = self.yuv.borrow();
+ let t = g.as_ref().expect("ensure_yuv");
+ (t.bpr_y, t.bpr_uv, t.off_u, t.total)
+ };
+ if staging.size < total {
+ anyhow::bail!("staging de {} octets pour {total} attendus", staging.size);
+ }
+ let (w, h) = (self.render_w, self.render_h);
+ let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
+
+ let mut encoder = self
+ .gpu
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("yuv-dmabuf") });
+ {
+ let g = self.yuv.borrow();
+ let t = g.as_ref().expect("ensure_yuv");
+ let (uv_view, pipe_uv, _uv) = match &t.chroma {
+ Chroma::Interleaved { uv_view, pipe_uv, _uv } => (uv_view, pipe_uv, _uv),
+ Chroma::Planar { .. } => {
+ anyhow::bail!("compose_into_dmabuf attend des cibles NV12")
+ }
+ };
+ for (view, pipe) in [(&t.y_view, &t.pipe_y), (uv_view, pipe_uv)] {
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("yuv-dmabuf-plane"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ pass.set_pipeline(pipe);
+ pass.set_bind_group(0, &t.bind, &[]);
+ pass.draw(0..3, 0..1);
+ }
+ for (tex, off, bpr, pw, ph) in
+ [(&t._y, 0u64, bpr_y, w, h), (_uv, off_uv, bpr_uv, cw, ch)]
+ {
+ encoder.copy_texture_to_buffer(
+ wgpu::TexelCopyTextureInfo {
+ texture: tex,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ wgpu::TexelCopyBufferInfo {
+ buffer: staging.buffer(),
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: off,
+ bytes_per_row: Some(bpr),
+ rows_per_image: Some(ph),
+ },
+ },
+ wgpu::Extent3d { width: pw, height: ph, depth_or_array_layers: 1 },
+ );
+ }
+ }
+ Ok(self.gpu.context.submit(std::iter::once(encoder.finish())))
+ }
+
+ /// Attend qu'une soumission soit terminee.
+ ///
+ /// INDISPENSABLE AVANT DE PASSER LE FD. L'encodeur lit cette memoire par un
+ /// chemin que wgpu ignore : rien d'autre ne garantirait que la copie a bien
+ /// atterri.
+ pub fn wait_submission(&self, idx: wgpu::SubmissionIndex) {
+ self.gpu.device.poll(wgpu::Maintain::WaitForSubmissionIndex(idx));
+ }
+
+ /// La geometrie NV12 courante, pour decrire le dmabuf au consommateur.
+ pub fn nv12_geometry(&self) -> (u32, u32, u64, u64) {
+ Compositor::yuv_layout_for(self.render_w, self.render_h, YuvFormat::Nv12)
+ }
+}
diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs
index cffc9395d..e3c8cff5e 100644
--- a/crates/compositor/src/compositor_macos.rs
+++ b/crates/compositor/src/compositor_macos.rs
@@ -42,6 +42,15 @@ use anyhow::{anyhow, Result};
use metal::foreign_types::ForeignType;
use std::cell::RefCell;
+/// Budget du cache de textures image (`img_cache`), en octets. Même valeur et même raison que
+/// `compositor_windows::IMG_CACHE_BUDGET_BYTES`.
+///
+/// Doit tenir le JEU ACTIF d'une frame — au pire un wallpaper d'écran ET un fond de caméra, que
+/// rien n'empêche d'être deux 7680x7680 à 225 Mo pièce. Sous ce seuil l'éviction ne peut plus
+/// rendre de mémoire sans toucher au jeu actif, ce qu'elle refuse de faire. 512 Mo borne la fuite
+/// (1 774 Mo mesurés en parcourant les 18 wallpapers livrés) en laissant le jeu actif résident.
+const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
+
// ---------------------------------------------------------------------------
// CVMetalTextureCache — le pont CVPixelBuffer → MTLTexture
// ---------------------------------------------------------------------------
@@ -197,6 +206,42 @@ impl Drop for CVMetalTextureCache {
}
}
+// ---------------------------------------------------------------------------
+// Segmentation du sujet webcam
+// ---------------------------------------------------------------------------
+
+/// Cadence de l'inférence. Même valeur et même raison que
+/// `compositor_windows::SEGMENTATION_HZ` : une silhouette ne bouge pas de façon
+/// perceptible en 16 ms, et c'est le seul levier mesuré qui divise le coût par deux sans
+/// toucher au modèle.
+const SEGMENTATION_HZ: u32 = 30;
+
+/// Cible RGBA + miroir de lecture pour extraire la frame webcam à la résolution du modèle.
+///
+/// Deux textures, pas une : `rt` est `Private` parce que c'est une cible de rendu, et
+/// `get_bytes` n'est légal que sur du `Shared`. C'est exactement le couple
+/// `nv12_y`/`nv12_read_y` du chemin d'encodage, en RGBA et à 256x144 — cf. l'en-tête du
+/// module. `Managed` n'a pas sa place ici : rien dans ce fichier n'en utilise, et c'est le
+/// seul mode de stockage qui exigerait un `synchronizeResource` avant la lecture.
+struct SegCapture {
+ /// Cible de la passe de capture. `Private` : écrite par le GPU, jamais lue par le CPU.
+ rt: metal::Texture,
+ /// Miroir `Shared` de `rt`, rempli par blit dans le même command buffer.
+ read: metal::Texture,
+ width: u32,
+ height: u32,
+}
+
+/// Texture du masque de segmentation, recréée seulement quand la résolution du modèle
+/// change — c'est-à-dire jamais, en régime établi. Pendant Metal de
+/// `compositor_windows::WebcamMask` : pas de vue à côté de la texture, un `MTLTexture` est
+/// déjà ce que `set_fragment_texture` prend.
+struct WebcamMask {
+ tex: metal::Texture,
+ width: u32,
+ height: u32,
+}
+
// ---------------------------------------------------------------------------
// Compositor
// ---------------------------------------------------------------------------
@@ -226,8 +271,15 @@ pub struct Compositor {
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>,
+ /// preview sur un fond image. L'entrée reste néanmoins évinçable dès qu'elle sort du jeu
+ /// actif d'une frame — cf. `cached_image`.
+ img_cache: RefCell>,
+ /// Compteur d'accès de `img_cache`, pour l'ordre LRU. Un compteur plutôt que l'index de
+ /// frame : une frame touche plusieurs entrées, et il faut pouvoir les ordonner entre elles.
+ img_tick: std::cell::Cell,
+ /// Valeur de `img_tick` au début de la frame en cours. Tout ce qui a été touché depuis
+ /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`.
+ img_frame_start: std::cell::Cell,
// --- Engine : render targets ---
/// Render target principal RGBA8. Cible de `compose_frame`. `Private` : c'est une
@@ -280,6 +332,30 @@ pub struct Compositor {
/// Textes rastérisés, indexés par ID, avec la `cache_key` du spec pour invalider.
text_cache: RefCell>,
text_raster: Option,
+
+ // --- Segmentation du sujet webcam (cf. `pump_segmentation`) ---
+ /// Masque du sujet, R8 à la résolution du modèle. Écrit par `set_webcam_mask`, lu au
+ /// moment de dessiner la webcam. `None` tant qu'aucune frame n'a été segmentée — l'effet
+ /// reste alors éteint plutôt que de rendre une webcam invisible en mode détourage.
+ webcam_mask: RefCell>,
+ /// Cible + miroir de la capture, créés à la première capture et jamais redimensionnés :
+ /// le modèle a une entrée fixe.
+ seg_capture: RefCell >,
+ /// Worker d'inférence, absent tant que `enable_segmentation` n'a pas été appelé.
+ seg_worker: RefCell >,
+ /// Segmenteur tenu SUR LE THREAD DE RENDU, utilisé à la place du worker en mode
+ /// déterministe. Voir `set_segmentation_deterministic`.
+ seg_sync: RefCell >,
+ /// Export : cadence par frame et inférence synchrone, au lieu de l'horloge et du worker.
+ seg_deterministic: std::cell::Cell,
+ /// Boîte aux lettres du worker. Le masque est déposé depuis le thread d'inférence et
+ /// téléversé depuis le thread de rendu : aucun appel Metal ne traverse de thread.
+ seg_inbox: std::sync::Arc>>>,
+ seg_rate: RefCell,
+ /// Frame RGB réutilisée d'une capture à l'autre.
+ seg_scratch: RefCell>,
+ /// Le chargement du modèle a échoué : ne pas réessayer à chaque frame.
+ seg_failed: RefCell,
}
/// Descripteur de texture — les six cibles ne diffèrent que par format, taille et
@@ -539,6 +615,8 @@ impl Compositor {
metal_texture_cache: cache,
last_cmd: RefCell::new(None),
img_cache: RefCell::new(std::collections::HashMap::new()),
+ img_tick: std::cell::Cell::new(0),
+ img_frame_start: std::cell::Cell::new(0),
rt,
rt_read,
nv12_y,
@@ -561,6 +639,15 @@ impl Compositor {
ann_img_cache: RefCell::new(std::collections::HashMap::new()),
text_cache: RefCell::new(std::collections::HashMap::new()),
text_raster: crate::text::TextRasterizer::new().ok(),
+ webcam_mask: RefCell::new(None),
+ seg_capture: RefCell::new(None),
+ seg_worker: RefCell::new(None),
+ seg_sync: RefCell::new(None),
+ seg_deterministic: std::cell::Cell::new(false),
+ seg_inbox: std::sync::Arc::new(std::sync::Mutex::new(None)),
+ seg_rate: RefCell::new(crate::segmentation::RateLimiter::new(SEGMENTATION_HZ)),
+ seg_scratch: RefCell::new(Vec::new()),
+ seg_failed: RefCell::new(false),
})
}
@@ -783,6 +870,54 @@ impl Compositor {
Ok((tex, w, h))
}
+ /// Ouvre une frame du point de vue de `img_cache` : tout ce qui sera touché après cet appel
+ /// est le jeu actif, et devient inévinçable jusqu'à la frame suivante.
+ fn begin_image_frame(&self) {
+ // `+ 1` : la première entrée de cette frame recevra `img_tick + 1`, et la protection
+ // porte sur `tick >= img_frame_start`. Sans le décalage on protégerait aussi la
+ // DERNIÈRE entrée de la frame précédente, qui n'appartient plus au jeu actif — le
+ // résident pourrait alors dépasser le budget d'une texture entière.
+ self.img_frame_start.set(self.img_tick.get() + 1);
+ }
+
+ /// Texture d'un fichier image, décodée une seule fois puis réutilisée.
+ ///
+ /// Le cache était NON BORNÉ, et c'est un vrai coût : les wallpapers livrés pèsent 23,7 Mo sur
+ /// disque mais 1 774 Mo une fois décodés en RGBA8 — `wallpaper8.jpg` fait 7680x7680, soit
+ /// 225 Mo à lui seul. Parcourir le sélecteur les chargeait tous et n'en libérait aucun.
+ ///
+ /// L'éviction est LRU sous un budget en octets, et ne touche jamais une texture que la frame
+ /// EN COURS a déjà servie : sans ça, un fond d'écran et un fond de caméra un peu gros se
+ /// chasseraient l'un l'autre à chaque frame, et un décodage coûte 129 ms contre les ~3,5 ms
+ /// d'une frame. Si le jeu actif dépasse à lui seul le budget, on dépasse le budget.
+ fn cached_image(&self, path: &str) -> Result<(metal::Texture, u32, u32)> {
+ let tick = self.img_tick.get() + 1;
+ self.img_tick.set(tick);
+ // 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 hit = self.img_cache.borrow().get(path).cloned();
+ if let Some((tex, w, h, _)) = hit {
+ self.img_cache.borrow_mut().insert(path.to_string(), (tex.clone(), w, h, tick));
+ return Ok((tex, w, h));
+ }
+ let (tex, w, h) = self.load_image_texture(path)?;
+ let mut cache = self.img_cache.borrow_mut();
+ cache.insert(path.to_string(), (tex.clone(), w, h, tick));
+ // La politique vit dans `frame_geometry` : les trois backends la partagent, comme la
+ // géométrie, plutôt que d'entretenir trois copies qui finiraient par diverger.
+ let entries: Vec<(String, u64, u64)> = cache
+ .iter()
+ .map(|(k, e)| (k.clone(), e.1 as u64 * e.2 as u64 * 4, e.3))
+ .collect();
+ let protect_from = self.img_frame_start.get();
+ for key in
+ crate::frame_geometry::lru_evictions(&entries, IMG_CACHE_BUDGET_BYTES, protect_from)
+ {
+ cache.remove(&key);
+ }
+ 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
@@ -793,17 +928,23 @@ impl Compositor {
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
- }
- };
+ self.draw_image_in(enc, path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, output_aspect)
+ }
+
+ /// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins
+ /// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop
+ /// « cover » se calcule contre la zone qu'on remplit.
+ #[allow(clippy::too_many_arguments)]
+ unsafe fn draw_image_in(
+ &self,
+ enc: &metal::RenderCommandEncoderRef,
+ path: &str,
+ dst: [f32; 4],
+ quad_px: [f32; 2],
+ radius_px: f32,
+ output_aspect: f32,
+ ) -> Result<()> {
+ let (tex, iw, ih) = self.cached_image(path)?;
let ai = iw as f32 / ih.max(1) as f32;
let ao = output_aspect;
let (u0, v0, u1, v1) = if ai > ao {
@@ -817,8 +958,10 @@ impl Compositor {
self.draw_solid(
enc,
&LayerCB {
- dst: [0.0, 0.0, 1.0, 1.0],
+ dst,
src: [u0, v0, u1, v1],
+ quad_px,
+ radius_px,
mode: 6.0,
..Default::default()
},
@@ -826,7 +969,72 @@ impl Compositor {
Ok(())
}
-
+ /// Peint le fond du mode « personnalisé » DANS la bulle webcam, avant que la caméra n'y soit
+ /// découpée par-dessus.
+ ///
+ /// Le shader ne sait peindre qu'une couleur plate sous le masque, donc un dégradé ou une
+ /// image y tombaient sur du noir — et le défaut EST une image (`DEFAULT_WALLPAPER`), si bien
+ /// que le mode ne rendait jamais ce que le sélecteur montrait. Peindre le fond puis composer
+ /// la caméra en détourage donne exactement le même résultat (`lerp(fond, caméra, personne)`,
+ /// ici par le mélange alpha) pour les trois sortes de fond, en réutilisant les chemins déjà
+ /// éprouvés du fond d'écran, et sans rien ajouter aux trois shaders.
+ ///
+ /// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit épouser ses coins arrondis,
+ /// sinon un rectangle déborde derrière la caméra.
+ unsafe fn draw_webcam_bg(
+ &self,
+ enc: &metal::RenderCommandEncoderRef,
+ bg: Option<&SceneBackground>,
+ dst: [f32; 4],
+ quad_px: [f32; 2],
+ radius_px: f32,
+ ) {
+ const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
+ let solid = |color: [f32; 4]| LayerCB {
+ dst,
+ quad_px,
+ radius_px,
+ mode: 1.0,
+ color,
+ ..Default::default()
+ };
+ match bg {
+ Some(SceneBackground::Color { color }) => {
+ self.draw_solid(enc, &solid(parse_hex(color).unwrap_or(BLACK)));
+ }
+ Some(SceneBackground::Gradient { angle_deg, stops }) => {
+ let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK);
+ let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0);
+ // angle CSS → direction unitaire, même convention que le fond d'écran.
+ let a = angle_deg.to_radians();
+ self.draw_solid(
+ enc,
+ &LayerCB {
+ dst,
+ src: [c1[0], c1[1], c1[2], c1[3]],
+ quad_px,
+ radius_px,
+ mode: 5.0,
+ color: c0,
+ fx: [a.sin(), -a.cos(), 0.0, 0.0],
+ ..Default::default()
+ },
+ );
+ }
+ Some(SceneBackground::Image { path }) => {
+ // Même contrat que le fond d'écran : un chemin cassé est loggé puis remplacé par
+ // du noir. Un fallback silencieux redonnerait le bug qu'on corrige.
+ let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 };
+ if let Err(e) = self.draw_image_in(enc, path, dst, quad_px, radius_px, aspect) {
+ eprintln!("[compositor] fond webcam \"{path}\" : {e:#}");
+ self.draw_solid(enc, &solid(BLACK));
+ }
+ }
+ // Personnalisé sans fond : noir, comme avant — mais c'est désormais le seul chemin
+ // qui y mène, au lieu de l'être pour toute image et tout dégradé.
+ None => self.draw_solid(enc, &solid(BLACK)),
+ }
+ }
/// 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`.
@@ -1208,6 +1416,363 @@ impl Compositor {
}
+ /// Extrait la frame webcam en RGB8 à la résolution du modèle, dans `out`.
+ ///
+ /// Pendant Metal de `compositor_windows::capture_webcam_rgb`, avec les mêmes contraintes
+ /// d'appel et une seule divergence de mécanique : là où D3D11 réquisitionne la cible du
+ /// contexte persistant, Metal ouvre une passe sur `SegCapture::rt` et la referme, donc
+ /// rien n'est « réquisitionné ». La contrainte d'ordre reste malgré tout : cette méthode
+ /// **doit tourner avant que le command buffer de composition ne soit créé**, parce
+ /// qu'elle attend son propre buffer et qu'attendre au milieu d'une frame sérialiserait
+ /// CPU et GPU sur exactement le chemin que cette conception veut garder recouvert.
+ ///
+ /// `src` est le rect source en UV. L'appelant y passe la frame ENTIÈRE et non le
+ /// sous-rect dessiné — cf. `pump_segmentation`.
+ ///
+ /// # Le readback
+ ///
+ /// Trois étapes, la forme prescrite par l'en-tête du module et déjà tenue par
+ /// `render_nv12` + `read_nv12_scaled` : rendu dans une cible `Private`, blit vers un
+ /// miroir `Shared`, `get_bytes`. Pas de `Managed`, donc pas de `synchronizeResource` —
+ /// c'est le seul mode de stockage qui l'exigerait, et rien dans ce fichier n'en utilise.
+ ///
+ /// Le buffer `out` est réutilisé d'un appel à l'autre : il est dimensionné au RGBA lu
+ /// puis compacté sur place en RGB, ce qui laisse sa capacité au maximum des deux et ne
+ /// réalloue donc plus après la première capture.
+ pub unsafe fn capture_webcam_rgb(
+ &self,
+ wy: &metal::Texture,
+ wuv: &metal::Texture,
+ src: [f32; 4],
+ width: u32,
+ height: u32,
+ out: &mut Vec,
+ ) -> Result<()> {
+ if width == 0 || height == 0 {
+ return Err(anyhow!(
+ "capture webcam de dimensions nulles ({width}x{height})"
+ ));
+ }
+ {
+ let mut slot = self.seg_capture.borrow_mut();
+ if !matches!(slot.as_ref(), Some(c) if c.width == width && c.height == height) {
+ *slot = Some(SegCapture {
+ rt: make_texture(
+ &self.gpu.device,
+ metal::MTLPixelFormat::RGBA8Unorm,
+ width,
+ height,
+ metal::MTLStorageMode::Private,
+ metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead,
+ ),
+ read: make_texture(
+ &self.gpu.device,
+ metal::MTLPixelFormat::RGBA8Unorm,
+ width,
+ height,
+ metal::MTLStorageMode::Shared,
+ metal::MTLTextureUsage::ShaderRead,
+ ),
+ width,
+ height,
+ });
+ }
+ }
+ let slot = self.seg_capture.borrow();
+ let cap = slot.as_ref().expect("créé juste au-dessus");
+
+ // Command buffer PROPRE, et surtout PAS `submit`/`sync` : `sync` attend `last_cmd`,
+ // et `read_nv12_scaled` compte sur `last_cmd` pour être le buffer de `render_nv12`.
+ // Le remplacer ici ferait attendre la capture au lieu de la conversion NV12, et le
+ // readback d'encodage lirait des plans que rien n'a encore écrits.
+ let cmd_buf = self.gpu.context.new_command_buffer();
+ {
+ // Plein cadre de la cible, sans coins ni motion blur : le modèle veut l'image,
+ // pas la mise en forme. `fx` reste à zéro — la branche de masque du shader ne
+ // doit surtout pas se prendre sur la capture qui l'alimente.
+ let enc = self.begin_pass(
+ cmd_buf,
+ &cap.rt,
+ Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)),
+ &self.pipeline_main,
+ )?;
+ self.draw_video(
+ enc,
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ src,
+ quad_px: [width as f32, height as f32],
+ mode: 0.0,
+ color: [0.0, 0.0, 0.0, 1.0],
+ mb: [1.0, 1.0, 1.0, 0.0],
+ ..Default::default()
+ },
+ wy,
+ wuv,
+ );
+ enc.end_encoding();
+ }
+ let blit = cmd_buf.new_blit_command_encoder();
+ blit.copy_from_texture(
+ &cap.rt,
+ 0,
+ 0,
+ metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ metal::MTLSize { width: width as u64, height: height as u64, depth: 1 },
+ &cap.read,
+ 0,
+ 0,
+ metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ );
+ blit.end_encoding();
+ cmd_buf.commit();
+ cmd_buf.wait_until_completed();
+
+ let (w, h) = (width as usize, height as usize);
+ out.resize(w * h * 4, 0);
+ cap.read.get_bytes(
+ out.as_mut_ptr() as *mut std::ffi::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,
+ );
+ // RGBA → RGB sur place : le modèle n'a pas de canal alpha en entrée. La destination
+ // (`3i`) court derrière la source (`4i`), donc aucune écriture n'écrase un octet pas
+ // encore lu.
+ for i in 0..w * h {
+ let (r, g, b) = (out[i * 4], out[i * 4 + 1], out[i * 4 + 2]);
+ out[i * 3] = r;
+ out[i * 3 + 1] = g;
+ out[i * 3 + 2] = b;
+ }
+ out.truncate(w * h * 3);
+ Ok(())
+ }
+
+ /// Publie le masque de segmentation du sujet webcam (R8, `width`x`height`, 0 = fond).
+ ///
+ /// La texture est `Shared` et réécrite en place par `replace_region` ; elle n'est
+ /// recréée que si la résolution du modèle change, ce qui n'arrive pas en régime établi.
+ ///
+ /// Réécrire une texture que le GPU pourrait encore lire serait une course — ici il ne
+ /// le peut pas : les trois chemins de frame macOS drainent la file avant de rendre la
+ /// main (`readback_direct` et `rgb_to_nv12` font `submit` + `sync`, `read_nv12_scaled`
+ /// fait `sync`), donc plus rien n'est en vol quand `compose_frame` rappelle
+ /// `pump_segmentation`. C'est ce qui dispense d'un double buffer, pas la chance.
+ pub fn set_webcam_mask(&self, data: &[u8], width: u32, height: u32) -> Result<()> {
+ if width == 0 || height == 0 {
+ return Err(anyhow!("masque webcam de dimensions nulles ({width}x{height})"));
+ }
+ let expected = (width as usize) * (height as usize);
+ if data.len() < expected {
+ return Err(anyhow!(
+ "masque webcam trop court : {} octets pour {width}x{height}",
+ data.len()
+ ));
+ }
+
+ let mut slot = self.webcam_mask.borrow_mut();
+ if !matches!(slot.as_ref(), Some(m) if m.width == width && m.height == height) {
+ *slot = Some(WebcamMask {
+ tex: make_texture(
+ &self.gpu.device,
+ metal::MTLPixelFormat::R8Unorm,
+ width,
+ height,
+ metal::MTLStorageMode::Shared,
+ metal::MTLTextureUsage::ShaderRead,
+ ),
+ width,
+ height,
+ });
+ }
+ let mask = slot.as_ref().expect("alloué juste au-dessus");
+ mask.tex.replace_region(
+ metal::MTLRegion {
+ origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ size: metal::MTLSize { width: width as u64, height: height as u64, depth: 1 },
+ },
+ 0,
+ data.as_ptr() as *const std::ffi::c_void,
+ width as u64,
+ );
+ Ok(())
+ }
+
+ /// Un tour de segmentation : téléverse le masque prêt, puis soumet une nouvelle frame si
+ /// la cadence l'autorise. Port de `compositor_windows::pump_segmentation` — worker,
+ /// boîte aux lettres, limiteur de cadence et démarrage paresseux sont indépendants de la
+ /// plateforme, seuls les deux appels GPU changent.
+ ///
+ /// Les deux moitiés sont volontairement désynchronisées. Le masque téléversé ici vient de
+ /// la frame précédente — une frame de retard sur une silhouette est invisible, alors
+ /// qu'attendre l'inférence bloquerait le rendu, ce qui est exactement le coût que toute
+ /// cette conception cherche à ne pas payer.
+ unsafe fn pump_segmentation(
+ &self,
+ wy: &metal::Texture,
+ wuv: &metal::Texture,
+ valid: [f32; 2],
+ ) -> Result<()> {
+ if *self.seg_failed.borrow() {
+ return Ok(());
+ }
+ // Rien à faire si aucun effet n'est demandé : ni capture, ni inférence, ni masque.
+ // Le coût de la fonctionnalité est alors exactement nul.
+ let (wants_effect, model_path) = {
+ let scene = self.scene.borrow();
+ match scene.as_ref().and_then(|s| s.webcam_effect.as_ref()) {
+ Some(e) if e.shader_code() > 0.0 => (true, e.model_path.clone()),
+ _ => (false, None),
+ }
+ };
+ if !wants_effect {
+ return Ok(());
+ }
+
+ // Démarrage paresseux, piloté par la scène : personne n'a à appeler
+ // `enable_segmentation` à la main, et un modèle introuvable éteint l'effet au lieu
+ // de faire tomber le rendu.
+ if self.seg_worker.borrow().is_none() && self.seg_sync.borrow().is_none() {
+ let Some(path) = model_path else { return Ok(()) };
+ if let Err(e) = self.enable_segmentation(std::path::Path::new(&path)) {
+ eprintln!("[segmentation] désactivée : {e}");
+ // Une scène qui reste identique retenterait à chaque frame ; on lève le
+ // verrou plutôt que de journaliser 60 fois par seconde.
+ *self.seg_failed.borrow_mut() = true;
+ return Ok(());
+ }
+ // En preview on rend cette frame sans masque : le worker vient de démarrer et
+ // l'effet apparaîtra dans quelques millisecondes, ce que personne ne voit. À
+ // l'export cette frame part dans le fichier — on enchaîne donc sur la capture et
+ // l'inférence plutôt que de la laisser sortir non détourée.
+ if !self.seg_deterministic.get() {
+ return Ok(());
+ }
+ }
+
+ if let Some(mask) = self.seg_inbox.lock().unwrap().take() {
+ self.set_webcam_mask(
+ &mask,
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ )?;
+ }
+
+ // La cadence horloge est le bon réglage en preview et le mauvais à l'export, où les
+ // frames défilent aussi vite que la machine décode : le nombre de frames couvertes par
+ // un masque dépendrait alors de la charge. En déterministe, une inférence par frame.
+ if !self.seg_deterministic.get()
+ && !self.seg_rate.borrow_mut().should_run(std::time::Instant::now())
+ {
+ return Ok(());
+ }
+ let mut scratch = self.seg_scratch.borrow_mut();
+ // La frame ENTIÈRE, pas le sous-rect dessiné : un crop utilisateur serré amputerait
+ // le sujet en entrée du modèle, et le masque serait faux là où il compte le plus.
+ // Le shader ramène ses coordonnées dans cet espace via `fx.xy`.
+ self.capture_webcam_rgb(
+ wy,
+ wuv,
+ [0.0, 0.0, valid[0], valid[1]],
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ &mut scratch,
+ )?;
+ if self.seg_deterministic.get() {
+ // Synchrone : le masque doit exister avant que cette frame ne soit composée, sinon
+ // on retombe sur le défaut qu'on corrige. Une inférence ratée laisse le masque
+ // précédent, comme le fait le worker.
+ let mut sync = self.seg_sync.borrow_mut();
+ if let Some(seg) = sync.as_mut() {
+ match seg.run(&scratch) {
+ Ok(mask) => {
+ let mask = mask.to_vec();
+ drop(sync);
+ self.set_webcam_mask(
+ &mask,
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ )?;
+ }
+ Err(e) => eprintln!("[segmentation] frame ignorée : {e}"),
+ }
+ }
+ } else if let Some(w) = self.seg_worker.borrow().as_ref() {
+ w.submit(&scratch);
+ }
+ Ok(())
+ }
+
+ /// Démarre la segmentation du sujet webcam pour ce compositeur.
+ ///
+ /// Idempotent. Tant qu'elle n'est pas appelée, `compose_frame` ne fait rien de plus et
+ /// la webcam se dessine comme avant — c'est ce qui rend l'effet inerte plutôt que cassé
+ /// sur une build sans modèle.
+ pub fn enable_segmentation(&self, model_path: &std::path::Path) -> Result<()> {
+ if self.seg_worker.borrow().is_some() || self.seg_sync.borrow().is_some() {
+ return Ok(());
+ }
+ let segmenter = crate::segmentation::Segmenter::load(model_path)?;
+ // En déterministe, le segmenteur reste ici : l'inférence tourne sur le thread de rendu,
+ // donc le masque de la frame N est prêt AVANT qu'elle ne soit composée. Le worker est un
+ // choix de preview — ne jamais bloquer l'affichage — et c'est exactement ce qui rend
+ // l'export irreproductible, le masque arrivant quelques frames plus tard selon la charge.
+ if self.seg_deterministic.get() {
+ *self.seg_sync.borrow_mut() = Some(segmenter);
+ return Ok(());
+ }
+ let inbox = std::sync::Arc::clone(&self.seg_inbox);
+ let worker = crate::segmentation::SegmentationWorker::spawn(segmenter, move |mask, _, _| {
+ // Écrase le masque précédent s'il n'a pas encore été téléversé : c'est le plus
+ // récent qui vaut, jamais une file.
+ *inbox.lock().unwrap() = Some(mask.to_vec());
+ });
+ *self.seg_worker.borrow_mut() = Some(worker);
+ Ok(())
+ }
+
+ /// Bascule la segmentation en mode reproductible, pour l'export.
+ ///
+ /// En preview, la cadence suit l'horloge (30 Hz réels) et l'inférence tourne sur un worker :
+ /// c'est le bon choix, l'affichage ne doit jamais attendre. À l'export les frames sont rendues
+ /// aussi vite que la machine décode, sans rapport avec le temps réel — et ces deux choix
+ /// deviennent alors des bugs. La cadence horloge fait dépendre le nombre de frames couvertes
+ /// par un masque de la vitesse de la machine, et le worker asynchrone rend les premières
+ /// frames AVANT que le premier masque n'existe : elles partent dans le fichier avec le vrai
+ /// arrière-plan de la webcam. Deux exports du même projet ne donnent donc pas les mêmes
+ /// pixels, ce qui casse l'invariant « l'export est identique à la preview ».
+ ///
+ /// En déterministe : une inférence PAR FRAME, synchrone. Plus coûteux (~3 ms/frame), mais
+ /// l'export est hors ligne et chaque frame porte le masque calculé depuis SA propre image.
+ ///
+ /// À appeler avant la première frame — c'est ce qui décide comment `enable_segmentation`
+ /// s'installe.
+ pub fn set_segmentation_deterministic(&self, on: bool) {
+ if self.seg_deterministic.get() == on {
+ return;
+ }
+ self.seg_deterministic.set(on);
+ // Changer de mode change le MOTEUR, et `enable_segmentation` est idempotent sur la
+ // PRÉSENCE d'un moteur : sans démonter celui qui ne correspond plus, le drapeau mentirait.
+ // Un compositeur qui a déjà servi en preview garderait son worker, `seg_sync` resterait
+ // vide, et l'export entier ne ferait AUCUNE inférence. Le démarrage paresseux de
+ // `pump_segmentation` réinstalle le bon moteur à la frame suivante.
+ *self.seg_worker.borrow_mut() = None;
+ *self.seg_sync.borrow_mut() = None;
+ // Et le masque que le worker démonté avait peut-être déjà déposé : il vient de l'autre
+ // mode, il n'a rien à faire sur la première frame de celui-ci.
+ *self.seg_inbox.lock().unwrap() = None;
+ }
+
+ /// Éteint l'effet : la webcam se redessine telle quelle à la frame suivante.
+ pub fn clear_webcam_mask(&self) {
+ *self.webcam_mask.borrow_mut() = None;
+ }
+
/// Soumet sans attendre, et retient le buffer pour `sync`.
fn submit(&self, cmd: &metal::CommandBufferRef) {
cmd.commit();
@@ -1264,15 +1829,7 @@ impl Compositor {
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 (tex, iw, ih) = self.cached_image(sprite.path.as_str())?;
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) };
@@ -1373,6 +1930,7 @@ impl Compositor {
frame: f32,
cfg: &Cfg,
) -> Result<()> {
+ self.begin_image_frame();
if Self::pixel_buffer_of(screen).is_none() {
return self.clear_rt();
}
@@ -1391,6 +1949,19 @@ impl Compositor {
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);
+ // Étendue valide de la texture webcam : les décodeurs allouent des textures alignées,
+ // donc la frame n'occupe pas forcément toute la texture. `.max(1)` au dénominateur —
+ // `tex_dims` rend (0, 0) sur une webcam absente, là où le chemin Windows divise sans
+ // garde parce qu'il a toujours les deux frames.
+ let w_valid = [wcw / (wtw.max(1)) as f32, wch / (wth.max(1)) as f32];
+
+ // Segmentation, AVANT d'ouvrir le command buffer de composition : `capture_webcam_rgb`
+ // attend son propre buffer, et attendre au milieu de la frame sérialiserait CPU et GPU.
+ // Dernier point où `wtw/wth/wcw/wch` sont en portée sans emprunt de `self.scene` —
+ // `pump_segmentation` emprunte la scène lui-même.
+ if let Some((wy, wuv)) = webcam_tex.as_ref() {
+ self.pump_segmentation(wy, wuv, w_valid)?;
+ }
let scene_ref = self.scene.borrow();
let cursor_ref = self.cursor.borrow();
@@ -1531,7 +2102,7 @@ impl Compositor {
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],
+ mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
},
&sy,
@@ -1580,10 +2151,10 @@ impl Compositor {
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;
+ let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps);
+ e.set_blend_color(w, w, w, w);
self.draw_cur_themed(
e,
&sprites,
@@ -1618,7 +2189,25 @@ impl Compositor {
g.scene_preset.as_deref(),
Some("dual-frame") | Some("vertical-stack")
);
- if cfg.shadow && !webcam_is_block && g.shape_fade > 0.0 {
+ // Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de
+ // l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam
+ // invisible en détourage, donc tant que rien n'a été segmenté on dessine la
+ // piste telle quelle. C'est aussi ce qui rend le premier lancement gracieux.
+ let mask = self.webcam_mask.borrow();
+ let effect = scene_ref
+ .as_ref()
+ .and_then(|s| s.webcam_effect.as_ref())
+ .filter(|_| mask.is_some())
+ .map(|e| (e.shader_code(), e))
+ .filter(|(code, _)| *code > 0.0);
+
+ // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une
+ // ombre portée par un rectangle invisible se lit comme un artefact. Le test porte
+ // sur le code de la SCÈNE et non sur celui envoyé au shader : le fond personnalisé
+ // part lui aussi en détourage ci-dessous, mais sa bulle, elle, est bien peinte et
+ // garde donc son ombre.
+ let is_cutout = matches!(effect, Some((code, _)) if code == 1.0);
+ if cfg.shadow && !webcam_is_block && !is_cutout && g.shape_fade > 0.0 {
self.draw_shadow(
enc,
g.w_dst,
@@ -1629,6 +2218,35 @@ impl Compositor {
WEBCAM_SHADOW_OPACITY * g.shape_fade,
);
}
+
+ // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra
+ // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement
+ // ce que la branche « mode 3 » du shader calculait, mais pour les TROIS sortes de
+ // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et
+ // images y tombaient sur du noir, et le défaut EST une image. L'ordre est imposé :
+ // ombre, puis fond, puis caméra.
+ let (effect_code, blur_intensity) = match effect {
+ Some((code, e)) if code > 2.5 => {
+ self.draw_webcam_bg(enc, e.background.as_ref(), g.w_dst, g.w_px, g.w_radius);
+ (1.0, 0.0)
+ }
+ Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)),
+ None => (0.0, 0.0),
+ };
+
+ // Metal tolère l'index 3 non lié tant que `fx.z` reste à 0 : la branche n'est
+ // pas prise, la texture n'est pas échantillonnée. Dès qu'il monte, elle doit
+ // l'être sur TOUT draw capable de la prendre — ici il n'y en a qu'un. L'état
+ // d'un encodeur est rémanent, donc lier avant le draw suffit, et l'ombre puis le
+ // fond qui précèdent sont en modes 1/2/5/6, que `ps_main` garde hors de la branche
+ // (`mode < 0.5`).
+ //
+ // Pas de déliaison après coup, contrairement au chemin Windows qui remet le slot
+ // t3 à `None` : cet état meurt avec l'encodeur, et les annotations en ouvrent un
+ // autre. Il n'y a rien sur quoi fuir.
+ if let Some(m) = mask.as_ref() {
+ enc.set_fragment_texture(3, Some(&m.tex));
+ }
self.draw_video(
enc,
&LayerCB {
@@ -1637,10 +2255,13 @@ impl Compositor {
quad_px: g.w_px,
radius_px: g.w_radius,
mode: 0.0,
+ // `color.a` porte l'alpha du découpage (`color.a * personne`) ; le RGB n'est
+ // plus lu, le fond ayant déjà été peint sous la caméra.
color: [0.0, 0.0, 0.0, 1.0],
+ fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
src_prev: [u0, cv0, u1, cv1],
dst_prev: g.w_dst_prev,
- mb: [g.mb_taps, 1.0, 1.0, 0.0],
+ mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
},
wy,
@@ -1737,22 +2358,30 @@ impl Compositor {
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();
+ {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::Nv12Passes);
+ 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();
+ }
+ self.submit(cmd_buf);
}
// 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();
+ // L'attente porte sur TOUT le travail GPU de la frame, composition comprise :
+ // `compose_frame` n'a fait que soumettre.
+ {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::GpuWait);
+ self.sync();
+ }
Ok(())
}
@@ -1935,7 +2564,718 @@ impl Compositor {
#[cfg(test)]
mod tests {
-
+ use super::*;
+
+ // -----------------------------------------------------------------------
+ // Segmentation du sujet webcam
+ //
+ // Il n'y a PAS de banc hors Windows : `poc-d3d` est `cfg(windows)` dans son propre
+ // `Cargo.toml`, donc le `--cfg C8 --scene …` qui a prouvé le chemin Windows n'existe
+ // pas ici. Ce sont ces tests qui tiennent le rôle, et ils rendent de vrais pixels sur
+ // le device Metal du système plutôt que d'inspecter des champs : ce que le portage
+ // ajoute (une capture relue, un upload R8, une liaison à l'index 3, une branche
+ // `fx.z`) est précisément ce qu'aucun `cargo build` ne peut vérifier.
+ // -----------------------------------------------------------------------
+
+ /// Luma BT.709 limited d'un gris neutre : `yuv709_limited` fait `(Y - 16) / 219` sur
+ /// les trois canaux quand la chroma vaut 128, donc 235 rend du blanc franc et 16 du
+ /// noir franc. Ces deux valeurs rendent les assertions de couleur calculables à la main.
+ const Y_WHITE: u8 = 235;
+ const Y_BLACK: u8 = 16;
+ const UV_NEUTRAL: u8 = 128;
+
+ fn region(w: u32, h: u32) -> metal::MTLRegion {
+ metal::MTLRegion {
+ origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ size: metal::MTLSize { width: w as u64, height: h as u64, depth: 1 },
+ }
+ }
+
+ /// Une paire de plans NV12 synthétiques, sous forme de `MTLTexture` — ce que
+ /// `nv12_srvs` produirait d'une vraie frame, sans avoir à décoder quoi que ce soit.
+ fn nv12_textures(
+ device: &metal::Device,
+ w: u32,
+ h: u32,
+ luma: impl Fn(u32, u32) -> u8,
+ ) -> (metal::Texture, metal::Texture) {
+ let y = make_texture(
+ device,
+ metal::MTLPixelFormat::R8Unorm,
+ w,
+ h,
+ metal::MTLStorageMode::Shared,
+ metal::MTLTextureUsage::ShaderRead,
+ );
+ let mut plane = vec![0u8; (w * h) as usize];
+ for row in 0..h {
+ for col in 0..w {
+ plane[(row * w + col) as usize] = luma(col, row);
+ }
+ }
+ y.replace_region(region(w, h), 0, plane.as_ptr() as *const std::ffi::c_void, w as u64);
+
+ let (uw, uh) = (w / 2, h / 2);
+ let uv = make_texture(
+ device,
+ metal::MTLPixelFormat::RG8Unorm,
+ uw,
+ uh,
+ metal::MTLStorageMode::Shared,
+ metal::MTLTextureUsage::ShaderRead,
+ );
+ let chroma = vec![UV_NEUTRAL; (uw * uh * 2) as usize];
+ uv.replace_region(
+ region(uw, uh),
+ 0,
+ chroma.as_ptr() as *const std::ffi::c_void,
+ (uw * 2) as u64,
+ );
+ (y, uv)
+ }
+
+ /// Masque 0 sur la moitié gauche, 255 sur la droite. La frontière tombe pile au milieu,
+ /// donc un échantillon pris au quart et un aux trois quarts sont loin du dégradé que le
+ /// filtrage linéaire pose sur la couture.
+ fn half_mask(w: u32, h: u32) -> Vec {
+ (0..w * h).map(|i| if i % w < w / 2 { 0u8 } else { 255u8 }).collect()
+ }
+
+ #[test]
+ fn the_webcam_capture_comes_back_as_interleaved_rgb_at_model_resolution() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ // Moitié gauche noire, moitié droite blanche : la capture doit rendre les deux dans
+ // le bon sens. Une inversion d'axe passerait un test de taille sans se voir.
+ let (y, uv) = nv12_textures(&gpu.device, 64, 64, |col, _| {
+ if col < 32 { Y_BLACK } else { Y_WHITE }
+ });
+
+ let mut out = Vec::new();
+ unsafe {
+ comp.capture_webcam_rgb(
+ &y,
+ &uv,
+ [0.0, 0.0, 1.0, 1.0],
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ &mut out,
+ )
+ .expect("capture_webcam_rgb");
+ }
+
+ let (w, h) = (
+ crate::segmentation::MODEL_WIDTH as usize,
+ crate::segmentation::MODEL_HEIGHT as usize,
+ );
+ assert_eq!(out.len(), w * h * 3, "le modèle veut du RGB8 entrelacé, sans alpha");
+
+ let px = |buf: &[u8], col: usize, row: usize| -> [u8; 3] {
+ let i = (row * w + col) * 3;
+ [buf[i], buf[i + 1], buf[i + 2]]
+ };
+ let left = px(&out, w / 4, h / 2);
+ let right = px(&out, 3 * w / 4, h / 2);
+ assert!(left.iter().all(|&c| c < 24), "moitié gauche pas noire : {left:?}");
+ assert!(right.iter().all(|&c| c > 231), "moitié droite pas blanche : {right:?}");
+
+ // Deuxième capture sur le même buffer : c'est le régime établi (30 fois par
+ // seconde), et il ne doit ni réallouer ni traîner les octets du tour précédent.
+ let capacity = out.capacity();
+ unsafe {
+ comp.capture_webcam_rgb(
+ &y,
+ &uv,
+ [0.0, 0.0, 1.0, 1.0],
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ &mut out,
+ )
+ .expect("deuxième capture");
+ }
+ assert_eq!(out.len(), w * h * 3);
+ assert_eq!(out.capacity(), capacity, "le scratch se réalloue d'une frame à l'autre");
+ assert_eq!(px(&out, w / 4, h / 2), left);
+ assert_eq!(px(&out, 3 * w / 4, h / 2), right);
+ }
+
+ #[test]
+ fn a_capture_of_zero_size_is_refused_rather_than_rendered() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let (y, uv) = nv12_textures(&gpu.device, 16, 16, |_, _| Y_WHITE);
+ let mut out = Vec::new();
+ let err = unsafe { comp.capture_webcam_rgb(&y, &uv, [0.0, 0.0, 1.0, 1.0], 0, 144, &mut out) };
+ assert!(err.is_err(), "une cible de largeur nulle doit être refusée");
+ }
+
+ #[test]
+ fn the_mask_texture_is_allocated_once_and_a_short_buffer_is_refused() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let (w, h) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT);
+ let mask = vec![255u8; (w * h) as usize];
+
+ comp.set_webcam_mask(&mask, w, h).expect("premier téléversement");
+ let first = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.as_ptr());
+ comp.set_webcam_mask(&mask, w, h).expect("deuxième téléversement");
+ let second = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.as_ptr());
+ assert_eq!(
+ first, second,
+ "la texture est recréée à chaque frame alors que la résolution du modèle est fixe"
+ );
+
+ // Un masque trop court doit être refusé, pas lu hors bornes : `replace_region` lit
+ // `width` octets par ligne sans rien savoir de la longueur de la tranche.
+ assert!(comp.set_webcam_mask(&mask[..(w * h) as usize - 1], w, h).is_err());
+ assert!(comp.set_webcam_mask(&mask, 0, h).is_err());
+ assert!(comp.clear_webcam_mask() == () && comp.webcam_mask.borrow().is_none());
+ }
+
+ /// Le test qui compte : le masque DÉCOUPE vraiment la caméra.
+ ///
+ /// Il rend le calque webcam plein cadre sur le RT avec `fx.z = 1` (détourage) et un
+ /// masque mi-fond mi-sujet, puis relit les pixels. Il couvre d'un coup les trois choses
+ /// que le portage ajoute et qu'aucune compilation ne vérifie : l'upload R8, la liaison
+ /// de la texture à l'index 3, et la branche `fx.z` de `ps_main` sur un vrai device.
+ #[test]
+ fn the_mask_actually_cuts_the_camera_out() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized");
+ comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask");
+ let (y, uv) = nv12_textures(&gpu.device, 16, 16, |_, _| Y_WHITE);
+
+ // Fond bleu franc : une couleur que la caméra (blanche, chroma neutre) ne peut pas
+ // produire, donc « il reste du bleu » signifie « la caméra a été découpée ici ».
+ let cmd = gpu.context.new_command_buffer();
+ let enc = comp
+ .begin_pass(
+ cmd,
+ &comp.rt,
+ Some(metal::MTLClearColor::new(0.0, 0.0, 1.0, 1.0)),
+ &comp.pipeline_main,
+ )
+ .expect("begin_pass");
+ {
+ let mask = comp.webcam_mask.borrow();
+ enc.set_fragment_texture(3, Some(&mask.as_ref().expect("masque posé").tex));
+ }
+ unsafe {
+ comp.draw_video(
+ enc,
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ src: [0.0, 0.0, 1.0, 1.0],
+ quad_px: [64.0, 64.0],
+ mode: 0.0,
+ color: [0.0, 0.0, 0.0, 1.0],
+ // fx.xy = étendue valide (toute la texture ici), fx.z = 1 → détourage.
+ fx: [1.0, 1.0, 1.0, 0.0],
+ src_prev: [0.0, 0.0, 1.0, 1.0],
+ dst_prev: [0.0, 0.0, 1.0, 1.0],
+ mb: [1.0, 1.0, 1.0, 0.0],
+ ..Default::default()
+ },
+ &y,
+ &uv,
+ );
+ }
+ enc.end_encoding();
+ comp.submit(cmd);
+ let (rw, rh, rgba) = unsafe { comp.readback_direct().expect("readback_direct") };
+ assert_eq!((rw, rh), (64, 64));
+
+ let px = |col: usize, row: usize| -> [u8; 4] {
+ let i = (row * rw as usize + col) * 4;
+ [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
+ };
+ let cut = px(16, 32);
+ let kept = px(48, 32);
+ assert_eq!(cut, [0, 0, 255, 255], "masque à 0 : le fond doit rester visible");
+ assert_eq!(kept, [255, 255, 255, 255], "masque à 255 : la caméra doit rester opaque");
+ }
+
+ /// Même montage, mode fond personnalisé (`fx.z = 3`) : là où le masque dit « fond », le
+ /// shader doit peindre `color` — c'est le seul mode où `LayerCB::color` cesse d'être
+ /// du noir opaque décoratif et porte une valeur que le portage doit transmettre.
+ #[test]
+ fn the_custom_background_colour_replaces_the_masked_out_pixels() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized");
+ comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask");
+ let (y, uv) = nv12_textures(&gpu.device, 16, 16, |_, _| Y_WHITE);
+
+ let cmd = gpu.context.new_command_buffer();
+ let enc = comp
+ .begin_pass(
+ cmd,
+ &comp.rt,
+ Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)),
+ &comp.pipeline_main,
+ )
+ .expect("begin_pass");
+ {
+ let mask = comp.webcam_mask.borrow();
+ enc.set_fragment_texture(3, Some(&mask.as_ref().expect("masque posé").tex));
+ }
+ unsafe {
+ comp.draw_video(
+ enc,
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ src: [0.0, 0.0, 1.0, 1.0],
+ quad_px: [64.0, 64.0],
+ mode: 0.0,
+ color: [1.0, 0.0, 0.0, 1.0],
+ fx: [1.0, 1.0, 3.0, 0.0],
+ src_prev: [0.0, 0.0, 1.0, 1.0],
+ dst_prev: [0.0, 0.0, 1.0, 1.0],
+ mb: [1.0, 1.0, 1.0, 0.0],
+ ..Default::default()
+ },
+ &y,
+ &uv,
+ );
+ }
+ enc.end_encoding();
+ comp.submit(cmd);
+ let (rw, _, rgba) = unsafe { comp.readback_direct().expect("readback_direct") };
+ let px = |col: usize, row: usize| -> [u8; 4] {
+ let i = (row * rw as usize + col) * 4;
+ [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
+ };
+ assert_eq!(px(16, 32), [255, 0, 0, 255], "fond masqué : la couleur custom doit peindre");
+ assert_eq!(px(48, 32), [255, 255, 255, 255], "sujet : la caméra doit rester intacte");
+ }
+
+
+ // -----------------------------------------------------------------------
+ // `compose_frame` de bout en bout
+ //
+ // Les tests ci-dessus prouvent les pièces ; ceux-ci prouvent le CÂBLAGE — que
+ // `compose_frame` porte bien `fx`/`color` sur le calque webcam, qu'il lie le masque, et
+ // qu'il ne lève `fx.z` qu'une fois un masque réellement téléversé. Ils passent par de
+ // vraies `AVFrame` VideoToolbox (des `CVPixelBufferRef` IOSurface-backed), donc par le
+ // MÊME `nv12_srvs` que le décodeur : aucun raccourci n'est pris sur le seam de frame.
+ //
+ // Aucun n'a besoin d'ONNX Runtime : le masque est posé à la main par `set_webcam_mask`.
+ // C'est délibéré — ce que le portage ajoute côté GPU doit être vérifiable là où
+ // l'inférence n'est pas installée, ce qui est le cas de la CI.
+ // -----------------------------------------------------------------------
+
+ /// Une `AVFrame` VideoToolbox synthétique. `compose_frame` ne lit que `format`,
+ /// `data[3]`, `width` et `height` : le reste peut rester à zéro.
+ struct FakeFrame {
+ frame: Box,
+ _pb: crate::mac_frames::CVPixelBufferRef,
+ }
+
+ impl FakeFrame {
+ fn new(w: u32, h: u32, luma: impl Fn(u32, u32) -> u8) -> FakeFrame {
+ let mut y = vec![0u8; (w * h) as usize];
+ for row in 0..h {
+ for col in 0..w {
+ y[(row * w + col) as usize] = luma(col, row);
+ }
+ }
+ FakeFrame::from_planes(w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize])
+ }
+
+ fn from_planes(w: u32, h: u32, y: &[u8], uv: &[u8]) -> FakeFrame {
+ let pb = crate::mac_frames::nv12_pixel_buffer_from_planes(w, h, y, uv)
+ .expect("CVPixelBuffer NV12");
+ let mut frame: Box = Box::new(unsafe { std::mem::zeroed() });
+ frame.format = crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX as i32;
+ frame.data[3] = pb.as_ptr() as *mut u8;
+ frame.width = w as i32;
+ frame.height = h as i32;
+ FakeFrame { frame, _pb: pb }
+ }
+
+ fn as_ptr(&self) -> *const AVFrame {
+ &*self.frame as *const AVFrame
+ }
+ }
+
+ /// Scène PiP minimale. `effect` est le JSON de `webcamEffect` (`"null"` pour aucun).
+ ///
+ /// `effects.shadow` vaut 0 À DESSEIN : ce curseur ne pilote plus que l'ombre de l'écran,
+ /// alors que celle du PiP est fixe (`WEBCAM_SHADOW_OPACITY`) et ne dépend que de
+ /// `cfg.shadow`. Le mettre à zéro est donc ce qui isole les deux — sinon un test sur
+ /// `cfg.shadow` mesure les deux ombres à la fois et ne dit plus rien de la caméra.
+ fn pip_scene_json(effect: &str) -> String {
+ format!(
+ r##"{{"clips":[],
+ "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle",
+ "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},
+ "effects":{{"padding":0.18,"blur":false,"shadow":0,"roundnessFrac":0.05,"motionBlur":0}},
+ "background":{{"kind":"color","color":"#0080ff"}},
+ "zoomRegions":[],"annotations":[],
+ "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,
+ "clipToBounds":false,"theme":"default"}},
+ "cropByClip":[],
+ "webcamEffect":{effect},
+ "output":{{"width":1920,"height":1080,"fps":30}}}}"##
+ )
+ }
+
+ /// Compose une frame et rend le RGBA du RT. `screen` est gris moyen, `webcam` blanche :
+ /// le blanc franc devient alors la SIGNATURE de la caméra, une couleur qu'aucun autre
+ /// calque de cette scène ne produit, donc comptable sans connaître la géométrie du PiP.
+ ///
+ /// Le fond est un bleu franc et NON du noir : le PiP par défaut tombe dans la marge, hors
+ /// de l'écran, et une ombre noire sur un fond noir ne se voit pas — le contrôle du test
+ /// d'ombre passerait alors pour une suppression réussie.
+ fn compose_pip(comp: &super::Compositor, effect: &str, shadow: bool) -> Vec {
+ let scene = crate::scene::Scene::from_json(&pip_scene_json(effect)).expect("scene json");
+ comp.set_live_params(live_params_from_scene(&scene));
+ comp.set_has_webcam(true);
+ comp.set_scene(Some(scene));
+
+ let screen = FakeFrame::new(128, 128, |_, _| 126);
+ let webcam = FakeFrame::new(64, 64, |_, _| Y_WHITE);
+ let mut cfg = crate::config::Cfg::c8();
+ cfg.bg_blur = false;
+ cfg.zoom = false;
+ cfg.layout_anim = false;
+ cfg.cursor = false;
+ cfg.mblur_n = 1;
+ cfg.shadow = shadow;
+ unsafe {
+ comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg)
+ .expect("compose_frame");
+ let (_, _, rgba) = comp.readback_direct().expect("readback_direct");
+ rgba
+ }
+ }
+
+ /// Pixels quasi blancs = pixels de caméra encore visibles.
+ fn camera_pixels(rgba: &[u8]) -> usize {
+ rgba.chunks_exact(4)
+ .filter(|px| px[0] > 240 && px[1] > 240 && px[2] > 240)
+ .count()
+ }
+
+ const NO_EFFECT: &str = "null";
+ const CUTOUT: &str = r#"{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":null}"#;
+
+ /// Le piège que le brief nomme : un mode SANS masque ne doit rien changer.
+ ///
+ /// `effect_code` doit rester à 0 tant que rien n'a été segmenté, sinon le détourage rend
+ /// une webcam invisible sur les premières frames — le temps que l'inférence rende son
+ /// premier masque, c'est-à-dire à chaque ouverture de l'éditeur. L'assertion est
+ /// octet pour octet : « inchangé » ne souffre pas d'à-peu-près.
+ #[test]
+ fn a_mode_without_a_mask_composites_exactly_like_no_effect_at_all() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let plain = compose_pip(&comp, NO_EFFECT, true);
+ let requested = compose_pip(&comp, CUTOUT, true);
+ assert!(
+ comp.webcam_mask.borrow().is_none(),
+ "aucun masque n'a été téléversé : `modelPath` est absent, donc rien ne segmente"
+ );
+ assert!(camera_pixels(&plain) > 200, "la caméra n'est pas à l'écran, le test ne prouve rien");
+ assert_eq!(plain, requested, "un mode sans masque a changé des pixels");
+ }
+
+ /// Et une fois le masque là, le détourage doit VRAIMENT découper — dans la bonne
+ /// proportion. Le masque couvre la moitié de la caméra, donc la moitié de ses pixels
+ /// doit disparaître. Compter plutôt que d'échantillonner un point évite de coder en dur
+ /// la géométrie du PiP, qui appartient à `plan_frame` et non à ce portage.
+ #[test]
+ fn compose_frame_cuts_the_camera_out_once_a_mask_exists() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let whole = camera_pixels(&compose_pip(&comp, NO_EFFECT, true));
+ assert!(whole > 200, "la caméra n'est pas à l'écran, le test ne prouve rien");
+
+ let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT);
+ comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask");
+ let cut = camera_pixels(&compose_pip(&comp, CUTOUT, true));
+
+ let expected = whole as f32 / 2.0;
+ assert!(
+ (cut as f32 - expected).abs() < expected * 0.15,
+ "détourage : {cut} pixels de caméra restants pour ~{expected:.0} attendus \
+ (entier : {whole})"
+ );
+ }
+
+ /// L'ombre portée du PiP doit disparaître en détourage : une ombre projetée par un
+ /// rectangle devenu invisible se lit comme un artefact. Le test le prouve sans jamais
+ /// localiser l'ombre — en détourage, `cfg.shadow` ne doit plus rien changer du tout.
+ ///
+ /// Le contrôle est ce qui empêche l'assertion d'être vide : sans effet, `cfg.shadow`
+ /// DOIT changer des pixels, sinon la première moitié passerait aussi pour une scène où
+ /// aucune ombre n'a jamais été dessinée.
+ #[test]
+ fn the_pip_shadow_is_suppressed_in_cutout_mode() {
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ assert_ne!(
+ compose_pip(&comp, NO_EFFECT, true),
+ compose_pip(&comp, NO_EFFECT, false),
+ "contrôle : sans effet, l'ombre du PiP doit bel et bien se voir"
+ );
+
+ let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT);
+ comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask");
+ assert_eq!(
+ compose_pip(&comp, CUTOUT, true),
+ compose_pip(&comp, CUTOUT, false),
+ "en détourage, l'ombre est encore dessinée"
+ );
+ }
+
+ /// Le tour complet, celui qui a besoin d'ONNX Runtime : capture → inférence → masque →
+ /// composite, entraîné par `compose_frame` seul. Se saute proprement sans la
+ /// bibliothèque, ce que fait la CI — cf. `segmentation::runtime_available`.
+ #[test]
+ fn the_whole_loop_produces_a_mask_from_compose_frame_alone() {
+ if !crate::segmentation::runtime_available() {
+ eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — test sauté");
+ return;
+ }
+ let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx");
+ if !model.is_file() {
+ eprintln!("modèle absent ({}) — test sauté", model.display());
+ return;
+ }
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — test sauté");
+ return;
+ };
+ let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let effect = format!(
+ r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{}}}"#,
+ serde_json::to_string(&model.to_string_lossy()).expect("chemin sérialisable")
+ );
+
+ // Le limiteur est à 30 Hz : une frame par tour ne suffirait pas, et l'inférence est
+ // asynchrone. On laisse au worker le temps de rendre un masque, sans jamais
+ // l'attendre dans le rendu — ce qui est précisément le contrat.
+ let mut uploaded = false;
+ for _ in 0..40 {
+ let _ = compose_pip(&comp, &effect, true);
+ if comp.webcam_mask.borrow().is_some() {
+ uploaded = true;
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(40));
+ }
+ assert!(
+ uploaded,
+ "aucun masque n'est remonté : la boucle capture → inférence → upload est rompue"
+ );
+ assert!(
+ !*comp.seg_failed.borrow(),
+ "la segmentation s'est éteinte d'elle-même"
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Harnais visuel (opt-in)
+ //
+ // Les tests ci-dessus prouvent le mécanisme sur des images synthétiques, où le masque
+ // est posé à la main et donc trivialement juste. Ils ne peuvent rien dire de la QUALITÉ
+ // du masque que le modèle produit sur une vraie caméra — et « un masque qui composite »
+ // n'est pas la même affirmation que « un masque qui est correct ».
+ //
+ // `poc-d3d` étant `cfg(windows)`, il n'existe aucun banc ici pour trancher ça. Ceci en
+ // tient lieu : on lui donne une photo, il rend les quatre modes et écrit des PNG à
+ // regarder. Même forme d'opt-in que `tests/compose_linux.rs` (variable d'environnement
+ // + skip propre), et pour la même raison : ça rend sur GPU et ça lit un fichier que le
+ // dépôt ne porte pas.
+ //
+ // ```
+ // ORT_DYLIB_PATH=/chemin/libonnxruntime.dylib \
+ // OPENSCREEN_SEG_CAM=camera.png \
+ // OPENSCREEN_SEG_VISUAL=target/seg \
+ // cargo test -p openscreen-compositor --lib seg_visual -- --nocapture
+ // ```
+ // -----------------------------------------------------------------------
+
+ /// RGB8 → NV12 BT.709 limited. Inverse EXACT de `yuv709_limited` dans `shaders.metal` :
+ /// une autre matrice ferait dériver les couleurs du rendu et on croirait à un bug du
+ /// compositeur là où il n'y aurait qu'une conversion d'entrée fausse.
+ #[allow(clippy::type_complexity)]
+ fn rgb_to_nv12(rgb: &[u8], w: u32, h: u32) -> (Vec, Vec) {
+ let luma = |i: usize| -> (f32, f32, f32, f32) {
+ let (r, g, b) = (
+ rgb[i * 3] as f32 / 255.0,
+ rgb[i * 3 + 1] as f32 / 255.0,
+ rgb[i * 3 + 2] as f32 / 255.0,
+ );
+ (r, g, b, 0.2126 * r + 0.7152 * g + 0.0722 * b)
+ };
+ let mut y = vec![0u8; (w * h) as usize];
+ for i in 0..(w * h) as usize {
+ let (_, _, _, yl) = luma(i);
+ y[i] = (16.0 + 219.0 * yl).round().clamp(0.0, 255.0) as u8;
+ }
+ // Chroma au plus proche voisin : l'échantillon en haut à gauche de chaque bloc 2x2.
+ // Un vrai filtre ne changerait rien à ce que ce harnais donne à voir.
+ let mut uv = vec![0u8; (w * (h / 2)) as usize];
+ for row in 0..h / 2 {
+ for col in 0..w / 2 {
+ let (r, _, b, yl) = luma(((row * 2) * w + col * 2) as usize);
+ let cb = 128.0 + 224.0 * ((b - yl) / 1.8556);
+ let cr = 128.0 + 224.0 * ((r - yl) / 1.5748);
+ let o = (row * w + col * 2) as usize;
+ uv[o] = cb.round().clamp(0.0, 255.0) as u8;
+ uv[o + 1] = cr.round().clamp(0.0, 255.0) as u8;
+ }
+ }
+ (y, uv)
+ }
+
+ fn frame_from_png(path: &std::path::Path) -> FakeFrame {
+ let img = image::open(path)
+ .unwrap_or_else(|e| panic!("{} : {e}", path.display()))
+ .to_rgb8();
+ // NV12 veut des dimensions paires ; on rogne d'un pixel plutôt que de rééchantillonner.
+ let (w, h) = (img.width() & !1, img.height() & !1);
+ let src = img.as_raw();
+ let mut rgb = vec![0u8; (w * h * 3) as usize];
+ for row in 0..h {
+ let (d, s) = ((row * w * 3) as usize, (row * img.width() * 3) as usize);
+ rgb[d..d + (w * 3) as usize].copy_from_slice(&src[s..s + (w * 3) as usize]);
+ }
+ let (y, uv) = rgb_to_nv12(&rgb, w, h);
+ FakeFrame::from_planes(w, h, &y, &uv)
+ }
+
+ #[test]
+ fn seg_visual_renders_the_four_modes_from_a_real_photo() {
+ let (Ok(out_dir), Ok(cam)) = (
+ std::env::var("OPENSCREEN_SEG_VISUAL"),
+ std::env::var("OPENSCREEN_SEG_CAM"),
+ ) else {
+ eprintln!("harnais visuel : OPENSCREEN_SEG_VISUAL + OPENSCREEN_SEG_CAM absents — sauté");
+ return;
+ };
+ if !crate::segmentation::runtime_available() {
+ eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — sauté");
+ return;
+ }
+ let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx");
+ let Ok(gpu) = crate::d3d::Gpu::create(false) else {
+ eprintln!("pas de device Metal — sauté");
+ return;
+ };
+ std::fs::create_dir_all(&out_dir).expect("dossier de sortie");
+
+ let (rw, rh) = (1280u32, 720u32);
+ let comp = super::Compositor::new_sized(&gpu, rw, rh).expect("Compositor::new_sized");
+ let webcam = frame_from_png(std::path::Path::new(&cam));
+ let screen = match std::env::var("OPENSCREEN_SEG_SCREEN") {
+ Ok(p) => frame_from_png(std::path::Path::new(&p)),
+ // Sans capture d'écran sous la main, un damier : il rend le détourage lisible,
+ // là où un aplat laisserait croire à un fond simplement peint.
+ Err(_) => FakeFrame::new(640, 360, |col, row| {
+ if (col / 40 + row / 40) % 2 == 0 { 180 } else { 60 }
+ }),
+ };
+ let model_json = serde_json::to_string(&model.to_string_lossy()).expect("chemin");
+
+ let mut wrote = Vec::new();
+ for (name, effect) in [
+ ("00-none", "null".to_string()),
+ ("01-cutout", format!(r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{model_json}}}"#)),
+ ("02-blur", format!(r#"{{"mode":"blur","blurIntensity":0.8,"background":null,"modelPath":{model_json}}}"#)),
+ ("03-custom", format!(r##"{{"mode":"custom","blurIntensity":0,"background":{{"kind":"color","color":"#ff2d95"}},"modelPath":{model_json}}}"##)),
+ ] {
+ // Le masque arrive de façon asynchrone : on tourne jusqu'à ce qu'il soit là, ce
+ // qui est aussi une vérification en soi — la boucle du rendu ne l'attend jamais.
+ let mut rgba = Vec::new();
+ for _ in 0..60 {
+ rgba = compose_visual(&comp, &screen, &webcam, &effect);
+ if effect == "null" || comp.webcam_mask.borrow().is_some() {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(30));
+ }
+ let path = format!("{out_dir}/seg-{name}.png");
+ image::RgbaImage::from_raw(rw, rh, rgba)
+ .expect("dimensions du readback")
+ .save(&path)
+ .unwrap_or_else(|e| panic!("écriture {path} : {e}"));
+ wrote.push(path);
+ }
+ for p in &wrote {
+ println!("wrote {p}");
+ }
+ assert!(
+ comp.webcam_mask.borrow().is_some(),
+ "aucun masque n'a été produit : les trois modes d'effet sont sans objet"
+ );
+ }
+
+ /// Caméra plein cadre (`camera-fullscreen`… sans région : on force le rect via
+ /// `webcamRect`), pour que le masque occupe toute l'image et se juge à taille réelle.
+ fn compose_visual(
+ comp: &super::Compositor,
+ screen: &FakeFrame,
+ webcam: &FakeFrame,
+ effect: &str,
+ ) -> Vec {
+ let json = format!(
+ r##"{{"clips":[],
+ "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle",
+ "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false,
+ "webcamRect":{{"x":0.06,"y":0.10,"width":0.55,"height":0.72}}}},
+ "effects":{{"padding":0.10,"blur":false,"shadow":1,"roundnessFrac":0.02,"motionBlur":0}},
+ "background":{{"kind":"gradient","angleDeg":45,"stops":["#1b2a4a","#0b0f1a"]}},
+ "zoomRegions":[],"annotations":[],
+ "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,
+ "clipToBounds":false,"theme":"default"}},
+ "cropByClip":[],
+ "webcamEffect":{effect},
+ "output":{{"width":1280,"height":720,"fps":30}}}}"##
+ );
+ let scene = crate::scene::Scene::from_json(&json).expect("scene json");
+ comp.set_live_params(live_params_from_scene(&scene));
+ comp.set_has_webcam(true);
+ comp.set_scene(Some(scene));
+ let mut cfg = crate::config::Cfg::c8();
+ cfg.zoom = false;
+ cfg.layout_anim = false;
+ cfg.cursor = false;
+ cfg.mblur_n = 1;
+ unsafe {
+ comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg)
+ .expect("compose_frame");
+ let (_, _, rgba) = comp.readback_direct().expect("readback_direct");
+ rgba
+ }
+ }
/// Le pendant macOS de `compositor_windows`'s `every_shader_entry_point_compiles`.
///
diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs
index 51c3a3a28..e6ed30ecf 100644
--- a/crates/compositor/src/compositor_windows.rs
+++ b/crates/compositor/src/compositor_windows.rs
@@ -31,6 +31,13 @@ use windows::Win32::Graphics::Direct3D::{
use windows::Win32::Graphics::Direct3D11::*;
use windows::Win32::Graphics::Dxgi::Common::*;
+/// Budget du cache de textures image (`img_cache`), en octets.
+///
+/// Doit tenir le JEU ACTIF d'une frame — au pire un wallpaper d'écran ET un fond de caméra, que
+/// rien n'empêche d'être deux 7680x7680 à 225 Mo pièce. Sous ce seuil l'éviction ne peut plus
+/// rendre de mémoire sans toucher au jeu actif, ce qu'elle refuse de faire. 512 Mo borne la fuite
+/// (1 774 Mo mesurés en parcourant les 18 wallpapers livrés) en laissant le jeu actif résident.
+const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
@@ -46,6 +53,28 @@ use windows::Win32::Graphics::Dxgi::Common::*;
+
+/// Cadence de l'inférence. Pas 60 : une silhouette ne bouge pas de façon perceptible en
+/// 16 ms, et c'est le seul levier mesuré qui divise le coût par deux sans toucher au modèle.
+const SEGMENTATION_HZ: u32 = 30;
+
+/// Cible RGBA + staging CPU pour l'extraction de la frame webcam qui alimente le modèle.
+struct SegCapture {
+ rtv: ID3D11RenderTargetView,
+ rt: ID3D11Texture2D,
+ staging: ID3D11Texture2D,
+ width: u32,
+ height: u32,
+}
+
+/// Texture du masque de segmentation, recréée seulement quand la résolution du modèle change.
+struct WebcamMask {
+ tex: ID3D11Texture2D,
+ srv: ID3D11ShaderResourceView,
+ width: u32,
+ height: u32,
+}
+
pub struct Compositor {
dev: ID3D11Device,
ctx: ID3D11DeviceContext,
@@ -122,9 +151,23 @@ pub struct Compositor {
/// 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>,
+ /// Cache des textures wallpaper image (clé = chemin absolu) : décodé et uploadé une fois,
+ /// puis réutilisé par frame. (SRV, largeur, hauteur, tick d'usage).
+ ///
+ /// « Une fois » et non « une fois pour la session » : l'entrée est évinçable dès qu'elle
+ /// sort du jeu actif d'une frame, et un retour dessus la rechargera — cf. `cached_image`.
+ img_cache: RefCell>,
+ /// Compteur d'accès de `img_cache`, pour l'ordre LRU. Un compteur plutôt que l'index de
+ /// frame : une frame touche plusieurs entrées, et il faut pouvoir les ordonner entre elles.
+ img_tick: std::cell::Cell,
+ /// Valeur de `img_tick` au début de la frame en cours. Tout ce qui a été touché depuis
+ /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`.
+ img_frame_start: std::cell::Cell,
+ /// Masque de segmentation du sujet webcam, R8 à la résolution du modèle. Écrit par
+ /// `set_webcam_mask` depuis le thread d'inférence, lu au moment de dessiner la webcam.
+ /// `None` tant qu'aucune frame n'a été segmentée — l'effet reste alors éteint plutôt que
+ /// de rendre une webcam invisible en mode détourage.
+ webcam_mask: 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.
@@ -150,6 +193,26 @@ pub struct Compositor {
/// de prévisualisation demandée (variable, contrairement au `staging` fixe à
/// OUT_W×OUT_H). Recréée quand la taille change — voir `readback_resized`.
live_readback_staging: RefCell >,
+ /// Cible + staging pour extraire la frame webcam à la résolution du modèle de
+ /// segmentation. Créée à la première capture, jamais redimensionnée : le modèle a une
+ /// entrée fixe.
+ seg_capture: RefCell >,
+ /// Worker d'inférence, absent tant que `enable_segmentation` n'a pas été appelé.
+ seg_worker: RefCell >,
+ /// Segmenteur tenu SUR LE THREAD DE RENDU, utilisé à la place du worker en mode
+ /// déterministe. Voir `set_segmentation_deterministic`.
+ seg_sync: RefCell >,
+ /// Export : cadence par frame et inférence synchrone, au lieu de l'horloge et du worker.
+ seg_deterministic: std::cell::Cell,
+ /// Boîte aux lettres du worker. Le masque est déposé depuis le thread d'inférence et
+ /// téléversé depuis le thread de rendu : aucun appel D3D ne traverse de thread, malgré
+ /// le device multithread-protected qui l'autoriserait.
+ seg_inbox: std::sync::Arc>>>,
+ seg_rate: RefCell,
+ /// Frame RGB réutilisée d'une capture à l'autre.
+ seg_scratch: RefCell>,
+ /// Le chargement du modèle a échoué : ne pas réessayer à chaque frame.
+ seg_failed: 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`.
@@ -556,9 +619,20 @@ impl Compositor {
text_cache: RefCell::new(HashMap::new()),
ann_img_cache: RefCell::new(HashMap::new()),
img_cache: RefCell::new(HashMap::new()),
+ img_tick: std::cell::Cell::new(0),
+ img_frame_start: std::cell::Cell::new(0),
+ webcam_mask: RefCell::new(None),
render_size: Cell::new((out_w, out_h)),
resize_target: RefCell::new(None),
live_readback_staging: RefCell::new(None),
+ seg_capture: RefCell::new(None),
+ seg_worker: RefCell::new(None),
+ seg_sync: RefCell::new(None),
+ seg_deterministic: std::cell::Cell::new(false),
+ seg_inbox: std::sync::Arc::new(std::sync::Mutex::new(None)),
+ seg_rate: RefCell::new(crate::segmentation::RateLimiter::new(SEGMENTATION_HZ)),
+ seg_scratch: RefCell::new(Vec::new()),
+ seg_failed: RefCell::new(false),
nv12_readback_staging: RefCell::new(None),
})
}
@@ -767,18 +841,70 @@ impl Compositor {
/// 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.
+ /// Ouvre une frame du point de vue de `img_cache` : tout ce qui sera touché après cet appel
+ /// est le jeu actif, et devient inévinçable jusqu'à la frame suivante.
+ fn begin_image_frame(&self) {
+ // `+ 1` : la première entrée de cette frame recevra `img_tick + 1`, et la protection
+ // porte sur `tick >= img_frame_start`. Sans le décalage on protégerait aussi la
+ // DERNIÈRE entrée de la frame précédente, qui n'appartient plus au jeu actif — le
+ // résident pourrait alors dépasser le budget d'une texture entière.
+ self.img_frame_start.set(self.img_tick.get() + 1);
+ }
+
+ /// Texture d'un fichier image, décodée une seule fois puis réutilisée.
+ ///
+ /// Le cache était NON BORNÉ, et c'est un vrai coût : les wallpapers livrés pèsent 23,7 Mo sur
+ /// disque mais 1 774 Mo une fois décodés en RGBA8 — `wallpaper8.jpg` fait 7680x7680, soit
+ /// 225 Mo à lui seul. Parcourir le sélecteur les chargeait tous et n'en libérait aucun.
+ ///
+ /// L'éviction est LRU sous un budget en octets, et ne touche jamais une texture que la frame
+ /// EN COURS a déjà servie : sans ça, un fond d'écran et un fond de caméra un peu gros se
+ /// chasseraient l'un l'autre à chaque frame, et un décodage coûte 129 ms contre les ~3,5 ms
+ /// d'une frame. Si le jeu actif dépasse à lui seul le budget, on dépasse le budget.
+ unsafe fn cached_image(&self, path: &str) -> Result<(ID3D11ShaderResourceView, u32, u32)> {
+ let tick = self.img_tick.get() + 1;
+ self.img_tick.set(tick);
+ // 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 hit = self.img_cache.borrow().get(path).cloned();
+ if let Some((srv, w, h, _)) = hit {
+ self.img_cache.borrow_mut().insert(path.to_string(), (srv.clone(), w, h, tick));
+ return Ok((srv, w, h));
+ }
+ let (srv, w, h) = self.load_image_srv(path)?;
+ let mut cache = self.img_cache.borrow_mut();
+ cache.insert(path.to_string(), (srv.clone(), w, h, tick));
+ // La politique vit dans `frame_geometry` : les trois backends la partagent, comme la
+ // géométrie, plutôt que d'entretenir trois copies qui finiraient par diverger.
+ let entries: Vec<(String, u64, u64)> = cache
+ .iter()
+ .map(|(k, e)| (k.clone(), e.1 as u64 * e.2 as u64 * 4, e.3))
+ .collect();
+ let protect_from = self.img_frame_start.get();
+ for key in
+ crate::frame_geometry::lru_evictions(&entries, IMG_CACHE_BUDGET_BYTES, protect_from)
+ {
+ cache.remove(&key);
+ }
+ Ok((srv, w, h))
+ }
+
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
- }
- };
+ self.draw_image_in(path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, output_aspect)
+ }
+
+ /// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins
+ /// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop
+ /// « cover » se calcule contre la zone qu'on remplit.
+ unsafe fn draw_image_in(
+ &self,
+ path: &str,
+ dst: [f32; 4],
+ quad_px: [f32; 2],
+ radius_px: f32,
+ output_aspect: f32,
+ ) -> Result<()> {
+ let (srv, iw, ih) = self.cached_image(path)?;
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
@@ -795,8 +921,10 @@ impl Compositor {
(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],
+ dst,
src: [u0, v0, u1, v1],
+ quad_px,
+ radius_px,
mode: 6.0,
..Default::default()
});
@@ -805,6 +933,70 @@ impl Compositor {
Ok(())
}
+ /// Peint le fond du mode « personnalisé » DANS la bulle webcam, avant que la caméra n'y soit
+ /// découpée par-dessus.
+ ///
+ /// Le shader ne sait peindre qu'une couleur plate sous le masque, donc un dégradé ou une image
+ /// y tombaient sur du noir — et le défaut EST une image (`DEFAULT_WALLPAPER`), si bien que le
+ /// mode ne rendait jamais ce que le sélecteur montrait. Peindre le fond puis composer la
+ /// caméra en détourage donne exactement le même résultat (`lerp(fond, caméra, personne)`, ici
+ /// par le mélange alpha) pour les trois sortes de fond, en réutilisant les chemins déjà
+ /// éprouvés du fond d'écran, et sans rien ajouter aux trois shaders.
+ ///
+ /// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit épouser ses coins arrondis,
+ /// sinon un rectangle déborde derrière la caméra.
+ unsafe fn draw_webcam_bg(
+ &self,
+ bg: Option<&SceneBackground>,
+ dst: [f32; 4],
+ quad_px: [f32; 2],
+ radius_px: f32,
+ ) {
+ const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
+ let solid = |color: [f32; 4]| LayerCB {
+ dst,
+ quad_px,
+ radius_px,
+ mode: 1.0,
+ color,
+ ..Default::default()
+ };
+ match bg {
+ Some(SceneBackground::Color { color }) => {
+ self.draw_solid(&solid(parse_hex(color).unwrap_or(BLACK)));
+ }
+ Some(SceneBackground::Gradient { angle_deg, stops }) => {
+ let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK);
+ let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0);
+ // angle CSS → direction unitaire, même convention que le fond d'écran.
+ let a = angle_deg.to_radians();
+ let dir = [a.sin(), -a.cos()];
+ self.draw_solid(&LayerCB {
+ dst,
+ quad_px,
+ radius_px,
+ 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()
+ });
+ }
+ Some(SceneBackground::Image { path }) => {
+ // Même contrat que le fond d'écran : un chemin cassé est loggé puis remplacé par
+ // du noir. Un fallback silencieux redonnerait le bug qu'on corrige.
+ let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 };
+ if let Err(e) = self.draw_image_in(path, dst, quad_px, radius_px, aspect) {
+ eprintln!("[compositor] fond webcam \"{}\" : {:#}", path, e);
+ self.draw_solid(&solid(BLACK));
+ }
+ }
+ // Personnalisé sans fond : noir, comme avant — mais c'est désormais le seul chemin
+ // qui y mène, au lieu de l'être pour toute image et tout dégradé.
+ None => self.draw_solid(&solid(BLACK)),
+ }
+ }
+
/// 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
@@ -846,6 +1038,341 @@ impl Compositor {
Ok((srv.unwrap(), w, h))
}
+ /// Extrait la frame webcam en RGB8 à la résolution du modèle, dans `out`.
+ ///
+ /// `src` est le rect source de la webcam en UV (le même que celui passé à `draw_video`),
+ /// donc le crop utilisateur et le miroir sont déjà dedans — le modèle voit exactement ce
+ /// que le spectateur verra, et le masque n'a pas à être recadré après coup.
+ ///
+ /// **À appeler AVANT `begin()`** : la méthode réquisitionne la cible de rendu et le
+ /// viewport, et ne les restaure pas. Les appeler dans l'autre ordre dessinerait la scène
+ /// dans une texture de 256x144.
+ ///
+ /// C'est le seul readback GPU->CPU du chemin. Il porte 256x144x4 = 147 Ko, contre la
+ /// frame entière que la preview lit déjà à chaque image ; sur le chemin export, qui lui
+ /// est GPU-résident de bout en bout, c'est en revanche un point de synchronisation neuf
+ /// et c'est là qu'il faudra le mesurer.
+ pub unsafe fn capture_webcam_rgb(
+ &self,
+ wy: &ID3D11ShaderResourceView,
+ wuv: &ID3D11ShaderResourceView,
+ src: [f32; 4],
+ width: u32,
+ height: u32,
+ out: &mut Vec,
+ ) -> Result<()> {
+ if width == 0 || height == 0 {
+ bail!("capture webcam de dimensions nulles ({width}x{height})");
+ }
+ {
+ let mut slot = self.seg_capture.borrow_mut();
+ if !matches!(slot.as_ref(), Some(c) if c.width == width && c.height == height) {
+ let td = D3D11_TEXTURE2D_DESC {
+ Width: width,
+ Height: height,
+ 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 as u32,
+ CPUAccessFlags: 0,
+ MiscFlags: 0,
+ };
+ let mut rt: Option = None;
+ self.dev.CreateTexture2D(&td, None, Some(&mut rt))?;
+ let rt = rt.unwrap();
+ let mut rtv: Option = None;
+ self.dev.CreateRenderTargetView(&rt, None, Some(&mut rtv))?;
+
+ let sd = D3D11_TEXTURE2D_DESC {
+ Usage: D3D11_USAGE_STAGING,
+ BindFlags: 0,
+ CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
+ ..td
+ };
+ let mut staging: Option = None;
+ self.dev.CreateTexture2D(&sd, None, Some(&mut staging))?;
+
+ *slot = Some(SegCapture {
+ rtv: rtv.unwrap(),
+ rt,
+ staging: staging.unwrap(),
+ width,
+ height,
+ });
+ }
+ }
+
+ let cap = self.seg_capture.borrow();
+ let cap = cap.as_ref().expect("créé juste au-dessus");
+
+ self.bind_compose_state();
+ self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff);
+ self.ctx.OMSetRenderTargets(Some(&[Some(cap.rtv.clone())]), None);
+ let vp = D3D11_VIEWPORT {
+ TopLeftX: 0.0, TopLeftY: 0.0,
+ Width: width as f32, Height: height as f32, MinDepth: 0.0, MaxDepth: 1.0,
+ };
+ self.ctx.RSSetViewports(Some(&[vp]));
+ // Plein cadre de la cible, sans coins ni motion blur : le modèle veut l'image, pas
+ // la mise en forme.
+ self.draw_video(
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ src,
+ quad_px: [width as f32, height as f32],
+ mode: 0.0,
+ color: [0.0, 0.0, 0.0, 1.0],
+ mb: [1.0, 1.0, 1.0, 0.0],
+ ..Default::default()
+ },
+ wy,
+ wuv,
+ );
+
+ self.ctx.CopyResource(&cap.staging, &cap.rt);
+ let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
+ self.ctx.Map(&cap.staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?;
+ out.clear();
+ out.reserve((width * height * 3) as usize);
+ for row in 0..height as usize {
+ let line = (mapped.pData as *const u8).add(row * mapped.RowPitch as usize);
+ for col in 0..width as usize {
+ let px = line.add(col * 4);
+ // RGBA -> RGB : le modèle n'a pas de canal alpha en entrée.
+ out.push(*px);
+ out.push(*px.add(1));
+ out.push(*px.add(2));
+ }
+ }
+ self.ctx.Unmap(&cap.staging, 0);
+ Ok(())
+ }
+
+ /// Publie le masque de segmentation du sujet webcam (R8, `width`x`height`, 0 = fond).
+ ///
+ /// Appelé depuis le thread d'inférence, pas depuis le thread de rendu — d'où le
+ /// `SetMultithreadProtected(true)` posé à la création du device (`d3d_windows.rs`). La
+ /// texture est `DYNAMIC` et réécrite en place ; elle n'est recréée que si la résolution du
+ /// modèle change, ce qui n'arrive pas en régime établi.
+ pub fn set_webcam_mask(&self, data: &[u8], width: u32, height: u32) -> Result<()> {
+ if width == 0 || height == 0 {
+ bail!("masque webcam de dimensions nulles ({width}x{height})");
+ }
+ let expected = (width as usize) * (height as usize);
+ if data.len() < expected {
+ bail!("masque webcam trop court : {} octets pour {width}x{height}", data.len());
+ }
+
+ let mut slot = self.webcam_mask.borrow_mut();
+ let needs_alloc = !matches!(slot.as_ref(), Some(m) if m.width == width && m.height == height);
+ if needs_alloc {
+ let td = D3D11_TEXTURE2D_DESC {
+ Width: width,
+ Height: height,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: DXGI_FORMAT_R8_UNORM,
+ SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
+ Usage: D3D11_USAGE_DYNAMIC,
+ BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
+ CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
+ MiscFlags: 0,
+ };
+ let mut tex: Option = None;
+ unsafe { self.dev.CreateTexture2D(&td, None, Some(&mut tex))? };
+ let tex = tex.unwrap();
+ let mut srv: Option = None;
+ unsafe { self.dev.CreateShaderResourceView(&tex, None, Some(&mut srv))? };
+ *slot = Some(WebcamMask { tex, srv: srv.unwrap(), width, height });
+ }
+
+ let mask = slot.as_ref().expect("alloué juste au-dessus");
+ unsafe {
+ let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
+ self.ctx.Map(&mask.tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))?;
+ // `RowPitch` n'est pas `width` : le driver aligne les lignes, donc on recopie
+ // ligne à ligne plutôt que d'un bloc.
+ for row in 0..height as usize {
+ let dst = (mapped.pData as *mut u8).add(row * mapped.RowPitch as usize);
+ let src = data.as_ptr().add(row * width as usize);
+ std::ptr::copy_nonoverlapping(src, dst, width as usize);
+ }
+ self.ctx.Unmap(&mask.tex, 0);
+ }
+ Ok(())
+ }
+
+ /// Un tour de segmentation : téléverse le masque prêt, puis soumet une nouvelle frame si
+ /// la cadence l'autorise.
+ ///
+ /// Les deux moitiés sont volontairement désynchronisées. Le masque téléversé ici vient de
+ /// la frame précédente — une frame de retard sur une silhouette est invisible, alors
+ /// qu'attendre l'inférence bloquerait le rendu, ce qui est exactement le coût que toute
+ /// cette conception cherche à ne pas payer.
+ unsafe fn pump_segmentation(
+ &self,
+ wy: &ID3D11ShaderResourceView,
+ wuv: &ID3D11ShaderResourceView,
+ valid: [f32; 2],
+ ) -> Result<()> {
+ if *self.seg_failed.borrow() {
+ return Ok(());
+ }
+ // Rien à faire si aucun effet n'est demandé : ni capture, ni inférence, ni masque.
+ // Le coût de la fonctionnalité est alors exactement nul.
+ let (wants_effect, model_path) = {
+ let scene = self.scene.borrow();
+ match scene.as_ref().and_then(|s| s.webcam_effect.as_ref()) {
+ Some(e) if e.shader_code() > 0.0 => (true, e.model_path.clone()),
+ _ => (false, None),
+ }
+ };
+ if !wants_effect {
+ return Ok(());
+ }
+
+ // Démarrage paresseux, piloté par la scène : personne n'a à appeler
+ // `enable_segmentation` à la main, et un modèle introuvable éteint l'effet au lieu
+ // de faire tomber le rendu.
+ if self.seg_worker.borrow().is_none() && self.seg_sync.borrow().is_none() {
+ let Some(path) = model_path else { return Ok(()) };
+ if let Err(e) = self.enable_segmentation(std::path::Path::new(&path)) {
+ eprintln!("[segmentation] désactivée : {e}");
+ // Une scène qui reste identique retenterait à chaque frame ; on pose un
+ // worker vide plutôt que de journaliser 60 fois par seconde.
+ *self.seg_failed.borrow_mut() = true;
+ return Ok(());
+ }
+ // En preview on rend cette frame sans masque : le worker vient de démarrer et
+ // l'effet apparaîtra dans quelques millisecondes, ce que personne ne voit. À
+ // l'export cette frame part dans le fichier — on enchaîne donc sur la capture et
+ // l'inférence plutôt que de la laisser sortir non détourée.
+ if !self.seg_deterministic.get() {
+ return Ok(());
+ }
+ }
+
+ if let Some(mask) = self.seg_inbox.lock().unwrap().take() {
+ self.set_webcam_mask(
+ &mask,
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ )?;
+ }
+
+ // La cadence horloge est le bon réglage en preview et le mauvais à l'export, où les
+ // frames défilent aussi vite que la machine décode : le nombre de frames couvertes par
+ // un masque dépendrait alors de la charge. En déterministe, une inférence par frame.
+ if !self.seg_deterministic.get()
+ && !self.seg_rate.borrow_mut().should_run(std::time::Instant::now())
+ {
+ return Ok(());
+ }
+ let mut scratch = self.seg_scratch.borrow_mut();
+ // La frame ENTIÈRE, pas le sous-rect dessiné : un crop utilisateur serré amputerait
+ // le sujet en entrée du modèle, et le masque serait faux là où il compte le plus.
+ // Le shader ramène ses coordonnées dans cet espace via `fx.xy`.
+ self.capture_webcam_rgb(
+ wy,
+ wuv,
+ [0.0, 0.0, valid[0], valid[1]],
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ &mut scratch,
+ )?;
+ if self.seg_deterministic.get() {
+ // Synchrone : le masque doit exister avant que cette frame ne soit composée, sinon
+ // on retombe sur le défaut qu'on corrige. Une inférence ratée laisse le masque
+ // précédent, comme le fait le worker.
+ let mut sync = self.seg_sync.borrow_mut();
+ if let Some(seg) = sync.as_mut() {
+ match seg.run(&scratch) {
+ Ok(mask) => {
+ let mask = mask.to_vec();
+ drop(sync);
+ self.set_webcam_mask(
+ &mask,
+ crate::segmentation::MODEL_WIDTH,
+ crate::segmentation::MODEL_HEIGHT,
+ )?;
+ }
+ Err(e) => eprintln!("[segmentation] frame ignorée : {e}"),
+ }
+ }
+ } else if let Some(w) = self.seg_worker.borrow().as_ref() {
+ w.submit(&scratch);
+ }
+ Ok(())
+ }
+
+ /// Démarre la segmentation du sujet webcam pour ce compositeur.
+ ///
+ /// Idempotent. Tant qu'elle n'est pas appelée, `compose_frame` ne fait rien de plus et
+ /// la webcam se dessine comme avant — c'est ce qui rend l'effet inerte plutôt que cassé
+ /// sur une build sans modèle.
+ pub fn enable_segmentation(&self, model_path: &std::path::Path) -> Result<()> {
+ if self.seg_worker.borrow().is_some() || self.seg_sync.borrow().is_some() {
+ return Ok(());
+ }
+ let segmenter = crate::segmentation::Segmenter::load(model_path)?;
+ // En déterministe, le segmenteur reste ici : l'inférence tourne sur le thread de rendu,
+ // donc le masque de la frame N est prêt AVANT qu'elle ne soit composée. Le worker est un
+ // choix de preview — ne jamais bloquer l'affichage — et c'est exactement ce qui rend
+ // l'export irreproductible, le masque arrivant quelques frames plus tard selon la charge.
+ if self.seg_deterministic.get() {
+ *self.seg_sync.borrow_mut() = Some(segmenter);
+ return Ok(());
+ }
+ let inbox = std::sync::Arc::clone(&self.seg_inbox);
+ let worker = crate::segmentation::SegmentationWorker::spawn(segmenter, move |mask, _, _| {
+ // Écrase le masque précédent s'il n'a pas encore été téléversé : c'est le plus
+ // récent qui vaut, jamais une file.
+ *inbox.lock().unwrap() = Some(mask.to_vec());
+ });
+ *self.seg_worker.borrow_mut() = Some(worker);
+ Ok(())
+ }
+
+ /// Bascule la segmentation en mode reproductible, pour l'export.
+ ///
+ /// En preview, la cadence suit l'horloge (30 Hz réels) et l'inférence tourne sur un worker :
+ /// c'est le bon choix, l'affichage ne doit jamais attendre. À l'export les frames sont rendues
+ /// aussi vite que la machine décode, sans rapport avec le temps réel — et ces deux choix
+ /// deviennent alors des bugs. La cadence horloge fait dépendre le nombre de frames couvertes
+ /// par un masque de la vitesse de la machine, et le worker asynchrone rend les premières
+ /// frames AVANT que le premier masque n'existe : elles partent dans le fichier avec le vrai
+ /// arrière-plan de la webcam. Deux exports du même projet ne donnent donc pas les mêmes
+ /// pixels, ce qui casse l'invariant « l'export est identique à la preview ».
+ ///
+ /// En déterministe : une inférence PAR FRAME, synchrone. Plus coûteux (~3 ms/frame), mais
+ /// l'export est hors ligne et chaque frame porte le masque calculé depuis SA propre image.
+ ///
+ /// À appeler avant la première frame — c'est ce qui décide comment `enable_segmentation`
+ /// s'installe.
+ pub fn set_segmentation_deterministic(&self, on: bool) {
+ if self.seg_deterministic.get() == on {
+ return;
+ }
+ self.seg_deterministic.set(on);
+ // Changer de mode change le MOTEUR, et `enable_segmentation` est idempotent sur la
+ // PRÉSENCE d'un moteur : sans démonter celui qui ne correspond plus, le drapeau mentirait.
+ // Un compositeur qui a déjà servi en preview garderait son worker, `seg_sync` resterait
+ // vide, et l'export entier ne ferait AUCUNE inférence. Le démarrage paresseux de
+ // `pump_segmentation` réinstalle le bon moteur à la frame suivante.
+ *self.seg_worker.borrow_mut() = None;
+ *self.seg_sync.borrow_mut() = None;
+ // Et le masque que le worker démonté avait peut-être déjà déposé : il vient de l'autre
+ // mode, il n'a rien à faire sur la première frame de celui-ci.
+ *self.seg_inbox.lock().unwrap() = None;
+ }
+
+ /// Éteint l'effet : la webcam se redessine telle quelle à la frame suivante.
+ pub fn clear_webcam_mask(&self) {
+ *self.webcam_mask.borrow_mut() = None;
+ }
+
pub fn set_cursor(&self, track: CursorTrack) {
*self.cursor.borrow_mut() = Some(track);
}
@@ -899,15 +1426,7 @@ impl Compositor {
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 (srv, iw, ih) = self.cached_image(path)?;
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];
@@ -1091,26 +1610,26 @@ impl Compositor {
frame: f32,
cfg: &Cfg,
) -> Result<()> {
+ self.begin_image_frame();
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);
+ // Étendue valide de la texture webcam : les décodeurs allouent des textures alignées,
+ // donc la frame n'occupe pas forcément toute la texture.
+ let w_valid = [wcw / wtw as f32, wch / wth as f32];
+
+ // Segmentation, AVANT `begin()` : la capture réquisitionne la cible de rendu.
+ self.pump_segmentation(&wy, &wuv, w_valid)?;
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 {
+ let g = 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],
@@ -1124,6 +1643,23 @@ impl Compositor {
cursor: cursor_ref.as_ref(),
timeline_t_override: *self.timeline_t_override.borrow(),
});
+ let scene_preset = g.scene_preset.clone();
+ let mb_taps = g.mb_taps;
+ let mb_amount = g.mb_amount;
+ let source_t = g.source_t;
+ let zoom_rotation = g.zoom_rotation;
+ let _padding_scale = g.padding_scale;
+ let cut = g.cut;
+ let s_dst = g.s_dst;
+ let s_dst_prev = g.s_dst_prev;
+ let s_ann = g.s_ann;
+ let s_radius = g.s_radius;
+ let frame_min_px = g.frame_min_px;
+ let w_dst = g.w_dst;
+ let w_dst_prev = g.w_dst_prev;
+ let w_px = g.w_px;
+ let w_radius = g.w_radius;
+ let shape_fade = g.shape_fade;
self.begin([0.0, 0.0, 0.0, 1.0]);
@@ -1268,7 +1804,7 @@ impl Compositor {
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],
+ mb: [mb_taps, mb_amount, 1.0, 0.0],
..Default::default()
},
&sy,
@@ -1330,175 +1866,71 @@ impl Compositor {
}
// --- 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 {
+ // et flou de mouvement (parité `compositor_macos.rs` et `compositor_linux.rs`) ---
+ if let Some(track) = cursor_ref.as_ref() {
+ let plan = crate::frame_geometry::plan_cursor(
+ &g,
+ &crate::frame_geometry::CursorPlanInput {
+ render_px: [self.rw(), self.rh()],
+ u_max,
+ v_max,
+ cfg,
+ live: lp,
+ scene: scene_ref.as_ref(),
+ track,
+ t: self.cursor_t_override.borrow().unwrap_or(frame / FPS),
+ },
+ );
+ if let Some(plan) = plan {
+ let cursor_sprites: HashMap = scene_ref
+ .as_ref()
+ .map(|s| s.cursor.cursor_sprites.clone())
+ .unwrap_or_default();
+ let cursor_type = plan.cursor_type.as_deref();
+ if plan.taps <= 1 {
+ self.draw_cur_themed(
+ &cursor_sprites,
+ cursor_type,
+ plan.placement,
+ plan.size_px,
+ 1.0,
+ plan.clip,
+ );
+ } else {
+ // 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.
+ self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]);
+ self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None);
+ for k in 0..plan.taps {
+ let f = k as f32 / (plan.taps - 1) as f32;
+ let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps);
+ self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff);
self.draw_cur_themed(
&cursor_sprites,
cursor_type,
- cur,
- sz,
+ plan.prev_placement.lerp(plan.placement, f),
+ plan.size_px,
1.0,
- cursor_clip_rect,
+ plan.clip,
);
- } 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();
}
+ // 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();
}
}
}
@@ -1538,7 +1970,13 @@ impl Compositor {
scene_preset.as_deref(),
Some("dual-frame") | Some("vertical-stack"),
);
- if cfg.shadow && !webcam_is_block && shape_fade > 0.0 {
+ // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une
+ // ombre portée par un rectangle invisible se lit comme un artefact.
+ let is_cutout = matches!(
+ scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()),
+ Some(e) if e.shader_code() == 1.0
+ ) && self.webcam_mask.borrow().is_some();
+ if cfg.shadow && !webcam_is_block && !is_cutout && shape_fade > 0.0 {
let strength = WEBCAM_SHADOW_OPACITY * shape_fade;
self.draw_shadow(
w_dst,
@@ -1549,6 +1987,38 @@ impl Compositor {
strength,
);
}
+ // Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de
+ // l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam
+ // invisible en détourage, donc tant que rien n'a été segmenté on dessine la piste
+ // telle quelle. C'est aussi ce qui rend le premier lancement gracieux.
+ let mask = self.webcam_mask.borrow();
+ let effect = scene_ref
+ .as_ref()
+ .and_then(|s| s.webcam_effect.as_ref())
+ .filter(|_| mask.is_some())
+ .map(|e| (e.shader_code(), e))
+ .filter(|(code, _)| *code > 0.0);
+
+ // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra
+ // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement
+ // ce que la branche « mode 3 » du shader calculait, mais pour les TROIS sortes de
+ // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et
+ // images y tombaient sur du noir, et le défaut EST une image.
+ let (effect_code, blur_intensity) = match effect {
+ Some((code, e)) if code > 2.5 => {
+ self.draw_webcam_bg(e.background.as_ref(), w_dst, w_px, w_radius);
+ (1.0, 0.0)
+ }
+ Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)),
+ None => (0.0, 0.0),
+ };
+
+ if let Some(m) = mask.as_ref() {
+ // `draw_video` ne lie que les slots 0-1, donc le masque posé ici tient pour
+ // l'appel qui suit. Il est délié juste après pour ne pas fuir sur les calques
+ // d'annotation, qui utilisent eux aussi le slot 2 et au-delà.
+ self.ctx.PSSetShaderResources(3, Some(&[Some(m.srv.clone())]));
+ }
self.draw_video(
&LayerCB {
dst: w_dst,
@@ -1556,15 +2026,21 @@ impl Compositor {
quad_px: w_px,
radius_px: w_radius,
mode: 0.0,
+ // `color.a` porte l'alpha du découpage (`color.a * personne`) ; le RGB n'est
+ // plus lu, le fond ayant déjà été peint sous la caméra.
color: [0.0, 0.0, 0.0, 1.0],
+ fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
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],
+ mb: [mb_taps, mb_amount, 1.0, 0.0],
..Default::default()
},
&wy,
&wuv,
);
+ if mask.is_some() {
+ self.ctx.PSSetShaderResources(3, Some(&[None]));
+ }
}
// --- annotations : calque le plus haut, comme dans le DOM de la preview (le calque y est
@@ -2398,6 +2874,86 @@ impl Compositor {
mod tests {
use super::*;
+ /// Preuve de bout en bout que `img_cache` est borné : charge TOUS les wallpapers livrés,
+ /// une frame par wallpaper — ce que fait le sélecteur quand on le parcourt — et vérifie que
+ /// le total reste sous le budget.
+ ///
+ /// Opt-in : il crée un vrai device D3D11, ce qu'aucun autre test de ce fichier ne fait (celui
+ /// juste en dessous s'en passe volontairement) et qu'un runner sans adaptateur ne peut pas
+ /// fournir. Même convention que le harnais visuel de la segmentation :
+ ///
+ /// set OPENSCREEN_CACHE_DEMO=1 && cargo test -p openscreen-compositor --release
+ /// img_cache_stays_under_budget -- --nocapture
+ ///
+ /// Les tests de `lru_evictions` couvrent la POLITIQUE ; celui-ci couvre le CÂBLAGE — que le
+ /// backend l'appelle vraiment, sur les bonnes tailles, et que le budget morde sur nos assets.
+ #[test]
+ fn img_cache_stays_under_budget() {
+ if std::env::var_os("OPENSCREEN_CACHE_DEMO").is_none() {
+ eprintln!("OPENSCREEN_CACHE_DEMO absent — saute (ce test demande un device D3D11)");
+ return;
+ }
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(|p| p.parent())
+ .expect("racine du dépôt")
+ .join("public/wallpapers");
+ let mut papers: Vec<_> = std::fs::read_dir(&root)
+ .expect("public/wallpapers")
+ .filter_map(|e| e.ok().map(|e| e.path()))
+ .filter(|p| {
+ matches!(p.extension().and_then(|e| e.to_str()), Some("jpg" | "jpeg" | "png"))
+ })
+ .collect();
+ papers.sort();
+ assert!(papers.len() >= 10, "il faut plusieurs wallpapers pour que le budget morde");
+
+ let gpu = crate::d3d::Gpu::create_backend(crate::d3d::Backend::Hardware, false)
+ .expect("device D3D11");
+ let comp = Compositor::new(&gpu).expect("compositeur");
+
+ let mut cumule = 0u64;
+ let mut pic = 0u64;
+ for path in &papers {
+ // Une frame par wallpaper : c'est le rythme du sélecteur, et c'est ce qui rend
+ // l'entrée précédente évinçable. Dans une même frame elle ne le serait pas.
+ comp.begin_image_frame();
+ let p = path.to_string_lossy().to_string();
+ let (_, w, h) = unsafe { comp.cached_image(&p) }.expect("chargement");
+ cumule += w as u64 * h as u64 * 4;
+ let cache = comp.img_cache.borrow();
+ let total: u64 = cache.values().map(|e| e.1 as u64 * e.2 as u64 * 4).sum();
+ pic = pic.max(total);
+ eprintln!(
+ " {:<20} {:>5}x{:<5} | cache {:>2} entrées {:>4} Mo | cumulé sans éviction {:>5} Mo",
+ path.file_name().unwrap().to_string_lossy(),
+ w,
+ h,
+ cache.len(),
+ total / 1048576,
+ cumule / 1048576,
+ );
+ }
+ eprintln!(
+ "
+ budget {} Mo | pic observé {} Mo | cumulé si rien n'était évincé {} Mo",
+ IMG_CACHE_BUDGET_BYTES / 1048576,
+ pic / 1048576,
+ cumule / 1048576,
+ );
+ assert!(
+ pic <= IMG_CACHE_BUDGET_BYTES,
+ "le cache a dépassé son budget : {} Mo > {} Mo",
+ pic / 1048576,
+ IMG_CACHE_BUDGET_BYTES / 1048576
+ );
+ assert!(
+ cumule > IMG_CACHE_BUDGET_BYTES,
+ "sans éviction le total ({} Mo) doit dépasser le budget, sinon le test ne prouve rien",
+ cumule / 1048576
+ );
+ }
+
/// 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`
diff --git a/crates/compositor/src/cpu_frames_windows.rs b/crates/compositor/src/cpu_frames_windows.rs
index c7a319308..790f23a7f 100644
--- a/crates/compositor/src/cpu_frames_windows.rs
+++ b/crates/compositor/src/cpu_frames_windows.rs
@@ -100,6 +100,11 @@ impl CpuFrames {
}
self.upload(w, h)?;
+ // `Decoder::seek_to` and `decode_forward_to` inspect the presentation frame returned by
+ // `present`, so it must carry the decoded frame's timing just like the macOS/Linux CPU
+ // paths. Leaving the allocation defaults here makes every non-H.264 frame look untimed.
+ (*self.present).pts = (*src).pts;
+ (*self.present).best_effort_timestamp = (*src).best_effort_timestamp;
Ok(self.present)
}
diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs
index 621309e67..a34c2ae00 100644
--- a/crates/compositor/src/cursor.rs
+++ b/crates/compositor/src/cursor.rs
@@ -92,7 +92,7 @@ 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 {
+ pub(crate) 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 }
}
@@ -130,13 +130,18 @@ impl CursorTrack {
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()));
- }
+ // Seules les TRANSITIONS sont retenues — voir `types`. Le helper
+ // macOS rend nil hors texte/pointeur pour que le rendu retombe sur
+ // la flèche. Le sidecar stocke ça en JSON null ou omet la clé.
+ // Ignorer ces échantillons gardait le dernier type sémantique
+ // (`pointer`/`text`) : un thème restait collé après le retour à la
+ // flèche. Null / absence = reset vers `arrow`.
+ let ct = match s.get("cursorType") {
+ Some(v) => v.as_str().filter(|label| !label.is_empty()).unwrap_or("arrow"),
+ None => "arrow",
+ };
+ 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());
@@ -291,4 +296,49 @@ mod tests {
assert_eq!(smoothed.type_at(0.1), Some("arrow"));
assert_eq!(smoothed.type_at(0.7), Some("text"));
}
+
+ /// JSON null et une clé `cursorType` absente resetent vers la flèche,
+ /// au lieu de garder le dernier `pointer`/`text`.
+ #[test]
+ fn null_cursor_type_resets_to_arrow() {
+ let unique = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ let path = std::env::temp_dir().join(format!(
+ "openscreen-cursor-null-reset-{}-{}.json",
+ std::process::id(),
+ unique
+ ));
+ std::fs::write(
+ &path,
+ r#"{"samples":[
+ {"timeMs":0,"cx":0.1,"cy":0.1,"cursorType":"pointer"},
+ {"timeMs":100,"cx":0.2,"cy":0.2,"cursorType":null},
+ {"timeMs":200,"cx":0.3,"cy":0.3,"cursorType":"pointer"},
+ {"timeMs":300,"cx":0.4,"cy":0.4}
+ ]}"#,
+ )
+ .expect("write temp sidecar");
+ let path_str = path.to_str().expect("utf-8 temp path");
+ let track = CursorTrack::load(path_str, 0.0, 1.0).expect("load sidecar");
+ let _ = std::fs::remove_file(&path);
+
+ assert_eq!(track.type_at(0.00), Some("pointer"));
+ assert_eq!(
+ track.type_at(0.10),
+ Some("arrow"),
+ "JSON null must reset to arrow"
+ );
+ assert_eq!(
+ track.type_at(0.20),
+ Some("pointer"),
+ "pointer after null must hold until the omitted-key sample"
+ );
+ assert_eq!(
+ track.type_at(0.30),
+ Some("arrow"),
+ "omitted cursorType after pointer must reset to arrow independently"
+ );
+ }
}
diff --git a/crates/compositor/src/d3d_linux.rs b/crates/compositor/src/d3d_linux.rs
index 7c344d831..3d74edf65 100644
--- a/crates/compositor/src/d3d_linux.rs
+++ b/crates/compositor/src/d3d_linux.rs
@@ -139,24 +139,36 @@ async fn create_async(want: Backend) -> Result {
info.name
);
}
- 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")?;
+ let desc = wgpu::DeviceDescriptor {
+ label: Some("openscreen-linux"),
+ required_features: wgpu::Features::empty(),
+ required_limits: wgpu::Limits::default(),
+ memory_hints: wgpu::MemoryHints::default(),
+ };
+ // Ouvre le device AVEC les extensions de memoire externe si la machine les a,
+ // et retombe sur le chemin standard sinon. Le repli couvre un hote sans
+ // pilote Vulkan utilisable, ou un pilote sans memoire externe ; sur cette
+ // machine il n'est plus atteint depuis qu'on n'exige que deux extensions.
+ let (device, queue, dmabuf_export) = match open_device_with_dmabuf_export(&adapter, &desc) {
+ Some((d, q)) => (d, q, true),
+ None => {
+ let (d, q) = adapter
+ .request_device(&desc, None)
+ .await
+ .context("request_device a echoue")?;
+ (d, q, false)
+ }
+ };
// Windows loggue son repli (`d3d_windows.rs`), Linux ne loggait rien : un hote
// tombe sur lavapipe rendait a quelques fps sans que rien -- ni log, ni rapport
// de bug -- ne permette de l'etablir a distance.
eprintln!(
- "[d3d] adaptateur Vulkan : {} ({:?}, {:?}) -> backend {:?}",
- info.name, info.device_type, info.backend, got
+ "[d3d] adaptateur Vulkan : {} ({:?}, {:?}) -> backend {:?}, export dmabuf {}",
+ info.name,
+ info.device_type,
+ info.backend,
+ got,
+ if dmabuf_export { "actif" } else { "indisponible" }
);
Ok(Gpu {
device,
@@ -394,3 +406,114 @@ mod tests {
}
}
}
+
+/// Ouvre le `VkDevice` en AJOUTANT les extensions qui permettent d'exporter une
+/// image en dmabuf, et rend `None` si la machine ne les a pas toutes.
+///
+/// POURQUOI PASSER SOUS wgpu. `request_device` n'a aucun moyen de demander une
+/// extension Vulkan : wgpu n'active que ce que ses propres `Features` imposent.
+/// Or l'export dmabuf n'a pas de `Feature` equivalente. Le seul point d'entree
+/// est donc de construire le device soi-meme et de le rendre a wgpu via
+/// `create_device_from_hal`.
+///
+/// CE QUE CETTE FONCTION NE FAIT PAS. Elle n'exporte rien : elle rend seulement
+/// l'export POSSIBLE plus tard. Tant que personne n'appelle `vkGetMemoryFdKHR`,
+/// activer ces extensions ne change ni le rendu ni les performances -- c'est
+/// justement ce qui permet de la livrer seule et de la verifier seule.
+///
+/// LE REPLI EST LE CAS NORMAL, PAS L'EXCEPTION. Le rasteriseur logiciel
+/// (lavapipe) n'expose pas `VK_EXT_image_drm_format_modifier`, et une machine
+/// sans pilote GPU utilisable non plus. Rendre `None` doit donc rester
+/// silencieux et sans consequence : l'appelant repart sur `request_device`.
+#[cfg(target_os = "linux")]
+fn open_device_with_dmabuf_export(
+ adapter: &wgpu::Adapter,
+ desc: &wgpu::DeviceDescriptor<'_>,
+) -> Option<(wgpu::Device, wgpu::Queue)> {
+ use std::ffi::CStr;
+
+ // Les deux qu'il faut EN PLUS de ce que wgpu demande deja. `dma_buf` depend
+ // de `external_memory_fd` ; ensemble elles suffisent a exporter la memoire
+ // d'un buffer sous forme de descripteur dmabuf.
+ //
+ // PAS `VK_EXT_image_drm_format_modifier`. Il ne servirait qu'a exporter une
+ // IMAGE, dont la disposition en memoire depend du pavage et doit donc etre
+ // decrite au consommateur. Ce qu'on exporte ici est le buffer de staging que
+ // le compositeur remplit deja par `copy_texture_to_buffer` : lineaire par
+ // construction, avec des pitches qu'on choisit. Il n'y a aucun pavage a
+ // decrire, donc rien a demander au pilote.
+ //
+ // L'exiger etait une erreur mesurable, pas une precaution : c'est
+ // precisement l'extension que le rasteriseur logiciel n'expose pas, donc
+ // reclamer les trois refusait l'export a des machines parfaitement capables
+ // de le faire.
+ const WANTED: [&CStr; 2] = [c"VK_KHR_external_memory_fd", c"VK_EXT_external_memory_dma_buf"];
+
+ unsafe {
+ adapter.as_hal::(|hal_adapter| {
+ let hal_adapter = hal_adapter?;
+ let phys = hal_adapter.raw_physical_device();
+ let instance = hal_adapter.shared_instance().raw_instance();
+
+ // Refuser tot plutot que d'echouer a `vkCreateDevice` : une extension
+ // absente y devient une erreur opaque.
+ let available = instance.enumerate_device_extension_properties(phys).ok()?;
+ let has = |name: &CStr| {
+ available
+ .iter()
+ .any(|e| e.extension_name_as_c_str() == Ok(name))
+ };
+ if !WANTED.iter().all(|n| has(n)) {
+ return None;
+ }
+
+ // Aux extensions de wgpu, pas a la place : en omettre une casserait
+ // le rendu, pas l'export.
+ let mut exts = hal_adapter.required_device_extensions(desc.required_features);
+ exts.extend_from_slice(&WANTED);
+ let ext_ptrs: Vec<*const std::os::raw::c_char> =
+ exts.iter().map(|e| e.as_ptr()).collect();
+
+ // La famille 0 n'est PAS garantie graphique. Elle l'est sur la
+ // plupart des pilotes, ce qui rend l'erreur invisible jusqu'a la
+ // machine ou elle ne l'est pas — et la panne serait alors un device
+ // qui s'ouvre puis ne sait rien dessiner.
+ let families = instance.get_physical_device_queue_family_properties(phys);
+ let family_index = families
+ .iter()
+ .position(|f| f.queue_flags.contains(ash::vk::QueueFlags::GRAPHICS))?
+ as u32;
+ let queue_prio = [1.0f32];
+ let queue_info = ash::vk::DeviceQueueCreateInfo::default()
+ .queue_family_index(family_index)
+ .queue_priorities(&queue_prio);
+ let queue_infos = [queue_info];
+
+ // `physical_device_features` porte les activations que wgpu attend
+ // (elles vivent dans des structures chainees) : les reprendre telles
+ // quelles est ce qui garantit que le device rendu est celui que wgpu
+ // aurait construit, extensions en plus.
+ let mut phys_features =
+ hal_adapter.physical_device_features(&exts, desc.required_features);
+ let info = phys_features.add_to_device_create(
+ ash::vk::DeviceCreateInfo::default()
+ .queue_create_infos(&queue_infos)
+ .enabled_extension_names(&ext_ptrs),
+ );
+ let raw_device = instance.create_device(phys, &info, None).ok()?;
+
+ let open = hal_adapter
+ .device_from_raw(
+ raw_device,
+ None,
+ &exts,
+ desc.required_features,
+ &desc.memory_hints,
+ family_index,
+ 0,
+ )
+ .ok()?;
+ adapter.create_device_from_hal(open, desc, None).ok()
+ })
+ }
+}
diff --git a/crates/compositor/src/export_probe.rs b/crates/compositor/src/export_probe.rs
new file mode 100644
index 000000000..667138042
--- /dev/null
+++ b/crates/compositor/src/export_probe.rs
@@ -0,0 +1,144 @@
+//! Sondes de temps par étage pour l'export, activées par `OPENSCREEN_EXPORT_PROFILE=1`.
+//!
+//! Le but est de répondre à UNE question — où part le temps d'un export — sans avoir à
+//! croire une intuition. Chaque étage accumule des nanosecondes et un compte d'appels ;
+//! `report` imprime le tableau sur stderr à la fin de la marche.
+//!
+//! # Coût quand c'est éteint
+//!
+//! `scope()` lit un `OnceLock` et, si la sonde est éteinte, ne prend AUCUNE horloge :
+//! le `Scope` rendu porte `None` et son `Drop` ne fait rien. Allumée, elle coûte deux
+//! `Instant::now()` (un `mach_absolute_time` chacun, ~20 ns sur Apple Silicon) et un
+//! `fetch_add` relaxé par étage et par frame.
+//!
+//! # Ce que les nombres veulent dire, et ne veulent pas dire
+//!
+//! Les étages sont mesurés là où le CPU les appelle, pas là où le GPU les exécute. Metal
+//! est asynchrone : `compose_frame` ne fait que soumettre, et l'attente de TOUT le travail
+//! GPU de la frame tombe dans `gpu_wait`. Lire `compose` comme « le coût de la composition »
+//! est donc faux — c'est le coût de la CONSTRUIRE, pas de la rendre.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::OnceLock;
+use std::time::Instant;
+
+#[derive(Clone, Copy)]
+pub enum Stage {
+ DecodeScreen = 0,
+ DecodeWebcam = 1,
+ Compose = 2,
+ VtGetBuffer = 3,
+ Nv12Passes = 4,
+ GpuWait = 5,
+ SendFrame = 6,
+ DrainMux = 7,
+ Progress = 8,
+ Finalize = 9,
+}
+
+const N: usize = 10;
+
+const NAMES: [&str; N] = [
+ "decode.screen",
+ "decode.webcam",
+ "compose.submit",
+ "vt.get_buffer",
+ "nv12.passes",
+ "gpu.wait",
+ "enc.send_frame",
+ "mux.drain",
+ "progress.cb",
+ "finalize",
+];
+
+static NANOS: [AtomicU64; N] = [
+ AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0),
+ AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0),
+];
+static COUNT: [AtomicU64; N] = [
+ AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0),
+ AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0),
+];
+
+static ENABLED: OnceLock = OnceLock::new();
+
+pub fn enabled() -> bool {
+ *ENABLED.get_or_init(|| {
+ matches!(
+ std::env::var("OPENSCREEN_EXPORT_PROFILE").ok().as_deref(),
+ Some("1") | Some("true")
+ )
+ })
+}
+
+pub struct Scope {
+ stage: usize,
+ t0: Option,
+}
+
+impl Drop for Scope {
+ fn drop(&mut self) {
+ if let Some(t0) = self.t0 {
+ NANOS[self.stage].fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
+ COUNT[self.stage].fetch_add(1, Ordering::Relaxed);
+ }
+ }
+}
+
+/// Ouvre une sonde sur `stage`. Le temps est compté jusqu'au `Drop` du `Scope` rendu.
+pub fn scope(stage: Stage) -> Scope {
+ Scope {
+ stage: stage as usize,
+ t0: if enabled() { Some(Instant::now()) } else { None },
+ }
+}
+
+/// Imprime le tableau sur stderr. `wall_s` est le mur total de la fonction d'export, ce qui
+/// permet de voir ce que les étages NE couvrent pas.
+pub fn report(wall_s: f64, frames: u64) {
+ if !enabled() {
+ return;
+ }
+ let total_ns: u64 = (0..N).map(|i| NANOS[i].load(Ordering::Relaxed)).sum();
+ eprintln!("[profile] {frames} frames en {wall_s:.3} s ({:.1} fps)", frames as f64 / wall_s.max(1e-9));
+ eprintln!("[profile] {:<16} {:>10} {:>9} {:>8} {:>7}", "étage", "total (s)", "µs/frame", "% mur", "appels");
+ let mut rows: Vec = (0..N).collect();
+ rows.sort_by_key(|&i| std::cmp::Reverse(NANOS[i].load(Ordering::Relaxed)));
+ for i in rows {
+ let ns = NANOS[i].load(Ordering::Relaxed);
+ let c = COUNT[i].load(Ordering::Relaxed);
+ if c == 0 {
+ continue;
+ }
+ eprintln!(
+ "[profile] {:<16} {:>10.3} {:>9.1} {:>7.1}% {:>7}",
+ NAMES[i],
+ ns as f64 / 1e9,
+ ns as f64 / 1e3 / c as f64,
+ 100.0 * (ns as f64 / 1e9) / wall_s.max(1e-9),
+ c
+ );
+ }
+ eprintln!(
+ "[profile] {:<16} {:>10.3} {:>9} {:>7.1}%",
+ "SOMME sondes",
+ total_ns as f64 / 1e9,
+ "",
+ 100.0 * (total_ns as f64 / 1e9) / wall_s.max(1e-9)
+ );
+ eprintln!(
+ "[profile] {:<16} {:>10.3} {:>9} {:>7.1}% <- ce que les sondes ne couvrent pas",
+ "non sondé",
+ wall_s - total_ns as f64 / 1e9,
+ "",
+ 100.0 * (wall_s - total_ns as f64 / 1e9) / wall_s.max(1e-9)
+ );
+}
+
+/// Remet tous les compteurs à zéro. Un même process peut enchaîner deux exports.
+pub fn reset() {
+ for i in 0..N {
+ NANOS[i].store(0, Ordering::Relaxed);
+ COUNT[i].store(0, Ordering::Relaxed);
+ }
+}
diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs
index 655b746b7..03a838437 100644
--- a/crates/compositor/src/frame_geometry.rs
+++ b/crates/compositor/src/frame_geometry.rs
@@ -379,7 +379,7 @@ pub(crate) fn cursor_sprite_dst(center: [f32; 2], w: f32, h: f32, hotspot: [f32;
/// 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 {
+pub 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.
@@ -732,6 +732,7 @@ pub struct FrameGeometryInput<'a> {
pub struct FrameGeometry {
pub scene_preset: Option,
pub mb_taps: f32,
+ pub mb_amount: f32,
pub source_t: f32,
pub zoom_rotation: [f32; 3],
pub padding_scale: f32,
@@ -862,6 +863,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
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);
+ let mb_amount = scene
+ .map(|s| s.effects.motion_blur.clamp(0.0, 1.0))
+ .unwrap_or(if cfg.mblur_n > 1 { 1.0 } else { 0.0 });
// 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.
@@ -1165,6 +1169,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
FrameGeometry {
scene_preset,
mb_taps,
+ mb_amount,
source_t,
zoom_rotation,
padding_scale,
@@ -1280,16 +1285,32 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Option f32 {
+ if taps <= 1 {
+ return 1.0;
+ }
+ let t = k as f32 / (taps - 1) as f32;
+ let ramp = 0.25 + 0.75 * t;
+ let sum = taps as f32 * 0.625;
+ ramp / sum
+}
+
+/// Les clés à évincer d'un cache de textures pour repasser sous `budget`, la moins récemment
+/// utilisée d'abord. `entries` porte `(clé, octets, tick d'usage)`.
+///
+/// `protect_from` est le tick au DÉBUT DE LA FRAME EN COURS : toute entrée touchée depuis est
+/// intouchable. Protéger la seule entrée qu'on vient de poser ne suffit pas — une frame échantillonne
+/// plusieurs textures (fond d'écran, fond de caméra, sprites de curseur), et évincer l'une d'elles
+/// parce qu'une autre vient d'arriver la ferait recharger à la frame suivante, puis rechasser la
+/// suivante : le cache se mettrait à battre au lieu de servir. Un décodage mesuré à 129 ms en
+/// release contre les ~3,5 ms d'une frame, c'est un échange qu'aucun budget mémoire ne justifie.
+///
+/// Si le jeu actif dépasse à lui seul le budget, la fonction s'arrête AU-DESSUS du budget plutôt
+/// que d'y toucher. Dépasser est le moindre mal.
+///
+/// Partagé plutôt que recopié dans chaque backend, pour la raison qui vaut pour tout ce module :
+/// trois copies d'une politique d'éviction finiraient par diverger sans que rien ne le dise.
+pub fn lru_evictions(entries: &[(String, u64, u64)], budget: u64, protect_from: u64) -> Vec {
+ let mut total: u64 = entries.iter().map(|(_, bytes, _)| *bytes).sum();
+ if total <= budget {
+ return Vec::new();
+ }
+ let mut candidates: Vec<&(String, u64, u64)> =
+ entries.iter().filter(|(_, _, tick)| *tick < protect_from).collect();
+ candidates.sort_by_key(|(_, _, tick)| *tick);
+ let mut out = Vec::new();
+ for (key, bytes, _) in candidates {
+ if total <= budget {
+ break;
+ }
+ total -= bytes;
+ out.push(key.clone());
+ }
+ out
+}
+
#[cfg(test)]
mod tests {
+ use super::lru_evictions;
+
+ /// `(clé, octets, tick)` — le tick croît avec l'usage, donc le plus petit est le plus ancien.
+ fn e(key: &str, mb: u64, tick: u64) -> (String, u64, u64) {
+ (key.to_string(), mb * 1024 * 1024, tick)
+ }
+
+ const BUDGET: u64 = 512 * 1024 * 1024;
+
+ #[test]
+ fn evicts_nothing_while_under_budget() {
+ assert!(lru_evictions(&[e("a", 100, 1), e("b", 100, 2)], BUDGET, 2).is_empty());
+ }
+
+ /// La plus ancienne part d'abord, et on s'arrête DÈS qu'on repasse sous le budget : évincer
+ /// au-delà ne rendrait que des rechargements.
+ #[test]
+ fn evicts_oldest_first_and_stops_at_the_budget() {
+ let entries = [e("vieux", 100, 1), e("moyen", 100, 2), e("neuf", 100, 9)];
+ assert_eq!(lru_evictions(&entries, 250 * 1024 * 1024, 9), vec!["vieux".to_string()]);
+ }
+
+ /// TOUT le jeu actif de la frame est protégé, pas seulement la dernière entrée posée. Une
+ /// frame qui échantillonne un fond d'écran ET un fond de caméra ne doit pas voir le premier
+ /// évincé parce que le second vient d'arriver — sinon les deux se chassent l'un l'autre à
+ /// chaque frame.
+ #[test]
+ fn protects_every_texture_used_this_frame() {
+ // frame commencée au tick 5 : `ecran` et `camera` servent tous deux maintenant.
+ let entries = [e("vieux", 100, 2), e("ecran", 400, 5), e("camera", 400, 6)];
+ assert_eq!(lru_evictions(&entries, BUDGET, 5), vec!["vieux".to_string()]);
+ }
+
+ /// Jeu actif plus gros que le budget : on rend ce qu'on peut et on reste au-dessus, plutôt que
+ /// de faire disparaître des textures dont cette frame a besoin.
+ #[test]
+ fn gives_up_rather_than_evicting_the_active_set() {
+ let entries = [e("a", 100, 1), e("actif", 900, 5)];
+ assert_eq!(lru_evictions(&entries, 256 * 1024 * 1024, 5), vec!["a".to_string()]);
+ }
+
use super::*;
/// La scène de référence du golden : un cas qui exerce le padding, le crop, le zoom,
@@ -1908,4 +2019,110 @@ mod tests {
let uv = webcam_source_rect([100.0, 100.0], [100.0, 100.0], Some(crop), 0.50 / 0.60);
assert_rect(uv, [0.25, 0.20, 0.75, 0.80]);
}
+
+ #[test]
+ fn cursor_tap_weight_sums_to_one_and_is_monotonically_increasing() {
+ assert_eq!(cursor_tap_weight(0, 1), 1.0);
+
+ for taps in [2, 4, 8, 11, 16] {
+ let mut sum = 0.0;
+ let mut prev_w = 0.0;
+ for k in 0..taps {
+ let w = cursor_tap_weight(k, taps);
+ assert!(w > 0.0, "poids positif");
+ if k > 0 {
+ assert!(w > prev_w, "tête plus marquée que la queue : {w} > {prev_w}");
+ }
+ prev_w = w;
+ sum += w;
+ }
+ assert!((sum - 1.0).abs() < 1e-5, "somme des poids = 1.0 pour taps={taps}, got {sum}");
+ }
+ }
+
+ #[test]
+ fn plan_cursor_motion_blur_adaptive_and_stationary() {
+ let cfg = crate::config::all().pop().expect("cfg");
+ let track_immobile = crate::cursor::CursorTrack::new(
+ vec![(0.0, 0.5, 0.5), (2.0, 0.5, 0.5)],
+ vec![],
+ vec![],
+ );
+ let track_moving = crate::cursor::CursorTrack::new(
+ vec![(0.0, 0.1, 0.1), (1.0, 0.9, 0.9)],
+ vec![],
+ vec![],
+ );
+ let scene = zoomed_golden_scene();
+ let fg = FrameGeometry {
+ scene_preset: None,
+ mb_taps: 1.0,
+ mb_amount: 0.0,
+ source_t: 0.0,
+ zoom_rotation: [0.0, 0.0, 0.0],
+ padding_scale: 1.0,
+ cut: [0.0, 0.0, 1.0, 1.0],
+ s_dst: [0.0, 0.0, 1.0, 1.0],
+ s_dst_prev: [0.0, 0.0, 1.0, 1.0],
+ s_ann: [0.0, 0.0, 1.0, 1.0],
+ s_radius: 0.0,
+ frame_min_px: 1080.0,
+ w_dst: [0.0, 0.0, 0.0, 0.0],
+ w_dst_prev: [0.0, 0.0, 0.0, 0.0],
+ w_px: [0.0, 0.0],
+ w_radius: 0.0,
+ shape_fade: 0.0,
+ };
+
+ // 1. Curseur immobile avec blur actif -> taps = 1
+ let live_with_blur = LiveParams {
+ cursor_motion_blur: 0.8,
+ ..LiveParams::default()
+ };
+ let input_immobile = CursorPlanInput {
+ render_px: [1920.0, 1080.0],
+ u_max: 1.0,
+ v_max: 1.0,
+ cfg: &cfg,
+ live: live_with_blur,
+ scene: Some(&scene),
+ track: &track_immobile,
+ t: 0.5,
+ };
+ let plan = plan_cursor(&fg, &input_immobile).expect("plan cursor");
+ assert_eq!(plan.taps, 1, "curseur immobile doit rester à 1 tap");
+
+ // 2. Curseur avec blur = 0 -> taps = 1
+ let live_no_blur = LiveParams {
+ cursor_motion_blur: 0.0,
+ ..LiveParams::default()
+ };
+ let input_no_blur = CursorPlanInput {
+ render_px: [1920.0, 1080.0],
+ u_max: 1.0,
+ v_max: 1.0,
+ cfg: &cfg,
+ live: live_no_blur,
+ scene: Some(&scene),
+ track: &track_moving,
+ t: 0.5,
+ };
+ let plan = plan_cursor(&fg, &input_no_blur).expect("plan cursor");
+ assert_eq!(plan.taps, 1, "blur=0 doit donner taps = 1");
+
+ // 3. Curseur en mouvement rapide avec blur -> taps adaptatifs entre 2 et 16
+ let input_moving = CursorPlanInput {
+ render_px: [1920.0, 1080.0],
+ u_max: 1.0,
+ v_max: 1.0,
+ cfg: &cfg,
+ live: live_with_blur,
+ scene: Some(&scene),
+ track: &track_moving,
+ t: 0.5,
+ };
+ let plan = plan_cursor(&fg, &input_moving).expect("plan cursor");
+ assert!(plan.taps >= 2 && plan.taps <= 16, "taps adaptatifs dans [2, 16], got {}", plan.taps);
+ }
}
+
diff --git a/crates/compositor/src/gif_export.rs b/crates/compositor/src/gif_export.rs
index a30a43007..c74adb090 100644
--- a/crates/compositor/src/gif_export.rs
+++ b/crates/compositor/src/gif_export.rs
@@ -254,7 +254,7 @@ fn export_gif_inner(
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)?
+ Decoder::open_for_export(&clips[0].screen, gpu)?
});
let frames = unsafe {
diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs
index d8972e7d1..ac9c93bab 100644
--- a/crates/compositor/src/lib.rs
+++ b/crates/compositor/src/lib.rs
@@ -28,8 +28,10 @@
//! — c'est précisément ce qui rend le port Metal possible (cf. PR #162).
pub mod audio;
+pub mod audio_jobs;
pub mod config;
pub mod cursor;
+pub mod export_probe;
pub mod ffi;
pub mod frame_geometry;
pub mod gif_export;
@@ -40,6 +42,10 @@ pub mod regions;
// n'est spécifique à Linux.
pub mod remux;
pub mod scene;
+// Segmentation du sujet webcam (masque -> `t3`). Le module compile toujours ; sans la feature
+// `segmentation` ses deux entrées échouent proprement, ce qui garde le reste du crate
+// indépendant du choix de packaging d'ONNX Runtime.
+pub mod segmentation;
pub mod text_anim;
pub mod text_plate;
pub(crate) mod timeline_walk;
diff --git a/crates/compositor/src/linux_frames.rs b/crates/compositor/src/linux_frames.rs
index dcb99bedb..044c05b7e 100644
--- a/crates/compositor/src/linux_frames.rs
+++ b/crates/compositor/src/linux_frames.rs
@@ -35,13 +35,22 @@ use crate::ffi::{
/// 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).
+/// Une frame decodee presentee au compositor sous forme de TROIS textures wgpu
+/// `R8Unorm` : Y en `w x h`, U et V en `(w/2) x (h/2)`. Pendant Linux de la
+/// `ID3D11Texture2D` NV12 (D3D11) / du CVPixelBuffer (macOS), qui eux portent un
+/// plan de chroma entrelace parce que leur decodeur materiel le rend ainsi.
+///
+/// POURQUOI TROIS PLANS ET PAS UN UV ENTRELACE. Le decodeur software rend du
+/// YUV420P, ou U et V sont DEJA deux plans distincts. Les entrelacer en NV12
+/// demandait un `sws_scale` CPU par frame et par flux — deux fois par frame de
+/// sortie sur une scene avec webcam — pour produire une disposition que le GPU
+/// echantillonne tout aussi bien en deux textures. Les uploader tels quels
+/// supprime cette conversion sans changer un pixel : c'etait un entrelacement,
+/// pas un reechantillonnage.
pub(crate) struct VkFrameTex {
pub y: wgpu::Texture,
- pub uv: wgpu::Texture,
+ pub u: wgpu::Texture,
+ pub v: wgpu::Texture,
pub width: u32,
pub height: u32,
}
@@ -107,24 +116,34 @@ impl CpuFrames {
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");
+ // CHEMIN RAPIDE : le decodeur rend deja du YUV420P (c'est le cas de tout
+ // h264 4:2:0, donc de tout ce que cette app enregistre), et c'est
+ // exactement la disposition que les trois textures attendent. Rien a
+ // convertir : on uploade les plans du decodeur tels quels.
+ if (*src).format == AVPixelFormat::AV_PIX_FMT_YUV420P as i32 {
+ self.upload_planes(src)?;
+ } else {
+ // REPLI : format exotique (4:2:2, 10 bits, un import quelconque).
+ // `sws_scale` ramene en YUV420P — pas en NV12 : la cible n'a plus de
+ // plan entrelace — et on uploade le resultat par le meme chemin.
+ self.ensure_sws(w, h, (*src).format)?;
+ self.ensure_nv12(w, h)?;
+ 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_planes(self.nv12)?;
}
-
- self.upload()?;
self.attach_carrier(w, h)?;
// Contrat lu par le compositor : sentinel + timestamps recopies (sinon la
// timeline se croit a t=0).
@@ -148,14 +167,14 @@ impl CpuFrames {
src_fmt as AVPixelFormat::Type,
w,
h,
- AVPixelFormat::AV_PIX_FMT_NV12,
+ AVPixelFormat::AV_PIX_FMT_YUV420P,
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");
+ bail!("sws_getContext {w}x{h} fmt {src_fmt} -> YUV420P");
}
self.sws_key = key;
Ok(())
@@ -164,14 +183,14 @@ impl CpuFrames {
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
+ && (*self.nv12).format == AVPixelFormat::AV_PIX_FMT_YUV420P 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;
+ (*self.nv12).format = AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
if av_frame_get_buffer(self.nv12, 32) < 0 {
bail!("av_frame_get_buffer NV12 {w}x{h}");
}
@@ -202,23 +221,28 @@ impl CpuFrames {
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: &[],
- });
+ let mut chroma = |label| {
+ self.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ 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::R8Unorm,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ })
+ };
+ let u = chroma("yuv420p-u");
+ let v = chroma("yuv420p-v");
self.tex = Some(Box::new(VkFrameTex {
y,
- uv,
+ u,
+ v,
width: dims.0,
height: dims.1,
}));
@@ -229,53 +253,52 @@ impl CpuFrames {
/// 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<()> {
+ /// Uploade les trois plans YUV420P de `f` dans les trois textures. `f` est
+ /// soit la frame du decodeur (chemin rapide), soit la sortie de swscale
+ /// (repli) : la disposition est la meme, seule la provenance change.
+ unsafe fn upload_planes(&mut self, f: *mut AVFrame) -> 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,
- },
- );
+ // LES DIMENSIONS DE TEXTURE SONT ARRONDIES AU PAIR, PAS LES PLANS.
+ // `ensure_textures` arrondit pour que le chroma 4:2:0 tombe juste, mais
+ // le decodeur, lui, alloue au visible : lire `stride * hauteur_arrondie`
+ // depasse le plan d'une ligne sur une source de hauteur impaire. On lit
+ // donc le VISIBLE et on laisse la derniere ligne de la texture telle
+ // qu'elle est — elle n'existe que pour l'alignement.
+ let (vw, vh) = ((*f).width.max(0) as u32, (*f).height.max(0) as u32);
+ let (vw, vh) = (vw.min(tex.width), vh.min(tex.height));
+ let (cw, chh) = (vw.div_ceil(2), vh.div_ceil(2));
+ for (plane, texture, pw, ph) in [
+ (0usize, &tex.y, vw, vh),
+ (1, &tex.u, cw, chh),
+ (2, &tex.v, cw, chh),
+ ] {
+ let stride = (*f).linesize[plane] as usize;
+ if stride == 0 || (*f).data[plane].is_null() {
+ bail!("plan YUV {plane} absent (linesize={stride})");
+ }
+ self.queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ std::slice::from_raw_parts((*f).data[plane], stride * ph as usize),
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(stride as u32),
+ rows_per_image: Some(ph),
+ },
+ wgpu::Extent3d {
+ width: pw,
+ height: ph,
+ depth_or_array_layers: 1,
+ },
+ );
+ }
Ok(())
}
@@ -292,7 +315,8 @@ impl CpuFrames {
}
(*self.present).data[0] = pack_carrier(Box::new(VkFrameTex {
y: tex.y.clone(),
- uv: tex.uv.clone(),
+ u: tex.u.clone(),
+ v: tex.v.clone(),
width: tex.width,
height: tex.height,
}));
@@ -322,14 +346,16 @@ pub(crate) unsafe fn carrier_dims(frame: *const AVFrame) -> (u32, u32) {
/// depuis le carrier `frame.data[0]`. Appele par `compositor_linux`.
pub(crate) unsafe fn nv12_planes(
frame: *const AVFrame,
-) -> Result<(wgpu::TextureView, wgpu::TextureView)> {
+) -> Result<(wgpu::TextureView, 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]);
+ let d = wgpu::TextureViewDescriptor::default();
Ok((
- tex.y.create_view(&wgpu::TextureViewDescriptor::default()),
- tex.uv.create_view(&wgpu::TextureViewDescriptor::default()),
+ tex.y.create_view(&d),
+ tex.u.create_view(&d),
+ tex.v.create_view(&d),
))
}
diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs
index 552d2264a..9d91fe643 100644
--- a/crates/compositor/src/live.rs
+++ b/crates/compositor/src/live.rs
@@ -1096,6 +1096,29 @@ type PendingPrefetch = (usize, std::sync::mpsc::Receiver>
/// décodeurs ouvertes plus longtemps que nécessaire.
const PREFETCH_LEAD_SEC: f64 = 0.75;
+/// Durée pendant laquelle la boucle continue de recomposer après un changement en pause, le
+/// temps qu'un effet asynchrone (segmentation webcam) livre son résultat. Généreuse : à
+/// l'échelle d'une pause, une demi-seconde de recomposes ne coûte rien, alors qu'une fenêtre
+/// trop courte laisse l'effet invisible sur une machine lente — exactement le bug d'origine.
+const SETTLE_WINDOW: Duration = Duration::from_millis(500);
+/// Cadence des recomposes dans cette fenêtre : celle de la segmentation (`SEGMENTATION_HZ`),
+/// pas celle de la boucle — recomposer à 250 Hz n'accélérerait pas une inférence limitée à 30 Hz.
+const SETTLE_STEP: Duration = Duration::from_millis(33);
+
+/// Ouvre (ou rouvre) la fenêtre de stabilisation. Les deux appelants — changement en pause et
+/// seek en pause — doivent poser la MÊME paire : un `last_settle` oublié ferait recomposer à la
+/// cadence de la boucle au lieu de celle de la segmentation.
+fn open_settle_window(now: Instant) -> (Option, Instant) {
+ (Some(now + SETTLE_WINDOW), now)
+}
+
+/// Faut-il recomposer alors que RIEN n'a changé ? Oui tant que la fenêtre de stabilisation
+/// court et que la cadence le permet. Extrait de la boucle pour être vérifiable sans GPU.
+fn should_settle(now: Instant, settle_until: Option, last_settle: Instant) -> bool {
+ settle_until.is_some_and(|deadline| now < deadline)
+ && now.duration_since(last_settle) >= SETTLE_STEP
+}
+
/// 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
@@ -1348,6 +1371,15 @@ unsafe fn render_thread(
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)
+ // Fenêtre de stabilisation après un changement EN PAUSE. Un seul recompose ne suffit pas
+ // quand l'effet demandé est asynchrone : la segmentation webcam (détourage / flou / fond
+ // personnalisé) démarre son worker au 1er compose, ne SOUMET la frame qu'au 2e et ne
+ // téléverse le masque qu'au 3e — d'où l'effet qui n'apparaissait qu'au scrub suivant, le
+ // scrub étant la seule chose qui recomposait encore.
+ // ponytail: fenêtre fixe plutôt qu'un vrai signal « masque en attente » exposé par les
+ // trois compositeurs ; à remplacer si une machine met plus que ça à inférer.
+ let mut settle_until: Option = None;
+ let mut last_settle = Instant::now();
// 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.
@@ -1575,6 +1607,11 @@ unsafe fn render_thread(
if let Some(target) = requested {
if player.present_frame(&comp, &cfg, target)? {
stepped = true;
+ // Un seek en pause compose UNE fois, exactement comme un changement de param :
+ // la frame webcam a changé, donc son masque aussi, et il arrivera deux composes
+ // plus tard. Sans cette fenêtre, le masque de la position PRÉCÉDENTE reste
+ // affiché jusqu'à ce qu'une autre action provoque un compose.
+ (settle_until, last_settle) = open_settle_window(now);
}
acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek
} else if shared.playing.load(Ordering::Relaxed) {
@@ -1687,6 +1724,14 @@ unsafe fn render_thread(
}
} else if first || ip_changed || scene_changed || clip_changed || resized {
// pause : recompose la frame courante (param / scène / clip / résolution changés).
+ (settle_until, last_settle) = open_settle_window(now);
+ let _ = player.recompose(&comp, &cfg);
+ stepped = true;
+ } else if should_settle(now, settle_until, last_settle) {
+ // Rien n'a changé, mais un masque de segmentation peut encore être en vol : on
+ // recompose à la cadence de la segmentation (pas à celle de la boucle) jusqu'à ce
+ // que la fenêtre expire.
+ last_settle = now;
let _ = player.recompose(&comp, &cfg);
stepped = true;
}
@@ -1905,6 +1950,44 @@ pub fn run_standalone(_screen: &str, _webcam: &str, _cursor_json: &str) -> anyho
#[cfg(test)]
mod tests {
+ use super::{open_settle_window, should_settle, SETTLE_STEP, SETTLE_WINDOW};
+ use std::time::Instant;
+
+ /// Le bug d'origine : en pause, un seul recompose par changement, donc le masque de
+ /// segmentation (asynchrone, 3 composes de latence) n'arrivait jamais avant un scrub.
+ #[test]
+ fn settle_recomposes_within_the_window_at_the_segmentation_rate() {
+ let t0 = Instant::now();
+ let deadline = Some(t0 + SETTLE_WINDOW);
+
+ assert!(!should_settle(t0, None, t0), "aucune fenêtre ouverte : rien à faire");
+ assert!(
+ !should_settle(t0 + SETTLE_STEP / 2, deadline, t0),
+ "dans la fenêtre mais trop tôt : on ne recompose pas à la cadence de la boucle",
+ );
+ assert!(
+ should_settle(t0 + SETTLE_STEP, deadline, t0),
+ "dans la fenêtre et la cadence est due : c'est le tour qui livre le masque",
+ );
+ assert!(
+ !should_settle(t0 + SETTLE_WINDOW, deadline, t0),
+ "fenêtre expirée : on retombe en pause inerte plutôt que de recomposer sans fin",
+ );
+ }
+
+ /// Un seek en pause compose aussi UNE seule fois : la frame webcam a changé, son masque
+ /// arrive deux composes plus tard. Le chemin `present_frame` doit donc ouvrir la même
+ /// fenêtre que le chemin « un param a changé », sans recomposer immédiatement.
+ #[test]
+ fn a_paused_seek_opens_the_same_window() {
+ let t0 = Instant::now();
+ let (until, last) = open_settle_window(t0);
+
+ assert!(!should_settle(t0, until, last), "pas de recompose en boucle juste après le seek");
+ assert!(should_settle(t0 + SETTLE_STEP, until, last), "le tour suivant livre le masque");
+ assert!(!should_settle(t0 + SETTLE_WINDOW, until, last), "puis la fenêtre se referme");
+ }
+
use super::*;
fn multiclip_scene() -> Scene {
diff --git a/crates/compositor/src/mac_frames.rs b/crates/compositor/src/mac_frames.rs
index ec2432637..0d04b3da5 100644
--- a/crates/compositor/src/mac_frames.rs
+++ b/crates/compositor/src/mac_frames.rs
@@ -375,3 +375,51 @@ impl Drop for CpuFrames {
}
}
}
+
+/// Fabrique un `CVPixelBufferRef` NV12 IOSurface-backed et y écrit les deux plans donnés.
+///
+/// Réservé aux tests, mais posé ICI plutôt que dans le module de test : `CVPixelBufferCreate`,
+/// le dictionnaire d'attributs IOSurface/Metal et le verrou d'accès CPU sont déjà écrits
+/// au-dessus, et les redéclarer ailleurs ferait vivre deux copies de la même FFI.
+///
+/// `y` fait `w * h` octets, `uv` fait `w * (h / 2)` (Cb, Cr entrelacés, demi-résolution).
+/// C'est exactement ce que `CVMetalTextureCache` sait présenter en `R8Unorm` + `RG8Unorm`,
+/// donc ce que `Compositor::nv12_srvs` attend — la même route qu'une frame VideoToolbox.
+#[cfg(test)]
+pub(crate) fn nv12_pixel_buffer_from_planes(
+ w: u32,
+ h: u32,
+ y: &[u8],
+ uv: &[u8],
+) -> Result {
+ let (w, h) = (w as usize, h as usize);
+ if y.len() < w * h || uv.len() < w * (h / 2) {
+ bail!(
+ "plans trop courts pour {w}x{h} : Y={} octets, UV={} octets",
+ y.len(),
+ uv.len()
+ );
+ }
+ unsafe {
+ let pb = create_nv12_pixel_buffer(w, h)?;
+ if CVPixelBufferLockBaseAddress(pb.as_ptr(), 0) != 0 {
+ bail!("CVPixelBufferLockBaseAddress (fixture NV12)");
+ }
+ let base_y = CVPixelBufferGetBaseAddressOfPlane(pb.as_ptr(), 0);
+ let pitch_y = CVPixelBufferGetBytesPerRowOfPlane(pb.as_ptr(), 0);
+ let base_uv = CVPixelBufferGetBaseAddressOfPlane(pb.as_ptr(), 1);
+ let pitch_uv = CVPixelBufferGetBytesPerRowOfPlane(pb.as_ptr(), 1);
+ if base_y.is_null() || base_uv.is_null() {
+ CVPixelBufferUnlockBaseAddress(pb.as_ptr(), 0);
+ bail!("plans nuls (fixture NV12)");
+ }
+ for row in 0..h {
+ ptr::copy_nonoverlapping(y.as_ptr().add(row * w), base_y.add(row * pitch_y), w);
+ }
+ for row in 0..h / 2 {
+ ptr::copy_nonoverlapping(uv.as_ptr().add(row * w), base_uv.add(row * pitch_uv), w);
+ }
+ CVPixelBufferUnlockBaseAddress(pb.as_ptr(), 0);
+ Ok(pb)
+ }
+}
diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs
index 910738fc0..387e29319 100644
--- a/crates/compositor/src/pipeline_linux.rs
+++ b/crates/compositor/src/pipeline_linux.rs
@@ -14,6 +14,12 @@
//! (`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).
+//!
+//! **Le débit demandé n'est pas un contrat sur ce chemin.** `libopenh264` n'a pas
+//! de contrôle de débit utilisable — la cause est en amont, pas ici, et elle est
+//! documentée avec ce qui a été mesuré dans `VideoEncoder::tune_openh264`. À lire
+//! avant de toucher au calcul de `bit_rate` ou d'ajouter une option d'encodage :
+//! la moitié des réglages qui semblent évidents ont été essayés et ne font rien.
use anyhow::{bail, Result};
use std::collections::HashMap;
@@ -21,9 +27,10 @@ use std::ffi::CString;
use std::ptr;
use crate::audio::{
- assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
- stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
+ AacEncoder, PlanarPcm,
};
+use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::config::Cfg;
use crate::d3d::Gpu;
use crate::ffi::AVFrame;
@@ -31,10 +38,6 @@ use crate::linux_decode::SwDecoder;
use crate::timeline_walk::NextFrameTime;
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,
@@ -97,6 +100,12 @@ pub struct Decoder {
unsafe impl Send for Decoder {}
impl Decoder {
+ /// Même point d'entrée que sur macOS, pour que `timeline_walk` reste portable. Le backend
+ /// Linux décode déjà en logiciel (`SwDecoder`) : l'intention n'a rien à trancher.
+ pub fn open_for_export(path: &str, gpu: &Gpu) -> Result {
+ Self::open(path, gpu)
+ }
+
pub fn open(path: &str, gpu: &Gpu) -> Result {
let sw = SwDecoder::open(path)?;
let fps = sw.fps();
@@ -194,10 +203,6 @@ impl Decoder {
/// `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,
}
@@ -260,66 +265,154 @@ impl VideoEncoder {
(*ctx).time_base = AVRational { num: 1, den: fps };
(*ctx).framerate = AVRational { num: fps, den: 1 };
(*ctx).bit_rate = bit_rate;
+ // Une image clé toutes les 2 s. SANS ce réglage le MP4 exporté n'en contient
+ // qu'UNE SEULE : le wrapper ffmpeg de `libopenh264` pose `g = -1` dans ses
+ // `FFCodecDefault`, et `try_open` ne touchait pas `gop_size`, donc openh264
+ // recevait `uiIntraPeriod = 0` — mesuré 1 image I pour 300 frames. Le fichier
+ // reste lisible mais tout seek doit redécoder depuis le début, et un paquet
+ // abîmé emporte le reste de la vidéo. 2 s est le compromis usuel pour un
+ // fichier de sortie ; mesuré sur un vrai enregistrement 1080p60 il coûte
+ // +5,7 % de débit sur du contenu dense et +14,6 % sur un écran statique.
+ // Le défaut générique d'`AVCodecContext` (12 frames, soit 0,2 s à 60 fps)
+ // serait bien plus cher : on le pose donc explicitement pour tous les
+ // encodeurs, pas seulement pour celui qui a le défaut cassé.
+ (*ctx).gop_size = (fps * 2).max(1);
+ Self::tune_openh264(ctx, name);
// 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)
- }
- }
+ // Plus d'AVFrame ni de `SwsContext` ici : les frames viennent du pool de
+ // l'`EncodeWorker`, deja a la disposition du GPU. Cet encodeur ne
+ // possede plus que son contexte, donc il n'y a plus rien qui puisse
+ // echouer apres `avcodec_open2`.
+ Ok(VideoEncoder { ctx, w, h })
}
- /// Envoie une frame composee DEJA RELUE (RGBA) a l'encodeur, en YUV420P.
+ /// Réglages propres à `libopenh264`, à poser AVANT `avcodec_open2` (openh264 fige
+ /// ses `SEncParamExt` à l'ouverture). No-op pour tout autre encodeur.
+ ///
+ /// **`libopenh264` n'a pas de contrôle de débit utilisable, et ce n'est pas
+ /// réparable ici** (issue #572). Le wrapper ffmpeg laisse `bEnableFrameSkip = 0`,
+ /// et openh264 le dit lui-même à l'ouverture :
+ ///
+ /// > `bEnableFrameSkip = 0, bitrate can't be controlled for RC_QUALITY_MODE,`
+ /// > `RC_BITRATE_MODE and RC_TIMESTAMP_MODE without enabling skip frame.`
+ ///
+ /// La seule option qui rétablit un vrai plafond est `allow_skip_frames`, et elle
+ /// le paie en frames jetées — mesuré 3 frames sur 120 conservées sur du contenu
+ /// incompressible. Inacceptable pour un export, donc on n'y touche pas. Sont
+ /// aussi des impasses vérifiées à la mesure : `rc_max_rate` et `rc_buffer_size`
+ /// (que ce wrapper ne lit pas, ou dont openh264 ne se sert que dans le chemin
+ /// frame-skip), `rc_mode`, `max_nal_size` et `level`.
+ ///
+ /// Restent deux réglages qui, eux, se mesurent :
+ ///
+ /// 1. **Ouvrir la fenêtre de QP.** Le wrapper ne transmet `iMinQp`/`iMaxQp` que
+ /// si `qmin`/`qmax` sont >= 0, et ses `FFCodecDefault` les posent à -1.
+ /// openh264 part alors sur ses propres défauts (0, 51) — puis sa
+ /// `ParamValidation()` juge `iMinQp = 0` invalide et REMPLACE toute la fenêtre
+ /// par (12, 42) (`GOM_MIN_QP_MODE`, `MAX_LOW_BR_QP`). Un `qmin` >= 1 évite la
+ /// substitution et rend le QP 51 atteignable. La borne basse, elle, reste
+ /// clampée à 12 quoi qu'on demande, donc `qmin = 1` ne fait qu'éviter le piège.
+ ///
+ /// L'intérêt premier est de ne plus subir une fenêtre qu'on n'a pas choisie :
+ /// (12, 42) n'est pas une décision, c'est ce qu'openh264 substitue en silence.
+ ///
+ /// Sur les quatre classes de contenu réel essayées, le changement est INERTE —
+ /// sortie identique à l'octet sur un écran statique, sur une capture dense, sur
+ /// du mixte (UI + fenêtre vidéo sur un tiers de l'image) et sur de la webcam
+ /// plein cadre. Sur ces mêmes clips le débit demandé est d'ailleurs plutôt bien
+ /// suivi (mixte 1080p30 : 1 -> 0,98, 2 -> 1,94, 4 -> 3,67 Mbps ; webcam :
+ /// 2 -> 1,99, 8 -> 7,51), ce qui vaut d'être su avant de conclure du titre de
+ /// #572 que l'export Linux serait à l'abandon.
///
- /// 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<()> {
+ /// La fenêtre ne mord que sur du contenu que l'encodeur ne sait pas comprimer,
+ /// et là elle échange de la qualité contre de la taille : sur 120 frames de
+ /// bruit incompressible 720p30 à 2 Mbps demandés, 113,2 -> 65,5 Mbps mais
+ /// SSIM 0,949 -> 0,652. C'est le bon sens de l'échange pour un export (le
+ /// fichier restait 56x au-dessus de la cible), mais si on le regrettait,
+ /// `qmin = 12 ; qmax = 42` fige exactement le comportement d'avant tout en
+ /// gardant le réglage explicite.
+ /// 2. **Profil High.** Par défaut ce wrapper produit du Constrained Baseline en
+ /// CAVLC (`profile_idc = 66`, `entropy_coding_mode_flag = 0`). Le profil High
+ /// active CABAC. Mesuré à qualité égale sur un vrai enregistrement 1080p60
+ /// (VMAF 96,2 dans les deux cas) : -3,8 % de débit sur du contenu dense,
+ /// -8,0 % sur un écran statique.
+ ///
+ /// Ce qu'il reste de cassé après ça, et qui ne se règle pas depuis l'application :
+ /// le débit demandé n'est qu'une entrée faible d'un modèle complexité -> QP, borné
+ /// à [12, 51]. Sur un écran statique l'encodeur se colle à QP 12 et ne dépense pas
+ /// plus, quel que soit le `bit_rate` (mesuré 0,68 Mbps pour 8 comme pour 40 Mbps
+ /// demandés) ; sur du contenu qu'il ne sait pas comprimer il dépasse la cible sans
+ /// borne. Le mode `SCREEN_CONTENT_REAL_TIME` d'openh264 — celui qui conviendrait à
+ /// un enregistreur d'écran — n'est atteignable par aucune option ffmpeg.
+ /// Suivi en amont : cisco/openh264#3259 (fermé sans correctif).
+ unsafe fn tune_openh264(ctx: *mut crate::ffi::AVCodecContext, name: &str) {
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);
- }
+ if name != "libopenh264" {
+ return;
}
- 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");
+ (*ctx).qmin = 1;
+ (*ctx).qmax = 51;
+ // L'encodeur expose AUSSI `profile` en option privée, mais le wrapper lit
+ // `avctx->profile` quand la privée vaut `AV_PROFILE_UNKNOWN` (son défaut,
+ // -99). Les deux chemins produisent le même SPS — vérifié, `profile_idc`
+ // passe à 100 dans les deux cas — donc on prend le champ : il est typé,
+ // vérifié à la compilation, et n'a pas besoin d'une `CString` ni d'une
+ // branche d'erreur, contrairement à `av_opt_set` sur `priv_data`.
+ (*ctx).profile = AV_PROFILE_H264_HIGH as i32;
+ }
+
+ /// Recopie le buffer relu dans une AVFrame du pool. Les deux ont la MEME
+ /// disposition (`alloc_padded_yuv_frame`), donc c'est un seul bloc contigu :
+ /// pas de reformatage, juste un transfert hors de la memoire mappee avant que
+ /// la ring ne recycle le slot.
+ ///
+ /// C'EST UNE COPIE, ET ELLE RESTE. La supprimer voudrait dire encoder
+ /// directement depuis le buffer de staging, donc le maintenir mappe pendant
+ /// que le worker travaille, a travers une frontiere de thread. Le gain est le
+ /// meme ~0,30 ms/frame que ce memcpy coute deja ; le prix serait un slot wgpu
+ /// dont la duree de vie depend de l'encodeur. Pas le bon echange tant que ce
+ /// n'est pas ce thread-ci le goulot.
+ pub unsafe fn copy_into(
+ dst_frame: *mut AVFrame,
+ planes: &[u8],
+ rw: i32,
+ rh: i32,
+ enc_w: i32,
+ enc_h: i32,
+ ) -> Result<()> {
+ // Les DEUX bornes comptent. La verification de taille seule laisserait
+ // passer un buffer assez gros mais de mauvaise geometrie : la disposition
+ // serait recalculee depuis rw/rh et l'image sortirait silencieusement
+ // decalee, bien plus difficile a diagnostiquer qu'un echec franc.
+ if rw != enc_w || rh != enc_h {
+ bail!("copy_into {rw}x{rh} != encodeur {enc_w}x{enc_h}");
}
- (*self.sw).pts = pts;
- averr(avcodec_send_frame(self.ctx, self.sw), "send_frame")
+ let lay = YuvLayout::for_size(rw, rh);
+ if planes.len() < lay.total {
+ bail!("plans YUV tronques : {} octets pour {}", planes.len(), lay.total);
+ }
+ // REND LA FRAME ECRIVABLE AVANT DE LA REECRIRE. `avcodec_send_frame`
+ // prend une reference sur le buffer ; un encodeur qui garde la frame —
+ // parce qu'il a du delai, ou parce que `OPENSCREEN_EXPORT_ENCODER` en a
+ // choisi un autre — la tiendrait encore quand le pool la recycle, et on
+ // ecrirait dans une image en cours d'encodage.
+ //
+ // J'avais retire cet appel en le jugeant inutile : avec `libopenh264` le
+ // refcount EST retombe a 1 au retour, mesure. Mais c'est une propriete de
+ // CET encodeur-la, pas du pool, et rien dans le code ne la maintenait.
+ // Ici l'appel est gratuit quand elle tient (refcount 1 = no-op) et
+ // correct quand elle ne tient pas. Le buffer ne porte pas
+ // `AV_BUFFER_FLAG_READONLY`, donc pas de branche recopie a redouter.
+ crate::ffi::averr(crate::ffi::av_frame_make_writable(dst_frame), "make_writable")?;
+ debug_assert_eq!((*dst_frame).linesize[0] as usize, lay.bpr_y);
+ debug_assert_eq!((*dst_frame).linesize[1] as usize, lay.bpr_uv);
+ std::ptr::copy_nonoverlapping(planes.as_ptr(), (*dst_frame).data[0], lay.total);
+ Ok(())
}
/// Flush : une frame nulle finalise le bitstream de l'encodeur.
@@ -335,51 +428,326 @@ 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> {
+
+/// Geometrie du buffer relu : strides alignes a 256 (ce que
+/// `copy_texture_to_buffer` impose) et offsets des trois plans dans l'allocation
+/// unique. Calculee a UN SEUL endroit, parce que le producteur (le compositeur)
+/// et le consommateur (l'AVFrame du pool) doivent s'accorder a l'octet pres.
+#[derive(Clone, Copy)]
+struct YuvLayout {
+ bpr_y: usize,
+ bpr_uv: usize,
+ off_u: usize,
+ off_v: usize,
+ total: usize,
+}
+
+impl YuvLayout {
+ /// DERIVE de `Compositor::yuv_layout_for`, jamais recalculee. Cette
+ /// arithmetique existait ici en double, et c'est precisement le genre de
+ /// duplication qui ne casse rien tant qu'elle est identique : le producteur
+ /// (le compositeur, qui remplit le buffer) et le consommateur (l'AVFrame du
+ /// pool) doivent s'accorder A L'OCTET, et un ecart ne donnerait pas une
+ /// panne mais une image decalee.
+ fn for_size(w: i32, h: i32) -> YuvLayout {
+ let (bpr_y, bpr_uv, off_u, total) = crate::compositor::Compositor::yuv_layout_for(
+ w.max(0) as u32,
+ h.max(0) as u32,
+ crate::compositor::YuvFormat::I420,
+ );
+ let ch = (h.max(0) as u64).div_ceil(2);
+ let size_uv = u64::from(bpr_uv) * ch;
+ YuvLayout {
+ bpr_y: bpr_y as usize,
+ bpr_uv: bpr_uv as usize,
+ off_u: off_u as usize,
+ off_v: (off_u + size_uv) as usize,
+ total: total as usize,
+ }
+ }
+}
+
+/// Alloue une AVFrame YUV420P dont les `linesize` sont EXACTEMENT les strides du
+/// buffer relu, et dont les trois plans se suivent dans une seule allocation,
+/// dans le meme ordre.
+///
+/// POURQUOI PAS `av_frame_get_buffer`. Il choisit ses propres strides — 1920 et
+/// 960 en 1080p — la ou le GPU impose 2048 et 1024. Recopier de l'un vers
+/// l'autre demandait 3240 petits memcpy decales par frame (~0,67 ms) ; avec une
+/// disposition identique des deux cotes, la meme donnee se recopie d'un seul
+/// bloc contigu (~0,30 ms). libopenh264 lit `linesize[i]` et `data[i]` tels
+/// quels et se moque qu'un plan soit sur-stride.
+///
+/// LA FRAME RESTE REFCOMPTEE (`av_buffer_alloc`). Sans `buf[0]`, `av_frame_ref`
+/// a l'interieur d'`avcodec_send_frame` prend la branche « donnee non
+/// refcomptee » et REFAIT une allocation plus une copie complete — a l'interieur
+/// de l'encodeur, donc precisement la ou on ne penserait pas a la chercher.
+unsafe fn alloc_padded_yuv_frame(w: i32, h: i32) -> Result<*mut AVFrame> {
+ let lay = YuvLayout::for_size(w, h);
let mut frame = crate::ffi::av_frame_alloc();
if frame.is_null() {
- bail!("av_frame_alloc (encodeur)");
+ bail!("av_frame_alloc (pool)");
}
- (*frame).format = pix_fmt as i32;
+ (*frame).format = crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
(*frame).width = w;
(*frame).height = h;
- if crate::ffi::av_frame_get_buffer(frame, 32) < 0 {
+ let buf = crate::ffi::av_buffer_alloc(lay.total);
+ if buf.is_null() {
crate::ffi::av_frame_free(&mut frame);
- bail!("av_frame_get_buffer {w}x{h} pix_fmt={pix_fmt}");
+ bail!("av_buffer_alloc {} octets", lay.total);
}
+ let base = (*buf).data;
+ (*frame).buf[0] = buf;
+ (*frame).data[0] = base;
+ (*frame).data[1] = base.add(lay.off_u);
+ (*frame).data[2] = base.add(lay.off_v);
+ (*frame).linesize[0] = lay.bpr_y as i32;
+ (*frame).linesize[1] = lay.bpr_uv as i32;
+ (*frame).linesize[2] = lay.bpr_uv as i32;
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,
+/// Etat du muxer MP4, deplacable en bloc sur le thread d'encodage.
+///
+/// POURQUOI UN SEUL TYPE PLUTOT QUE QUATRE VARIABLES. `av_interleaved_write_frame`
+/// touche `octx`, la piste video `ostream` et le paquet de travail `opkt` ; et
+/// `AacEncoder` garde un `*mut AVStream` qui pointe DANS la table de flux de
+/// `octx` (audio.rs). Les separer laisserait un pointeur vers l'interieur d'un
+/// objet possede par un autre thread. Ils partent donc ensemble, ou pas du tout.
+struct Muxer {
octx: *mut crate::ffi::AVFormatContext,
+ pb: *mut crate::ffi::AVIOContext,
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(());
+ aac: AacEncoder,
+}
+
+// SAFETY : aucun de ces pointeurs n'a d'affinite de thread. Le muxer est DEPLACE
+// vers le worker puis rendu au thread appelant par le `join` ; il n'est jamais
+// partage, d'ou `Send` sans `Sync`.
+unsafe impl Send for Muxer {}
+
+impl Muxer {
+ /// Draine les paquets de l'encodeur vers le fichier. Symetrique de
+ /// `pipeline_macos::drain_encoder`.
+ unsafe fn drain(&mut self, ectx: *mut crate::ffi::AVCodecContext) -> Result<()> {
+ use crate::ffi::*;
+ loop {
+ let r = avcodec_receive_packet(ectx, self.opkt);
+ if r == AVERROR_EOF || r == AVERROR_EAGAIN {
+ return Ok(());
+ }
+ averr(r, "receive_packet")?;
+ av_packet_rescale_ts(self.opkt, (*ectx).time_base, (*self.ostream).time_base);
+ averr(
+ av_interleaved_write_frame(self.octx, self.opkt),
+ "interleaved_write_frame",
+ )?;
+ av_packet_unref(self.opkt);
+ }
+ }
+
+ /// Ferme le conteneur. La liberation, elle, est dans `Drop` : un `?` entre
+ /// l'ouverture et ici ne doit pas fuir le contexte ni le fichier.
+ unsafe fn finish(&mut self) -> Result<()> {
+ crate::ffi::averr(crate::ffi::av_write_trailer(self.octx), "write_trailer")
+ }
+}
+
+impl Drop for Muxer {
+ fn drop(&mut self) {
+ unsafe {
+ crate::ffi::avio_closep(&mut self.pb);
+ crate::ffi::avformat_free_context(self.octx);
+ crate::ffi::av_packet_free(&mut self.opkt);
+ }
+ }
+}
+
+/// Une frame remplie, en route vers l'encodeur.
+struct EncJob {
+ frame: *mut AVFrame,
+ pts: i64,
+}
+// SAFETY : la frame appartient au pool et n'est touchee que par UN thread a la
+// fois — le passage par le canal est le transfert de propriete.
+unsafe impl Send for EncJob {}
+
+/// Une frame vidée que le worker rend au pool.
+struct FreeFrame(*mut AVFrame);
+// SAFETY : idem `EncJob`, dans l'autre sens.
+unsafe impl Send for FreeFrame {}
+
+/// Encodeur + muxer deportes sur leur propre thread.
+///
+/// POURQUOI. L'export tenait sur UN thread : decodage, composition, relecture,
+/// de-padding puis encodage a la queue leu leu, pendant que sept coeurs ne
+/// faisaient rien. `avcodec_send_frame` pese a lui seul 29,5 s des ~57 s d'un
+/// export de 3600 frames ; le sortir du chemin critique laisse la marche de
+/// timeline avancer pendant que l'encodeur travaille la frame precedente.
+///
+/// LE POOL BORNE LA MEMOIRE, PAS UN CANAL. Le thread de marche va plus vite que
+/// l'encodeur : une file non bornee finirait par contenir les 3600 frames, soit
+/// ~11,2 Go. Ici il existe EXACTEMENT `depth` AVFrames, qui tournent entre le
+/// canal `empty` et le canal `full`. Le depassement n'est pas evite, il est
+/// inexprimable — et `empty_rx.recv()` est le seul point ou la marche attend
+/// l'encodeur, donc le seul endroit a instrumenter si le debit deçoit.
+///
+/// LE DE-PADDING RESTE COTE MARCHE. Recopier les plans depuis le buffer relu
+/// (lignes alignees a 256) vers l'AVFrame coute ~0,67 ms par frame. Le mettre
+/// ici le poserait sur le thread qui est desormais le goulot ; le laisser sur la
+/// marche, qui a du mou, ne coute rien. Meme raison pour laquelle il ne sert a
+/// rien de donner la memoire mappee du GPU directement a l'encodeur : ca
+/// supprimerait cette copie sans deplacer le goulot, en echange d'un slot de
+/// staging maintenu mappe a travers une frontiere de thread.
+struct EncodeWorker {
+ full_tx: Option>,
+ empty_rx: std::sync::mpsc::Receiver,
+ /// Frame empruntee mais finalement pas remplie — l'amorcage de la ring de
+ /// relecture ne produit rien les premiers tours — gardee ici pour le tour
+ /// suivant. `null` quand il n'y en a pas.
+ ///
+ /// POURQUOI PAS UN CLONE DU `Sender`. C'etait la premiere version, et elle
+ /// interdisait de detecter la mort du worker : tant que `EncodeWorker`
+ /// gardait un emetteur vivant, `empty_rx.recv()` ne pouvait JAMAIS rendre
+ /// `Err`, donc un worker qui panique laissait la marche bloquee pour
+ /// toujours sur `take_free` — `finish` n'etait jamais atteint. Le canal ne
+ /// doit avoir qu'un seul emetteur, celui du worker, pour que sa disparition
+ /// soit observable.
+ spare: std::cell::Cell<*mut AVFrame>,
+ handle: Option>>,
+ /// Premiere erreur rencontree par le worker. La marche la relit a chaque
+ /// frame : sans ca, un encodeur mort a la frame 12 laisserait composer les
+ /// 3588 suivantes avant que quiconque s'en apercoive.
+ fatal: std::sync::Arc>>,
+}
+
+impl EncodeWorker {
+ /// Demarre le thread et alloue le pool. `enc` et `mux` lui appartiennent
+ /// jusqu'au `finish`.
+ fn spawn(mut enc: VideoEncoder, mut mux: Muxer, depth: usize) -> Result {
+ let (full_tx, full_rx) = std::sync::mpsc::channel::();
+ let (empty_tx, empty_rx) = std::sync::mpsc::channel::();
+ for _ in 0..depth.max(2) {
+ let f = unsafe { alloc_padded_yuv_frame(enc.w, enc.h)? };
+ empty_tx
+ .send(FreeFrame(f))
+ .map_err(|_| anyhow::anyhow!("pool d'encodage: canal ferme a l'amorcage"))?;
+ }
+ let fatal = std::sync::Arc::new(std::sync::Mutex::new(None::));
+ let fatal_worker = std::sync::Arc::clone(&fatal);
+ let handle = std::thread::Builder::new()
+ .name("openscreen-encode".into())
+ .spawn(move || -> Result {
+ while let Ok(job) = full_rx.recv() {
+ let r = unsafe {
+ (*job.frame).pts = job.pts;
+ crate::ffi::averr(
+ crate::ffi::avcodec_send_frame(enc.ctx, job.frame),
+ "send_frame",
+ )
+ .and_then(|()| mux.drain(enc.ctx))
+ };
+ // La frame retourne au pool DANS TOUS LES CAS : la garder
+ // sur une erreur bloquerait la marche sur `empty_rx.recv()`
+ // au lieu de lui laisser voir `fatal`.
+ let _ = empty_tx.send(FreeFrame(job.frame));
+ if let Err(e) = r {
+ *fatal_worker.lock().unwrap() = Some(format!("{e:#}"));
+ return Err(e);
+ }
+ }
+ // Canal ferme = plus aucune frame ne viendra : on vide
+ // l'encodeur ici, pendant qu'il nous appartient encore.
+ unsafe {
+ enc.flush()?;
+ mux.drain(enc.ctx)?;
+ }
+ Ok(mux)
+ })?;
+ Ok(EncodeWorker {
+ full_tx: Some(full_tx),
+ empty_rx,
+ spare: std::cell::Cell::new(std::ptr::null_mut()),
+ handle: Some(handle),
+ fatal,
+ })
+ }
+
+ /// Garde une frame empruntee sans avoir ete remplie, pour le tour suivant.
+ fn give_back(&self, frame: *mut AVFrame) {
+ let prev = self.spare.replace(frame);
+ debug_assert!(prev.is_null(), "give_back deux fois sans take_free");
+ }
+
+ /// Emprunte une frame libre au pool. C'est ICI que la marche attend quand
+ /// l'encodeur prend du retard.
+ fn take_free(&self) -> Result<*mut AVFrame> {
+ let spare = self.spare.replace(std::ptr::null_mut());
+ if !spare.is_null() {
+ return Ok(spare);
+ }
+ match self.empty_rx.recv() {
+ Ok(FreeFrame(f)) => Ok(f),
+ Err(_) => Err(self.fatal_error("le thread d'encodage s'est arrete")),
+ }
+ }
+
+ fn submit(&self, frame: *mut AVFrame, pts: i64) -> Result<()> {
+ match self.full_tx.as_ref() {
+ Some(tx) => tx
+ .send(EncJob { frame, pts })
+ .map_err(|_| self.fatal_error("le thread d'encodage s'est arrete")),
+ None => Err(anyhow::anyhow!("submit apres finish")),
+ }
+ }
+
+ /// Prefere l'erreur reelle du worker au symptome (« canal ferme »).
+ fn fatal_error(&self, fallback: &str) -> anyhow::Error {
+ match self.fatal.lock().unwrap().clone() {
+ Some(e) => anyhow::anyhow!("encodage: {e}"),
+ None => anyhow::anyhow!("{fallback}"),
+ }
+ }
+
+ /// Ferme la file, attend le worker et RECUPERE le muxer : le `join` est
+ /// l'arete de synchronisation qui rend `octx` utilisable ici pour l'audio et
+ /// le trailer.
+ fn finish(&mut self) -> Result {
+ drop(self.full_tx.take());
+ let handle = self
+ .handle
+ .take()
+ .ok_or_else(|| anyhow::anyhow!("finish appele deux fois"))?;
+ match handle.join() {
+ Ok(r) => r,
+ // Un panic du worker ne passe pas par `fatal` : le relayer en erreur
+ // plutot que de le repropager sur le thread de marche.
+ Err(_) => Err(self.fatal_error("le thread d'encodage a panique")),
+ }
+ }
+}
+
+impl Drop for EncodeWorker {
+ fn drop(&mut self) {
+ // Chemin d'abandon (un `?` ailleurs) : fermer la file debloque le worker,
+ // et le join evite de liberer le pool sous ses pieds.
+ drop(self.full_tx.take());
+ if let Some(h) = self.handle.take() {
+ let _ = h.join();
+ }
+ let mut spare = self.spare.replace(std::ptr::null_mut());
+ if !spare.is_null() {
+ unsafe { crate::ffi::av_frame_free(&mut spare) };
+ }
+ while let Ok(FreeFrame(f)) = self.empty_rx.try_recv() {
+ let mut f = f;
+ unsafe { crate::ffi::av_frame_free(&mut f) };
}
- 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);
}
}
@@ -390,6 +758,82 @@ unsafe fn drain_encoder(
/// 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.
+
+/// Fin de marche du chemin SOFTWARE : vider la ring de relecture, puis rendre au
+/// compositeur sa profondeur par defaut.
+///
+/// Le drain doit avoir lieu AVANT de fermer la file : les `depth - 1` dernieres
+/// copies sont encore en vol, et sans lui la derniere frame composee ne serait
+/// jamais encodee — video amputee d'une frame.
+fn hw_none_tail(
+ comp: &crate::compositor::Compositor,
+ worker: &mut EncodeWorker,
+ out_w: u32,
+ out_h: u32,
+ encoded_pts: &mut i64,
+) -> Result<()> {
+ unsafe {
+ loop {
+ let frame = worker.take_free()?;
+ let mut filled = false;
+ let got = comp.readback_take_yuv_with(|rw, rh, planes| {
+ VideoEncoder::copy_into(frame, planes, rw as i32, rh as i32, out_w as i32, out_h as i32)?;
+ filled = true;
+ Ok(())
+ })?;
+ if filled {
+ worker.submit(frame, *encoded_pts)?;
+ *encoded_pts += 1;
+ } else {
+ worker.give_back(frame);
+ }
+ if !got {
+ break;
+ }
+ }
+ // 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 qui va avec.
+ comp.set_readback_yuv_depth(1)?;
+ }
+ Ok(())
+}
+
+/// Ou partent les frames composees. Voir le commentaire au point de choix.
+enum Sink {
+ /// Encodage software, deporte sur un thread.
+ Software(Box),
+ /// Encodage materiel depuis un dmabuf, sur place.
+ ///
+ /// PLUSIEURS TAMPONS, PAS UN. Avec un seul, composer et encoder se
+ /// serialisent : le GPU compose, on l'attend, on encode, et rien ne se
+ /// recouvre. Deux tampons suffisent a decaler d'une frame — on compose la
+ /// n pendant que la n-1 s'encode — et c'est le meme raisonnement que la
+ /// profondeur 2 de la ring de relecture software.
+ Hardware {
+ enc: VaapiEncoder,
+ mux: Muxer,
+ staging: Vec,
+ /// Frame soumise mais pas encore encodee : (slot, soumission, pts).
+ pending: Option<(usize, wgpu::SubmissionIndex, i64)>,
+ /// Frame mappee remise a l'encodeur pour le slot precedent, gardee VIVANTE
+ /// tant qu'il peut la lire. `(slot, frame)`.
+ in_flight: Option<(usize, *mut AVFrame)>,
+ next: usize,
+ },
+}
+
+impl Sink {
+ /// Le worker software. Ne doit etre appele qu'apres avoir ecarte le cas
+ /// materiel — le chemin materiel n'en a pas.
+ fn worker(&mut self) -> &mut EncodeWorker {
+ match self {
+ Sink::Software(w) => w,
+ Sink::Hardware { .. } => unreachable!("worker() sur le chemin materiel"),
+ }
+ }
+}
+
pub fn run_composited_multi(
clips: &[ClipSource],
out: &str,
@@ -404,12 +848,89 @@ pub fn run_composited_multi(
}
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).
+ // bitrate proportionnel a la surface (reference : 8 Mbps @ 1920x1080). Formule
+ // IDENTIQUE a celle de `pipeline_macos.rs` et `pipeline_windows.rs` : la garder
+ // alignee est ce qui fait que les trois plateformes exportent au meme poids.
+ //
+ // Sur `libopenh264` ce nombre n'est qu'indicatif : c'est une entree d'un modele
+ // complexite -> QP, pas un contrat. Il agit comme un plafond APPROXIMATIF sur du
+ // contenu dense (mesure sur un vrai enregistrement 1080p60 : 1 Mbps demande ->
+ // 0,97 produit, 2 -> 1,72, 4 -> 2,86, 8 -> 3,85) et n'a aucun effet sur un ecran
+ // statique, ou l'encodeur sature son plancher de QP. Voir
+ // `VideoEncoder::tune_openh264` pour le pourquoi et ce qui a ete tente.
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;
+ // L'ENCODEUR SE CHOISIT AVANT LE MUXER, parce que c'est lui qui decrit le
+ // flux video. `h264_vaapi` s'il s'ouvre et que le compositeur sait exporter
+ // sa memoire ; sinon l'encodeur software, inchange.
+ //
+ // Le repli couvre plus que l'absence de GPU : pas de `/dev/dri/renderD128`,
+ // un pilote sans VAAPI, un device wgpu ouvert sans les extensions de memoire
+ // externe. Aucun de ces cas n'est une erreur — l'export doit juste rester
+ // celui d'avant.
+ // L'ECHAPPATOIRE DOIT AUSSI COUVRIR CE CHOIX. `OPENSCREEN_EXPORT_ENCODER`
+ // existe pour forcer un encodeur ; si le chemin materiel l'ignorait, demander
+ // `libopenh264` donnerait quand meme du VAAPI — et le reglage servirait
+ // surtout a diagnostiquer, donc mentir ici est pire qu'ailleurs.
+ let forced = std::env::var("OPENSCREEN_EXPORT_ENCODER").ok();
+ let hw_allowed = match forced.as_deref() {
+ None => true,
+ Some(name) => name.contains("vaapi"),
+ };
+ let hw = if hw_allowed && matches!(params.codec, ExportCodec::H264) {
+ unsafe { VaapiEncoder::open(out_w as i32, out_h as i32, out_fps, bit_rate) }
+ .and_then(|v| {
+ // PLUS DE TAMPONS QUE L'ENCODEUR N'A DE LATENCE. Deux suffisaient
+ // pour recouvrir composition et encodage, mais pas pour la
+ // question de propriete : `h264_vaapi` garde plusieurs frames
+ // avant d'emettre le premier paquet, donc a deux tampons on
+ // revenait sur le slot 0 alors que la surface qui le mappe etait
+ // encore detenue. Le garde-fou de `frame_released` le prouve —
+ // avec deux, il declenche des la premiere boucle.
+ //
+ // Six, pas deux : c'est au-dessus de la latence observee, ca
+ // coute 6 x 3,3 Mo, et le garde-fou reste en place pour le cas ou
+ // un pilote irait plus loin.
+ let total = comp.nv12_geometry().3;
+ let mut v_st = Vec::new();
+ for _ in 0..6 {
+ v_st.push(comp.create_exportable_staging(total)?);
+ }
+ Some((v, v_st))
+ })
+ } else {
+ None
+ };
+ // N'OUVRE PAS L'ENCODEUR SOFTWARE SI LE MATERIEL A GAGNE. Il etait construit
+ // dans tous les cas, donc alloue puis jamais utilise — visible par deux
+ // lignes « encodeur video » dans le log, et par un AVFrame de 3,1 Mo qui ne
+ // sert a rien.
+ let enc = match &hw {
+ Some(_) => None,
+ None => Some(VideoEncoder::open(
+ ¶ms.codec,
+ out_w as i32,
+ out_h as i32,
+ out_fps,
+ bit_rate,
+ )?),
+ };
+ // ALIAS LU UNIQUEMENT AVANT LE DEMARRAGE DU WORKER. Il ne sert qu'a decrire
+ // le flux au muxer, juste en dessous ; passe `EncodeWorker::spawn`, le
+ // contexte appartient au thread d'encodage et cette variable ne doit plus
+ // etre touchee. S'en resservir apres serait un `Sync` officieux :
+ // `VideoEncoder` est `Send` et volontairement pas `Sync`, et un
+ // `*mut AVCodecContext` recopie efface exactement cette distinction.
+ let ectx = match (&hw, &enc) {
+ (Some((v, _)), _) => v.ctx(),
+ (None, Some(e)) => e.ctx,
+ (None, None) => bail!("aucun encodeur video disponible"),
+ };
+ eprintln!(
+ "[pipeline] encodeur video : {}",
+ if hw.is_some() { "h264_vaapi (materiel, dmabuf)" } else { "software" }
+ );
let mut screen_decs: HashMap = HashMap::new();
let mut webcam_decs: HashMap = HashMap::new();
@@ -420,7 +941,7 @@ pub fn run_composited_multi(
let mut pb: *mut crate::ffi::AVIOContext = ptr::null_mut();
let ostream;
let opkt;
- let mut audio_encoder;
+ let audio_encoder;
unsafe {
crate::ffi::averr(
crate::ffi::avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()),
@@ -450,18 +971,47 @@ pub fn run_composited_multi(
)?;
opkt = crate::ffi::av_packet_alloc();
}
+ // A partir d'ici le muxer est un seul objet, et il part avec l'encodeur.
+ let mux = Muxer { octx, pb, ostream, opkt, aac: audio_encoder };
+ // Profondeur 3 : deux frames en vol suffisent a couvrir l'encodeur, la
+ // troisieme absorbe les a-coups de la marche (une fin de clip y decode tout
+ // l'audio du clip d'un coup, cf. `on_clip_end`).
+ // Deux formes, pas deux variantes d'une meme : le chemin software encode sur
+ // un thread (l'encodeur y coute ~8 ms/frame, il faut le sortir du chemin
+ // critique), le chemin materiel encode sur place (~3 ms) et garde le muxer
+ // sous la main. Les melanger rendrait les deux illisibles.
+ let mut sink = match hw {
+ Some((venc, staging)) => Sink::Hardware {
+ enc: venc,
+ mux,
+ staging,
+ pending: None,
+ in_flight: None,
+ next: 0,
+ },
+ None => {
+ let enc = enc.ok_or_else(|| anyhow::anyhow!("aucun encodeur video disponible"))?;
+ Sink::Software(Box::new(EncodeWorker::spawn(enc, mux, 3)?))
+ }
+ };
// 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 audio_jobs: ClipAudioJobs > = ClipAudioJobs::new(clips.len());
let mut clip_frame_counts: Vec = vec![0; clips.len()];
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
+ // Imported audio tracks (issue #350), cloned out of the borrowed scene so the
+ // mix step below owns them. Empty for a project with no imported audio.
+ let audio_tracks = scene
+ .as_ref()
+ .map(|scene| scene.audio_tracks.clone())
+ .unwrap_or_default();
// Ring de staging a 2 : l'export ne veut que du debit, une frame de latence
// ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour
// la raison pour laquelle la preview, elle, reste a 1.
- comp.set_readback_depth(2)?;
+ comp.set_readback_yuv_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
@@ -479,13 +1029,80 @@ pub fn run_composited_multi(
&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)?;
+ // precedente : c'est tout le pipelining GPU. L'encodage, lui,
+ // n'est plus ici du tout — il tourne sur `worker` pendant que
+ // cette closure compose deja la frame suivante.
+ match &mut sink {
+ Sink::Hardware { enc, mux, staging, pending, in_flight, next } => {
+ // Soumet la frame n SANS l'attendre, puis encode la
+ // precedente : le GPU compose pendant que l'encodeur
+ // travaille. La toute premiere passe n'a rien a encoder,
+ // comme l'amorcage de la ring software.
+ let slot = *next;
+ // AVANT d'ecrire dans ce slot : s'assurer que l'encodeur
+ // ne lit plus la surface qui le mappait. Draine tant qu'il
+ // la retient — c'est le drain qui fait sortir les paquets
+ // et relache les references, donc la boucle progresse.
+ if let Some((busy, frame)) = in_flight.take() {
+ if busy == slot {
+ let mut spins = 0;
+ while !VaapiEncoder::frame_released(frame) {
+ mux.drain(enc.ctx())?;
+ spins += 1;
+ if spins > 1000 {
+ bail!("l'encodeur retient la surface du slot {slot}");
+ }
+ }
+ let mut f = frame;
+ crate::ffi::av_frame_free(&mut f);
+ } else {
+ *in_flight = Some((busy, frame));
+ }
+ }
+ let idx = comp.compose_into_dmabuf(&staging[slot])?;
+ if let Some((prev, prev_idx, pts)) = pending.take() {
+ comp.wait_submission(prev_idx);
+ let (bpr_y, bpr_uv, off_uv, _) = comp.nv12_geometry();
+ let f = enc.send_dmabuf(staging[prev].fd, bpr_y, bpr_uv, off_uv, pts)?;
+ mux.drain(enc.ctx())?;
+ // Remplace le precedent : il a ete relache plus haut
+ // si son slot revenait, sinon il l'est par ce drain.
+ if let Some((_, old)) = in_flight.take() {
+ let mut o = old;
+ crate::ffi::av_frame_free(&mut o);
+ }
+ *in_flight = Some((prev, f));
+ }
+ *pending = Some((slot, idx, encoded_pts));
+ encoded_pts += 1;
+ *next = (slot + 1) % staging.len();
+ progress(n + 1);
+ return Ok(());
+ }
+ Sink::Software(_) => {}
+ }
+ let worker = sink.worker();
+ let frame = worker.take_free()?;
+ let mut filled = false;
+ comp.readback_submit_yuv(|rw, rh, planes| {
+ VideoEncoder::copy_into(
+ frame,
+ planes,
+ rw as i32,
+ rh as i32,
+ out_w as i32,
+ out_h as i32,
+ )?;
+ filled = true;
+ Ok(())
+ })?;
+ if filled {
+ worker.submit(frame, encoded_pts)?;
encoded_pts += 1;
- drain_encoder(ectx, octx, ostream, opkt)?;
+ } else {
+ // Amorcage de la ring : rien a encoder, la frame empruntee
+ // retourne au pool telle quelle.
+ worker.give_back(frame);
}
// Progression = frames COMPOSEES (inchangee) : la barre ne doit
// pas reculer d'une frame parce que l'encodage a un tour de
@@ -497,52 +1114,95 @@ pub fn run_composited_multi(
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",
- ),
- }
+ // L'audio d'un clip ne dépend que de ce clip : le décoder et l'étirer ici,
+ // sur le thread de rendu, immobilisait la barre d'export pour toute sa
+ // durée — rien n'appelle `progress()` entre deux clips. Le travail part
+ // sur un thread et se recouvre avec la composition du clip suivant ; les
+ // résultats sont récupérés après le parcours, rangés par index de clip.
+ let path = clip.screen.clone();
+ let source_start_sec = clip.source_start_sec;
+ let segments = speed_segments.to_vec();
+ audio_jobs.spawn(clip_index, move || {
+ decode_and_stretch_clip_audio(
+ clip_index,
+ &path,
+ source_start_sec,
+ source_end_sec,
+ &segments,
+ out_fps as f64,
+ )
+ });
}
Ok(())
},
)?
};
+ // Le chemin materiel n'a ni ring ni file : il ne reste qu'a vider l'encodeur.
+ if let Sink::Hardware { enc, mux, staging, pending, in_flight, .. } = &mut sink {
+ unsafe {
+ // La derniere frame composee est encore en vol : sans ca la video
+ // sortirait amputee d'une frame, exactement comme le drain de la
+ // ring cote software.
+ if let Some((prev, prev_idx, pts)) = pending.take() {
+ comp.wait_submission(prev_idx);
+ let (bpr_y, bpr_uv, off_uv, _) = comp.nv12_geometry();
+ let f = enc.send_dmabuf(staging[prev].fd, bpr_y, bpr_uv, off_uv, pts)?;
+ mux.drain(enc.ctx())?;
+ if let Some((_, old)) = in_flight.take() {
+ let mut o = old;
+ crate::ffi::av_frame_free(&mut o);
+ }
+ *in_flight = Some((prev, f));
+ }
+ crate::ffi::avcodec_send_frame(enc.ctx(), ptr::null_mut());
+ mux.drain(enc.ctx())?;
+ // Le flush a fait sortir tout ce qui restait : plus rien ne reference
+ // les surfaces, on peut liberer la derniere.
+ if let Some((_, f)) = in_flight.take() {
+ let mut f = f;
+ crate::ffi::av_frame_free(&mut f);
+ }
+ // Le compositeur survit a l'export : lui rendre sa profondeur par
+ // defaut vaut pour LES DEUX chemins. Le chemin materiel n'utilise pas
+ // la ring, mais `ensure_yuv_fmt` a pu la vider et la redimensionner,
+ // et la preview qui suit n'a pas a heriter de cet etat.
+ comp.set_readback_yuv_depth(1)?;
+ }
+ }
+ let mut mux = match sink {
+ Sink::Hardware { mux, .. } => mux,
+ Sink::Software(worker) => {
+ let mut worker = worker;
+ hw_none_tail(comp, &mut worker, out_w, out_h, &mut encoded_pts)?;
+ worker.finish()?
+ }
+ };
+
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.
+ // Récupération des jobs audio lancés pendant le parcours. `spawn` en admet quatre
+ // avant d'en collecter un, donc il en reste au plus quatre à attendre ici — bornés
+ // par le plus lent, pas par leur somme ; les autres se sont recouverts avec
+ // l'encodage vidéo.
+ let clip_pcm: Vec> = audio_jobs
+ .into_results()
+ .into_iter()
+ .map(|slot| slot.flatten())
+ .collect();
+
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(
- &finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
+ let octx = mux.octx;
+ mux.aac.encode(
+ &finish_audio(
+ mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
+ audio_settings,
+ ),
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);
+ mux.finish()?;
}
let wall_s = t0.elapsed().as_secs_f64();
@@ -553,3 +1213,482 @@ pub fn run_composited_multi(
video_duration_s: frames as f64 / out_fps as f64,
})
}
+
+// ---------------------------------------------------------------------------
+// Encodage materiel depuis un dmabuf
+// ---------------------------------------------------------------------------
+
+/// Encodeur `h264_vaapi` alimente par un dmabuf, sans relecture CPU.
+///
+/// POURQUOI `av_hwframe_map` ET JAMAIS `av_hwframe_transfer_data`. Le second
+/// est le chemin d'UPLOAD CPU -> GPU, et c'est lui qui appelle `vaMapBuffer2`,
+/// absent de libva avant 2.22 : sur Ubuntu 24.04 (libva 2.20) il ne rend pas une
+/// erreur, il `assert(0)` et le processus meurt (cf. issue #552). Le mapping,
+/// lui, ne prend pas ce chemin -- c'est ce qui rend cet encodeur utilisable la
+/// ou l'upload ne l'est pas.
+pub struct VaapiEncoder {
+ ctx: *mut crate::ffi::AVCodecContext,
+ drm_device: *mut crate::ffi::AVBufferRef,
+ va_device: *mut crate::ffi::AVBufferRef,
+ drm_frames: *mut crate::ffi::AVBufferRef,
+ va_frames: *mut crate::ffi::AVBufferRef,
+ w: i32,
+ h: i32,
+}
+
+// SAFETY : memes raisons que `VideoEncoder` -- pointeurs FFI sans affinite de
+// thread, un seul thread a la fois.
+unsafe impl Send for VaapiEncoder {}
+
+/// Libere le descripteur porte par l'`AVBufferRef` de la frame source.
+unsafe extern "C" fn drm_desc_free(_opaque: *mut std::ffi::c_void, data: *mut u8) {
+ crate::ffi::av_free(data as *mut std::ffi::c_void);
+}
+
+impl VaapiEncoder {
+ /// Ouvre la chaine DRM -> VAAPI -> `h264_vaapi`. `None` si quoi que ce soit
+ /// manque : l'appelant retombe alors sur l'encodeur software.
+ pub unsafe fn open(w: i32, h: i32, fps: i32, bit_rate: i64) -> Option {
+ use crate::ffi::*;
+ let mut me = VaapiEncoder {
+ ctx: ptr::null_mut(),
+ drm_device: ptr::null_mut(),
+ va_device: ptr::null_mut(),
+ drm_frames: ptr::null_mut(),
+ va_frames: ptr::null_mut(),
+ w,
+ h,
+ };
+ // LE DEVICE DRM D'ABORD, PUIS VAAPI DERIVE DE LUI. L'ordre inverse
+ // (VAAPI ouvert seul) rend ENOSYS sur radeonsi : le mapping veut les deux
+ // cotes d'un meme device.
+ let node = std::ffi::CString::new("/dev/dri/renderD128").ok()?;
+ if av_hwdevice_ctx_create(
+ &mut me.drm_device,
+ AVHWDeviceType::AV_HWDEVICE_TYPE_DRM,
+ node.as_ptr(),
+ ptr::null_mut(),
+ 0,
+ ) < 0
+ {
+ return None;
+ }
+ if av_hwdevice_ctx_create_derived(
+ &mut me.va_device,
+ AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
+ me.drm_device,
+ 0,
+ ) < 0
+ {
+ return None;
+ }
+ // `initial_pool_size = 0` sur LES DEUX contextes : ils ne font
+ // qu'ENVELOPPER des surfaces fournies de l'exterieur (le dmabuf d'un
+ // cote, ce que `av_hwframe_map` remplit de l'autre). Demander un pool
+ // pre-alloue fait rejeter le format par `av_hwframe_ctx_init` en EINVAL,
+ // faute d'allocateur pour ces dispositions.
+ let mk_frames = |dev: *mut AVBufferRef, fmt: AVPixelFormat::Type| -> *mut AVBufferRef {
+ let frames = av_hwframe_ctx_alloc(dev);
+ if frames.is_null() {
+ return ptr::null_mut();
+ }
+ let c = (*frames).data as *mut AVHWFramesContext;
+ (*c).format = fmt;
+ (*c).sw_format = AVPixelFormat::AV_PIX_FMT_NV12;
+ (*c).width = w;
+ (*c).height = h;
+ (*c).initial_pool_size = 0;
+ if av_hwframe_ctx_init(frames) < 0 {
+ return ptr::null_mut();
+ }
+ frames
+ };
+ me.drm_frames = mk_frames(me.drm_device, AVPixelFormat::AV_PIX_FMT_DRM_PRIME);
+ me.va_frames = mk_frames(me.va_device, AVPixelFormat::AV_PIX_FMT_VAAPI);
+ if me.drm_frames.is_null() || me.va_frames.is_null() {
+ return None;
+ }
+
+ let name = std::ffi::CString::new("h264_vaapi").ok()?;
+ let enc = avcodec_find_encoder_by_name(name.as_ptr());
+ if enc.is_null() {
+ return None;
+ }
+ me.ctx = avcodec_alloc_context3(enc);
+ if me.ctx.is_null() {
+ return None;
+ }
+ (*me.ctx).width = w;
+ (*me.ctx).height = h;
+ (*me.ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_VAAPI as i32;
+ (*me.ctx).time_base = AVRational { num: 1, den: fps };
+ (*me.ctx).framerate = AVRational { num: fps, den: 1 };
+ (*me.ctx).bit_rate = bit_rate;
+ // MEME INTERVALLE D'IMAGES CLES QUE LE CHEMIN SOFTWARE, et pour la meme
+ // raison : deux secondes est le compromis choisi pour un fichier de
+ // sortie, il n'a pas a dependre de l'encodeur qui se trouve disponible.
+ //
+ // Sans cette ligne le resultat est correct A 60 FPS ET NULLE PART
+ // AILLEURS : `h264_vaapi` a un defaut de 120 frames (mesure), qui vaut
+ // deux secondes a 60 fps par coincidence. A 30 fps ca donnerait quatre
+ // secondes, a 120 fps une seule. Le defaut de `libopenh264` est -1, ce
+ // qui est un autre probleme encore (une seule image cle pour tout le
+ // fichier) traite dans `try_open`.
+ (*me.ctx).gop_size = (fps * 2).max(1);
+ // MP4 veut SPS/PPS dans l'extradata, pas repetes devant chaque image
+ // cle. Le chemin software le pose depuis toujours (`try_open`) ; l'avoir
+ // oublie ici produisait un fichier qui se lit quand meme, parce que le
+ // muxer recupere ce qu'il trouve — mais un lecteur qui se fie a
+ // `codecpar` seul aurait de quoi echouer.
+ (*me.ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32;
+ (*me.ctx).hw_frames_ctx = av_buffer_ref(me.va_frames);
+ if avcodec_open2(me.ctx, enc, ptr::null_mut()) < 0 {
+ return None;
+ }
+ Some(me)
+ }
+
+ /// Envoie a l'encodeur l'image qui se trouve derriere `fd`, decrite comme un
+ /// NV12 lineaire de pitches `bpr_y` / `bpr_uv`.
+ pub unsafe fn send_dmabuf(
+ &mut self,
+ fd: i32,
+ bpr_y: u32,
+ bpr_uv: u32,
+ off_uv: u64,
+ pts: i64,
+ ) -> Result<*mut AVFrame> {
+ use crate::ffi::*;
+ let desc = av_mallocz(std::mem::size_of::())
+ as *mut AVDRMFrameDescriptor;
+ if desc.is_null() {
+ bail!("av_mallocz(AVDRMFrameDescriptor)");
+ }
+ (*desc).nb_objects = 1;
+ (*desc).objects[0].fd = fd;
+ // 0 : la taille est retrouvee par le pilote depuis le fd lui-meme.
+ (*desc).objects[0].size = 0;
+ (*desc).objects[0].format_modifier = 0; // DRM_FORMAT_MOD_LINEAR
+ (*desc).nb_layers = 1;
+ // fourcc 'NV12', ecrit a la main : bindgen ne genere pas MKTAG.
+ (*desc).layers[0].format = u32::from_le_bytes(*b"NV12");
+ (*desc).layers[0].nb_planes = 2;
+ (*desc).layers[0].planes[0].object_index = 0;
+ (*desc).layers[0].planes[0].offset = 0;
+ (*desc).layers[0].planes[0].pitch = bpr_y as isize;
+ (*desc).layers[0].planes[1].object_index = 0;
+ (*desc).layers[0].planes[1].offset = off_uv as isize;
+ (*desc).layers[0].planes[1].pitch = bpr_uv as isize;
+
+ let src = av_frame_alloc();
+ (*src).format = AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
+ (*src).width = self.w;
+ (*src).height = self.h;
+ (*src).data[0] = desc as *mut u8;
+ // LA SOURCE DOIT ETRE REFCOMPTEE. Sans `buf[0]`, `av_hwframe_map` rend
+ // EINVAL -- et son message ne dit pas un mot de comptage de references,
+ // ce qui rend la panne tres difficile a lire.
+ (*src).buf[0] = av_buffer_create(
+ desc as *mut u8,
+ std::mem::size_of::(),
+ Some(drm_desc_free),
+ ptr::null_mut(),
+ 0,
+ );
+ (*src).hw_frames_ctx = av_buffer_ref(self.drm_frames);
+
+ let dst = av_frame_alloc();
+ (*dst).format = AVPixelFormat::AV_PIX_FMT_VAAPI as i32;
+ (*dst).width = self.w;
+ (*dst).height = self.h;
+ (*dst).hw_frames_ctx = av_buffer_ref(self.va_frames);
+ // Bindgen range les `AV_HWFRAME_MAP_*` dans un module anonyme : les
+ // nommer par leur valeur serait plus fragile que de passer par lui.
+ let flags = (crate::ffi::_bindgen_ty_3::AV_HWFRAME_MAP_READ
+ | crate::ffi::_bindgen_ty_3::AV_HWFRAME_MAP_DIRECT) as i32;
+ let mapped = av_hwframe_map(dst, src, flags);
+ if mapped < 0 {
+ let mut s = src;
+ let mut d = dst;
+ av_frame_free(&mut s);
+ av_frame_free(&mut d);
+ averr(mapped, "av_hwframe_map(DRM -> VAAPI)")?;
+ unreachable!("averr rend une erreur pour mapped < 0");
+ }
+ (*dst).pts = pts;
+ let r = averr(avcodec_send_frame(self.ctx, dst), "send_frame(vaapi)");
+ // `src` a fini son role : `av_hwframe_map` a copie ce qu'il fallait dans
+ // `dst`, et le descripteur DRM meurt avec lui.
+ let mut s = src;
+ av_frame_free(&mut s);
+ // `dst` PAS libere ici. `avcodec_send_frame` en a pris une reference, et
+ // cette frame mappe le dmabuf du slot : tant qu'elle vit, l'encodeur peut
+ // encore lire cette memoire. L'appelant la garde et ne la relache — donc
+ // ne recycle le slot — qu'apres avoir draine le paquet correspondant.
+ r.map(|()| dst)
+ }
+
+ /// Vrai si l'encodeur ne detient plus la frame mappee, donc si le slot qu'elle
+ /// couvre peut etre reecrit.
+ ///
+ /// C'est la SEULE question qui compte pour reutiliser un slot. Un `drain` qui
+ /// rend `EAGAIN` ne dit rien la-dessus : il signale qu'aucun paquet n'est
+ /// pret, pas que la surface est relachee.
+ pub unsafe fn frame_released(frame: *mut AVFrame) -> bool {
+ frame.is_null()
+ || (*frame).buf[0].is_null()
+ || crate::ffi::av_buffer_get_ref_count((*frame).buf[0]) <= 1
+ }
+
+ pub fn ctx(&self) -> *mut crate::ffi::AVCodecContext {
+ self.ctx
+ }
+}
+
+impl Drop for VaapiEncoder {
+ fn drop(&mut self) {
+ unsafe {
+ if !self.ctx.is_null() {
+ crate::ffi::avcodec_free_context(&mut self.ctx);
+ }
+ for b in [
+ &mut self.va_frames,
+ &mut self.drm_frames,
+ &mut self.va_device,
+ &mut self.drm_device,
+ ] {
+ if !b.is_null() {
+ crate::ffi::av_buffer_unref(b);
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod vaapi_tests {
+ use super::*;
+
+ /// La chaine complete, dans le crate et non dans un bac a sable : un tampon
+ /// de staging EXPORTABLE alloue par le compositeur, son fd donne a
+ /// `av_hwframe_map`, et `h264_vaapi` qui en sort un paquet.
+ ///
+ /// C'est le premier test qui touche reellement l'encodeur materiel. Il se
+ /// saute proprement partout ou la chaine n'existe pas (pas de GPU, pas de
+ /// `/dev/dri/renderD128`, pas de VAAPI) -- la CI rend sur lavapipe, et
+ /// l'echec y serait un faux negatif.
+ #[test]
+ fn vaapi_encodes_from_an_exported_dmabuf() {
+ let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else {
+ eprintln!("pas d'adaptateur Vulkan — test saute");
+ return;
+ };
+ let (w, h) = (640i32, 480i32);
+ let comp = match crate::compositor::Compositor::new_sized(&gpu, w as u32, h as u32) {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("compositeur indisponible ({e:#}) — test saute");
+ return;
+ }
+ };
+ let (bpr_y, bpr_uv, off_uv, total) = crate::compositor::Compositor::yuv_layout_for(
+ w as u32,
+ h as u32,
+ crate::compositor::YuvFormat::Nv12,
+ );
+ let Some(st) = comp.create_exportable_staging(total) else {
+ eprintln!("pas de memoire externe — test saute");
+ return;
+ };
+
+ // Du gris legal plutot que des zeros : un plan Y a 0 est du noir hors
+ // plage en BT.601 limite, et on veut que l'encodeur voie une image
+ // valide, pas qu'il la rattrape.
+ let mut grey = vec![128u8; total as usize];
+ grey[..off_uv as usize].fill(128);
+ gpu.context.write_buffer(st.buffer(), 0, &grey);
+ gpu.context.submit(std::iter::empty());
+ gpu.device.poll(wgpu::Maintain::Wait);
+
+ unsafe {
+ let Some(mut enc) = VaapiEncoder::open(w, h, 60, 4_000_000) else {
+ eprintln!("h264_vaapi indisponible — test saute");
+ return;
+ };
+ enc.send_dmabuf(st.fd, bpr_y, bpr_uv, off_uv, 0)
+ .expect("send_dmabuf");
+ // Un encodeur peut legitimement retenir la premiere frame : on le
+ // vide pour forcer la sortie du paquet.
+ let _ = crate::ffi::avcodec_send_frame(enc.ctx(), std::ptr::null_mut());
+ let pkt = crate::ffi::av_packet_alloc();
+ let r = crate::ffi::avcodec_receive_packet(enc.ctx(), pkt);
+ assert!(r >= 0, "avcodec_receive_packet a rendu {r}");
+ assert!((*pkt).size > 0, "paquet H.264 vide");
+ let mut p = pkt;
+ crate::ffi::av_packet_free(&mut p);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const W: i32 = 320;
+ const H: i32 = 180;
+
+ /// Sortie d'un encodage de test : les drapeaux « image clé » paquet par paquet,
+ /// et l'extradata (le SPS, en Annex-B, puisque `try_open` pose
+ /// `AV_CODEC_FLAG_GLOBAL_HEADER`).
+ struct Encoded {
+ keyframes: Vec,
+ extradata: Vec,
+ qmin: i32,
+ qmax: i32,
+ gop_size: i32,
+ }
+
+ /// Encode `n` images 320x180 par le chemin RÉEL de l'export — `VideoEncoder::open`,
+ /// donc `try_open`, donc toute la configuration que ce fichier pose. Un contexte
+ /// monté à la main dans le test ne prouverait rien.
+ ///
+ /// Rend `None` quand l'encodeur n'est pas ouvrable (build ffmpeg sans
+ /// `libopenh264`), pour que le test se saute proprement au lieu d'échouer sur
+ /// l'environnement.
+ fn encode(fps: i32, n: usize) -> Option {
+ // Un dev qui force un autre encodeur ne doit pas voir ce test rougir : il ne
+ // décrit que `libopenh264`.
+ match std::env::var("OPENSCREEN_EXPORT_ENCODER") {
+ Ok(name) if name != "libopenh264" => return None,
+ _ => {}
+ }
+ let mut enc = VideoEncoder::open(&ExportCodec::H264, W, H, fps, 1_000_000).ok()?;
+
+ let mut out = Encoded {
+ keyframes: Vec::new(),
+ extradata: Vec::new(),
+ qmin: 0,
+ qmax: 0,
+ gop_size: 0,
+ };
+ unsafe {
+ use crate::ffi::*;
+ out.qmin = (*enc.ctx).qmin;
+ out.qmax = (*enc.ctx).qmax;
+ out.gop_size = (*enc.ctx).gop_size;
+ let (ptr, len) = ((*enc.ctx).extradata, (*enc.ctx).extradata_size);
+ if !ptr.is_null() && len > 0 {
+ out.extradata = std::slice::from_raw_parts(ptr, len as usize).to_vec();
+ }
+
+ let pkt = av_packet_alloc();
+ // Alimente par le CHEMIN DE PRODUCTION : un buffer a la disposition
+ // du GPU (`YuvLayout`) recopie par `copy_into`, exactement ce que
+ // fait l'export. Le test passait par `send_rgba`, qui n'a plus aucun
+ // appelant en production depuis que la conversion YUV est sur le GPU
+ // — il verifiait donc la configuration de l'encodeur en empruntant un
+ // chemin que plus personne ne prend.
+ let lay = YuvLayout::for_size(W, H);
+ let mut planes = vec![0u8; lay.total];
+ let frame = alloc_padded_yuv_frame(W, H).expect("alloc_padded_yuv_frame");
+ // Copie du pointeur AVANT la closure : la capturer via `enc.ctx`
+ // emprunterait `enc` en lecture pour toute la durée du drain, et
+ // `send_rgba` en veut un emprunt mutable juste après.
+ let ectx = enc.ctx;
+ let mut drain = |pkt: *mut AVPacket, out: &mut Encoded| loop {
+ let r = avcodec_receive_packet(ectx, pkt);
+ if r == AVERROR_EOF || r == AVERROR_EAGAIN {
+ return;
+ }
+ out.keyframes.push((*pkt).flags & AV_PKT_FLAG_KEY as i32 != 0);
+ av_packet_unref(pkt);
+ };
+ for i in 0..n {
+ // Un motif qui glisse : de quoi donner du residu a coder, sans
+ // quoi l'encodeur travaille sur une image morte et le test ne
+ // dirait rien des images cles.
+ // Un degrade GROSSIER et de faible amplitude, qui glisse d'un pas
+ // par frame. Assez de residu pour que l'encodeur travaille, pas
+ // assez de rupture pour reveiller sa detection de changement de
+ // scene — un motif contraste donnait des images cles a 0/17/34 au
+ // lieu de 0/20/40, et le test mesurait alors la detection de
+ // scene plutot que `gop_size`.
+ for y in 0..H as usize {
+ let row = &mut planes[y * lay.bpr_y..y * lay.bpr_y + W as usize];
+ for (x, px) in row.iter_mut().enumerate() {
+ *px = 100u8.wrapping_add(((x / 16 + y / 16 + i) % 40) as u8);
+ }
+ }
+ planes[lay.off_u..].fill(128);
+ VideoEncoder::copy_into(frame, &planes, W, H, W, H).expect("copy_into");
+ (*frame).pts = i as i64;
+ averr(avcodec_send_frame(enc.ctx, frame), "send_frame").expect("send_frame");
+ drain(pkt, &mut out);
+ }
+ enc.flush().expect("flush");
+ drain(pkt, &mut out);
+ let mut pkt = pkt;
+ av_packet_free(&mut pkt);
+ let mut frame = frame;
+ av_frame_free(&mut frame);
+ }
+ Some(out)
+ }
+
+ /// `profile_idc` porté par le SPS de l'extradata (Annex-B : `00 00 00 01 67 `).
+ fn profile_idc(extradata: &[u8]) -> Option {
+ extradata
+ .windows(6)
+ .find(|w| w[..4] == [0, 0, 0, 1] && w[4] & 0x1f == 7)
+ .map(|w| w[5])
+ }
+
+ /// L'export doit porter des images clés PÉRIODIQUES.
+ ///
+ /// Sans `(*ctx).gop_size`, le wrapper ffmpeg de `libopenh264` pose `g = -1` dans
+ /// ses `FFCodecDefault`, openh264 reçoit `uiIntraPeriod = 0` et n'émet qu'UNE
+ /// image clé pour tout le fichier — mesuré 1 image I sur 300 avant ce correctif.
+ /// Le MP4 reste lisible, mais tout seek redécode depuis le début et un paquet
+ /// abîmé emporte le reste. C'est le test qui aurait attrapé ça.
+ #[test]
+ fn l_export_h264_emet_des_images_cles_periodiques() {
+ let Some(enc) = encode(10, 45) else {
+ eprintln!("libopenh264 indisponible — test sauté");
+ return;
+ };
+ assert_eq!(enc.gop_size, 20, "gop_size doit valoir fps * 2");
+ let keys: Vec = enc
+ .keyframes
+ .iter()
+ .enumerate()
+ .filter(|(_, k)| **k)
+ .map(|(i, _)| i)
+ .collect();
+ assert_eq!(
+ keys,
+ vec![0, 20, 40],
+ "images clés attendues toutes les {} frames, obtenu {keys:?} sur {} paquets",
+ enc.gop_size,
+ enc.keyframes.len()
+ );
+ }
+
+ /// `tune_openh264` doit atteindre l'encodeur : fenêtre de QP explicite (sans quoi
+ /// openh264 remplace la sienne par (12, 42), cf. la doc de la fonction) et profil
+ /// High dans le SPS (sans quoi on expédie du Constrained Baseline en CAVLC).
+ #[test]
+ fn l_export_h264_configure_openh264() {
+ let Some(enc) = encode(10, 3) else {
+ eprintln!("libopenh264 indisponible — test sauté");
+ return;
+ };
+ assert_eq!((enc.qmin, enc.qmax), (1, 51), "fenêtre de QP non transmise");
+ assert_eq!(
+ profile_idc(&enc.extradata),
+ Some(100),
+ "le SPS doit annoncer le profil High (100) ; 66 = Constrained Baseline, \
+ le défaut du wrapper. extradata={:02x?}",
+ &enc.extradata[..enc.extradata.len().min(12)]
+ );
+ }
+}
diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs
index 10de3fac2..5721c2011 100644
--- a/crates/compositor/src/pipeline_macos.rs
+++ b/crates/compositor/src/pipeline_macos.rs
@@ -30,9 +30,10 @@
//! décodeurs, symétrique.
use crate::audio::{
- assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, finish_audio,
- stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
+ AacEncoder, PlanarPcm,
};
+use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::compositor::Compositor;
use crate::d3d::Gpu;
use crate::timeline_walk::NextFrameTime;
@@ -63,6 +64,20 @@ impl Drop for FrameGuard {
/// seuil dépend du GOP des captures, pas du backend de décodage.
const SEEK_FORWARD_MAX_SEC: f64 = 0.5;
+/// Pourquoi ce décodeur est ouvert. La preview et l'export ne demandent pas la même chose
+/// au décodeur, et sur macOS ils ne prennent donc pas le même backend.
+///
+/// La preview lit au temps réel : il lui suffit de tenir la cadence, et elle scrube, donc la
+/// latence d'un seek pèse plus que le débit. Une marche d'export déroule aussi vite que la
+/// machine le permet — c'est du débit pur, et l'arbitrage n'est pas le même.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum DecodeIntent {
+ /// Lecture temps réel (`live.rs`). Arbitrage historique, inchangé.
+ Preview,
+ /// Marche d'export (`timeline_walk`, `gif_export`).
+ Export,
+}
+
/// 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
@@ -97,7 +112,19 @@ pub struct Decoder {
}
impl Decoder {
+ /// Ouvre pour la PREVIEW. Signature conservée pour tous les appelants existants.
pub fn open(path: &str, gpu: &Gpu) -> Result {
+ Self::open_with(path, gpu, DecodeIntent::Preview)
+ }
+
+ /// Ouvre pour une marche d'EXPORT, où seul le débit compte. Windows et Linux exposent le
+ /// même point d'entrée sans rien en faire de particulier ; c'est ici qu'il change quelque
+ /// chose.
+ pub fn open_for_export(path: &str, gpu: &Gpu) -> Result {
+ Self::open_with(path, gpu, DecodeIntent::Export)
+ }
+
+ pub fn open_with(path: &str, gpu: &Gpu, intent: DecodeIntent) -> Result {
unsafe {
let mut fmt: *mut crate::ffi::AVFormatContext = ptr::null_mut();
let cpath = CString::new(path)?;
@@ -162,11 +189,58 @@ impl Decoder {
const FF_PROFILE_H264_CONSTRAINED_BASELINE: i32 = 578;
let is_baseline =
profile == FF_PROFILE_H264_BASELINE || profile == FF_PROFILE_H264_CONSTRAINED_BASELINE;
+ // H.264 8 bits 4:2:0 : ce que produit toute capture d'écran, et le SEUL cas sur
+ // lequel l'arbitrage ci-dessous a été mesuré. `format` vient de `codecpar`, donc
+ // rempli par `avformat_find_stream_info` ; un flux dont le format reste inconnu
+ // n'est pas éligible et garde le comportement d'avant.
+ let is_h264_8bit = (*codecpar).codec_id == crate::ffi::AVCodecID::AV_CODEC_ID_H264
+ && (*codecpar).format == crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
let forced = std::env::var("OPENSCREEN_MAC_DECODE").ok();
let want_hw = match forced.as_deref() {
Some("software") => false,
Some("videotoolbox") => true,
- _ => !is_baseline,
+ // Baseline : arbitrage historique, inchangé (cf. la note ci-dessus).
+ _ if is_baseline => false,
+ // MESURÉ, et contraire à ce que la note ci-dessus annonçait. Sur une marche
+ // d'export, un flux H.264 8 bits se décode plus vite en logiciel que par
+ // VideoToolbox — y compris en profil High, que cette note donnait à VT.
+ //
+ // Mac mini M1 8 Go / macOS 26.5. Source 1920x1080@60, 60 s, profil High.
+ // Scénario S4 du benchmark, sortie 1080p60 H.264. Trois cycles, un floor
+ // ffmpeg intercalé par cycle, dérive de fermeture 1,0002, machine à 86 % idle :
+ //
+ // VideoToolbox 32 079 ms 1,819x floor (MAD 34 ms)
+ // logiciel 22 863 ms 1,296x floor (MAD 16 ms) -28,7 %
+ //
+ // Par étage : décodage écran 13,13 s -> 1,02 s, webcam 4,20 s -> 0,29 s.
+ // L'image ne bouge pas — bitstream H.264 (NAL SEI retirés), pixels décodés et
+ // audio ont le même md5 sur les six sorties des deux variantes.
+ //
+ // La raison est celle que la note Baseline donne déjà, et elle ne dépend pas
+ // du profil : VideoToolbox a une latence FIXE par frame et alloue un
+ // CVPixelBuffer à chacune, là où le décodeur logiciel étale le travail sur des
+ // cœurs qui sont multiples. Ce qui compte est que la frame soit assez bon
+ // marché à décoder — ce que du 1080p 8 bits est.
+ //
+ // LA 4K AUSSI, mesurée depuis. Décodage seul, 1200 frames, meilleur de trois
+ // passes, même machine — avec le cas 1080p en témoin pour valider la méthode
+ // contre le résultat bout-en-bout ci-dessus :
+ //
+ // 1080p logiciel 2586 fps VideoToolbox 212 fps x12,2
+ // 4K logiciel 849 fps VideoToolbox 71 fps x11,9
+ //
+ // Le rapport ne bouge quasiment pas avec la résolution : la latence fixe par
+ // frame de VideoToolbox domine des deux côtés. Il n'y a donc pas de seuil de
+ // résolution à poser, et en poser un « par prudence » écarterait le chemin
+ // rapide du cas qui en profite le plus — 71 fps, c'est en dessous du temps
+ // réel pour une timeline 4K60.
+ //
+ // RESTE NON MESURÉ : 10 bits et HEVC. Ils gardent VideoToolbox, et la
+ // condition les écarte par construction (`format == YUV420P` et
+ // `codec_id == H264`). La preview aussi n'a pas été mesurée, et la changer
+ // sans la mesurer serait exactement l'erreur que ce commit corrige.
+ _ if intent == DecodeIntent::Export && is_h264_8bit => false,
+ _ => true,
};
let r = if want_hw {
crate::ffi::av_hwdevice_ctx_create(
@@ -179,6 +253,18 @@ impl Decoder {
} else {
-1 // repli logiciel délibéré, pas un échec
};
+ // Dire lequel a été pris. Sans cette ligne, « l'export est lent » et « l'export a
+ // pris VideoToolbox » ne se distinguent pas dans un rapport de bug, et un
+ // changement d'arbitrage ne se vérifie qu'au chronomètre.
+ eprintln!(
+ "[pipeline] décodage {} : {} (codec={} profil={} format={} intention={:?})",
+ path.rsplit('/').next().unwrap_or(path),
+ if r == 0 { "videotoolbox" } else { "logiciel" },
+ (*codecpar).codec_id,
+ profile,
+ (*codecpar).format,
+ intent,
+ );
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
@@ -852,14 +938,18 @@ impl VideoEncoder {
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 mut frame;
+ {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::VtGetBuffer);
+ let f = crate::ffi::av_frame_alloc();
+ if f.is_null() {
+ bail!("av_frame_alloc (frame VT)");
+ }
+ frame = f;
+ 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() {
@@ -872,10 +962,13 @@ impl VideoEncoder {
return Err(e);
}
(*frame).pts = pts;
- let sent = crate::ffi::averr(
- crate::ffi::avcodec_send_frame(self.ctx, frame),
- "send_frame_composited_vt",
- );
+ let sent = {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::SendFrame);
+ crate::ffi::averr(
+ crate::ffi::avcodec_send_frame(self.ctx, frame),
+ "send_frame_composited_vt",
+ )
+ };
crate::ffi::av_frame_free(&mut frame);
return sent;
}
@@ -897,6 +990,7 @@ impl VideoEncoder {
(*self.sw).linesize[1] as usize,
)?;
(*self.sw).pts = pts;
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::SendFrame);
crate::ffi::averr(
crate::ffi::avcodec_send_frame(self.ctx, self.sw),
"send_frame_composited",
@@ -998,6 +1092,7 @@ pub fn run_composited_multi(
bail!("run_composited_multi: aucun clip à exporter");
}
let (out_w, out_h) = (params.width, params.height);
+ crate::export_probe::reset();
let t0 = std::time::Instant::now();
let mut frames: u64 = 0;
@@ -1064,7 +1159,7 @@ pub fn run_composited_multi(
}
// 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 audio_jobs: ClipAudioJobs > = ClipAudioJobs::new(clips.len());
let mut clip_frame_counts: Vec = vec![0; clips.len()];
let mut opkt = unsafe { crate::ffi::av_packet_alloc() };
@@ -1077,6 +1172,11 @@ pub fn run_composited_multi(
// raconte avoir déjà coûté une fois.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
+ // Imported audio tracks (issue #350), cloned out of the borrowed scene.
+ let audio_tracks = scene
+ .as_ref()
+ .map(|scene| scene.audio_tracks.clone())
+ .unwrap_or_default();
frames = unsafe {
crate::timeline_walk::walk_composited_timeline(
clips,
@@ -1089,29 +1189,38 @@ pub fn run_composited_multi(
&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);
+ {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::DrainMux);
+ drain_encoder(ectx, octx, ostream, opkt)?;
+ }
+ {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::Progress);
+ 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é",
- ),
- }
+ // L'audio d'un clip ne dépend que de ce clip : le décoder et l'étirer ici,
+ // sur le thread de rendu, immobilisait la barre d'export pour toute sa
+ // durée — rien n'appelle `progress()` entre deux clips. Le travail part
+ // sur un thread et se recouvre avec la composition du clip suivant ; les
+ // résultats sont récupérés après le parcours, rangés par index de clip.
+ let path = clip.screen.clone();
+ let source_start_sec = clip.source_start_sec;
+ let segments = speed_segments.to_vec();
+ audio_jobs.spawn(clip_index, move || {
+ decode_and_stretch_clip_audio(
+ clip_index,
+ &path,
+ source_start_sec,
+ source_end_sec,
+ &segments,
+ out_fps as f64,
+ )
+ });
}
Ok(())
},
@@ -1119,6 +1228,7 @@ pub fn run_composited_multi(
};
// Flush : un null frame à l'encodeur finalise son bitstream.
+ let _finalize = crate::export_probe::scope(crate::export_probe::Stage::Finalize);
unsafe {
crate::ffi::averr(
crate::ffi::avcodec_send_frame(ectx, ptr::null_mut()),
@@ -1129,10 +1239,23 @@ pub fn run_composited_multi(
// 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.
+ // Récupération des jobs audio lancés pendant le parcours. `spawn` en admet quatre
+ // avant d'en collecter un, donc il en reste au plus quatre à attendre ici — bornés
+ // par le plus lent, pas par leur somme ; les autres se sont recouverts avec
+ // l'encodage vidéo.
+ let clip_pcm: Vec> = audio_jobs
+ .into_results()
+ .into_iter()
+ .map(|slot| slot.flatten())
+ .collect();
+
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(
- &finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
+ &finish_audio(
+ mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
+ audio_settings,
+ ),
octx,
)?;
@@ -1146,6 +1269,8 @@ pub fn run_composited_multi(
}
let wall_s = t0.elapsed().as_secs_f64();
+ drop(_finalize);
+ crate::export_probe::report(wall_s, frames);
Ok(Stats {
frames,
wall_s,
diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs
index 11738bcd4..67bd325d9 100644
--- a/crates/compositor/src/pipeline_windows.rs
+++ b/crates/compositor/src/pipeline_windows.rs
@@ -3,9 +3,10 @@
//! 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, finish_audio,
- stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm,
+ assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
+ AacEncoder, PlanarPcm,
};
+use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::compositor::{Compositor, OUT_H, OUT_W};
use crate::config::Cfg;
use crate::cpu_frames::CpuFrames;
@@ -25,6 +26,154 @@ use std::ptr;
use std::time::Instant;
use windows::core::Interface;
+#[cfg(test)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum DecodeFrameTestFault {
+ AfterAllocations,
+ PacketAllocNull,
+ FrameAllocNull,
+ CloneNull,
+ EofSendError,
+ AttachBufferRefNull,
+}
+
+#[cfg(test)]
+thread_local! {
+ static DECODE_FRAME_TEST_FAULT: std::cell::Cell> =
+ const { std::cell::Cell::new(None) };
+ static DECODE_FRAME_TEST_PACKET_RELEASED: std::cell::RefCell<
+ Option>
+ > = const { std::cell::RefCell::new(None) };
+ static DECODE_FRAME_TEST_FRAME_RELEASED: std::cell::RefCell<
+ Option>
+ > = const { std::cell::RefCell::new(None) };
+ static DECODE_FRAME_TEST_HWDEV_OBSERVER: std::cell::Cell<*mut AVBufferRef> =
+ const { std::cell::Cell::new(ptr::null_mut()) };
+}
+
+#[cfg(test)]
+unsafe extern "C" fn observe_test_buffer_release(opaque: *mut c_void, data: *mut u8) {
+ let released = Box::from_raw(opaque as *mut std::sync::Arc);
+ released.store(true, std::sync::atomic::Ordering::SeqCst);
+ drop(Box::from_raw(data));
+}
+
+#[cfg(test)]
+unsafe fn install_decode_frame_lifetime_probes(
+ hwdev: *mut AVBufferRef,
+ pkt: *mut AVPacket,
+ frame: *mut AVFrame,
+) -> Result<()> {
+ let should_fail = DECODE_FRAME_TEST_FAULT
+ .with(|fault| fault.get() == Some(DecodeFrameTestFault::AfterAllocations));
+ if !should_fail {
+ return Ok(());
+ }
+
+ let packet_released = DECODE_FRAME_TEST_PACKET_RELEASED.with(|signal| {
+ signal
+ .borrow()
+ .as_ref()
+ .expect("packet release signal")
+ .clone()
+ });
+ let frame_released = DECODE_FRAME_TEST_FRAME_RELEASED.with(|signal| {
+ signal
+ .borrow()
+ .as_ref()
+ .expect("frame release signal")
+ .clone()
+ });
+ let packet_data = Box::into_raw(Box::new(0u8));
+ let packet_opaque = Box::into_raw(Box::new(packet_released));
+ let packet_buf = av_buffer_create(
+ packet_data,
+ 1,
+ Some(observe_test_buffer_release),
+ packet_opaque as *mut c_void,
+ 0,
+ );
+ if packet_buf.is_null() {
+ drop(Box::from_raw(packet_data));
+ drop(Box::from_raw(packet_opaque));
+ bail!("test av_buffer_create(packet)");
+ }
+ (*pkt).buf = packet_buf;
+ (*pkt).data = packet_data;
+ (*pkt).size = 1;
+
+ let frame_data = Box::into_raw(Box::new(0u8));
+ let frame_opaque = Box::into_raw(Box::new(frame_released));
+ let frame_buf = av_buffer_create(
+ frame_data,
+ 1,
+ Some(observe_test_buffer_release),
+ frame_opaque as *mut c_void,
+ 0,
+ );
+ if frame_buf.is_null() {
+ drop(Box::from_raw(frame_data));
+ drop(Box::from_raw(frame_opaque));
+ bail!("test av_buffer_create(frame)");
+ }
+ (*frame).buf[0] = frame_buf;
+ (*frame).data[0] = frame_data;
+
+ let observer = av_buffer_ref(hwdev);
+ if observer.is_null() {
+ bail!("test av_buffer_ref(hwdev)");
+ }
+ DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.set(observer));
+ bail!("injected failure after decode allocations")
+}
+
+#[cfg(test)]
+fn decode_frame_test_fault_is(expected: DecodeFrameTestFault) -> bool {
+ DECODE_FRAME_TEST_FAULT.with(|fault| fault.get() == Some(expected))
+}
+
+unsafe fn decode_packet_alloc() -> *mut AVPacket {
+ #[cfg(test)]
+ if decode_frame_test_fault_is(DecodeFrameTestFault::PacketAllocNull) {
+ return ptr::null_mut();
+ }
+ av_packet_alloc()
+}
+
+unsafe fn decode_frame_alloc() -> *mut AVFrame {
+ #[cfg(test)]
+ if decode_frame_test_fault_is(DecodeFrameTestFault::FrameAllocNull) {
+ return ptr::null_mut();
+ }
+ av_frame_alloc()
+}
+
+unsafe fn clone_decoded_frame(frame: *const AVFrame) -> *mut AVFrame {
+ #[cfg(test)]
+ if decode_frame_test_fault_is(DecodeFrameTestFault::CloneNull) {
+ return ptr::null_mut();
+ }
+ av_frame_clone(frame)
+}
+
+unsafe fn send_decode_eof(dctx: *mut AVCodecContext) -> i32 {
+ #[cfg(test)]
+ if decode_frame_test_fault_is(DecodeFrameTestFault::EofSendError) {
+ return AVERROR_INVALIDDATA;
+ }
+ avcodec_send_packet(dctx, ptr::null())
+}
+
+unsafe fn ref_decode_hw_device(hwdev: *const AVBufferRef) -> *mut AVBufferRef {
+ #[cfg(test)]
+ if decode_frame_test_fault_is(DecodeFrameTestFault::AttachBufferRefNull) {
+ let observer = av_buffer_ref(hwdev);
+ DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.set(observer));
+ return ptr::null_mut();
+ }
+ av_buffer_ref(hwdev)
+}
+
// Macros libav non générées par bindgen (function-like). Valeurs Windows/MSVC.
// `AVERROR(EAGAIN)` dépend de la plateforme (cf. `crate::ffi`) ; ce fichier est
// Windows-only, mais garder une troisième copie de la valeur est ce qui a laissé
@@ -57,6 +206,13 @@ impl Drop for FrameGuard {
}
}
+struct PacketGuard(*mut AVPacket);
+impl Drop for PacketGuard {
+ fn drop(&mut self) {
+ unsafe { av_packet_free(&mut self.0) };
+ }
+}
+
/// Décode la n-ième frame d'une source sur NOTRE device (textures échantillonnables).
/// Sert le harnais de composition (S3+), hors mesure. Retourne une frame indépendante.
pub fn decode_frame_n(path: &str, gpu: &Gpu, n: u32) -> Result {
@@ -70,49 +226,77 @@ unsafe fn decode_frame_n_inner(path: &str, gpu: &Gpu, n: u32) -> Result Result bool {
+ codec_id == AVCodecID::AV_CODEC_ID_H264
+}
+
+unsafe fn require_decoder_id(
+ codec_id: AVCodecID::Type,
+) -> Result<(*const AVCodec, *mut AVCodecContext)> {
+ let dec = avcodec_find_decoder(codec_id);
+ if dec.is_null() {
+ bail!("no decoder for codec_id {}", codec_id as i32);
+ }
+ let dctx = avcodec_alloc_context3(dec);
+ if dctx.is_null() {
+ bail!("avcodec_alloc_context3");
+ }
+ Ok((dec, dctx))
+}
+
+unsafe fn require_decoder(
+ codecpar: *mut AVCodecParameters,
+) -> Result<(*const AVCodec, *mut AVCodecContext)> {
+ if codecpar.is_null() {
+ bail!("codecpar null");
+ }
+ require_decoder_id((*codecpar).codec_id)
+}
+
+unsafe fn attach_d3d11va(dctx: *mut AVCodecContext, gpu: &Gpu) -> Result<*mut AVBufferRef> {
+ let mut hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
+ if hwdev.is_null() {
+ bail!("av_hwdevice_ctx_alloc");
+ }
+ let hwdc = (*hwdev).data as *mut AVHWDeviceContext;
+ let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext;
+ let dev_clone = gpu.device.clone();
+ (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device;
+ std::mem::forget(dev_clone);
+ if let Err(error) = averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init") {
+ av_buffer_unref(&mut hwdev);
+ return Err(error);
+ }
+ let dctx_hwdev = ref_decode_hw_device(hwdev);
+ if dctx_hwdev.is_null() {
+ av_buffer_unref(&mut hwdev);
+ bail!("av_buffer_ref(hw_device_ctx)");
+ }
+ (*dctx).hw_device_ctx = dctx_hwdev;
+ (*dctx).get_format = Some(get_hw_format);
+ Ok(hwdev)
+}
+
// D3D11_TEXTURE2D_DESC.BindFlags (valeurs SDK)
const D3D11_BIND_SHADER_RESOURCE: u32 = 0x8;
const D3D11_BIND_DECODER: u32 = 0x200;
@@ -229,10 +465,18 @@ unsafe fn run_c0_inner(screen: &str, out: &str, gpu: &Gpu) -> Result {
avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()),
"avformat_open_input",
)?;
- averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?;
+ let mut resources = DecoderOpenResources {
+ fmt,
+ dctx: ptr::null_mut(),
+ hwdev: ptr::null_mut(),
+ };
+ averr(
+ avformat_find_stream_info(resources.fmt, ptr::null_mut()),
+ "find_stream_info",
+ )?;
let vidx = av_find_best_stream(
- fmt,
+ resources.fmt,
AVMediaType::AVMEDIA_TYPE_VIDEO,
-1,
-1,
@@ -242,33 +486,30 @@ unsafe fn run_c0_inner(screen: &str, out: &str, gpu: &Gpu) -> Result {
if vidx < 0 {
bail!("aucun flux vidéo");
}
- let stream = sn_fmt_stream(fmt, vidx);
+ let stream = sn_fmt_stream(resources.fmt, vidx);
let codecpar = (*stream).codecpar;
- // ---- décodeur D3D11VA sur NOTRE device ----
- let dec = avcodec_find_decoder((*codecpar).codec_id);
- if dec.is_null() {
- bail!("décodeur introuvable");
- }
- let dctx = avcodec_alloc_context3(dec);
- averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?;
- allow_d3d11va_h264_baseline(dctx);
-
- let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
- if hwdev.is_null() {
- bail!("av_hwdevice_ctx_alloc");
+ // ---- décodeur D3D11VA sur NOTRE device (H.264 only; see d3d11va_for_codec) ----
+ if !d3d11va_for_codec((*codecpar).codec_id) {
+ bail!(
+ "C0 D3D11VA only supports H.264 (codec_id {})",
+ (*codecpar).codec_id as i32
+ );
}
- let hwdc = (*hwdev).data as *mut AVHWDeviceContext;
- let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext;
- // AddRef : ffmpeg Release ce device au teardown. On garde un +1 en fuyant un clone.
- let dev_clone = gpu.device.clone();
- (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device;
- std::mem::forget(dev_clone);
- averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?;
+ let (dec, dctx) = require_decoder(codecpar)?;
+ resources.dctx = dctx;
+ averr(
+ avcodec_parameters_to_context(resources.dctx, codecpar),
+ "params_to_ctx",
+ )?;
+ allow_d3d11va_h264_baseline(resources.dctx);
- (*dctx).hw_device_ctx = av_buffer_ref(hwdev);
- (*dctx).get_format = Some(get_hw_format);
- averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2(dec)")?;
+ resources.hwdev = attach_d3d11va(resources.dctx, gpu)?;
+ averr(
+ avcodec_open2(resources.dctx, dec, ptr::null_mut()),
+ "avcodec_open2(dec)",
+ )?;
+ let (mut fmt, dctx, hwdev) = resources.into_raw();
// ---- encodeur (ouvert paresseusement à la 1re frame : il lui faut ses dims + hw_frames_ctx) ----
let mut enc: Option = None;
@@ -493,6 +734,37 @@ pub(crate) struct Decoder {
has_peek: bool,
}
+/// Owns the FFmpeg resources allocated while `Decoder::open` is still fallible.
+/// Once a complete `Decoder` exists, `into_raw` transfers the same three pointers
+/// to it and disarms this guard so exactly one Drop path remains responsible.
+struct DecoderOpenResources {
+ fmt: *mut AVFormatContext,
+ dctx: *mut AVCodecContext,
+ hwdev: *mut AVBufferRef,
+}
+
+impl DecoderOpenResources {
+ unsafe fn cleanup(&mut self) {
+ avcodec_free_context(&mut self.dctx);
+ av_buffer_unref(&mut self.hwdev);
+ avformat_close_input(&mut self.fmt);
+ }
+
+ unsafe fn into_raw(mut self) -> (*mut AVFormatContext, *mut AVCodecContext, *mut AVBufferRef) {
+ let resources = (self.fmt, self.dctx, self.hwdev);
+ self.fmt = ptr::null_mut();
+ self.dctx = ptr::null_mut();
+ self.hwdev = ptr::null_mut();
+ resources
+ }
+}
+
+impl Drop for DecoderOpenResources {
+ fn drop(&mut self) {
+ unsafe { self.cleanup() };
+ }
+}
+
// SAFETY: `Decoder` only owns FFI pointers into FFmpeg's own heap-allocated state, which
// has no OS thread affinity — safe to create on one thread and hand off to another as long
// as it's touched from a single thread at a time (never concurrently), which is exactly the
@@ -501,6 +773,13 @@ pub(crate) struct Decoder {
unsafe impl Send for Decoder {}
impl Decoder {
+ /// Même point d'entrée que sur macOS, pour que `timeline_walk` reste portable. Ici le
+ /// choix D3D11VA/logiciel dépend du feature level du device, pas de l'usage : l'intention
+ /// n'a rien à trancher.
+ pub(crate) unsafe fn open_for_export(path: &str, gpu: &Gpu) -> Result {
+ Self::open(path, gpu)
+ }
+
pub(crate) unsafe fn open(path: &str, gpu: &Gpu) -> Result {
let mut fmt: *mut AVFormatContext = ptr::null_mut();
let cpath = CString::new(path)?;
@@ -508,47 +787,56 @@ impl Decoder {
avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()),
"open_input",
)?;
- averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?;
- let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0);
+ let mut resources = DecoderOpenResources {
+ fmt,
+ dctx: ptr::null_mut(),
+ hwdev: ptr::null_mut(),
+ };
+ averr(
+ avformat_find_stream_info(resources.fmt, ptr::null_mut()),
+ "find_stream_info",
+ )?;
+ let vidx = av_find_best_stream(
+ resources.fmt,
+ AVMediaType::AVMEDIA_TYPE_VIDEO,
+ -1,
+ -1,
+ ptr::null_mut(),
+ 0,
+ );
if vidx < 0 {
bail!("aucun flux vidéo dans {path}");
}
- let stream = sn_fmt_stream(fmt, vidx);
+ let stream = sn_fmt_stream(resources.fmt, vidx);
let codecpar = (*stream).codecpar;
- let dec = avcodec_find_decoder((*codecpar).codec_id);
- let dctx = avcodec_alloc_context3(dec);
- averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?;
- allow_d3d11va_h264_baseline(dctx);
-
- // Backend CPU : on n'attache AUCUN hw_device_ctx et on ne force pas `get_format`,
- // donc libavcodec choisit son décodeur logiciel et sort en mémoire système. Passer
- // le device WARP à D3D11VA ne marcherait pas de toute façon — WARP n'expose pas
- // d'`ID3D11VideoDevice` (`tests/warp_device_cannot_decode.rs`).
- let cpu = if gpu.backend == Backend::Cpu {
- // `threads = 0` : libavcodec prend le nombre de cœurs. C'est le seul réglage
- // qui compte vraiment ici — sans lui le décodage logiciel est mono-thread et
- // le benchmark mesurerait surtout ça.
- (*dctx).thread_count = 0;
- Some(CpuFrames::new(gpu)?)
- } else {
- None
- };
+ let codec_id = (*codecpar).codec_id;
+ let (dec, dctx) = require_decoder(codecpar)?;
+ resources.dctx = dctx;
+ averr(
+ avcodec_parameters_to_context(resources.dctx, codecpar),
+ "params_to_ctx",
+ )?;
+ allow_d3d11va_h264_baseline(resources.dctx);
- let hwdev = if cpu.is_some() {
- ptr::null_mut()
+ // Hardware D3D11VA is the H.264 capture path. WARP has no video decoder
+ // (`tests/warp_device_cannot_decode.rs`). AV1/VP9 (legacy WebMs, #554)
+ // take the same software CpuFrames axis as Backend::Cpu.
+ let want_hw = gpu.backend != Backend::Cpu && d3d11va_for_codec(codec_id);
+ let cpu = if want_hw {
+ None
} else {
- let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
- let hwdc = (*hwdev).data as *mut AVHWDeviceContext;
- let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext;
- let dev_clone = gpu.device.clone();
- (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device;
- std::mem::forget(dev_clone);
- averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?;
- (*dctx).hw_device_ctx = av_buffer_ref(hwdev);
- (*dctx).get_format = Some(get_hw_format);
- hwdev
+ (*resources.dctx).thread_count = 0;
+ Some(CpuFrames::new(gpu)?)
};
- averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2")?;
+
+ if want_hw {
+ resources.hwdev = attach_d3d11va(resources.dctx, gpu)?;
+ }
+ averr(
+ avcodec_open2(resources.dctx, dec, ptr::null_mut()),
+ "avcodec_open2",
+ )?;
+ let (fmt, dctx, hwdev) = resources.into_raw();
Ok(Decoder {
fmt,
@@ -573,9 +861,18 @@ impl Decoder {
/// `next`, donc les deux doivent rendre la même chose. Le temps (`cur_time_sec`), lui,
/// continue de se lire sur la vraie frame décodée.
pub(crate) fn cur_frame(&self) -> *mut AVFrame {
- match &self.cpu {
+ let frame = match &self.cpu {
Some(cpu) => cpu.current(),
None => self.frame,
+ };
+ // `AVFrame*` identifies the reusable container, not whether it currently contains a
+ // presentable frame. It stays allocated before the first decode and can be unreffed by
+ // a seek that runs to EOF. Returning that non-null shell let Player's webcam hold path
+ // feed a null D3D11 texture to the compositor on replay after #554's AV1 clip.
+ if frame.is_null() || unsafe { (*frame).data[0].is_null() } {
+ ptr::null_mut()
+ } else {
+ frame
}
}
@@ -1343,6 +1640,11 @@ unsafe fn run_multi_inner(
// fenêtrage par clip ; `walk_composited_timeline` s'en charge.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
+ // Imported audio tracks (issue #350), cloned out of the borrowed scene.
+ let audio_tracks = scene
+ .as_ref()
+ .map(|scene| scene.audio_tracks.clone())
+ .unwrap_or_default();
// ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ----
// Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur
@@ -1390,8 +1692,7 @@ unsafe fn run_multi_inner(
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 mut audio_jobs: ClipAudioJobs > = ClipAudioJobs::new(clips.len());
let t0 = Instant::now();
let frames = walk_composited_timeline(
@@ -1431,23 +1732,24 @@ unsafe fn run_multi_inner(
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é",
+ // L'audio d'un clip ne dépend que de ce clip : le décoder et l'étirer ici,
+ // sur le thread de rendu, immobilisait la barre d'export pour toute sa durée
+ // — rien n'appelle `progress()` entre deux clips. Le travail part sur un
+ // thread et se recouvre avec la composition du clip suivant ; les résultats
+ // sont récupérés après le parcours, rangés par index de clip.
+ let path = clip.screen.clone();
+ let source_start_sec = clip.source_start_sec;
+ let segments = speed_segments.to_vec();
+ audio_jobs.spawn(clip_index, move || {
+ decode_and_stretch_clip_audio(
clip_index,
- ),
- }
+ &path,
+ source_start_sec,
+ source_end_sec,
+ &segments,
+ out_fps as f64,
+ )
+ });
}
Ok(())
},
@@ -1460,6 +1762,15 @@ unsafe fn run_multi_inner(
enc.send(ptr::null_mut())?;
drain_encoder(ectx, octx, ostream, opkt)?;
+ // Récupération des jobs audio lancés pendant le parcours. `spawn` en admet quatre avant
+ // d'en collecter un, donc il en reste au plus quatre à attendre ici — bornés par le plus
+ // lent, pas par leur somme ; tous les autres se sont recouverts avec l'encodage.
+ let clip_pcm: Vec > = audio_jobs
+ .into_results()
+ .into_iter()
+ .map(|slot| slot.flatten())
+ .collect();
+
let declared_audio: Vec = clips.iter().map(|clip| clip.has_audio).collect();
let audio_plan = build_audio_concat_plan(
&clip_frame_counts,
@@ -1467,7 +1778,7 @@ unsafe fn run_multi_inner(
out_fps as f64,
);
let assembled_audio = finish_audio(
- assemble_concatenated_pcm(&clip_pcm, &audio_plan),
+ mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &audio_plan), &audio_tracks),
audio_settings,
);
audio_encoder.encode(&assembled_audio, octx)?;
@@ -1496,6 +1807,141 @@ unsafe fn run_multi_inner(
mod tests {
use super::*;
+ struct DecodeFrameFaultReset;
+
+ impl Drop for DecodeFrameFaultReset {
+ fn drop(&mut self) {
+ DECODE_FRAME_TEST_FAULT.with(|fault| fault.set(None));
+ DECODE_FRAME_TEST_PACKET_RELEASED.with(|signal| *signal.borrow_mut() = None);
+ DECODE_FRAME_TEST_FRAME_RELEASED.with(|signal| *signal.borrow_mut() = None);
+ DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| unsafe {
+ let mut observer = slot.replace(ptr::null_mut());
+ av_buffer_unref(&mut observer);
+ });
+ }
+ }
+
+ fn install_decode_frame_fault(
+ fault: DecodeFrameTestFault,
+ packet_released: std::sync::Arc,
+ frame_released: std::sync::Arc,
+ ) -> DecodeFrameFaultReset {
+ DECODE_FRAME_TEST_FAULT.with(|slot| slot.set(Some(fault)));
+ DECODE_FRAME_TEST_PACKET_RELEASED.with(|slot| *slot.borrow_mut() = Some(packet_released));
+ DECODE_FRAME_TEST_FRAME_RELEASED.with(|slot| *slot.borrow_mut() = Some(frame_released));
+ DecodeFrameFaultReset
+ }
+
+ fn install_simple_decode_frame_fault(fault: DecodeFrameTestFault) -> DecodeFrameFaultReset {
+ install_decode_frame_fault(
+ fault,
+ std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
+ std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
+ )
+ }
+
+ #[derive(Debug, PartialEq, Eq)]
+ enum HardwarePrerequisite {
+ Available,
+ Unsupported,
+ }
+
+ const DXGI_ERROR_UNSUPPORTED_CODE: i32 = 0x887A_0004u32 as i32;
+
+ fn classify_hardware_prerequisite(
+ result: windows::core::Result<()>,
+ ) -> windows::core::Result {
+ match result {
+ Ok(()) => Ok(HardwarePrerequisite::Available),
+ Err(error) if error.code().0 == DXGI_ERROR_UNSUPPORTED_CODE => {
+ Ok(HardwarePrerequisite::Unsupported)
+ }
+ Err(error) => Err(error),
+ }
+ }
+
+ fn raw_hardware_prerequisite() -> windows::core::Result<()> {
+ use windows::Win32::Foundation::{E_UNEXPECTED, HMODULE};
+ use windows::Win32::Graphics::Direct3D::{
+ D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL, D3D_FEATURE_LEVEL_11_1,
+ };
+ use windows::Win32::Graphics::Direct3D11::{
+ D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
+ D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
+ };
+
+ let levels = [D3D_FEATURE_LEVEL_11_1];
+ let mut device: Option = None;
+ let mut context: Option = None;
+ let mut got = D3D_FEATURE_LEVEL::default();
+ unsafe {
+ D3D11CreateDevice(
+ None,
+ D3D_DRIVER_TYPE_HARDWARE,
+ HMODULE::default(),
+ D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT,
+ Some(&levels),
+ D3D11_SDK_VERSION,
+ Some(&mut device),
+ Some(&mut got),
+ Some(&mut context),
+ )?;
+ }
+ if device.is_none() || context.is_none() || got != D3D_FEATURE_LEVEL_11_1 {
+ return Err(windows::core::Error::from(E_UNEXPECTED));
+ }
+ Ok(())
+ }
+
+ fn strict_hardware_gpu(test_name: &str) -> Option {
+ match classify_hardware_prerequisite(raw_hardware_prerequisite()) {
+ Ok(HardwarePrerequisite::Unsupported) => {
+ println!(
+ "NOT_EXECUTED:{test_name}:raw D3D11 preflight returned DXGI_ERROR_UNSUPPORTED (0x887A0004)"
+ );
+ None
+ }
+ Ok(HardwarePrerequisite::Available) => Some(
+ Gpu::create(false)
+ .unwrap_or_else(|error| panic!("{test_name}: strict Gpu::create failed after successful raw hardware preflight: {error:#}")),
+ ),
+ Err(error) => panic!(
+ "{test_name}: raw D3D11 hardware preflight failed with non-skippable HRESULT {:#010X}: {error}",
+ error.code().0 as u32
+ ),
+ }
+ }
+
+ fn decode_frame_error(path: &std::path::Path, gpu: &Gpu, n: u32) -> anyhow::Error {
+ match decode_frame_n(path.to_str().expect("utf8 path"), gpu, n) {
+ Ok(_) => panic!("decode_frame_n unexpectedly succeeded"),
+ Err(error) => error,
+ }
+ }
+
+ #[test]
+ fn hardware_prerequisite_classification() {
+ use windows::Win32::Foundation::{E_INVALIDARG, E_OUTOFMEMORY, E_UNEXPECTED};
+
+ assert_eq!(
+ classify_hardware_prerequisite(Ok(())).expect("success classification"),
+ HardwarePrerequisite::Available
+ );
+ let unsupported =
+ windows::core::Error::from(windows::core::HRESULT(DXGI_ERROR_UNSUPPORTED_CODE));
+ assert_eq!(
+ classify_hardware_prerequisite(Err(unsupported))
+ .expect("DXGI_ERROR_UNSUPPORTED classification"),
+ HardwarePrerequisite::Unsupported
+ );
+ for hresult in [E_INVALIDARG, E_OUTOFMEMORY, E_UNEXPECTED] {
+ let error = windows::core::Error::from(hresult);
+ let returned = classify_hardware_prerequisite(Err(error))
+ .expect_err("ordinary failures must remain failures");
+ assert_eq!(returned.code(), hresult);
+ }
+ }
+
/// L'ordre EST le contrat : tous les candidats zéro-copie d'abord, ceux qui exigent la
/// mémoire système ensuite (`*_qsv` et `*_mf` sont matériels eux aussi — ce qui les
/// distingue est le format d'entrée, pas le silicium). Un candidat système remonté
@@ -1589,6 +2035,831 @@ mod tests {
}
}
}
+
+ #[test]
+ fn d3d11va_is_h264_only() {
+ assert!(d3d11va_for_codec(AVCodecID::AV_CODEC_ID_H264));
+ assert!(!d3d11va_for_codec(AVCodecID::AV_CODEC_ID_AV1));
+ assert!(!d3d11va_for_codec(AVCodecID::AV_CODEC_ID_VP9));
+ }
+
+ #[test]
+ fn require_decoder_rejects_none() {
+ let err = unsafe { require_decoder_id(AVCodecID::AV_CODEC_ID_NONE) }
+ .expect_err("NONE must not allocate a context");
+ let msg = format!("{err:#}");
+ assert!(msg.contains("codec_id"), "{msg}");
+ }
+
+ fn select_ffmpeg_exe(
+ crate_dir: &std::path::Path,
+ configured_dir: Option,
+ ) -> Option {
+ let mut candidates = Vec::new();
+ if let Some(dir) = configured_dir {
+ candidates.push(dir.join("bin").join("ffmpeg.exe"));
+ }
+ candidates
+ .push(crate_dir.join("../thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared/bin/ffmpeg.exe"));
+ candidates.into_iter().find(|p| p.is_file())
+ }
+
+ fn ffmpeg_exe() -> std::path::PathBuf {
+ let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ select_ffmpeg_exe(
+ &crate_dir,
+ std::env::var_os("FFMPEG_DIR").map(std::path::PathBuf::from),
+ )
+ .unwrap_or_else(|| panic!("ffmpeg.exe not found next to FFMPEG_DIR / crates/thirdparty"))
+ }
+
+ fn ffprobe_exe() -> std::path::PathBuf {
+ let ffprobe = ffmpeg_exe().with_file_name("ffprobe.exe");
+ assert!(
+ ffprobe.is_file(),
+ "ffprobe.exe not found next to ffmpeg.exe: {ffprobe:?}"
+ );
+ ffprobe
+ }
+
+ fn encode_color(codec_args: &[&str], filename: &str) -> std::path::PathBuf {
+ encode_color_for_duration(codec_args, filename, "0.4")
+ }
+
+ fn encode_color_for_duration(
+ codec_args: &[&str],
+ filename: &str,
+ duration_sec: &str,
+ ) -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join(format!("openscreen-554-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).expect("temp dir");
+ let out = dir.join(filename);
+ let ff = ffmpeg_exe();
+ let mut cmd = std::process::Command::new(&ff);
+ let input = format!("color=c=red:s=64x64:d={duration_sec}");
+ cmd.args(["-y", "-f", "lavfi", "-i", input.as_str()]);
+ cmd.args(codec_args);
+ cmd.arg(&out);
+ let output = cmd.output().unwrap_or_else(|e| panic!("spawn {ff:?}: {e}"));
+ assert!(
+ output.status.success() && out.is_file(),
+ "ffmpeg {cmd:?} failed status={} stderr={}",
+ output.status,
+ String::from_utf8_lossy(&output.stderr)
+ );
+ out
+ }
+
+ unsafe fn allocated_decoder_open_resources(filename: &str) -> DecoderOpenResources {
+ let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], filename);
+ let cpath = CString::new(path.to_string_lossy().as_bytes()).expect("fixture path CString");
+ let mut fmt = ptr::null_mut();
+ averr(
+ avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()),
+ "test open_input",
+ )
+ .expect("open fixture");
+ averr(
+ avformat_find_stream_info(fmt, ptr::null_mut()),
+ "test find_stream_info",
+ )
+ .expect("find fixture streams");
+ let vidx = av_find_best_stream(
+ fmt,
+ AVMediaType::AVMEDIA_TYPE_VIDEO,
+ -1,
+ -1,
+ ptr::null_mut(),
+ 0,
+ );
+ assert!(vidx >= 0, "fixture video stream");
+ let codecpar = (*sn_fmt_stream(fmt, vidx)).codecpar;
+ let (_, dctx) = require_decoder(codecpar).expect("fixture decoder context");
+ let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
+ assert!(!hwdev.is_null(), "test hardware-device context");
+ DecoderOpenResources { fmt, dctx, hwdev }
+ }
+
+ #[test]
+ fn decoder_open_resources_cleanup_nulls_every_owned_pointer() {
+ unsafe {
+ let mut resources =
+ allocated_decoder_open_resources("decoder-open-resources-cleanup.mp4");
+ resources.cleanup();
+ assert!(resources.dctx.is_null());
+ assert!(resources.hwdev.is_null());
+ assert!(resources.fmt.is_null());
+ }
+ }
+
+ #[test]
+ fn decoder_open_resources_release_transfers_every_pointer_once() {
+ unsafe {
+ let resources = allocated_decoder_open_resources("decoder-open-resources-release.mp4");
+ let (mut fmt, mut dctx, mut hwdev) = resources.into_raw();
+ assert!(!dctx.is_null());
+ assert!(!hwdev.is_null());
+ assert!(!fmt.is_null());
+ avcodec_free_context(&mut dctx);
+ av_buffer_unref(&mut hwdev);
+ avformat_close_input(&mut fmt);
+ }
+ }
+
+ #[test]
+ fn decode_frame_n_failure_paths_release_resources() {
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::Arc;
+
+ let Some(gpu) = strict_hardware_gpu("decode_frame_n_failure_paths_release_resources")
+ else {
+ return;
+ };
+ let path = encode_color(
+ &["-c:v", "libopenh264", "-b:v", "200k"],
+ "decode-frame-n-lifetime.mp4",
+ );
+ let packet_released = Arc::new(AtomicBool::new(false));
+ let frame_released = Arc::new(AtomicBool::new(false));
+ let _fault = install_decode_frame_fault(
+ DecodeFrameTestFault::AfterAllocations,
+ Arc::clone(&packet_released),
+ Arc::clone(&frame_released),
+ );
+
+ let error = decode_frame_error(&path, &gpu, 0);
+ assert!(
+ format!("{error:#}").contains("injected failure after decode allocations"),
+ "unexpected injected error: {error:#}"
+ );
+ let mut observer =
+ DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.replace(ptr::null_mut()));
+ assert!(
+ !observer.is_null(),
+ "hardware-device observer was not installed"
+ );
+ let hwdev_ref_count = unsafe { av_buffer_get_ref_count(observer) };
+ let input_handle_released = std::fs::remove_file(&path).is_ok();
+ let packet_was_released = packet_released.load(Ordering::SeqCst);
+ let frame_was_released = frame_released.load(Ordering::SeqCst);
+ unsafe { av_buffer_unref(&mut observer) };
+
+ println!(
+ "RELEASE_OBSERVATION:fmt_handle={input_handle_released}:hwdev_refs={hwdev_ref_count}:packet_callback={packet_was_released}:frame_callback={frame_was_released}"
+ );
+ assert!(
+ input_handle_released
+ && hwdev_ref_count == 1
+ && packet_was_released
+ && frame_was_released,
+ "UNRELEASED_RESOURCE fmt_handle={input_handle_released} hwdev_refs={hwdev_ref_count} packet_callback={packet_was_released} frame_callback={frame_was_released}"
+ );
+
+ let unsupported_path = encode_color(
+ &["-c:v", "libaom-av1", "-cpu-used", "8"],
+ "decode-frame-n-unsupported.webm",
+ );
+ let unsupported_error = decode_frame_error(&unsupported_path, &gpu, 0);
+ let unsupported_message = format!("{unsupported_error:#}");
+ assert!(
+ unsupported_message
+ .contains(&format!("codec_id {}", AVCodecID::AV_CODEC_ID_AV1 as i32)),
+ "unsupported codec id was not preserved before format teardown: {unsupported_message}"
+ );
+ std::fs::remove_file(&unsupported_path).expect("unsupported input handle released");
+
+ for (fault, filename, expected) in [
+ (
+ DecodeFrameTestFault::PacketAllocNull,
+ "decode-frame-n-packet-null.mp4",
+ "av_packet_alloc",
+ ),
+ (
+ DecodeFrameTestFault::FrameAllocNull,
+ "decode-frame-n-frame-null.mp4",
+ "av_frame_alloc",
+ ),
+ (
+ DecodeFrameTestFault::CloneNull,
+ "decode-frame-n-clone-null.mp4",
+ "av_frame_clone",
+ ),
+ (
+ DecodeFrameTestFault::EofSendError,
+ "decode-frame-n-eof-send.mp4",
+ "send_eof",
+ ),
+ ] {
+ let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], filename);
+ let frame_number = if fault == DecodeFrameTestFault::EofSendError {
+ u32::MAX
+ } else {
+ 0
+ };
+ let error = {
+ let _fault = install_simple_decode_frame_fault(fault);
+ decode_frame_error(&path, &gpu, frame_number)
+ };
+ let message = format!("{error:#}");
+ assert!(message.contains(expected), "{fault:?}: {message}");
+ std::fs::remove_file(&path)
+ .unwrap_or_else(|error| panic!("{fault:?}: input handle leaked: {error}"));
+ }
+
+ let attach_path = encode_color(
+ &["-c:v", "libopenh264", "-b:v", "200k"],
+ "decode-frame-n-attach-ref-null.mp4",
+ );
+ let attach_fault =
+ install_simple_decode_frame_fault(DecodeFrameTestFault::AttachBufferRefNull);
+ let attach_error = decode_frame_error(&attach_path, &gpu, 0);
+ assert!(
+ format!("{attach_error:#}").contains("av_buffer_ref(hw_device_ctx)"),
+ "unexpected attach error: {attach_error:#}"
+ );
+ let mut attach_observer =
+ DECODE_FRAME_TEST_HWDEV_OBSERVER.with(|slot| slot.replace(ptr::null_mut()));
+ assert!(
+ !attach_observer.is_null(),
+ "attach observer was not installed"
+ );
+ let attach_refs = unsafe { av_buffer_get_ref_count(attach_observer) };
+ unsafe { av_buffer_unref(&mut attach_observer) };
+ drop(attach_fault);
+ std::fs::remove_file(&attach_path).expect("attach-ref-null input handle released");
+ assert_eq!(
+ attach_refs, 1,
+ "UNRELEASED_RESOURCE attach_d3d11va local hwdev refs={attach_refs}"
+ );
+ println!("FAILURE_PATH_ASSERTIONS_COMPLETED");
+ }
+
+ #[test]
+ fn decode_frame_n_returned_frame_keeps_its_buffers() {
+ let Some(gpu) = strict_hardware_gpu("decode_frame_n_returned_frame_keeps_its_buffers")
+ else {
+ return;
+ };
+ let path = encode_color(
+ &["-c:v", "libopenh264", "-b:v", "200k"],
+ "decode-frame-n-returned-frame.mp4",
+ );
+ let frame = decode_frame_n(path.to_str().expect("utf8 path"), &gpu, 0)
+ .unwrap_or_else(|error| panic!("decode first H.264 frame: {error:#}"));
+ assert!(!frame.0.is_null(), "returned frame pointer");
+ let source_buffer = unsafe { (*frame.0).buf[0] };
+ assert!(!source_buffer.is_null(), "returned frame buffer reference");
+ let mut observer = unsafe { av_buffer_ref(source_buffer) };
+ assert!(
+ !observer.is_null(),
+ "observer reference for returned frame buffer"
+ );
+ let refs_with_frame = unsafe { av_buffer_get_ref_count(observer) };
+ drop(frame);
+ let refs_after_frame_drop = unsafe { av_buffer_get_ref_count(observer) };
+ println!(
+ "RETURNED_FRAME_REFS:with_frame={refs_with_frame}:after_frame_drop={refs_after_frame_drop}"
+ );
+ assert_eq!(
+ refs_after_frame_drop + 1,
+ refs_with_frame,
+ "FrameGuard must own one independent AVBuffer reference"
+ );
+ assert!(
+ refs_after_frame_drop >= 1,
+ "observer reference must remain valid"
+ );
+ unsafe { av_buffer_unref(&mut observer) };
+ std::fs::remove_file(path).expect("returned-frame input handle released");
+ }
+
+ #[test]
+ fn ffmpeg_dir_selects_the_configured_executable_over_the_builtin() {
+ let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ let built_in =
+ crate_dir.join("../thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared/bin/ffmpeg.exe");
+ assert!(
+ built_in.is_file(),
+ "built-in control is missing: {built_in:?}"
+ );
+
+ let configured_dir = std::env::temp_dir().join(format!(
+ "openscreen-554-ffmpeg-override-{}",
+ std::process::id()
+ ));
+ let configured = configured_dir.join("bin").join("ffmpeg.exe");
+ std::fs::create_dir_all(configured.parent().expect("configured ffmpeg parent"))
+ .expect("create configured ffmpeg directory");
+ std::fs::File::create(&configured).expect("create configured ffmpeg executable");
+
+ let selected = select_ffmpeg_exe(&crate_dir, Some(configured_dir.clone()))
+ .expect("select configured ffmpeg executable");
+ assert_eq!(
+ selected, configured,
+ "an explicit FFMPEG_DIR must override the built-in test fixture executable"
+ );
+ std::fs::remove_dir_all(configured_dir).expect("remove configured ffmpeg directory");
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ struct EbmlElement {
+ id: u64,
+ start: usize,
+ size_offset: usize,
+ size_width: usize,
+ data_start: usize,
+ data_end: usize,
+ unknown_size: bool,
+ }
+
+ fn vint_width(first: u8, at: usize) -> usize {
+ let width = first.leading_zeros() as usize + 1;
+ assert!(width <= 8, "invalid EBML vint at offset {at:#x}");
+ width
+ }
+
+ fn read_ebml_id(bytes: &[u8], at: usize) -> (u64, usize) {
+ let first = *bytes
+ .get(at)
+ .unwrap_or_else(|| panic!("missing EBML id at {at:#x}"));
+ let width = vint_width(first, at);
+ assert!(width <= 4, "EBML id is wider than four bytes at {at:#x}");
+ let end = at.checked_add(width).expect("EBML id offset overflow");
+ let encoded = bytes
+ .get(at..end)
+ .unwrap_or_else(|| panic!("truncated EBML id at {at:#x}"));
+ let id = encoded
+ .iter()
+ .fold(0u64, |value, byte| (value << 8) | u64::from(*byte));
+ (id, width)
+ }
+
+ fn read_ebml_size(bytes: &[u8], at: usize) -> (Option, usize) {
+ let first = *bytes
+ .get(at)
+ .unwrap_or_else(|| panic!("missing EBML size at {at:#x}"));
+ let width = vint_width(first, at);
+ let end = at.checked_add(width).expect("EBML size offset overflow");
+ let encoded = bytes
+ .get(at..end)
+ .unwrap_or_else(|| panic!("truncated EBML size at {at:#x}"));
+ let value_mask = if width == 8 { 0 } else { 0xffu8 >> width };
+ let value = encoded[1..]
+ .iter()
+ .fold(u64::from(first & value_mask), |value, byte| {
+ (value << 8) | u64::from(*byte)
+ });
+ let unknown_value = (1u64 << (7 * width)) - 1;
+ if value == unknown_value {
+ (None, width)
+ } else {
+ let value = usize::try_from(value).expect("EBML size does not fit usize");
+ (Some(value), width)
+ }
+ }
+
+ fn ebml_element_at(bytes: &[u8], start: usize, parent_end: usize) -> EbmlElement {
+ let (id, id_width) = read_ebml_id(bytes, start);
+ let size_offset = start
+ .checked_add(id_width)
+ .expect("EBML size offset overflow");
+ let (size, size_width) = read_ebml_size(bytes, size_offset);
+ let data_start = size_offset
+ .checked_add(size_width)
+ .expect("EBML payload offset overflow");
+ assert!(
+ data_start <= parent_end,
+ "EBML header exceeds parent at {start:#x}"
+ );
+ let data_end = match size {
+ Some(size) => data_start
+ .checked_add(size)
+ .expect("EBML payload offset overflow"),
+ None => parent_end,
+ };
+ assert!(
+ data_end <= parent_end,
+ "EBML element {id:#x} exceeds its parent"
+ );
+ EbmlElement {
+ id,
+ start,
+ size_offset,
+ size_width,
+ data_start,
+ data_end,
+ unknown_size: size.is_none(),
+ }
+ }
+
+ fn ebml_children(bytes: &[u8], start: usize, end: usize) -> Vec {
+ let mut children = Vec::new();
+ let mut at = start;
+ while at < end {
+ let child = ebml_element_at(bytes, at, end);
+ assert!(
+ child.data_end > at,
+ "empty EBML element cannot advance at {at:#x}"
+ );
+ children.push(child);
+ at = child.data_end;
+ if child.unknown_size {
+ assert_eq!(
+ at, end,
+ "unknown-sized child must consume the parent remainder"
+ );
+ }
+ }
+ assert_eq!(at, end, "EBML children did not exactly fill their parent");
+ children
+ }
+
+ fn exactly_one(children: &[EbmlElement], id: u64, label: &str) -> EbmlElement {
+ let found: Vec<_> = children
+ .iter()
+ .copied()
+ .filter(|child| child.id == id)
+ .collect();
+ assert_eq!(
+ found.len(),
+ 1,
+ "expected exactly one {label}, found {}",
+ found.len()
+ );
+ found[0]
+ }
+
+ /// Turn the pinned ffmpeg's valid AV1 WebM into the three malformed-but-decodable
+ /// characteristics from #554. This is deliberately a structural EBML edit: a raw byte
+ /// search could hit an AV1 payload byte and produce a fixture that only looked relevant.
+ fn make_legacy_av1_fixture(filename: &str) -> std::path::PathBuf {
+ const SEGMENT_ID: u64 = 0x1853_8067;
+ const TRACKS_ID: u64 = 0x1654_ae6b;
+ const TRACK_ENTRY_ID: u64 = 0xae;
+ const CODEC_ID_ID: u64 = 0x86;
+ const DEFAULT_DURATION_ID: u64 = 0x23e383;
+ const CODEC_PRIVATE_ID: u64 = 0x63a2;
+ const CLUSTER_ID: u64 = 0x1f43_b675;
+
+ // One frame is intentional: after DefaultDuration is removed, there is no second
+ // timestamp from which avformat_find_stream_info can infer a replacement frame rate.
+ let path = encode_color_for_duration(
+ &[
+ "-c:v",
+ "libaom-av1",
+ "-cpu-used",
+ "8",
+ "-usage",
+ "realtime",
+ "-b:v",
+ "50k",
+ "-output_ts_offset",
+ "0.4",
+ ],
+ filename,
+ "0.04",
+ );
+ let mut bytes = std::fs::read(&path).expect("read generated AV1 WebM");
+ let original = bytes.clone();
+ let top_level = ebml_children(&bytes, 0, bytes.len());
+ let segment = exactly_one(&top_level, SEGMENT_ID, "Segment");
+ assert!(
+ !segment.unknown_size,
+ "generated Segment must have a finite size"
+ );
+ let segment_children = ebml_children(&bytes, segment.data_start, segment.data_end);
+ let tracks = exactly_one(&segment_children, TRACKS_ID, "Tracks");
+ let cluster = exactly_one(&segment_children, CLUSTER_ID, "Cluster");
+ assert!(
+ !cluster.unknown_size,
+ "generated Cluster must start with a finite size"
+ );
+
+ let track_entries: Vec<_> = ebml_children(&bytes, tracks.data_start, tracks.data_end)
+ .into_iter()
+ .filter(|child| child.id == TRACK_ENTRY_ID)
+ .collect();
+ let av1_tracks: Vec<_> = track_entries
+ .iter()
+ .copied()
+ .filter(|entry| {
+ let children = ebml_children(&bytes, entry.data_start, entry.data_end);
+ children.iter().any(|child| {
+ child.id == CODEC_ID_ID && &bytes[child.data_start..child.data_end] == b"V_AV1"
+ })
+ })
+ .collect();
+ assert_eq!(av1_tracks.len(), 1, "expected exactly one V_AV1 TrackEntry");
+ let track_children =
+ ebml_children(&bytes, av1_tracks[0].data_start, av1_tracks[0].data_end);
+ let codec_private = exactly_one(&track_children, CODEC_PRIVATE_ID, "AV1 CodecPrivate");
+ let default_duration = exactly_one(&track_children, DEFAULT_DURATION_ID, "DefaultDuration");
+
+ assert!(
+ codec_private.data_start < codec_private.data_end,
+ "empty AV1 CodecPrivate"
+ );
+ assert_eq!(
+ bytes[codec_private.data_start], 0x81,
+ "pinned encoder's AV1CodecConfigurationRecord layout changed"
+ );
+ bytes[codec_private.data_start] = 0xff;
+
+ let default_duration_len = default_duration.data_end - default_duration.start;
+ assert!(
+ (3..=128).contains(&default_duration_len),
+ "DefaultDuration cannot be replaced by a one-byte-size Void"
+ );
+ let void_payload_len = default_duration_len - 2;
+ assert!(
+ void_payload_len <= 126,
+ "one-byte Void size would become unknown"
+ );
+ bytes[default_duration.start] = 0xec;
+ bytes[default_duration.start + 1] = 0x80 | void_payload_len as u8;
+ bytes[default_duration.start + 2..default_duration.data_end].fill(0);
+
+ assert!(
+ (1..=8).contains(&cluster.size_width),
+ "invalid Cluster size width {}",
+ cluster.size_width
+ );
+ bytes[cluster.size_offset] = 0xff >> (cluster.size_width - 1);
+ bytes[cluster.size_offset + 1..cluster.data_start].fill(0xff);
+
+ assert_eq!(
+ bytes.len(),
+ original.len(),
+ "fixture patch must preserve file length"
+ );
+ let allowed = [
+ codec_private.data_start..codec_private.data_start + 1,
+ default_duration.start..default_duration.data_end,
+ cluster.size_offset..cluster.data_start,
+ ];
+ let changed: Vec<_> = original
+ .iter()
+ .zip(&bytes)
+ .enumerate()
+ .filter_map(|(index, (before, after))| (before != after).then_some(index))
+ .collect();
+ assert!(!changed.is_empty(), "fixture patch changed no bytes");
+ assert!(
+ changed
+ .iter()
+ .all(|index| allowed.iter().any(|range| range.contains(index))),
+ "fixture patch changed bytes outside the three intended EBML fields: {changed:?}"
+ );
+ for range in &allowed {
+ assert!(
+ changed.iter().any(|index| range.contains(index)),
+ "fixture patch did not change intended range {range:?}"
+ );
+ }
+ std::fs::write(&path, &bytes).expect("write structurally patched AV1 WebM");
+
+ let ffprobe = ffprobe_exe();
+ let output = std::process::Command::new(&ffprobe)
+ .args([
+ "-v",
+ "warning",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=codec_name,avg_frame_rate",
+ "-of",
+ "default=noprint_wrappers=1",
+ ])
+ .arg(&path)
+ .output()
+ .unwrap_or_else(|e| panic!("spawn {ffprobe:?}: {e}"));
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ output.status.success(),
+ "ffprobe failed status={} stdout={stdout} stderr={stderr}",
+ output.status
+ );
+ assert!(
+ stdout.contains("codec_name=av1"),
+ "ffprobe stdout: {stdout}"
+ );
+ assert!(
+ stdout.contains("avg_frame_rate=0/0"),
+ "ffprobe stdout: {stdout}"
+ );
+ assert!(
+ stderr.contains("Unknown version 127 of AV1CodecConfigurationRecord"),
+ "ffprobe stderr: {stderr}"
+ );
+ assert!(
+ stderr
+ .to_ascii_lowercase()
+ .contains("unknown-sized element"),
+ "ffprobe stderr: {stderr}"
+ );
+
+ if let Some(out) = std::env::var_os("OPENSCREEN_554_FIXTURE_OUT") {
+ let out = std::path::PathBuf::from(out);
+ if let Some(parent) = out.parent() {
+ std::fs::create_dir_all(parent).expect("create fixture output directory");
+ }
+ std::fs::copy(&path, &out).expect("copy verified issue 554 fixture");
+ println!("ISSUE554_FIXTURE={}", out.display());
+ }
+ path
+ }
+
+ unsafe fn first_decoded_frame(dec: &mut Decoder) -> *mut AVFrame {
+ let frame = dec
+ .next()
+ .unwrap_or_else(|e| panic!("Decoder::next: {e:#}"));
+ assert!(!frame.is_null(), "expected a decoded frame, got null (EOF)");
+ assert!(
+ (*frame).width > 0 && (*frame).height > 0,
+ "decoded frame has no pixels ({}x{})",
+ (*frame).width,
+ (*frame).height
+ );
+ frame
+ }
+
+ #[test]
+ fn av1_webm_opens_on_software_path() {
+ let Some(gpu) = strict_hardware_gpu("av1_webm_opens_on_software_path") else {
+ return;
+ };
+ assert_eq!(gpu.backend, Backend::Hardware);
+ let path = make_legacy_av1_fixture("tiny-legacy.webm");
+ let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) }
+ .unwrap_or_else(|e| panic!("AV1 Decoder::open: {e:#}"));
+ assert!(
+ dec.cpu.is_some(),
+ "AV1 on Hardware must use CpuFrames, not D3D11VA"
+ );
+ let target_sec = 0.4;
+ let frame = unsafe { dec.seek_to(target_sec) }
+ .unwrap_or_else(|e| panic!("legacy AV1 nonzero seek: {e:#}"));
+ assert!(!frame.is_null(), "legacy AV1 nonzero seek reached EOF");
+ assert_eq!(unsafe { (*frame).width }, 64);
+ assert_eq!(unsafe { (*frame).height }, 64);
+ let pts = unsafe { (*frame).best_effort_timestamp };
+ assert_ne!(pts, i64::MIN, "legacy AV1 presentation timestamp");
+ let time_base = unsafe { dec.tb_sec() };
+ let observed_sec = pts as f64 * time_base;
+ assert!(
+ observed_sec >= target_sec - time_base * 0.5,
+ "legacy AV1 seek landed before its nonzero source target: target={target_sec:.3}s observed={observed_sec:.3}s"
+ );
+ println!("CORE_ASSERTIONS_COMPLETED:av1_webm_opens_on_software_path");
+ }
+
+ #[test]
+ fn av1_software_seek_preserves_the_nonzero_target_timestamp() {
+ let Some(gpu) =
+ strict_hardware_gpu("av1_software_seek_preserves_the_nonzero_target_timestamp")
+ else {
+ return;
+ };
+ assert_eq!(gpu.backend, Backend::Hardware);
+ let path = encode_color_for_duration(
+ &[
+ "-c:v",
+ "libaom-av1",
+ "-cpu-used",
+ "8",
+ "-usage",
+ "realtime",
+ "-g",
+ "100",
+ ],
+ "nonzero-seek-av1.webm",
+ "1.2",
+ );
+ let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) }
+ .unwrap_or_else(|e| panic!("AV1 Decoder::open: {e:#}"));
+ assert!(
+ dec.cpu.is_some(),
+ "AV1 must use the software presentation path"
+ );
+
+ let target_sec = 0.72;
+ let frame = unsafe { dec.seek_to(target_sec) }.expect("seek to nonzero AV1 source time");
+ assert!(!frame.is_null(), "nonzero AV1 seek reached EOF");
+ let pts = unsafe { (*frame).best_effort_timestamp };
+ assert_ne!(
+ pts,
+ i64::MIN,
+ "software presentation frame must retain the decoded timestamp"
+ );
+ let time_base = unsafe { dec.tb_sec() };
+ let observed_sec = pts as f64 * time_base;
+ assert!(
+ observed_sec >= target_sec - time_base * 0.5,
+ "seek accepted a pre-target frame: target={target_sec:.3}s observed={observed_sec:.3}s"
+ );
+ println!(
+ "CORE_ASSERTIONS_COMPLETED:av1_software_seek_preserves_the_nonzero_target_timestamp"
+ );
+ }
+
+ #[test]
+ fn h264_opens_on_d3d11va() {
+ let Some(gpu) = strict_hardware_gpu("h264_opens_on_d3d11va") else {
+ return;
+ };
+ assert_eq!(gpu.backend, Backend::Hardware);
+ let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], "tiny.mp4");
+ let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) }
+ .unwrap_or_else(|e| panic!("H.264 Decoder::open: {e:#}"));
+ assert!(
+ dec.cpu.is_none(),
+ "H.264 on Hardware must keep D3D11VA"
+ );
+ unsafe { first_decoded_frame(&mut dec) };
+ println!("CORE_ASSERTIONS_COMPLETED:h264_opens_on_d3d11va");
+ }
+
+ #[test]
+ fn current_frame_requires_pixels_and_recovers_after_eof_seek() {
+ let Some(gpu) =
+ strict_hardware_gpu("current_frame_requires_pixels_and_recovers_after_eof_seek")
+ else {
+ return;
+ };
+ assert_eq!(gpu.backend, Backend::Hardware);
+ let path = encode_color(
+ &["-c:v", "libopenh264", "-b:v", "200k"],
+ "current-frame.mp4",
+ );
+ let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) }
+ .unwrap_or_else(|e| panic!("H.264 Decoder::open: {e:#}"));
+
+ assert!(
+ dec.cur_frame().is_null(),
+ "allocated AVFrame without pixels is not current"
+ );
+ unsafe { first_decoded_frame(&mut dec) };
+ assert!(
+ !dec.cur_frame().is_null(),
+ "decoded frame must be presentable"
+ );
+
+ let unavailable = unsafe { dec.seek_to(10.0) }.expect("seek beyond EOF must not error");
+ assert!(
+ unavailable.is_null(),
+ "seek beyond EOF must report no target frame"
+ );
+ assert!(
+ dec.cur_frame().is_null(),
+ "unreffed AVFrame shell after EOF must not be exposed to the compositor"
+ );
+
+ let recovered = unsafe { dec.seek_to(0.0) }.expect("seek back to start");
+ assert!(
+ !recovered.is_null(),
+ "seek back to start must decode a frame"
+ );
+ assert!(
+ !dec.cur_frame().is_null(),
+ "recovered frame must be presentable"
+ );
+ println!(
+ "CORE_ASSERTIONS_COMPLETED:current_frame_requires_pixels_and_recovers_after_eof_seek"
+ );
+ }
+
+ /// Playhead crossing clips is `Decoder::open` of the next source on the
+ /// same `Gpu` (#554). H.264 must stay on D3D11VA after an AV1 software
+ /// decoder has been opened and dropped.
+ #[test]
+ fn switching_h264_then_av1_then_h264_stays_alive() {
+ let Some(gpu) = strict_hardware_gpu("switching_h264_then_av1_then_h264_stays_alive") else {
+ return;
+ };
+ assert_eq!(gpu.backend, Backend::Hardware);
+ let h264_path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], "switch-h264.mp4");
+ let av1_path = make_legacy_av1_fixture("switch-legacy-av1.webm");
+ let h264 = h264_path.to_str().expect("utf8");
+ let av1 = av1_path.to_str().expect("utf8");
+
+ unsafe {
+ let mut a = Decoder::open(h264, &gpu).unwrap_or_else(|e| panic!("H.264 open: {e:#}"));
+ assert!(a.cpu.is_none(), "first clip must stay D3D11VA");
+ first_decoded_frame(&mut a);
+ drop(a);
+
+ let mut b = Decoder::open(av1, &gpu).unwrap_or_else(|e| panic!("AV1 open after H.264: {e:#}"));
+ assert!(b.cpu.is_some(), "AV1 clip must use CpuFrames");
+ first_decoded_frame(&mut b);
+ drop(b);
+
+ let mut c = Decoder::open(h264, &gpu).unwrap_or_else(|e| panic!("H.264 reopen: {e:#}"));
+ assert!(c.cpu.is_none(), "H.264 after AV1 must keep D3D11VA");
+ first_decoded_frame(&mut c);
+ }
+ println!("CORE_ASSERTIONS_COMPLETED:switching_h264_then_av1_then_h264_stays_alive");
+ }
}
unsafe fn drain_encoder(
diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs
index 000939708..3be707066 100644
--- a/crates/compositor/src/regions.rs
+++ b/crates/compositor/src/regions.rs
@@ -175,9 +175,17 @@ fn lerp(a: f32, b: f32, t: f32) -> f32 {
/// `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.
+///
+/// `under_trim` coupe les enveloppes : la région vit sous une coupe, donc pleine force sur son
+/// span et rien en dehors. Sans ça son ease-in (1,5 s AVANT `start_sec`) et son ease-out
+/// déborderaient sur les frames GARDÉES de part et d'autre du trim — un zoom que l'export ne
+/// rendra jamais, visible dans la preview juste à côté de la coupe. Cf. `SceneZoomRegion`.
fn zoom_region_strength(region: &SceneZoomRegion, t: f32) -> f32 {
let start = region.start_sec as f32;
let end = region.end_sec as f32;
+ if region.under_trim {
+ return if t >= start && t < end { 1.0 } else { 0.0 };
+ }
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;
@@ -311,8 +319,13 @@ fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>)
/// 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).
+///
+/// Les régions `under_trim` sont exclues du chaînage, des DEUX côtés : leur contenu est coupé au
+/// rendu, donc un pan lissé vers (ou depuis) l'une d'elles ferait bouger des frames gardées au
+/// nom d'une région que l'export ne joue pas. Elles restent des régions dominantes indépendantes,
+/// sèches sur leur propre span (cf. `zoom_region_strength`).
fn connected_pairs(regions: &[SceneZoomRegion]) -> Vec<(usize, usize, f32, f32)> {
- let mut order: Vec = (0..regions.len()).collect();
+ let mut order: Vec = (0..regions.len()).filter(|&i| !regions[i].under_trim).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) {
@@ -628,6 +641,7 @@ mod zoom_focus_tests {
focus_y: 0.5,
focus_mode: Some("manual".into()),
rotation: None,
+ under_trim: false,
}
}
@@ -678,6 +692,33 @@ mod zoom_focus_tests {
assert_eq!(state.scale, 1.0);
assert_eq!(state.focus, [0.5, 0.5]);
}
+
+ /// Une région sous un trim est jouée SÈCHE : pleine échelle sur son span, identité juste
+ /// avant et juste après. `region()` couvre [2,8] et son ease-in normal démarre 1,5 s avant
+ /// `start_sec` — c'est exactement ce débordement qui atteindrait les frames GARDÉES autour
+ /// de la coupe et ferait diverger la preview de l'export. Cf. issue #216.
+ #[test]
+ fn a_region_under_a_trim_has_no_transition_window() {
+ let mut r = region(2.5, 0.5);
+ r.under_trim = true;
+ let regions = [r];
+ assert_eq!(zoom_state_at(®ions, 1.5, None).scale, 1.0);
+ assert_eq!(zoom_state_at(®ions, 2.0, None).scale, 2.5);
+ assert_eq!(zoom_state_at(®ions, 7.9, None).scale, 2.5);
+ assert_eq!(zoom_state_at(®ions, 8.0, None).scale, 1.0);
+ }
+
+ /// Et elle ne se chaîne pas avec sa voisine gardée : un pan lissé vers une région que
+ /// l'export ne joue pas ferait bouger des frames qui, elles, sont rendues.
+ #[test]
+ fn a_region_under_a_trim_is_not_chained_with_its_neighbour() {
+ let mut cut = region(3.0, 0.5);
+ cut.under_trim = true;
+ cut.start_sec = 9.0;
+ cut.end_sec = 10.0;
+ // Sans le filtre, l'écart de 1 s < CHAINED_ZOOM_PAN_GAP_S apparierait [2,8] et [9,10].
+ assert!(connected_pairs(&[region(2.0, 0.5), cut]).is_empty());
+ }
}
#[cfg(test)]
@@ -1021,3 +1062,63 @@ mod tilt_tests {
}
}
}
+
+#[cfg(test)]
+mod exporter_frame_totals {
+ use super::*;
+ use crate::scene::SceneSpeedRegion;
+
+ fn region(start_sec: f64, end_sec: f64, speed: f64) -> SceneSpeedRegion {
+ SceneSpeedRegion { clip_index: None, start_sec, end_sec, speed }
+ }
+
+ fn frames(start_sec: f64, end_sec: f64, regions: &[SceneSpeedRegion], fps: f64) -> u64 {
+ speed_segments_for_window(regions, start_sec, end_sec, fps)
+ .iter()
+ .map(|segment| segment.frame_count)
+ .sum()
+ }
+
+ /// Le total que la barre d'export doit viser, mesuré sur ce que `walk_composited_timeline`
+ /// itère réellement.
+ ///
+ /// Le jumeau de ce test est `src/lib/exporter/outputFrameCount.test.ts`, avec la MÊME
+ /// table de chiffres. Le natif n'envoie qu'un compteur de frames brut ; le total et donc
+ /// le pourcentage sont calculés côté TS, et rien ne reliait les deux calculs. Résultat
+ /// livré : le total TS ignorait les speed regions, donc un clip entièrement en 1,25×
+ /// rendait 80 % des frames annoncées et la barre s'arrêtait à 80 % — le « figé à ~80 % »
+ /// d'OpenScreen#371, au chiffre près. Toucher un côté doit faire rougir l'autre.
+ #[test]
+ fn speed_segments_match_the_exporter_frame_totals() {
+ const FPS: f64 = 30.0;
+ assert_eq!(frames(0.0, 10.0, &[], FPS), 300, "sans région");
+ assert_eq!(
+ frames(0.0, 10.0, &[region(0.0, 10.0, 1.25)], FPS),
+ 240,
+ "1,25× : 80 % de 300, exactement le symptôme"
+ );
+ assert_eq!(frames(0.0, 10.0, &[region(0.0, 10.0, 0.5)], FPS), 600, "0,5×");
+ assert_eq!(
+ frames(0.0, 10.0, &[region(2.0, 4.0, 2.0)], FPS),
+ 60 + 30 + 180,
+ "couverture partielle"
+ );
+ assert_eq!(
+ frames(1.0, 5.0, &[region(0.0, 100.0, 2.0)], FPS),
+ 60,
+ "région débordant la fenêtre gardée"
+ );
+ assert_eq!(
+ frames(0.0, 10.0, &[region(2.0, 6.0, 2.0), region(4.0, 8.0, 4.0)], FPS),
+ 60 + 60 + 15 + 60,
+ "recouvrement : la première région garde la portion déjà couverte"
+ );
+ assert_eq!(
+ frames(0.0, 10.0, &[region(0.0, 10.0, 0.0)], FPS),
+ 300,
+ "vitesse non positive traitée comme 1×"
+ );
+ assert_eq!(frames(4.0, 4.0, &[], FPS), 0, "fenêtre vide");
+ assert_eq!(frames(0.0, 10.0, &[], 0.0), 0, "fps non positif");
+ }
+}
diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs
index 2d20e233b..a42241672 100644
--- a/crates/compositor/src/scene.rs
+++ b/crates/compositor/src/scene.rs
@@ -331,6 +331,18 @@ pub struct SceneZoomRegion {
pub focus_mode: Option,
/// "iso" | "left" | "right" | null.
pub rotation: Option,
+ /// La région entière tombe sur une portion qu'un trim retire. Ses temps sont donc HORS de
+ /// la fenêtre source de `clip_index`, qui n'est là que pour l'adresser (le segment que la
+ /// coupe interrompt, cf. `cutAddressingSegmentIndex` côté TS).
+ ///
+ /// Conséquence de rendu : la région est jouée SÈCHE, pleine force sur `[start_sec, end_sec)`
+ /// et rien en dehors — ni fenêtre d'ease-in/ease-out, ni chaînage avec une région voisine.
+ /// C'est ce qui garde la coupe : un export ne compose jamais de frame à ces temps source,
+ /// alors qu'une enveloppe de transition, elle, déborderait sur les frames gardées d'à côté.
+ /// L'utilisateur qui pose la tête de lecture sur le trim voit l'effet ; le rendu, non.
+ /// `#[serde(default)]` : absent de tout payload sans trim sous un modificateur (issue #216).
+ #[serde(default)]
+ pub under_trim: bool,
}
/// Une zone de vitesse portée par le temps source d'un clip.
@@ -420,6 +432,39 @@ pub struct SceneAudio {
pub gain_db: f32,
}
+/// One imported audio track (issue #350) mixed over the assembled programme —
+/// voiceover / BGM / SFX. Deliberately a SEPARATE `Scene` field rather than a
+/// member of `SceneAudio`, so `SceneAudio` stays `Copy` and the pipelines keep
+/// copying it out of a borrow unchanged.
+///
+/// `start_sec` is the track's head on the OUTPUT programme; `trim_start_sec` /
+/// `trim_end_sec` window the source file (both source seconds). The renderer
+/// resolves `start_sec` from the track's raw timeline position — equal to it when
+/// the project has no trims/speed, which is the case this first cut mixes exactly.
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SceneAudioTrack {
+ pub path: String,
+ #[serde(default)]
+ pub start_sec: f64,
+ #[serde(default)]
+ pub gain_db: f32,
+ #[serde(default)]
+ pub trim_start_sec: f64,
+ #[serde(default)]
+ pub trim_end_sec: Option,
+ /// Ramp lengths at this entry's own edges, in seconds. The app puts them only
+ /// on the pieces that touch the track's real start and end, so a split or
+ /// looping track fades once instead of at every cut or repeat.
+ ///
+ /// `#[serde(default)]` for the usual reason: a payload from a build that
+ /// predates the field must degrade to "no fade", not fail the whole scene.
+ #[serde(default)]
+ pub fade_in_sec: f64,
+ #[serde(default)]
+ pub fade_out_sec: f64,
+}
+
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneOutput {
@@ -429,6 +474,43 @@ pub struct SceneOutput {
pub fps: Option,
}
+/// Effet d'arrière-plan de la webcam.
+///
+/// Ne porte que le MODE et ses paramètres — jamais des pixels. Le masque par pixel vient de
+/// la segmentation qui tourne dans ce processus (`segmentation.rs`) et arrive au shader comme
+/// texture `t3`. Une version antérieure faisait cuire le composite côté app et l'envoyait
+/// comme piste vidéo : le codec ne sait pas porter l'alpha, et preview et export divergeaient.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SceneWebcamEffect {
+ /// "none" | "transparent" | "blur" | "custom"
+ pub mode: String,
+ /// 0..1, seulement pour `blur`.
+ #[serde(default)]
+ pub blur_intensity: f32,
+ /// Fond derrière le sujet pour `custom`, parsé comme `settings.wallpaper`.
+ #[serde(default)]
+ pub background: Option,
+ /// Chemin du modèle ONNX de segmentation. Même convention que `SceneCursorSprite::path`
+ /// ou qu'un wallpaper image : c'est l'app qui sait où ses assets sont installés, le
+ /// natif ne devine pas. Absent = pas de segmentation, l'effet reste éteint.
+ #[serde(default)]
+ pub model_path: Option,
+}
+
+impl SceneWebcamEffect {
+ /// Code passé au shader dans `fx.z` : 0 = aucun (la webcam se dessine telle quelle),
+ /// 1 = détourage, 2 = flou, 3 = fond personnalisé.
+ pub(crate) fn shader_code(&self) -> f32 {
+ match self.mode.as_str() {
+ "transparent" => 1.0,
+ "blur" => 2.0,
+ "custom" => 3.0,
+ _ => 0.0,
+ }
+ }
+}
+
/// 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")]
@@ -451,9 +533,16 @@ pub struct Scene {
/// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible.
#[serde(default)]
pub audio: SceneAudio,
+ /// Imported audio tracks mixed over the programme (issue #350). `#[serde(default)]`:
+ /// absent from every scene written before this, and from a project with none.
+ #[serde(default)]
+ pub audio_tracks: Vec,
/// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS).
#[serde(default)]
pub crop_by_clip: Vec>,
+ /// Effet d'arrière-plan de la webcam. Absent = aucun effet.
+ #[serde(default)]
+ pub webcam_effect: Option,
/// État de rendu interne, positionné par `for_clip_window` (jamais envoyé par l'app).
#[serde(skip)]
pub(crate) active_clip_index: usize,
@@ -469,6 +558,13 @@ impl Scene {
/// 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.
+ ///
+ /// Les deux tests étaient jusqu'ici cumulés, ce que la phrase ci-dessus ne dit pas : le
+ /// chevauchement est le REPLI, pas une seconde condition. La différence n'apparaît que pour
+ /// une région hors fenêtre, et une seule l'est — celle qui vit sous un trim (`under_trim`,
+ /// cf. `SceneZoomRegion`). L'app en émet une par modificateur entièrement coupé, adressée au
+ /// segment que la coupe interrompt, pour que la tête de lecture posée sur le trim montre ce
+ /// qu'il y a dessous. Exiger le chevauchement l'aurait filtrée ici même.
pub(crate) fn for_clip_window(
&self,
clip_index: usize,
@@ -477,7 +573,9 @@ impl Scene {
) -> 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)
+ region_clip_index
+ .map(|i| i == clip_index)
+ .unwrap_or(overlaps_window)
};
let mut scene = self.clone();
scene.zoom_regions.retain(|region| {
@@ -515,6 +613,9 @@ impl Scene {
mod tests {
use super::*;
+ /// lui. Sans ce défaut, ouvrir un projet fait par une version antérieure échouerait au
+ /// parse au lieu de simplement ne rien tenir (issue #560).
+
#[test]
fn parses_a_minimal_scene_json() {
let json = r##"{
@@ -601,6 +702,37 @@ mod tests {
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");
+ assert!(s.webcam_effect.is_none());
+ }
+
+ #[test]
+ fn webcam_effect_maps_each_mode_to_its_shader_code() {
+ let scene_with = |effect: &str| {
+ let json = format!(
+ 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}},"webcamEffect":{}}}"##,
+ effect
+ );
+ Scene::from_json(&json).expect("parse avec webcamEffect").webcam_effect.expect("présent")
+ };
+
+ assert_eq!(scene_with(r#"{"mode":"none"}"#).shader_code(), 0.0);
+ assert_eq!(scene_with(r#"{"mode":"transparent"}"#).shader_code(), 1.0);
+ assert_eq!(scene_with(r#"{"mode":"blur","blurIntensity":0.75}"#).shader_code(), 2.0);
+ assert_eq!(scene_with(r#"{"mode":"custom"}"#).shader_code(), 3.0);
+ // Un mode inconnu (document trafiqué, schéma futur) ne doit pas allumer un effet.
+ assert_eq!(scene_with(r#"{"mode":"hologram"}"#).shader_code(), 0.0);
+
+ let blur = scene_with(r#"{"mode":"blur","blurIntensity":0.75}"#);
+ assert_eq!(blur.blur_intensity, 0.75);
+ // `blurIntensity` absent => 0, pas une erreur de parse.
+ assert_eq!(scene_with(r#"{"mode":"blur"}"#).blur_intensity, 0.0);
+
+ let custom =
+ scene_with(r##"{"mode":"custom","background":{"kind":"color","color":"#ff0080"}}"##);
+ match custom.background {
+ Some(SceneBackground::Color { color }) => assert_eq!(color, "#ff0080"),
+ other => panic!("attendu un fond couleur, obtenu {other:?}"),
+ }
}
}
@@ -709,17 +841,36 @@ mod annotation_tests {
#[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.
+ // Même règle que les zoom/speed/camera regions : `clipIndex` décide seul quand il est là.
+ // `under-trim` porte des temps hors fenêtre EXPRÈS (il vit sous une coupe) et doit donc
+ // survivre : le dessin est ensuite borné par `startSec`/`endSec`, jamais atteints par un
+ // export. Cf. issue #216.
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}]"##,
+ {"id":"under-trim","clipIndex":0,"underTrim":true,"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", "under-trim"]
+ );
+ }
+
+ #[test]
+ fn for_clip_window_still_falls_back_to_window_overlap_without_a_clip_index() {
+ // Vieux payload : rien ne dit à quel clip la région appartient, le chevauchement de
+ // fenêtre reste la seule réponse disponible. C'est le REPLI, pas une seconde condition.
+ let json = scene_json(
+ r##"[{"id":"in-window","startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0},
+ {"id":"out-of-window","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"]
+ vec!["in-window"]
);
}
}
diff --git a/crates/compositor/src/segmentation.rs b/crates/compositor/src/segmentation.rs
new file mode 100644
index 000000000..ddc4dd381
--- /dev/null
+++ b/crates/compositor/src/segmentation.rs
@@ -0,0 +1,424 @@
+//! Segmentation du sujet webcam — le masque que `ps_main` consomme en `t3`.
+//!
+//! # Pourquoi l'EP CPU et pas le GPU
+//!
+//! Mesuré sur la cible (Radeon 610M intégré, cf.
+//! `technical-documentation/engineering/webcam-segmentation.md`) : l'EP CPU coûte **+0,47 ms
+//! par frame** au compositeur contre **+1,03 ms** pour DirectML, et — le point qui décide —
+//! son coût **ne dépend pas de la résolution d'entrée**, là où celui de DirectML suit les
+//! pixels. L'EP CPU à pleine résolution est donc moins cher que DirectML ne l'est jamais,
+//! même à résolution réduite.
+//!
+//! Le vrai gain n'est pas la marge, il est architectural : pas de DirectML ⇒ pas de device
+//! D3D12, pas de handle partagé, pas d'appariement de LUID d'adaptateur, pas de fence
+//! inter-queue, et un seul chemin sur les trois plateformes au lieu de trois.
+//!
+//! # Le piège du nombre de threads
+//!
+//! Une session ONNX Runtime laissée par défaut prend tous les cœurs. Sur la machine de
+//! mesure (4 cœurs) ça donne un **p95 de 24,9 ms** — une frame perdue à chaque fois que ça
+//! tombe. `intra_op_num_threads = 2` est à 8 % du meilleur p10 avec moins de la moitié de la
+//! traîne, et laisse deux cœurs au compositeur. La bonne valeur n'était pas la plus rapide.
+
+use anyhow::{bail, Result};
+use std::path::Path;
+use std::sync::{Arc, Condvar, Mutex};
+use std::time::{Duration, Instant};
+
+/// Résolution d'entrée du modèle vendorisé (`selfie_segmentation_landscape.onnx`).
+///
+/// Le graphe est entièrement convolutif, donc réductible — mais mesuré, ça ne sert à rien :
+/// le coût de l'EP CPU est plat en résolution. Et 128x80 n'est pas livrable, la caméra en
+/// plein écran agrandit le masque ~15x et les cheveux s'effondrent en rampe.
+pub const MODEL_WIDTH: u32 = 256;
+pub const MODEL_HEIGHT: u32 = 144;
+
+/// Deux threads intra-op. Voir la note du module : le défaut prend toute la machine.
+const INTRA_OP_THREADS: usize = 2;
+
+/// La bibliothèque ONNX Runtime est-elle chargeable ?
+///
+/// `ort` est lié en `load-dynamic` et **panique** quand la bibliothèque manque —
+/// `load_dynamic::init(&path).expect("Failed to load ONNX Runtime dylib")`, ort/src/lib.rs. Ce
+/// n'est pas une erreur qu'on peut propager : sans ce garde, un build où le staging de la lib
+/// n'a pas eu lieu ferait tomber le compositeur à la première frame avec un effet, au lieu de
+/// dessiner la webcam telle quelle.
+#[cfg(feature = "segmentation")]
+pub fn runtime_available() -> bool {
+ // `ORT_DYLIB_PATH` est ce que l'app pose (`ensureOnnxRuntimeOnPath`) ; sans lui, ort ira
+ // chercher un nom nu dans les chemins système, ce qui est le cas « pas installé ».
+ match std::env::var_os("ORT_DYLIB_PATH") {
+ Some(p) if Path::new(&p).is_file() => true,
+ _ => false,
+ }
+}
+
+#[cfg(not(feature = "segmentation"))]
+pub fn runtime_available() -> bool {
+ false
+}
+
+/// Segmenteur chargé, prêt à produire un masque par frame.
+pub struct Segmenter {
+ #[cfg(feature = "segmentation")]
+ session: ort::session::Session,
+ /// Réutilisé d'une frame à l'autre pour ne pas réallouer 110 Ko à 30 Hz.
+ input_scratch: Vec,
+ mask_scratch: Vec,
+}
+
+impl Segmenter {
+ /// Charge le modèle ONNX. `model_path` est le `.onnx` vendorisé à côté des `.tflite`.
+ #[cfg(feature = "segmentation")]
+ pub fn load(model_path: &Path) -> Result {
+ if !model_path.exists() {
+ bail!("modèle de segmentation absent : {}", model_path.display());
+ }
+ if !runtime_available() {
+ bail!(
+ "bibliothèque ONNX Runtime introuvable (ORT_DYLIB_PATH={:?}) — l'effet reste éteint",
+ std::env::var_os("ORT_DYLIB_PATH")
+ );
+ }
+ // `ort::Error` est générique sur le type du builder, donc il ne satisfait pas les
+ // bornes d'`anyhow::Context` — d'où le `map_err` explicite plutôt qu'un `?` direct.
+ // Deuxième garde, pour le cas « le fichier est là mais ne se charge pas » (mauvaise
+ // architecture, dépendance manquante) : ort panique là aussi, et une panique qui
+ // traverse le thread de rendu tue la preview.
+ let session = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ (|| -> ort::Result {
+ ort::session::Session::builder()?
+ .with_intra_threads(INTRA_OP_THREADS)?
+ // Un seul thread inter-op : le graphe est une chaîne, il n'y a rien à
+ // paralléliser entre branches, et un pool de plus ne ferait que disputer les
+ // cœurs au compositeur.
+ .with_inter_threads(1)?
+ .commit_from_file(model_path)
+ })()
+ }))
+ .map_err(|_| anyhow::anyhow!("ONNX Runtime a paniqué au chargement — effet désactivé"))?
+ .map_err(|e| anyhow::anyhow!("chargement de {} : {e}", model_path.display()))?;
+ Ok(Self {
+ session,
+ input_scratch: vec![0.0; (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize],
+ mask_scratch: vec![0; (MODEL_WIDTH * MODEL_HEIGHT) as usize],
+ })
+ }
+
+ #[cfg(not(feature = "segmentation"))]
+ pub fn load(_model_path: &Path) -> Result {
+ bail!("compilé sans la feature `segmentation`")
+ }
+
+ /// Produit le masque du sujet à partir d'une frame RGB8 déjà mise à l'échelle du modèle.
+ ///
+ /// `rgb` fait `MODEL_WIDTH * MODEL_HEIGHT * 3` octets, entrelacé R,G,B. Le retour fait
+ /// `MODEL_WIDTH * MODEL_HEIGHT` octets, 0 = fond, 255 = sujet — exactement ce que
+ /// `Compositor::set_webcam_mask` attend.
+ ///
+ /// Le redimensionnement n'est pas fait ici : l'appelant a déjà la frame sur le GPU et sait
+ /// la réduire bien mieux qu'une boucle CPU.
+ #[cfg(feature = "segmentation")]
+ pub fn run(&mut self, rgb: &[u8]) -> Result<&[u8]> {
+ let expected = (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize;
+ if rgb.len() != expected {
+ bail!("frame de {} octets, {expected} attendus", rgb.len());
+ }
+ // Le modèle veut du 0..1 en NHWC — le même ordre que la frame entrelacée, donc une
+ // simple division sans transposition.
+ for (dst, &src) in self.input_scratch.iter_mut().zip(rgb.iter()) {
+ *dst = src as f32 * (1.0 / 255.0);
+ }
+
+ // `TensorRef` emprunte le scratch au lieu de le copier : à 30 Hz, 442 Ko recopiés par
+ // frame pour rien seraient exactement le genre de coût que cette conception évite.
+ let shape = [1_i64, MODEL_HEIGHT as i64, MODEL_WIDTH as i64, 3];
+ let input = ort::value::TensorRef::from_array_view((shape, self.input_scratch.as_slice()))
+ .map_err(|e| anyhow::anyhow!("construction du tenseur d'entrée : {e}"))?;
+ let outputs = self
+ .session
+ .run(ort::inputs!["input_1" => input])
+ .map_err(|e| anyhow::anyhow!("inférence : {e}"))?;
+ let (_, mask) = outputs["segment_back"]
+ .try_extract_tensor::()
+ .map_err(|e| anyhow::anyhow!("extraction du masque : {e}"))?;
+
+ if mask.len() != self.mask_scratch.len() {
+ bail!("masque de {} valeurs, {} attendues", mask.len(), self.mask_scratch.len());
+ }
+ // Déjà passé par une sigmoïde dans le graphe, donc borné 0..1 — le clamp ne protège
+ // que d'un modèle regénéré différemment.
+ for (dst, &src) in self.mask_scratch.iter_mut().zip(mask.iter()) {
+ *dst = (src.clamp(0.0, 1.0) * 255.0) as u8;
+ }
+ Ok(&self.mask_scratch)
+ }
+
+ #[cfg(not(feature = "segmentation"))]
+ pub fn run(&mut self, _rgb: &[u8]) -> Result<&[u8]> {
+ bail!("compilé sans la feature `segmentation`")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Le chemin par défaut du modèle vendorisé, depuis la racine du dépôt.
+ fn vendored_model() -> std::path::PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx")
+ }
+
+ #[test]
+ fn the_vendored_model_is_where_the_loader_expects_it() {
+ // Ne charge pas le modèle (la feature peut être éteinte) : vérifie seulement que le
+ // fichier que `load` ira chercher existe et n'est pas un pointeur LFS ou un tronçon.
+ let path = vendored_model();
+ let meta = std::fs::metadata(&path)
+ .unwrap_or_else(|e| panic!("modèle introuvable en {} : {e}", path.display()));
+ assert!(meta.len() > 100_000, "modèle suspicieusement petit : {} octets", meta.len());
+ }
+
+
+ #[cfg(feature = "segmentation")]
+ #[test]
+ fn a_missing_model_is_refused_before_the_runtime_is_even_touched() {
+ // Ce test-ci tourne PARTOUT : le chemin est vérifié avant tout appel à ort, ce qui
+ // est précisément la garantie qu'on veut (pas de panique sur une machine sans lib).
+ let err = match Segmenter::load(Path::new("nexiste/pas.onnx")) {
+ Ok(_) => panic!("un modèle inexistant ne doit pas charger"),
+ Err(e) => e.to_string(),
+ };
+ assert!(err.contains("nexiste"), "message peu utile : {err}");
+ }
+
+ #[cfg(feature = "segmentation")]
+ #[test]
+ fn a_missing_runtime_is_an_error_not_a_panic() {
+ if runtime_available() {
+ eprintln!("ONNX Runtime présent — le cas « absent » n'est pas exerçable ici");
+ return;
+ }
+ // Le modèle EXISTE, donc on va bien jusqu'au garde du runtime. Sans lui, ort
+ // paniquerait et emporterait le thread de rendu.
+ match Segmenter::load(&vendored_model()) {
+ Ok(_) => panic!("chargement réussi sans bibliothèque ?"),
+ Err(e) => assert!(
+ e.to_string().contains("ONNX Runtime"),
+ "l'erreur doit nommer la bibliothèque manquante : {e}"
+ ),
+ }
+ }
+
+ /// Les tests qui font tourner une vraie inférence n'ont de sens que là où la bibliothèque
+ /// est installée. La CI macOS et Linux ne la stage pas encore, et un test rouge pour ça
+ /// dirait quelque chose de faux sur le code.
+ #[cfg(feature = "segmentation")]
+ fn skip_without_runtime() -> bool {
+ if runtime_available() {
+ return false;
+ }
+ eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH non posé) — test sauté");
+ true
+ }
+
+ #[cfg(feature = "segmentation")]
+ #[test]
+ fn a_frame_of_the_wrong_size_is_refused_rather_than_read_out_of_bounds() {
+ if skip_without_runtime() {
+ return;
+ }
+ let mut seg = Segmenter::load(&vendored_model()).expect("chargement du modèle");
+ let err = seg.run(&[0u8; 12]).unwrap_err().to_string();
+ assert!(err.contains("attendus"), "message peu utile : {err}");
+ }
+
+ #[test]
+ fn the_rate_limiter_admits_one_frame_per_interval() {
+ let mut rl = RateLimiter::new(30);
+ let t0 = Instant::now();
+ assert!(rl.should_run(t0), "la première frame passe toujours");
+ assert!(!rl.should_run(t0 + Duration::from_millis(10)), "10 ms < 33 ms");
+ assert!(!rl.should_run(t0 + Duration::from_millis(33)), "juste sous l'intervalle");
+ assert!(rl.should_run(t0 + Duration::from_millis(34)), "au-delà de l'intervalle");
+ // Le pas repart du dernier passage accepté, pas du premier : sinon la cadence
+ // dériverait vers le haut après chaque frame refusée.
+ assert!(!rl.should_run(t0 + Duration::from_millis(40)));
+ assert!(rl.should_run(t0 + Duration::from_millis(68)));
+ }
+
+ #[test]
+ fn a_60_hz_render_loop_yields_about_30_inferences_per_second() {
+ let mut rl = RateLimiter::new(30);
+ let t0 = Instant::now();
+ let admitted = (0..60)
+ .filter(|i| rl.should_run(t0 + Duration::from_micros(16_667 * i)))
+ .count();
+ assert_eq!(admitted, 30, "60 frames rendues doivent donner 30 inférences");
+ }
+
+ #[cfg(feature = "segmentation")]
+ #[test]
+ fn the_worker_drops_stale_frames_rather_than_queueing_them() {
+ if skip_without_runtime() {
+ return;
+ }
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ let seen = Arc::new(AtomicUsize::new(0));
+ let counter = Arc::clone(&seen);
+ let worker = SegmentationWorker::spawn(
+ Segmenter::load(&vendored_model()).expect("chargement du modèle"),
+ move |mask, w, h| {
+ assert_eq!(mask.len(), (w * h) as usize);
+ counter.fetch_add(1, Ordering::SeqCst);
+ },
+ );
+
+ // Cent frames déposées d'affilée : le worker en traite bien moins que cent, puisque
+ // chaque dépôt écrase le précédent non consommé. La borne est large — le test pin le
+ // fait qu'on écrase, pas un débit.
+ let frame = vec![90u8; (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize];
+ for _ in 0..100 {
+ worker.submit(&frame);
+ }
+ std::thread::sleep(Duration::from_millis(300));
+ let done = seen.load(Ordering::SeqCst);
+ assert!(done > 0, "le worker n'a rien traité");
+ assert!(done < 100, "{done} inférences pour 100 dépôts : la file s'accumule");
+ }
+
+ #[cfg(feature = "segmentation")]
+ #[test]
+ fn segments_a_uniform_frame_without_panicking_and_returns_the_right_size() {
+ if skip_without_runtime() {
+ return;
+ }
+ let mut seg = Segmenter::load(&vendored_model()).expect("chargement du modèle");
+ let frame = vec![128u8; (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize];
+ let mask = seg.run(&frame).expect("inférence");
+ assert_eq!(mask.len(), (MODEL_WIDTH * MODEL_HEIGHT) as usize);
+ // Un gris uniforme ne contient pas de sujet : le masque doit être massivement du
+ // fond. C'est une borne large, pas une assertion de qualité — elle attrape un modèle
+ // qui renverrait du bruit ou du plein.
+ let subject = mask.iter().filter(|&&v| v > 128).count();
+ assert!(
+ subject * 10 < mask.len(),
+ "{subject} pixels sujet sur {} pour une image unie",
+ mask.len()
+ );
+ }
+}
+
+/// Cadence l'inférence : 30 Hz, pas la fréquence de rendu.
+///
+/// Une silhouette ne change pas de façon perceptible en 16 ms, et c'est le seul levier
+/// mesuré qui divise le coût par deux sans toucher au modèle ni à sa précision. Le chemin
+/// export tourne déjà à 30 Hz.
+pub struct RateLimiter {
+ interval: Duration,
+ last: Option,
+}
+
+impl RateLimiter {
+ pub fn new(hz: u32) -> Self {
+ Self { interval: Duration::from_secs_f64(1.0 / hz.max(1) as f64), last: None }
+ }
+
+ /// `true` si assez de temps s'est écoulé depuis le dernier passage. Prend `now` en
+ /// paramètre plutôt que de lire l'horloge : c'est ce qui rend la cadence testable.
+ pub fn should_run(&mut self, now: Instant) -> bool {
+ match self.last {
+ Some(prev) if now.duration_since(prev) < self.interval => false,
+ _ => {
+ self.last = Some(now);
+ true
+ }
+ }
+ }
+}
+
+/// Boîte d'échange à une place, qui écrase au lieu d'empiler.
+///
+/// Si l'inférence prend du retard, la bonne réponse est de sauter des frames, pas d'en
+/// accumuler : un masque en retard de trois frames est pire qu'un masque sauté, et une file
+/// qui grandit finit par manger la mémoire. `submit` remplace donc silencieusement une frame
+/// non consommée.
+struct Slot {
+ frame: Mutex>>,
+ ready: Condvar,
+ stop: Mutex,
+}
+
+/// Thread d'inférence : reçoit des frames RGB, publie des masques via un callback.
+///
+/// Le callback est appelé depuis le thread du worker, pas depuis celui du rendu — c'est
+/// `Compositor::set_webcam_mask` qui est prévu pour ça, le device étant multithread-protected.
+pub struct SegmentationWorker {
+ slot: Arc,
+ handle: Option>,
+}
+
+impl SegmentationWorker {
+ /// Démarre le worker. `on_mask` reçoit le masque et ses dimensions à chaque inférence.
+ pub fn spawn(
+ mut segmenter: Segmenter,
+ on_mask: impl Fn(&[u8], u32, u32) + Send + 'static,
+ ) -> Self {
+ let slot = Arc::new(Slot {
+ frame: Mutex::new(None),
+ ready: Condvar::new(),
+ stop: Mutex::new(false),
+ });
+ let worker_slot = Arc::clone(&slot);
+ let handle = std::thread::Builder::new()
+ .name("openscreen-segmentation".into())
+ .spawn(move || loop {
+ let frame = {
+ let mut guard = worker_slot.frame.lock().unwrap();
+ while guard.is_none() {
+ if *worker_slot.stop.lock().unwrap() {
+ return;
+ }
+ let (g, timeout) = worker_slot
+ .ready
+ .wait_timeout(guard, Duration::from_millis(100))
+ .unwrap();
+ guard = g;
+ if timeout.timed_out() && guard.is_none() {
+ if *worker_slot.stop.lock().unwrap() {
+ return;
+ }
+ }
+ }
+ guard.take().expect("non vide, la boucle vient de le vérifier")
+ };
+ match segmenter.run(&frame) {
+ Ok(mask) => on_mask(mask, MODEL_WIDTH, MODEL_HEIGHT),
+ // Une frame ratée est sautée, pas fatale : le masque précédent reste
+ // affiché, ce qui vaut mieux qu'un effet qui clignote.
+ Err(e) => eprintln!("[segmentation] frame ignorée : {e}"),
+ }
+ })
+ .expect("le thread de segmentation doit démarrer");
+ Self { slot, handle: Some(handle) }
+ }
+
+ /// Dépose une frame à segmenter. Écrase celle qui attendait, s'il y en avait une.
+ pub fn submit(&self, rgb: &[u8]) {
+ let mut guard = self.slot.frame.lock().unwrap();
+ *guard = Some(rgb.to_vec());
+ self.slot.ready.notify_one();
+ }
+}
+
+impl Drop for SegmentationWorker {
+ fn drop(&mut self) {
+ *self.slot.stop.lock().unwrap() = true;
+ self.slot.ready.notify_all();
+ if let Some(h) = self.handle.take() {
+ let _ = h.join();
+ }
+ }
+}
diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl
index b9d438d7d..d9b6a13ac 100644
--- a/crates/compositor/src/shaders.hlsl
+++ b/crates/compositor/src/shaders.hlsl
@@ -39,6 +39,10 @@ VSOut vs_main(uint vid : SV_VertexID)
Texture2D texY : register(t0);
Texture2D texUV : register(t1);
Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6)
+// Masque de segmentation du sujet, 0 = fond, 1 = sujet. Produit par `segmentation.rs` a la
+// resolution du modele (256x144) ; l'upscale vers la resolution webcam est fait par le sampler
+// lineaire, ce qui est exactement le filtrage qu'on veut sur un masque.
+Texture2D texMask : register(t3);
SamplerState samp : register(s0);
// BT.709 limited -> RGB (§7 E1), matrice en dur, range mesuré en S1.
@@ -78,6 +82,22 @@ float sd_round_rect(float2 p, float2 halfsz, float r)
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
+// Couverture du quad avec coins arrondis, pour les modes qui retournent AVANT la queue de
+// `ps_main` (5 gradient, 6 image). Ils s'en passaient tant qu'ils ne servaient qu'au fond plein
+// cadre, qui n'a pas de rayon ; depuis que la bulle webcam peut porter un dégradé ou une image,
+// sans ça le fond déborde en carré opaque sur les coins arrondis de la bulle et mange l'ombre.
+// Renvoie 1.0 quand aucun rayon n'est demandé — le fond plein cadre est donc inchangé.
+float quad_round_alpha(float2 local, float2 quad_px, float radius_px)
+{
+ if (radius_px <= 0.0 || quad_px.x <= 0.0 || quad_px.y <= 0.0)
+ {
+ return 1.0;
+ }
+ float2 halfsz = quad_px * 0.5;
+ float d = sd_round_rect(local - halfsz, halfsz, radius_px);
+ return 1.0 - smoothstep(0.0, 1.5, d); // même feather ~1.5px que la queue
+}
+
// Intersection de deux droites données par (normale, offset) : n·x = d. Cramer.
float2 line_cross(float2 n1, float d1, float2 n2, float d2)
{
@@ -154,6 +174,55 @@ float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float
return (r0.z > 0.5) ? r0 : r1;
}
+// Fond flouté pour le mode "blur" de la webcam.
+// Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et
+// rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide.
+static const float3 VOGEL_TAPS[21] = {
+ float3( 0.154303, 0.000000, 0.942213),
+ float3(-0.197070, 0.180532, 0.836464),
+ float3( 0.030165, -0.343712, 0.742584),
+ float3( 0.248394, 0.323986, 0.659241),
+ float3(-0.455834, -0.080631, 0.585251),
+ float3( 0.431806, -0.274679, 0.519566),
+ float3(-0.144431, 0.537274, 0.461253),
+ float3(-0.275445, -0.530352, 0.409484),
+ float3( 0.597605, 0.218244, 0.363526),
+ float3(-0.621708, 0.256632, 0.322726),
+ float3( 0.299704, -0.640451, 0.286505),
+ float3( 0.221474, 0.706094, 0.254349),
+ float3(-0.667525, -0.386844, 0.225802),
+ float3( 0.783083, -0.172159, 0.200460),
+ float3(-0.477903, 0.679768, 0.177961),
+ float3(-0.110407, -0.852001, 0.157988),
+ float3( 0.677789, 0.571241, 0.140256),
+ float3(-0.912091, 0.037718, 0.124514),
+ float3( 0.665301, -0.662063, 0.110540),
+ float3(-0.044511, 0.962596, 0.098133),
+ float3(-0.633036, -0.758588, 0.087119)
+};
+
+float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px)
+{
+ float max_r_px = max(intensity, 0.0) * 22.0 + 1.5;
+ float2 step = max_r_px / max(qpx, 1.0);
+ // Interleaved Gradient Noise pour rotation aléatoire par pixel
+ float noise = frac(52.9829189 * frac(0.06711056 * local_px.x + 0.00583715 * local_px.y));
+ float angle = noise * 6.2831853;
+ float s, c;
+ sincos(angle, s, c);
+ float3 sum = 0.0;
+ float total = 0.0;
+ [unroll] for (int k = 0; k < 21; k++)
+ {
+ float2 p = VOGEL_TAPS[k].xy;
+ float w = VOGEL_TAPS[k].z;
+ float2 rot_p = float2(p.x * c - p.y * s, p.x * s + p.y * c);
+ sum += sample_yuv(saturate(uv + rot_p * step)) * w;
+ total += w;
+ }
+ return sum / max(total, 1e-4);
+}
+
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
@@ -352,7 +421,8 @@ float4 ps_main(VSOut i) : SV_Target
// recouvrement), i.uv l'interpole. Opaque.
if (mode > 5.5)
{
- return float4(texImg.Sample(samp, i.uv).rgb, 1.0);
+ float a = quad_round_alpha(i.local, quad_px, radius_px);
+ return float4(texImg.Sample(samp, i.uv).rgb * a, a); // prémultiplié
}
// mode 5 : gradient linéaire 2 stops (parité web wallpaper dégradé). color = stop0,
@@ -362,9 +432,15 @@ float4 ps_main(VSOut i) : SV_Target
{
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);
+ // Paramétré sur le QUAD dès qu'il en a un (la bulle webcam), sinon sur la sortie. Pour le
+ // fond plein cadre les deux coïncident ; pour une bulle dans un coin, `pout` ne montrerait
+ // que la tranche du dégradé plein cadre qui passe dessous, jamais la rampe complète que
+ // le sélecteur affiche.
+ float2 gp = (quad_px.x > 0.0 && quad_px.y > 0.0) ? (i.local / quad_px) : i.pout;
+ float t = saturate(0.5 + dot(gp - 0.5, dir) / denom);
float3 g = lerp(color.rgb, src.xyz, t);
- return float4(g, 1.0); // opaque, prémultiplié (a=1)
+ float a = quad_round_alpha(i.local, quad_px, radius_px);
+ return float4(g * a, a); // prémultiplié
}
// mode 4 : curseur custom (dot + ring, dessiné depuis les maths). color = teinte.
@@ -410,6 +486,8 @@ float4 ps_main(VSOut i) : SV_Target
}
float3 rgb;
+ // 1 sauf en mode detourage, ou il porte le masque du sujet (cf. la branche fx.z ci-dessous).
+ float alpha_mask = 1.0;
if (mode < 0.5)
{
// flou de mouvement par vélocité (§8) : pour CE pixel sortie, uv à la frame
@@ -419,8 +497,10 @@ float4 ps_main(VSOut i) : SV_Target
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;
+ float mb_scale = saturate(mb.y);
+ float2 duv_blur = duv * mb_scale;
int taps = (int) mb.x;
- if (taps <= 1 || dot(duv, duv) < 1e-9)
+ if (taps <= 1 || mb_scale <= 0.001 || dot(duv_blur, duv_blur) < 1e-9)
{
rgb = sample_yuv(uv_now);
}
@@ -431,17 +511,45 @@ float4 ps_main(VSOut i) : SV_Target
{
if (k >= taps) break;
float t = (float) k / (float) (taps - 1);
- acc += sample_yuv(uv_prev + duv * t);
+ acc += sample_yuv(uv_now - duv_blur * (1.0 - t));
}
rgb = acc / (float) taps;
}
+
+ // Effet d'arriere-plan webcam. fx.z : 1 = detourage, 2 = flou, 3 = fond personnalise.
+ // `color` porte la couleur de fond du mode 3, fx.w l'intensite du flou du mode 2.
+ // fx.xy porte l'etendue VALIDE de la texture webcam (wcw/wtw, wch/wth) : le masque a
+ // ete produit sur la frame ENTIERE, pas sur le sous-rect dessine, pour que le modele
+ // ne se fasse pas amputer le sujet par un crop utilisateur. Il faut donc ramener uv,
+ // qui vit dans l'espace source, dans cet espace-la.
+ // Le masque est absent (texture 1x1 noire) tant que la segmentation n'a pas produit sa
+ // premiere frame : `person` vaut alors 0 et le mode 1 rendrait la webcam invisible, donc
+ // c'est l'appelant qui ne met fx.z a autre chose que 0 qu'une fois un masque disponible.
+ float effect = fx.z;
+ if (effect > 0.5)
+ {
+ float2 mask_uv = uv_now / max(fx.xy, 1e-6);
+ float person = saturate(texMask.Sample(samp, mask_uv));
+ if (effect > 2.5)
+ {
+ rgb = lerp(color.rgb, rgb, person);
+ }
+ else if (effect > 1.5)
+ {
+ rgb = lerp(blur_webcam_bg(uv_now, fx.w, quad_px, i.local), rgb, person);
+ }
+ else
+ {
+ alpha_mask = person;
+ }
+ }
}
else
{
rgb = color.rgb;
}
- float alpha = color.a;
+ float alpha = color.a * alpha_mask;
if (radius_px > 0.0)
{
// `quad_px` est en px de SORTIE (le render target porte la géométrie de sortie) et
diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal
index 64dd8d476..9c89eca70 100644
--- a/crates/compositor/src/shaders.metal
+++ b/crates/compositor/src/shaders.metal
@@ -151,6 +151,22 @@ inline float sd_round_rect(float2 p, float2 halfsz, float r)
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
+// Couverture du quad avec coins arrondis, pour les modes qui retournent AVANT la queue de
+// `ps_main` (5 gradient, 6 image). Ils s'en passaient tant qu'ils ne servaient qu'au fond plein
+// cadre, qui n'a pas de rayon ; depuis que la bulle webcam peut porter un dégradé ou une image,
+// sans ça le fond déborde en carré opaque sur les coins arrondis de la bulle et mange l'ombre.
+// Renvoie 1.0 quand aucun rayon n'est demandé — le fond plein cadre est donc inchangé.
+inline float quad_round_alpha(float2 local, float2 quad_px, float radius_px)
+{
+ if (radius_px <= 0.0 || quad_px.x <= 0.0 || quad_px.y <= 0.0)
+ {
+ return 1.0;
+ }
+ float2 halfsz = quad_px * 0.5;
+ float d = sd_round_rect(local - halfsz, halfsz, radius_px);
+ return 1.0 - smoothstep(0.0, 1.5, d); // même feather ~1.5px que la queue
+}
+
// 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)
{
@@ -219,11 +235,67 @@ inline float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11
// Identique à `ps_main` côté HLSL ligne pour ligne (à la syntaxe MSL près).
// =================================================================================
+// Fond floute du mode "blur" webcam. Miroir de `blur_webcam_bg` cote HLSL : memes 25 taps,
+// memes poids, meme rayon — les deux back-ends doivent rendre le meme pixel.
+// Fond flouté pour le mode "blur" de la webcam.
+// Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et
+// rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide.
+constant float3 VOGEL_TAPS[21] = {
+ float3( 0.154303, 0.000000, 0.942213),
+ float3(-0.197070, 0.180532, 0.836464),
+ float3( 0.030165, -0.343712, 0.742584),
+ float3( 0.248394, 0.323986, 0.659241),
+ float3(-0.455834, -0.080631, 0.585251),
+ float3( 0.431806, -0.274679, 0.519566),
+ float3(-0.144431, 0.537274, 0.461253),
+ float3(-0.275445, -0.530352, 0.409484),
+ float3( 0.597605, 0.218244, 0.363526),
+ float3(-0.621708, 0.256632, 0.322726),
+ float3( 0.299704, -0.640451, 0.286505),
+ float3( 0.221474, 0.706094, 0.254349),
+ float3(-0.667525, -0.386844, 0.225802),
+ float3( 0.783083, -0.172159, 0.200460),
+ float3(-0.477903, 0.679768, 0.177961),
+ float3(-0.110407, -0.852001, 0.157988),
+ float3( 0.677789, 0.571241, 0.140256),
+ float3(-0.912091, 0.037718, 0.124514),
+ float3( 0.665301, -0.662063, 0.110540),
+ float3(-0.044511, 0.962596, 0.098133),
+ float3(-0.633036, -0.758588, 0.087119)
+};
+
+inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px,
+ texture2d texY,
+ texture2d texUV)
+{
+ float max_r_px = max(intensity, 0.0) * 22.0 + 1.5;
+ float2 step = max_r_px / max(qpx, float2(1.0));
+ float noise = fract(52.9829189 * fract(0.06711056 * local_px.x + 0.00583715 * local_px.y));
+ float angle = noise * 6.2831853;
+ float s = sin(angle);
+ float c = cos(angle);
+ float3 sum = float3(0.0);
+ float total = 0.0;
+ for (int k = 0; k < 21; k++)
+ {
+ float2 p = VOGEL_TAPS[k].xy;
+ float w = VOGEL_TAPS[k].z;
+ float2 rot_p = float2(p.x * c - p.y * s, p.x * s + p.y * c);
+ sum += sample_yuv(saturate(uv + rot_p * step), texY, texUV) * w;
+ total += w;
+ }
+ return sum / max(total, 1e-4);
+}
+
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)]])
+ texture2d texImg [[texture(2)]],
+ // Masque de segmentation du sujet webcam. Non lie tant qu'aucun
+ // masque n'existe : Metal rend alors 0, ce qui est sans effet
+ // puisque la branche n'est prise que si layer.fx.z > 0.5.
+ texture2d texMask [[texture(3)]])
{
// mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Cf. commentaires HLSL.
if (layer.mode > 12.5)
@@ -344,7 +416,8 @@ fragment float4 ps_main(VSOut i [[stage_in]],
// « 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);
+ float a = quad_round_alpha(i.local, layer.quad_px, layer.radius_px);
+ return float4(texImg.sample(samp, i.uv).rgb * a, a); // prémultiplié
}
// mode 5 : gradient linéaire 2 stops (parité web wallpaper dégradé). color = stop0,
@@ -357,9 +430,17 @@ fragment float4 ps_main(VSOut i [[stage_in]],
{
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);
+ // Paramétré sur le QUAD dès qu'il en a un (la bulle webcam), sinon sur la sortie. Pour le
+ // fond plein cadre les deux coïncident ; pour une bulle dans un coin, `pout` ne montrerait
+ // que la tranche du dégradé plein cadre qui passe dessous, jamais la rampe complète que
+ // le sélecteur affiche.
+ float2 gp = (layer.quad_px.x > 0.0 && layer.quad_px.y > 0.0)
+ ? (i.local / layer.quad_px)
+ : i.pout;
+ float t = clamp(0.5 + dot(gp - 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)
+ float a = quad_round_alpha(i.local, layer.quad_px, layer.radius_px);
+ return float4(g * a, a); // prémultiplié
}
// mode 4 : curseur dessiné (dot + ring SDF).
@@ -467,6 +548,8 @@ fragment float4 ps_main(VSOut i [[stage_in]],
}
float3 rgb;
+ // 1 sauf en detourage, ou il porte le masque du sujet. Cf. la branche fx.z plus bas.
+ float alpha_mask = 1.0;
if (layer.mode < 0.5)
{
// flou de mouvement par vélocité (§8)
@@ -474,8 +557,10 @@ fragment float4 ps_main(VSOut i [[stage_in]],
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;
+ float mb_scale = saturate(layer.mb.y);
+ float2 duv_blur = duv * mb_scale;
int taps = int(layer.mb.x);
- if (taps <= 1 || dot(duv, duv) < 1e-9)
+ if (taps <= 1 || mb_scale <= 0.001 || dot(duv_blur, duv_blur) < 1e-9)
{
rgb = sample_yuv(uv_now, texY, texUV);
}
@@ -486,17 +571,39 @@ fragment float4 ps_main(VSOut i [[stage_in]],
{
if (k >= taps) break;
float t = float(k) / float(taps - 1);
- acc += sample_yuv(uv_prev + duv * t, texY, texUV);
+ acc += sample_yuv(uv_now - duv_blur * (1.0 - t), texY, texUV);
}
rgb = acc / float(taps);
}
+
+ // Effet d'arriere-plan webcam. Miroir exact de la branche HLSL : fx.z porte le mode
+ // (1 = detourage, 2 = flou, 3 = fond plat), fx.w l'intensite du flou, fx.xy l'etendue
+ // valide de la texture webcam pour ramener uv dans l'espace du masque.
+ float effect = layer.fx.z;
+ if (effect > 0.5)
+ {
+ float2 mask_uv = uv_now / max(layer.fx.xy, float2(1e-6));
+ float person = saturate(texMask.sample(samp, mask_uv).r);
+ if (effect > 2.5)
+ {
+ rgb = mix(layer.color.rgb, rgb, person);
+ }
+ else if (effect > 1.5)
+ {
+ rgb = mix(blur_webcam_bg(uv_now, layer.fx.w, layer.quad_px, i.local, texY, texUV), rgb, person);
+ }
+ else
+ {
+ alpha_mask = person;
+ }
+ }
}
else
{
rgb = layer.color.rgb;
}
- float alpha = layer.color.a;
+ float alpha = layer.color.a * alpha_mask;
if (layer.radius_px > 0.0)
{
float2 halfsz = layer.quad_px * 0.5;
diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs
index 721fe7902..fe3a2e795 100644
--- a/crates/compositor/src/timeline_walk.rs
+++ b/crates/compositor/src/timeline_walk.rs
@@ -17,6 +17,7 @@ use crate::compositor::Compositor;
use crate::config::Cfg;
use crate::cursor::CursorTrack;
use crate::d3d::Gpu;
+use crate::ffi::AVFrame;
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::{ClipSource, Decoder};
use crate::regions::{speed_segments_for_window, SpeedSegment};
@@ -164,6 +165,14 @@ pub(crate) unsafe fn walk_composited_timeline(
let mut frames: u64 = 0;
+ // L'export doit être reproductible : deux rendus du même projet, les mêmes pixels. Cette
+ // boucle avance aussi vite que la machine décode, sans rapport avec le temps réel, alors que
+ // la segmentation est cadencée à l'horloge et calculée sur un worker — deux choix faits pour
+ // la preview, et qui deviennent ici des bugs : le nombre de frames couvertes par un masque
+ // suivrait la charge machine, et les premières frames sortiraient AVANT le premier masque,
+ // donc avec le vrai arrière-plan de la webcam gravé dans le fichier.
+ comp.set_segmentation_deterministic(true);
+
for (clip_index, clip) in clips.iter().enumerate() {
// Le preset de layout est GLOBAL (un seul panneau pour toute la timeline) mais la
// caméra est PAR CLIP : un projet mélange sans problème un enregistrement avec webcam
@@ -194,10 +203,10 @@ pub(crate) unsafe fn walk_composited_timeline(
comp.set_has_webcam(has_camera);
let webcam_key = if has_camera { &clip.webcam } else { &clip.screen };
if !screen_decs.contains_key(&clip.screen) {
- screen_decs.insert(clip.screen.clone(), Decoder::open(&clip.screen, gpu)?);
+ screen_decs.insert(clip.screen.clone(), Decoder::open_for_export(&clip.screen, gpu)?);
}
if !webcam_decs.contains_key(webcam_key) {
- webcam_decs.insert(webcam_key.clone(), Decoder::open(webcam_key, gpu)?);
+ webcam_decs.insert(webcam_key.clone(), Decoder::open_for_export(webcam_key, gpu)?);
}
let sdec = screen_decs.get_mut(&clip.screen).unwrap();
let wdec = webcam_decs.get_mut(webcam_key).unwrap();
@@ -297,11 +306,17 @@ pub(crate) unsafe fn walk_composited_timeline(
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;
+ {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::DecodeScreen);
+ 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 _p = crate::export_probe::scope(crate::export_probe::Stage::DecodeWebcam);
+ 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();
@@ -313,12 +328,16 @@ pub(crate) unsafe fn walk_composited_timeline(
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)?;
+ {
+ let _p = crate::export_probe::scope(crate::export_probe::Stage::Compose);
+ comp.compose_frame(sf, wf, frames as f32, cfg)?;
+ }
on_frame(frames)?;
frames += 1;
}
}
+
on_clip_end(
clip_index,
source_end_sec,
@@ -329,6 +348,8 @@ pub(crate) unsafe fn walk_composited_timeline(
comp.set_cursor_time(None);
comp.set_timeline_time(None);
+ // Le compositeur est réutilisé par la preview après un export : lui rendre sa cadence.
+ comp.set_segmentation_deterministic(false);
Ok(frames)
}
diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl
index 6fb2a73ed..fd7da966d 100644
--- a/crates/compositor/src/vk_shaders/layer.wgsl
+++ b/crates/compositor/src/vk_shaders/layer.wgsl
@@ -32,8 +32,15 @@ struct Layer {
@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(2) var texU: texture_2d; // R8Unorm, sample .r
@group(0) @binding(3) var samp: sampler;
+// Masque de segmentation du sujet webcam, R8. Une vue 1x1 est liee quand aucun masque
+// n'existe : la branche n'est de toute facon prise que si layer.fx.z > 0.5.
+@group(0) @binding(4) var texMask: texture_2d;
+// V est en binding 5 et pas 3 : les bindings 0-4 etaient deja pris quand le plan
+// de chroma a ete dedouble, et renumeroter aurait touche tous les bind groups
+// pour un gain nul.
+@group(0) @binding(5) var texV: texture_2d; // R8Unorm, sample .r
struct VsOut {
@builtin(position) pos: vec4,
@@ -69,7 +76,10 @@ fn yuv709_limited(y: f32, cbcr: vec2) -> vec3 {
fn sample_yuv(uv: vec2